PhoenixKitWebAnalytics.Collector (PhoenixKitWebAnalytics v0.5.0)

Copy Markdown View Source

The write path: takes a raw hit, enriches it, and stores one PhoenixKitWebAnalytics.Schemas.Event.

A hit never costs the request anything

track_async/1 hands the work to a Task.Supervisor and returns immediately, so the enrichment (a settings read, one session-stitching query, the insert) happens after the response is on its way out. The request process spends microseconds building a map.

The task supervisor is started with a max_children cap (20 by default, config :phoenix_kit_web_analytics, max_concurrent_writes: n). Under a flood, once the cap is reached, further hits are dropped rather than queued — an analytics backlog must not become the reason a host runs out of database connections. Drops are logged at debug level. With no supervisor running at all (a host that didn't start the module's children) hits are dropped too, with one warning, rather than spawned without a cap.

In tests, config :phoenix_kit_web_analytics, async_tracking: false makes track_async/1 write inline in the caller, so the write happens on the test's sandbox connection and can be asserted on.

Sessions

A hit joins the visitor's most recent session on the same site when that session saw activity within the inactivity window; otherwise it starts a new one. The lookup and the insert run in one transaction under an advisory lock on the visitor, so two hits arriving together (a page and its prefetch, a double-click) can't both decide to start a session.

Before any of that, a hit takes one of 3 slots for its visitor on this node, and is dropped when they're all taken. One "visitor" can be a whole office behind one address or a crawler: without the cap its hits would queue on the advisory lock, each holding a database connection while it waits, until the pool is theirs.

Raw hit shape

Every key is optional except :path:

%{
  event_type: "pageview" | "event",   # default "pageview"
  event_name: "signup",               # required for "event"
  path: "/pricing",
  page_title: "Pricing",
  site: "myapp.com",
  referrer: "https://news.ycombinator.com/",
  query_params: %{"utm_source" => "hn"},
  ip: {127, 0, 0, 1},
  user_agent: "Mozilla/5.0 …",
  language: "en-US",
  user_uuid: "018e…",
  status: 200,
  duration_ms: 12,                    # server render time (page views)
  engaged_ms: 45_000,                 # time on page ("leave")
  scroll_depth: 80,                   # 0–100 ("leave", client script)
  target: "https://example.com/x",    # what was clicked ("interaction")
  session_anchor: ~U[…],              # when the hit's page was opened
  location: %{country_code: "EE"},     # pre-resolved (edge headers)
  metadata: %{"plan" => "pro"}
}

:ip and :user_agent are used for the daily visitor hash and the client classification, then discarded — see PhoenixKitWebAnalytics.Visitor.

:session_anchor is for hits reported after the page they belong to — a leave recorded when a tab closes an hour after it opened. The visitor hash and the session lookup use the anchor instead of the insert time, so the leave joins the session its page view started rather than opening a new one.

Summary

Functions

Child spec for the registry that counts each visitor's writes in flight. Returned from PhoenixKitWebAnalytics.children/0.

Resolves which session a visitor's hit belongs to.

Runs fun under the same supervisor and cap as the writes — for other after-the-fact work that must stay off the caller's process (alerts). Inline when async_tracking is off. Always returns :ok; failures are logged.

Child spec for the task supervisor that runs the async writes.

Stores a hit synchronously.

Stores a hit off the request path. Always returns :ok.

Functions

gate_spec()

@spec gate_spec() :: Supervisor.child_spec()

Child spec for the registry that counts each visitor's writes in flight. Returned from PhoenixKitWebAnalytics.children/0.

resolve_session(visitor_id, timeout_minutes, now, site \\ :any)

@spec resolve_session(
  String.t(),
  pos_integer(),
  DateTime.t(),
  String.t() | nil | :any
) :: Ecto.UUID.t()

Resolves which session a visitor's hit belongs to.

Reuses the visitor's previous session on site when their last hit there is within timeout_minutes, otherwise mints a new one. site defaults to :any, which ignores the site. This is the whole reason no session cookie is needed: the stitch is an indexed lookup on (visitor_id, inserted_at), server-side.

run_async(fun)

@spec run_async((-> any())) :: :ok

Runs fun under the same supervisor and cap as the writes — for other after-the-fact work that must stay off the caller's process (alerts). Inline when async_tracking is off. Always returns :ok; failures are logged.

task_supervisor_spec()

@spec task_supervisor_spec() :: Supervisor.child_spec()

Child spec for the task supervisor that runs the async writes.

Returned from PhoenixKitWebAnalytics.children/0, so a host running PhoenixKit's module supervision gets it with no configuration.

track(hit)

@spec track(map()) ::
  {:ok, PhoenixKitWebAnalytics.Schemas.Event.t()}
  | {:error, :disabled | :bot | :invalid | Ecto.Changeset.t() | term()}

Stores a hit synchronously.

Used by tests (an async task can't see the Ecto sandbox connection) and by callers that want the result. Returns {:error, :disabled} when tracking is off and {:error, :bot} when the hit was filtered as automated traffic — both are ordinary outcomes, not failures.

track_async(hit)

@spec track_async(map()) :: :ok

Stores a hit off the request path. Always returns :ok.