PhoenixKitWebAnalytics.Reports (PhoenixKitWebAnalytics v0.5.0)

Copy Markdown View Source

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, from inclusive, to exclusive
  • :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). Since visitor_id is a daily hash (see PhoenixKitWebAnalytics.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.

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.

Types

filter()

@type filter() :: %{
  from: DateTime.t(),
  to: DateTime.t(),
  site: String.t() | nil,
  path: String.t() | nil,
  period: String.t(),
  bots: boolean()
}

Functions

active_visitors(minutes \\ 5, site \\ nil)

@spec active_visitors(pos_integer(), String.t() | nil) :: non_neg_integer()

Distinct visitors seen in the last minutes — the "right now" number.

browsers(filter, opts \\ [])

@spec browsers(filter(), keyword()) :: [map()]

Browser breakdown.

bucket_for(period)

@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.

channels(filter, opts \\ [])

@spec channels(filter(), keyword()) :: [map()]

Traffic grouped by channel — direct, organic, social, referral, email, paid.

countries(filter, opts \\ [])

@spec countries(filter(), keyword()) :: [map()]

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_timeseries(filter)

@spec daily_timeseries(filter()) :: [map()]

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.

default_period()

@spec default_period() :: String.t()

The default period when none was requested.

devices(filter, opts \\ [])

@spec devices(filter(), keyword()) :: [map()]

Device class breakdown (desktop / mobile / tablet).

engagement(filter)

@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).

exit_pages(filter, opts \\ [])

@spec exit_pages(filter(), keyword()) :: [map()]

The pages visitors left the site from, ranked (pageviews holds the exit count).

filter(opts \\ [])

@spec filter(keyword()) :: filter()

Builds a filter.

Options

  • :period — one of periods/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 (default false)
  • :now — reference time (tests)

languages(filter, opts \\ [])

@spec languages(filter(), keyword()) :: [map()]

Browser language breakdown.

operating_systems(filter, opts \\ [])

@spec operating_systems(filter(), keyword()) :: [map()]

Operating system breakdown.

overview(filter)

@spec overview(filter()) :: map()

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).

page_engagement(filter, paths)

@spec page_engagement(filter(), [String.t()]) :: %{required(String.t()) => map()}

Per-path engagement for the given paths: %{path => %{exits, avg_time_ms, avg_scroll}}. Paths with no leave recorded are absent.

period_label(period)

@spec period_label(String.t()) :: String.t()

The human label for a period value.

period_range(period, now \\ nil)

@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]}

periods()

@spec periods() :: [{String.t(), String.t()}]

The selectable periods as {value, label} pairs, for the UI.

previous_overview(filter)

@spec previous_overview(filter()) :: map() | nil

Totals for the window immediately before this one, for period-over-period comparison. nil for the "all" period, which has no "before".

recent_hits(filter, opts \\ [])

@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

recent_sessions(minutes, opts \\ [])

@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 — a DateTime; only visits whose latest hit is older (paging)

session_summary(session_id)

@spec session_summary(String.t()) :: map() | nil

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).

session_timeline(session_id, opts \\ [])

@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.

sessions(filter, opts \\ [])

@spec sessions(filter(), keyword()) :: [map()]

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 — a DateTime; only visits that started before it (paging)
  • :user_uuid — only visits in which that signed-in user was active

sessions_page(filter, opts \\ [])

@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.

sites(filter)

@spec sites(filter()) :: [String.t()]

Hosts that received traffic, for the site selector.

slow_min_views()

@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.

slowest_paths(filter, opts \\ [])

@spec slowest_paths(filter(), keyword()) :: [map()]

storage_stats()

@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.

timeseries(filter, bucket \\ :day)

@spec timeseries(filter(), :hour | :day | :month) :: [map()]

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_campaigns(filter, opts \\ [])

@spec top_campaigns(filter(), keyword()) :: [map()]

Top UTM campaigns.

top_events(filter, opts \\ [])

@spec top_events(filter(), keyword()) :: [map()]

Custom events, ranked by occurrence.

top_interactions(filter, opts \\ [])

@spec top_interactions(filter(), keyword()) :: [map()]

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}.

top_paths(filter, opts \\ [])

@spec top_paths(filter(), keyword()) :: [map()]

Most viewed paths. :offset pages through them (with :limit).

top_referrers(filter, opts \\ [])

@spec top_referrers(filter(), keyword()) :: [map()]

Top referring sources, excluding internal navigation and direct traffic.

top_utm_sources(filter, opts \\ [])

@spec top_utm_sources(filter(), keyword()) :: [map()]

Top UTM sources.