Read-side aggregation — every number the admin pages show.
All functions take a filter map and answer one question about it. Build
the filter with filter/1:
filter = PhoenixKitWebAnalytics.Reports.filter(period: "7d")
PhoenixKitWebAnalytics.Reports.overview(filter)
PhoenixKitWebAnalytics.Reports.top_paths(filter, limit: 10)Filter keys
:from/:to— the time window,frominclusive,toexclusive:site— restrict to one host (nil = all):path— restrict to one path (nil = all):period— the label the window came from, carried for the UI
Counting rules
These are the same rules a hosted analytics product applies, stated explicitly because they're what makes two tools disagree:
- Page views count rows with
event_type = "pageview". Custom events are never page views. - Visitors is
COUNT(DISTINCT visitor_id). Sincevisitor_idis a daily hash (seePhoenixKitWebAnalytics.Visitor), one person browsing on three days counts as three visitors over a week-long window. That is the honest consequence of not tracking people across days. - Sessions is
COUNT(DISTINCT session_id), where a session ends after the configured inactivity gap (30 minutes by default). - Bounce rate is sessions with exactly one page view, over all sessions.
- Average session length measures last hit minus first hit in a session, so single-page sessions contribute zero — the standard definition, and the reason it reads low on content sites.
Scale: rollups for finished days
Every report that adds things up over a period reads the finished days from
the daily rollups (DailyStat, DailyDim) and only the rest — today, and
yesterday until its rollup has run — from raw events
(PhoenixKitWebAnalytics.RollupReader). A 30-day or 12-month window costs
about what a single day does, whatever the traffic, and breakdowns keep
working for days whose raw events retention has deleted. The numbers are
the same either way: distinct visitors add up across days exactly, because
the visitor ID changes daily.
Today (hourly), a single-page filter and bot traffic are read raw — the
rollups don't break down by hour, page or bot. Results are cached for
30 seconds (PhoenixKitWebAnalytics.ReportCache), so the raw slice runs at
most once per interval however many admins are watching.
Engagement
"leave" events — recorded when a visitor leaves a page, with the time it
was open — answer "how long do people stay" and "where do they leave"
(engagement/1, exit_pages/2, page_engagement/2). "interaction" events
are what they did there (top_interactions/2). sessions/2 lists visits and
session_timeline/1 replays one.
Bot traffic (stored only when web_analytics_track_bots is on) is left out
of every report unless the filter asks for it with bots: true.
Failure behaviour
Every query degrades to an empty result rather than raising — a module that is installed but whose migrations haven't run yet, or a momentarily unreachable database, renders as "no data yet" instead of a 500 on the admin page. The failure is logged.
Summary
Functions
Distinct visitors seen in the last minutes — the "right now" number.
Browser breakdown.
The bucket size a period should be charted at.
Traffic grouped by channel — direct, organic, social, referral, email, paid.
Country breakdown.
Daily page views and visitors, rolled-up days included — each entry
%{date: Date.t(), pageviews: n, visitors: n, source: :events | :rollup},
where source says which a day was read from.
The default period when none was requested.
Device class breakdown (desktop / mobile / tablet).
How long visitors stay: exits (pages left), avg_time_ms (average time
on a page, from leave events), and avg_scroll (average scroll depth, 0–100,
nil without the client script).
The pages visitors left the site from, ranked (pageviews holds the exit count).
Builds a filter.
Browser language breakdown.
Operating system breakdown.
Headline totals for the window.
Per-path engagement for the given paths: %{path => %{exits, avg_time_ms, avg_scroll}}. Paths with no leave recorded are absent.
The human label for a period value.
The {from, to} window for a period label, to exclusive.
The selectable periods as {value, label} pairs, for the UI.
Totals for the window immediately before this one, for period-over-period
comparison. nil for the "all" period, which has no "before".
The most recent hits, newest first — the live feed.
Visits with any activity in the last minutes, most recently active first
— {rows, next_before} like sessions_page/2.
One visit's totals over all its events, however long the visit —
%{started, seconds, pageviews, actions, max_scroll, bot?, bot_reason},
or nil. bot_reason says why a visit counts as a bot's when its
behaviour gave it away (see PhoenixKitWebAnalytics.BotSignals).
The events of one session, oldest first — the visit replayed. :limit
caps how many (default 1000; the visit page asks for 500 at a time); []
for an id that isn't a UUID.
Visits in the window, newest first — one row per visit that has at least one page view
sessions/2, plus the cursor for the next page: {rows, next_before},
where next_before is nil on the last page.
Hosts that received traffic, for the site selector.
Slowest paths by average server response time.
Stored rows and the oldest retained timestamp — shown on the settings page so an operator can see what retention is actually doing.
Page views and visitors per time bucket, oldest first.
Top UTM campaigns.
Custom events, ranked by occurrence.
What visitors did, ranked: LiveView events by name, and the client script's
clicks by kind and target ("outbound · github.com/acme"). Each row is
%{label, name, target, pageviews: count, visitors}.
Most viewed paths. :offset pages through them (with :limit).
Top referring sources, excluding internal navigation and direct traffic.
Top UTM sources.
Types
@type filter() :: %{ from: DateTime.t(), to: DateTime.t(), site: String.t() | nil, path: String.t() | nil, period: String.t(), bots: boolean() }
Functions
@spec active_visitors(pos_integer(), String.t() | nil) :: non_neg_integer()
Distinct visitors seen in the last minutes — the "right now" number.
Browser breakdown.
@spec bucket_for(String.t()) :: :hour | :day | :month
The bucket size a period should be charted at.
Short windows get hourly points; a year gets monthly ones, so the chart never tries to draw 365 bars.
Traffic grouped by channel — direct, organic, social, referral, email, paid.
Country breakdown.
Empty unless a geo resolver is configured or the host sits behind a CDN that
sets a country header — see PhoenixKitWebAnalytics.Geo.
Daily page views and visitors, rolled-up days included — each entry
%{date: Date.t(), pageviews: n, visitors: n, source: :events | :rollup},
where source says which a day was read from.
@spec default_period() :: String.t()
The default period when none was requested.
Device class breakdown (desktop / mobile / tablet).
@spec engagement(filter()) :: %{ exits: non_neg_integer(), avg_time_ms: float() | nil, avg_scroll: float() | nil }
How long visitors stay: exits (pages left), avg_time_ms (average time
on a page, from leave events), and avg_scroll (average scroll depth, 0–100,
nil without the client script).
The pages visitors left the site from, ranked (pageviews holds the exit count).
Builds a filter.
Options
:period— one ofperiods/0's values; an unknown value falls back to the default rather than raising, since it arrives from a query parameter:site,:path— optional restrictions:bots— include automated traffic (defaultfalse):now— reference time (tests)
Browser language breakdown.
Operating system breakdown.
Headline totals for the window.
Returns pageviews, visitors, sessions, events, bounce_rate (percent,
nil with no sessions), avg_session_seconds (nil with no sessions), and
avg_response_ms (nil when nothing recorded a duration).
Per-path engagement for the given paths: %{path => %{exits, avg_time_ms, avg_scroll}}. Paths with no leave recorded are absent.
The human label for a period value.
@spec period_range(String.t(), DateTime.t() | nil) :: {DateTime.t(), DateTime.t()}
The {from, to} window for a period label, to exclusive.
iex> {from, to} = PhoenixKitWebAnalytics.Reports.period_range("today", ~U[2026-03-04 10:00:00Z])
iex> {DateTime.to_date(from), DateTime.to_date(to)}
{~D[2026-03-04], ~D[2026-03-05]}
The selectable periods as {value, label} pairs, for the UI.
Totals for the window immediately before this one, for period-over-period
comparison. nil for the "all" period, which has no "before".
@spec recent_hits(filter(), keyword()) :: [PhoenixKitWebAnalytics.Schemas.Event.t()]
The most recent hits, newest first — the live feed.
Returns whole PhoenixKitWebAnalytics.Schemas.Event structs. Every column is
already non-identifying, so there is nothing to redact for display.
Options
:limit— default 50:event_type—"pageview","event","interaction"or"leave"to show one kind only
@spec recent_sessions(pos_integer(), keyword()) :: {[map()], DateTime.t() | nil}
Visits with any activity in the last minutes, most recently active first
— {rows, next_before} like sessions_page/2.
Reads the newest events through the time index and keeps the first limit
distinct visits, so it costs the same with ten people online or ten
thousand.
Options
:limit— default 50:before— aDateTime; only visits whose latest hit is older (paging)
One visit's totals over all its events, however long the visit —
%{started, seconds, pageviews, actions, max_scroll, bot?, bot_reason},
or nil. bot_reason says why a visit counts as a bot's when its
behaviour gave it away (see PhoenixKitWebAnalytics.BotSignals).
@spec session_timeline(String.t(), keyword()) :: [ PhoenixKitWebAnalytics.Schemas.Event.t() ]
The events of one session, oldest first — the visit replayed. :limit
caps how many (default 1000; the visit page asks for 500 at a time); []
for an id that isn't a UUID.
Visits in the window, newest first — one row per visit that has at least one page view:
%{session_id, started_at, ended_at, seconds, pageviews, interactions,
entry_path, exit_path, source, medium, browser, os, device_type,
country_code, user_uuid}source/medium are the first page view's. seconds spans every event
type, so a visit that ended with a leave counts the time on its last page.
Built to stay cheap on a busy site: it pages through visit starts (the
indexed session_start rows) and only then sums up the visits on the page,
instead of grouping every event in the window. With a path filter it lists
visits that landed on that page.
Options
:limit— default 50:before— aDateTime; only visits that started before it (paging):user_uuid— only visits in which that signed-in user was active
@spec sessions_page(filter(), keyword()) :: {[map()], DateTime.t() | nil}
sessions/2, plus the cursor for the next page: {rows, next_before},
where next_before is nil on the last page.
Hosts that received traffic, for the site selector.
@spec slow_min_views() :: pos_integer()
Slowest paths by average server response time.
This comes free with page-view tracking — the plug already measured the response — and is often the most actionable table here: a page that is both popular and slow shows up in one query. Paths with fewer than three views are excluded, since one cold request would otherwise top the list.
@spec storage_stats() :: %{ events: non_neg_integer(), events_estimated?: boolean(), rollup_days: non_neg_integer(), oldest: DateTime.t() | nil }
Stored rows and the oldest retained timestamp — shown on the settings page so an operator can see what retention is actually doing.
The event count is exact below 100,000 rows and Postgres's planner estimate
above that (events_estimated?: true): an exact count(*) over a large
append-only table is a full scan, and this runs on every settings and
Modules page load.
Page views and visitors per time bucket, oldest first.
Empty buckets are filled in with zeros, so a chart doesn't have to reason
about gaps. Each entry is %{bucket: DateTime.t(), pageviews: n, visitors: n}.
Top UTM campaigns.
Custom events, ranked by occurrence.
What visitors did, ranked: LiveView events by name, and the client script's
clicks by kind and target ("outbound · github.com/acme"). Each row is
%{label, name, target, pageviews: count, visitors}.
Most viewed paths. :offset pages through them (with :limit).
Top referring sources, excluding internal navigation and direct traffic.
Top UTM sources.