PhoenixKitWebAnalytics.LiveHook (PhoenixKitWebAnalytics v0.6.1)

Copy Markdown View Source

Everything a visitor does on a LiveView page, recorded server-side from the socket — the module's client script isn't needed for any of it.

PhoenixKitWebAnalytics.Plug sees HTTP requests, which covers the first load of a page. Once a LiveView is connected, the visitor's clicks, form submits and navigations travel over the websocket instead, and that's where this hook sits. It records:

  • Page views for live navigation — push_navigate, push_patch, <.link navigate> / <.link patch> change the URL without an HTTP request, so the plug never sees them.
  • Page views served from Chrome's prefetch cache — a page Chrome prefetched or prerendered and the visitor then opened never reaches the plug; with the host's connect params, its first connect is counted instead. See "Recovering a visitor served from a prefetch cache" below.
  • Interactions — every phx-click, phx-submit, phx-keydown … event the LiveView handles, by event name ("add_to_cart", "save"). Form contents are never recorded; see "What an interaction stores" below.
  • Leaving — the page is registered with PhoenixKitWebAnalytics.LivePresence, which records a "leave" event with the time spent on the page when the LiveView process ends, and powers the "on the site right now" view.

Attach it in the host's live_session, after whatever mounts the current user, so logged-in visitors are attributed:

live_session :public,
  on_mount: [
    {PhoenixKitWeb.Users.Auth, :phoenix_kit_mount_current_scope},
    {PhoenixKitWebAnalytics.LiveHook, :track_navigation}
  ] do
  live "/", HomeLive
  live "/pricing", PricingLive
end

Requires connect_info on the socket

The hook needs the same two inputs the plug has — client IP and User-Agent — because they feed the daily visitor hash. Without them a LiveView navigation would hash to a different visitor than the page load that preceded it, inflating the visitor count. Rather than record data it knows to be wrong, the hook does nothing unless the endpoint provides them:

socket "/live", Phoenix.LiveView.Socket,
  websocket: [connect_info: [:peer_data, :x_headers, :user_agent, session: @session_options]],
  longpoll: [connect_info: [:peer_data, :x_headers, :user_agent, session: @session_options]]

The keys must be listed on both transports: LiveView falls back to long polling when a websocket can't be opened (a corporate proxy, a flaky network), and a visitor on the fallback transport is invisible to this hook if only websocket: carries them. If :peer_data or :user_agent is missing the hook stays inert and only full page loads are counted — check this first if LiveView activity isn't showing up.

:x_headers is what lets the hook see the visitor behind a reverse proxy: when the socket's peer is a private or loopback address (the proxy), the visitor's address is the forwarded one, read by the same rule as the plug (see "Client IP" in PhoenixKitWebAnalytics.Plug). Without :x_headers such a socket can't name its visitor — a proxy, a container network and localhost in development look the same — so the hook stays inert for it — no page view, no interaction, no "Right now" entry — and counts the skip; Settings then shows a warning. With :x_headers listed and no proxy in front (development, a LAN), the peer is the visitor. The same headers carry an x-accept-language, when a proxy sets one, for the visitor's language.

Not double-counted

A page load is an ordinary HTTP response the plug already recorded, so the handle_params that follows the connected mount is not counted again. A live navigation is told apart by LiveView's _live_referer connect parameter, which the client sends only when it arrived by push_navigate / <.link navigate>. A reconnect (a deploy, a dropped network) remounts with _mounts > 0 and is never counted — the visitor didn't go anywhere. The one page load the hook does count is one the plug never saw: see "Recovering a visitor served from a prefetch cache" below.

What an interaction stores

The event name, the page it happened on, and — only for parameter names listed in the web_analytics_event_params setting (tab, view, step … by default) — short scalar values, so "switched to the pricing tab" is visible. Everything else in the params is discarded before it leaves the LiveView process: form fields, free text, uploads.

Form typing (phx-change, recognisable by its "_target" parameter) is not recorded at all, and neither are event names listed in the web_analytics_ignore_events setting. The same event repeated within a second on one page counts once, so a double-click or a key held down is one interaction.

Events handled by a LiveComponent (phx-target={@myself}) run in the component, not the LiveView, and are not seen by this hook.

Do Not Track

The socket can't see request headers, so the plug notes a DNT: 1 / Sec-GPC: 1 visitor in the session and this hook reads it from there; such a visitor's LiveView activity is not recorded either. That needs the plug to run after :fetch_session, as it does in a standard :browser pipeline.

Recovering a visitor served from a prefetch cache

When Chrome delivers a page from a Speculation-Rules prefetch (or a prerender gets activated), it makes no second HTTP request for it — the human's click never reaches PhoenixKitWebAnalytics.Plug at all. Only this hook's first handle_params after the connected mount sees them, with the socket's real IP, so that connect is treated as a page view precisely when it looks like one of these deliveries and isn't a reconnect of an already-counted page (_mounts == 0, the same guard live_navigation?/1 uses).

The module's optional client script (PhoenixKitWebAnalytics.js_sources/0) doesn't report these deliveries — the socket's connect params are the host's — so the host reports them itself, by sending these LiveSocket connect params from its own assets/js/app.js and connecting only once a prerendered page is actually shown:

const nav = () => performance.getEntriesByType("navigation")[0]

let liveSocket = new LiveSocket("/live", Socket, {
  // A function, so the values are read when the socket connects.
  params: () => ({
    _csrf_token: csrfToken,
    nav_delivery: nav()?.deliveryType,
    prerendered: (nav()?.activationStart ?? 0) > 0,
    prerendering: document.prerendering === true,
    doc_referrer: document.referrer || null
  })
})

// Chrome runs a prerendered page's JavaScript before anyone opens it.
// It holds back a WebSocket until then, but LiveView's long-poll
// fallback would join anyway and count a page nobody saw.
if (document.prerendering) {
  document.addEventListener("prerenderingchange", () => liveSocket.connect(), {once: true})
} else {
  liveSocket.connect()
}

deliveryType === "navigational-prefetch" is Chrome/Edge 117+ only (MDN PerformanceResourceTiming.deliveryType) — empty or undefined elsewhere, so the hook simply never sees it on Firefox or Safari, which don't do this prefetching. prerendered is true once a prerendered page has been activated (activationStart > 0). prerendering is a guard for a page that connects anyway while it is still being prerendered: such a connect is never counted. doc_referrer (document.referrer) is the only way to recover a referrer for this page at all, since no plug request ever fired for it.

Only LiveView pages are recovered. A controller-rendered page Chrome served from its prefetch cache has no socket to connect, so the visitor's click on it is not counted at all (its prefetch is stored, flagged as a bot's).

See PhoenixKitWebAnalytics.Plug's bot: "prefetch" flag for the phantom side of this: the speculative request Chrome makes on its own, which the visitor may never open.

Summary

Functions

on_mount callback. Use :track_navigation.

Functions

on_mount(atom, params, session, socket)

@spec on_mount(
  :track_navigation,
  map() | :not_mounted_at_router,
  map(),
  Phoenix.LiveView.Socket.t()
) ::
  {:cont, Phoenix.LiveView.Socket.t()}

on_mount callback. Use :track_navigation.