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
@spec gate_spec() :: Supervisor.child_spec()
Child spec for the registry that counts each visitor's writes in flight.
Returned from PhoenixKitWebAnalytics.children/0.
@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.
@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.
@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.
@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.
@spec track_async(map()) :: :ok
Stores a hit off the request path. Always returns :ok.