PhoenixKitWebAnalytics.Plug (PhoenixKitWebAnalytics v0.6.1)

Copy Markdown View Source

Records one page view per HTML response — the whole tracker, server-side.

Add it once to the host's browser pipeline:

# lib/my_app_web/router.ex
pipeline :browser do
  # … existing plugs …
  plug PhoenixKitWebAnalytics.Plug
end

That's all page views need. No script tag, no client-side bundle, no cookie, and nothing added to the rendered page — pages stay byte-for-byte what they were. LiveView navigation, interactions and exits come from PhoenixKitWebAnalytics.LiveHook, which has its own (small) setup.

Cost to a request

The plug does three cheap things in the request process: a method/path check, one ETS read for settings, and register_before_send/2. When the response is on its way out it builds a map and hands it to PhoenixKitWebAnalytics.Collector, which does the settings-dependent enrichment, the session-stitch query, and the insert in a supervised task. No database work happens while the client is waiting.

What is skipped

Anything that isn't a person looking at a page:

  • non-GET requests, and non-2xx responses (a redirect isn't a page view)
  • responses that aren't text/html, so assets, JSON APIs, and file downloads never appear in reports
  • paths matching the exclusion patterns in settings (/admin* by default)
  • requests sending DNT: 1 or Sec-GPC: 1, when web_analytics_respect_dnt is on (it is by default)
  • automated User-Agents, unless web_analytics_track_bots is on
  • anything explicitly marked with skip/1
  • requests from Tidewave's development tooling (an x-tidewave-diagnostic header): it fetches the page again after every live navigation, which would count each one twice

Chrome's speculative prefetch

A request Chrome marks as its own speculative load — the Speculation Rules API prefetching or prerendering a page a visitor hasn't clicked yet, carrying Sec-Purpose: prefetch… / Purpose: prefetch (legacy) — is still stored, since the owner wants to see the volume of prefetches, but flagged bot: "prefetch" on the raw hit (same mechanism as PhoenixKitWebAnalytics.BotSignals: is_bot: true, metadata["bot"] = "prefetch"). That keeps it out of every default visit/paid-visit count the same way any other declared bot is excluded, while staying visible under the existing "show bot traffic" report switch. See PhoenixKitWebAnalytics.LiveHook's "Recovering a visitor served from a prefetch cache" for how the human's own click still gets attributed when Chrome serves this prefetch from cache instead of firing a second request. The flagged hit is its own visitor (see :bot in PhoenixKitWebAnalytics.Collector), so a prerender Chrome makes from the visitor's own browser doesn't become, or join, the visitor's visit.

Only a navigational load is flagged. A <link rel=prefetch> of a page carries the same header but Sec-Fetch-Dest: empty, and Chrome serves the later click on it from its HTTP cache without a request the plug would see — so that request is counted as the page view it most likely is.

The site's own people

A request by a signed-in user holding a staff role (web_analytics_internal_roles) marks the client's network as a staff network — for any path, excluded ones included, since staff mostly work in /admin. See PhoenixKitWebAnalytics.InternalTraffic. Their page views are stored with the admin flag (PhoenixKitWebAnalytics.TrafficFlags).

Who is signed in is read when the response is sent, not when this plug runs, so the plug may sit anywhere in the pipeline: PhoenixKit's own routes pipe through the host's :browser first and only then load the user (:phoenix_kit_auto_setup) and, for the admin, the scope. With a scope the roles come from it; with only :phoenix_kit_current_user (core's auto-setup) they come from a five-minute cache of the user's roles, looked up off the request on a miss — that request goes unnoted, the next one counts.

Options

  • :exclude — extra path patterns on top of the ones in settings, e.g. plug PhoenixKitWebAnalytics.Plug, exclude: ["/healthz", "/internal*"]. A trailing * makes a pattern a prefix match. These apply to this plug's page views only: the LiveView hook can't see plug options, so a LiveView page to leave out entirely belongs in the web_analytics_exclude_paths setting, which both honour.

Client IP

The client address is visitor-hash input (and only that — no IP is ever stored). It is read the way PhoenixKit reads it for a login (PhoenixKit.Utils.IpAddress.client_address/1): a public conn.remote_ip is the visitor; a private or loopback one is a reverse proxy on the same box or network, and the visitor is the last X-Forwarded-For entry — the one that proxy appended; a visitor can send their own header, but not control what the proxy adds after it — then X-Real-IP when there is no readable X-Forwarded-For. A proxy that appends the port (203.0.113.7:51234, [2001:db8::7]:443) is read too.

That trust needs no configuration, so the proxy must set or append X-Forwarded-For itself: one that only sets X-Real-IP passes the visitor's own header through. On an intranet where visitors have private addresses and reach the app directly, each of them can send it.

Behind a chain — a CDN in front of a load balancer — the last entry is the CDN's address, not the visitor's. There, put a plug that rewrites remote_ip from the headers your infrastructure controls — remote_ip — before this one; a public remote_ip is taken as is. The same goes for a CDN or proxy with a public address in front of the app: as a public peer its headers are ignored, and without RemoteIp every visitor is one of its addresses.

config :phoenix_kit_web_analytics, trust_x_forwarded_for: true is deprecated and does nothing: the forwarded header from a private peer is always read.

Summary

Functions

Marks a request as not-to-be-tracked.

Whether this request has been marked to skip tracking.

Functions

skip(conn)

@spec skip(Plug.Conn.t()) :: Plug.Conn.t()

Marks a request as not-to-be-tracked.

Useful for endpoints that render HTML but aren't pages — a preview iframe, a health check that returns a status page, a LiveView upload target:

conn |> PhoenixKitWebAnalytics.Plug.skip() |> render("preview.html")

Also honoured when set before this plug runs, e.g. from an earlier plug.

skipped?(conn)

@spec skipped?(Plug.Conn.t()) :: boolean()

Whether this request has been marked to skip tracking.