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.
Long windows and pruned data
daily_timeseries/1 transparently falls back to
PhoenixKitWebAnalytics.Schemas.DailyStat rows for days whose raw events have
been pruned, so trend lines survive retention. Breakdowns (pages, referrers,
…) are raw-only: once a day is pruned its breakdowns are gone by design, and
this module will not show a partial ranking as if it were complete.
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.
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, backfilled from rollups where raw events are gone.
The default period when none was requested.
Device class breakdown (desktop / mobile / tablet).
Builds a filter.
Browser language breakdown.
Operating system breakdown.
Headline totals for the window.
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.
Hosts that received traffic, for the site selector.
Slowest paths by average server response time.
Total 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.
Most viewed paths.
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() }
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, backfilled from rollups where raw events are gone.
Raw events win wherever they exist — a day still present in the events table
is always computed from it, and only fully pruned days come from
PhoenixKitWebAnalytics.Schemas.DailyStat. Each entry is
%{date: Date.t(), pageviews: n, visitors: n, source: :events | :rollup}.
@spec default_period() :: String.t()
The default period when none was requested.
Device class breakdown (desktop / mobile / tablet).
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: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).
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"or"event"to show one kind only
Hosts that received traffic, for the site selector.
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(), rollup_days: non_neg_integer(), oldest: DateTime.t() | nil }
Total 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.
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.
Most viewed paths.
Top referring sources, excluding internal navigation and direct traffic.
Top UTM sources.