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
endThat'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-
GETrequests, 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: 1orSec-GPC: 1, whenweb_analytics_respect_dntis on (it is by default) - automated User-Agents, unless
web_analytics_track_botsis on - anything explicitly marked with
skip/1 - requests from Tidewave's development tooling (an
x-tidewave-diagnosticheader): 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 theweb_analytics_exclude_pathssetting, 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
@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.
@spec skipped?(Plug.Conn.t()) :: boolean()
Whether this request has been marked to skip tracking.