PhoenixKitWebAnalytics.Recordings (PhoenixKitWebAnalytics v0.5.0)

Copy Markdown View Source

Session recordings: how a visitor moved the pointer, what they clicked and hovered, how the page scrolled — enough to replay a visit over the page it happened on.

Off by default (web_analytics_recording). The optional client script asks GET /phoenix-kit/analytics/recording once per page load whether to record; while it does, it posts a chunk every few seconds (and one when the page is hidden or left) to POST /phoenix-kit/analytics/recording.

What is never recorded

Text. No keystrokes, no form values, no page content, no element text — only coordinates and short element selectors (main > form.signup > button:nth-of-type(2)). Visitors sending Do Not Track or Global Privacy Control, bots, and excluded paths are never recorded.

Volume

A recorded page view is a chunk every ~10 seconds while something happens, so on a busy site recording everyone is a lot of rows. Two knobs:

  • web_analytics_recording_sample — the percent of visitors recorded (decided from the daily visitor hash, so a visitor is recorded for the whole day or not at all);
  • web_analytics_recording_retention_days — recordings older than this are deleted by the retention pass (30 days by default).

Writes go through the collector's capped task pool, so under a flood chunks are dropped rather than queued.

Frame format (version 1)

A chunk's frames is %{"v" => 1, "f" => frames}; each frame is a list starting with the milliseconds since the page was shown, then a type:

FrameMeaning
[t, "m", x, y]pointer at viewport x, y
[t, "c", x, y, selector]click (or tap) at x, y on selector
[t, "h", selector]pointer rested on selector (hover)
[t, "s", scroll_x, scroll_y]page scrolled to
[t, "r", width, height]viewport resized to
[t, "v", 0 | 1]page hidden / shown again

The chunk carries the viewport size at the start of the page view; a replay loads path at that size, applies the scrolls and draws the pointer, clicks and hovers over it.

Summary

Functions

A visit's recording, page view by page view, oldest first: [%{path, viewport_w, viewport_h, started_at, frames}], the frames of each page's chunks joined in order. Stops at 100000 frames.

Deletes recordings older than the retention setting, in batches. Returns the number deleted.

Whether this request's visitor should be recorded on path: recording on, the visitor sampled in, not a bot, not opted out, the path not excluded.

Whether a visit has a recording.

for_session/1 for the player, each page marked loadable when the player may load it behind the recording: a path this visit really requested (a page view the server recorded), not excluded from tracking. The path in a chunk comes from the visitor's browser, so it is never loaded on its word alone — an admin opening a replay must not be sent to a URL a visitor chose.

Validates a chunk the client script posted and stores it, off the caller's process. Returns :ok when it was accepted for writing and {:error, reason} when it was refused — :not_recording, :invalid, :too_fast (over 30 chunks a minute from one visitor).

Functions

for_session(session_id)

@spec for_session(String.t()) :: [map()]

A visit's recording, page view by page view, oldest first: [%{path, viewport_w, viewport_h, started_at, frames}], the frames of each page's chunks joined in order. Stops at 100000 frames.

prune()

@spec prune() :: non_neg_integer()

Deletes recordings older than the retention setting, in batches. Returns the number deleted.

record?(client, path)

@spec record?(map(), String.t() | nil) :: boolean()

Whether this request's visitor should be recorded on path: recording on, the visitor sampled in, not a bot, not opted out, the path not excluded.

recorded?(session_id)

@spec recorded?(String.t()) :: boolean()

Whether a visit has a recording.

replay(session_id)

@spec replay(String.t()) :: [map()]

for_session/1 for the player, each page marked loadable when the player may load it behind the recording: a path this visit really requested (a page view the server recorded), not excluded from tracking. The path in a chunk comes from the visitor's browser, so it is never loaded on its word alone — an admin opening a replay must not be sent to a URL a visitor chose.

store(client, params)

@spec store(map(), map()) :: :ok | {:error, :not_recording | :invalid | :too_fast}

Validates a chunk the client script posted and stores it, off the caller's process. Returns :ok when it was accepted for writing and {:error, reason} when it was refused — :not_recording, :invalid, :too_fast (over 30 chunks a minute from one visitor).