SaaS Pro Max
Sign inStart free
Documentation menu

Web and product analytics

The analytics module is the substrate the rest of SaaS Pro Max reads from. It owns event ingestion, the event store, identity resolution, sessions, the daily rollups, and the query layer every analytics page and API call goes through.

  • Ingest endpoint: POST /api/v1/ingest
  • Storage: events, analytics_sessions, persons, person_identities, analytics_daily, web_vitals, goals, funnels
  • Query layer: apps/web/src/lib/analytics/queries.ts
  • Jobs: analytics.rollup, analytics.sessions.close, analytics.partitions, analytics.retention.purge

Data model#

events#

One row per event, range-partitioned by month on ts (events_yYYYYmMM), with PRIMARY KEY (app_id, env_id, ts, id). Every dimension a dashboard groups by is denormalized onto the row at ingest — path, referrer domain, source, medium, UTM parameters, ad click ids, country/region/city, browser/OS/device, screen, locale, app version and release — so a read never parses a URL or a user agent.

name carries the reserved name for SDK events ($pageview, $identify, $alias, $session_end, $web_vital) and the tenant's own name for track. type keeps the original envelope type.

analytics_sessions#

Naming deviation from PLAN §8. The plan calls this table sessions, but §5.1 already gives that name to console authentication sessions and both are created with CREATE TABLE IF NOT EXISTS, so the analytics table would have silently resolved to the auth one. Every column, key and index is exactly as §8 declares them; only the table name differs. Read it as sessions wherever the plan says so.

One row per visit. Created on the first event that carries a session id, then updated by every later event: last_seen_at, events, pageviews, exit_path, and bounced = pageviews <= 1. First-touch fields (entry path, referrer, source, UTM, click ids, geo, device) are written once and never overwritten.

A browser SDK sends sessionId. A server SDK usually does not, so the sink stitches server-side events into the visitor's open session — the most recent session for that person or anonymous id whose last_seen_at is within 30 minutes — and opens a new one otherwise.

persons and person_identities#

person_identities is the only identifier map: one row per (app_id, kind, value) where kind is anonymous or distinct, pointing at the person that owns it. Persons are created lazily — an anonymous visitor gets a person with is_identified = false on their first event.

analytics_daily#

The rollup table: one row per (app_id, env_id, day, dimension, value). The total row uses value = ''. Dimensions materialised today:

Text
total      goal       path         entry_path   exit_path
referrer_domain       source       medium       utm_source
utm_medium utm_campaign            utm_term     utm_content
country    region     city         device       browser
os         event

(The dimension column is free text, so adding a dimension is a code change, not a migration.)

goal is the one dimension whose value is not something a visitor sent: it is a goals.id, and the row counts that goal's own conversions. The total row counts a converting event once however many goals it matched; a goal row counts it once for its goal, so the goal rows sum to at least the total and goalConversions() is exact for a path goal instead of approximating it with pageviews. A goal defined after a day was rolled up reports 0 for that day until the next analytics.rollup run recomputes it. A deleted goal's rows survive until the same recompute and are ignored, because every reader joins them against the goals that exist now.


Identity rules#

resolvePerson({ distinctId, anonymousId }):

  1. A known distinct id wins, always.
  2. Otherwise a known anonymous id wins. If the event also carries an unclaimed distinct id, that person adopts it — the same outcome an identify would produce, so the pipeline does not depend on an identify ever arriving.
  3. Otherwise a person is created, identified when a distinct id was supplied.

identify binds a distinct id to a person. When the anonymous visitor and the distinct id already belong to different people, the anonymous person is merged into the identified one:

  • identities move to the survivor;
  • sessions, events and pageviews counters are summed;
  • the earliest first_seen_at survives, and with it the whole first-touch attribution block (first_source, first_medium, first_campaign, first_referrer, first_landing);
  • the latest last-touch survives;
  • traits and reserved columns fill in from the survivor first, the merged person second;
  • events.person_id and analytics_sessions.person_id are re-pointed for the last 90 days. Older rows keep the retired id: re-pointing an unbounded history on the ingest path is not worth the write amplification.
  • the merged person row is deleted.

alias links previousId to whatever person owns distinctId. Because both ids are of the same kind, the older person wins and the newer one is folded into it.

Reserved traits (email, name/firstName+lastName, avatar, phone) are promoted to their own columns; every trait is also merged into traits.


What each dashboard number means#

Number Definition
Visitor A distinct person_id, else anonymous_id, else distinct_id, counted once per day. A range total is the sum of its daily uniques, so somebody who visits on Monday and Friday counts twice in a 7-day total. This is what makes analytics_daily usable and what makes the total agree with the chart above it.
Unique users activeUsers() — the one place uniqueness is not collapsed per day. DAU, WAU (rolling 7 days) and MAU (rolling 30 days) are true distinct counts, computed from raw events.
Pageview An event with name = '$pageview'.
Session A row in analytics_sessions, attributed to the local day it started, which is what keeps daily numbers additive. It ends 30 minutes after its last event (analytics.sessions.close) or immediately on a $session_end event from the SDK.
Bounce A session with one pageview or fewer. Bounce rate is bounces / sessions.
Duration ended_at - started_at, only set once a session is closed; open sessions contribute 0. The tile shows sum(duration_ms) / sessions.
Conversion An event matching any goals row for the application: kind='event' matches events.name, kind='path' matches events.path. One event can satisfy several goals; each match counts.
Live visitors Distinct visitors with an event in the last 5 minutes.
Entry / exit page Session-level: entry_path is the first pageview of the visit, exit_path the last. Their conversions count the goal events that happened inside those sessions.
Source Classified once at ingest by classifySource in @saaspro/shared: ad click ids → utm_medium → utm_source → referrer domain → direct.

Funnels#

funnelResults retains its count response: entered, converted, dropOff and conversionRate per step. A visitor enters once, on their first matching entry event in the selected range. Identity types are namespaced so an anonymous ID cannot collide with a person or distinct ID. Each event can satisfy only one step; identical timestamps use event ID order. Future events do not count.

The saved definition's default is ordered steps with a renewed window_hours at each step (1–8,760 hours). The scan can extend beyond the selected entry range by the remaining step windows, up to the current time. Step filters support eq, neq, contains, in, gt, gte, lt, lte, and exists; unknown properties read properties. Values are bound parameters. Nonnumeric property values fail numeric comparisons instead of failing the query.

The funnel detail view adds URL-based analysis settings:

  • In order: unrelated events may occur between matching steps.
  • Consecutive events: each next event must match the next step.
  • Any order after entry: remaining steps match distinct events after entry within one entry-based window. Overlapping conditions consume events in the listed step order.
  • Window origin: renew at each step or measure from first entry. Any-order mode always uses entry.
  • Exclude event: a named event disqualifies a journey before the relevant conversion.

funnelDiagnostics reports median/p90 seconds to the next step among converters, sample counts, and median elapsed time from entry. Visitors who have not continued are separated into open and expired windows. The last step has no next-step timing. An open window is an observation period, not a prediction of future conversion. Only retained events are available.

Readers with people.read and the people module can select a count to investigate participants. The list returns person IDs (with links to permission-checked profiles) or application-specific anonymous fingerprints; it never includes raw anonymous/distinct IDs, names or emails. Pages contain 50 rows, limited to the first 1,000. Existing public shares use the saved ordered definition and never expose participant data. Private analysis settings survive a copied URL; they do not silently change the saved definition.

Management API additions:

  • GET /api/v1/apps/{app}/funnels/{id}/diagnostics?range=7d&env=production&order=ordered&window=step&exclude=cancelled requires analytics.read.
  • GET /api/v1/apps/{app}/funnels/{id}/participants?range=7d&step=1&outcome=dropped&page=1 also requires people.read and the people module. Outcomes are converted, waiting, and dropped; the same analysis settings apply.

Both paths use the same scoped query graph as the console. Reads have a transaction-local 15-second statement timeout and discourage nested-loop joins over unindexed intermediate results, avoiding quadratic plans when statistics underestimate a new application's traffic. No database-wide planner setting is changed.

Retention#

Choose starting and returning actions, daily/weekly/monthly buckets, first or recurring entry, and up to three segments. Cohort entry uses retained history. Incomplete cells are marked and excluded from summary rates; mature zero-return cohorts remain in the denominator. See the retention guide for definitions, limits and API examples.


How a read is answered#

Every bucket is an application-timezone bucket (applications.timezone), never the server's and never the reader's — Postgres does it with AT TIME ZONE, and src/lib/analytics/time.ts does the matching arithmetic in JavaScript.

A range is split by splitRange:

  • ranges of 2 days or less are read entirely from raw events (this is what the 24 h view uses, with hourly buckets);
  • longer ranges read every finished whole day from analytics_daily and the partial leading and trailing windows — most importantly today — from raw events.

The rollup and the raw path run the same SQL: src/lib/analytics/aggregate.ts builds one statement per dimension, analytics.rollup inserts its result into analytics_daily, and the query layer runs it directly for partial days. "The rollup equals the raw aggregation" is therefore a property of the code, and tests/analytics/jobs.test.ts asserts it dimension by dimension.

Query layer#

TypeScript
type AppScope = { appId: string; envId: string; timezone: string };
type Range = { from: Date; to: Date; interval: "hour" | "day" | "week" | "month" };
type Filters = Partial<Record<Dimension, string>>;   // one exact value per dimension
type ReadOptions = { filters?: Filters };

parseRange(input, timezone, now?): Range          // '24h'|'7d'|'30d'|'90d'|'12m'|'custom:YYYY-MM-DD..YYYY-MM-DD'
clampFiltered(range, filters): { range, capped }  // the window a filtered read may cover
hasAnyEvents(scope)                               // has this environment ever received one
overview(scope, range, now?, opts?)               // ReadOptions
timeseries(scope, range, metric, now?, opts?)     // 'visitors'|'pageviews'|'sessions'|'events'|'conversions'
breakdown(scope, range, dimension, opts?, now?)   // { limit?, search?, filters? }
eventNames(scope, range, now?, opts?)             // { name, count, users, lastSeenAt }
goalConversions(scope, range, now?, opts?)        // [{ goalId, conversions, visitors }]
eventsFeed(scope, filters, client?)               // { id?, name?, personId?, sessionId?, search?, before?, after?, limit?, from?, to? }
liveVisitors(scope)
funnelResults(scope, funnel, range, opts?)
retention(scope, range, { cohortBy: 'day'|'week'|'month', startEvent?, event?, mode?, filters?, definition? })
activeUsers(scope, range, opts?)

Comparison and bucket-size helpers live beside parseRange in src/lib/analytics/time.ts:

TypeScript
comparisonRange(range, timezone): Range         // compare=previous: whole buckets ending where the range's first begins
weekdayComparisonRange(range, timezone): Range  // compare=weekday: the same, moved back by whole weeks
yearComparisonRange(range, timezone): Range     // compare=year: the same calendar buckets one year earlier, 29 Feb -> 28 Feb
alignByYear(current, previous, interval, tz)    // pairs last year's buckets by date, NaN where it has none
clampInterval(range, interval, timezone): Range // ?interval= override, kept only when it yields 2-400 buckets
intervalChoices(range, timezone): Interval[]    // the sizes the range menu offers

overview returns the metrics plus a comparison block for the equally long window immediately before the range, carrying a delta of percentage changes (null when the previous value was zero and the current one is not).

eventsFeed pages newest first through an opaque cursor (nextCursor); pass it back as before. id selects one event outright, which is how the explorer resolves a deep-linked ?event= without paging towards it. after is the Live tail: events at most 15 minutes old and no older than the cursor less a two-minute overlap for late arrivals, newest first, at most 200; nextCursor is then the cursor to send next time, and the caller drops repeats by id. An empty after asks for the last 15 minutes. client runs the read on a given connection (the live topic's read-only transaction).

eventNames reports lastSeenAt (an ISO instant, or null) beside the counts. analytics_daily keeps a day rather than an instant, so recency is a grouped max(ts) over raw events rather than a rollup read.

Filters#

Every read above takes filters — one exact value per dimension, intersected:

TypeScript
await overview(scope, range, undefined, { filters: { source: "search", device: "mobile" } });

analytics_daily stores one row per dimension value, never per combination of them, so no rollup row can answer a filtered question. A filtered read goes straight to raw events and analytics_sessions for the whole range, and is therefore capped at 90 days (FILTERED_MAX_DAYS); clampFiltered returns the window that was actually read and whether it was shortened, and the console says so under the chips rather than letting the range picker claim more.

A filter is applied at each level to that level's own column when it has one:

DimensionOn the eventOn the session
path, eventevents.path, events.namethe sessions those events belong to
entry_path, exit_paththe sessions the events belong toanalytics_sessions.entry_path / exit_path
everything elseits own events columnits own analytics_sessions column

Two consequences worth knowing:

  • A breakdown ignores a filter on its own dimension. Filtering by source=search and then reading the Sources panel would leave one row, so the panel that the filter was set from stays navigable; every other filter still applies.
  • Retention narrows the cohort too. With source=search, a cohort is the visitors whose first matching event was a search arrival, and the grid counts their matching activity.

Ingest#

POST /api/v1/ingest accepts both transports:

  • Browser — Content-Type: text/plain with the key inside the envelope, for every request including sendBeacon, so no CORS preflight ever happens.
  • Server — Content-Type: application/json with Authorization: Bearer spm_sec_….

The key is read from Authorization: Bearer, then X-SPM-Key, then the envelope's key.

Situation Response
Accepted (wholly or partly) 202 { accepted, rejected: [{ index, reason }] }
Envelope is not the contract, or not JSON 400 { error }
Unknown, revoked, wrong-environment or key that is neither public nor secret 401
Origin not in a non-empty applications.allowed_origins 403
Body over SPM_INGEST_MAX_BODY_BYTES 413
More than 600 events in 10 s for one key 429 with Retry-After

CORS is Access-Control-Allow-Origin: * on this route only; the real control is allowed_origins, checked once the key resolves. A request with no Origin header (server to server) is always allowed.

What ingest does to an event#

  1. Envelope is validated as a whole; a malformed envelope is a 400.
  2. Each event is validated separately against ingestEventSchema, so one bad event costs only itself — it comes back in rejected with a reason.
  3. Public keys have context.ip and context.userAgent stripped before validation: a publishable key may not speak for somebody else's browser. Secret keys may send both, per event, and they win over the request's own.
  4. Bots are dropped for public keys (isBotUserAgent from @saaspro/shared); the events come back rejected rather than silently vanishing. Secret keys keep them — a server may legitimately describe a bot's request.
  5. Enrichment: received_at; ip_hash = HMAC-SHA256 of the request IP (first hop of X-Forwarded-For, else X-Real-IP) with SPM_IP_HASH_SECRET — the raw address is never stored; geo from the Google load balancer's x-client-geo-country, x-client-geo-region, x-client-geo-city, else x-vercel-ip-country, x-vercel-ip-country-region, x-vercel-ip-city, cf-ipcountry, cf-region, cf-ipcity; when no header names a country, the country of the time zone the browser reported (context.timezone); user-agent parsing; referrer domain and source classification.
  6. Deduplication: an events.id already stored for that application and environment within the last 48 hours counts as accepted and is not written again.
  7. Dispatch to the sinks (page|track|identify|alias|session|vital → analytics, error → error tracking, metric → metrics) inside one transaction per request. Events are grouped by sink rather than by type, so the analytics sink still sees an identify and the track after it in the order the SDK sent them.
  8. After the commit, if the application has an enabled destination, destinations.dispatch is enqueued once for the batch's page and track ids, keyed by the hash of those ids so a retry cannot double-send.

An event whose ts falls outside the months that currently have a partition is rejected with a reason rather than aborting the whole batch. Timestamps more than a minute in the future are clamped to the receive time.


Jobs#

KindIntervalWhat it does
analytics.rollup15 minRecomputes analytics_daily for every day touched since the last run (tracked per (app, env) in analytics_rollup_state), always including today and yesterday so a late session close is picked up. A day is recomputed from scratch, never incremented. Accepts { appId, envId, timezone, days } to force one day.
analytics.sessions.close1 minCloses sessions idle for more than 30 minutes: ended_at, duration_ms, bounced.
analytics.partitionsdailyKeeps events partitioned from last month through two months ahead.
analytics.retention.purgedailyDeletes events, analytics_sessions, web_vitals and analytics_daily rows past each application's retention_days, then drops any monthly partition whose whole range is older than the longest retention configured across all applications.

The dirty-day scan is bounded to the last 35 days so it can prune partitions; an event backdated further than that is history, and history does not change.


Demo data#

Shell
pnpm db:seed -- --demo

seedDemoAnalytics(appId, envId, days = 30) fills the bootstrap application's production environment with a deterministic dataset (seeded mulberry32 PRNG — the same input always produces the same output):

  • ~300 visitors a day over 30 days, weekends quieter, a mild upward trend, and a working-day hourly curve; about 28 % are returning visitors, which is what makes the retention grid non-trivial;
  • sources: direct, Google organic, Google Ads (gclid, utm_medium=cpc), X, Hacker News, GitHub referral, and a newsletter campaign;
  • 7 devices/browsers/OS combinations and 10 countries with regions and cities;
  • pages /, /pricing, /features, /docs, /docs/getting-started, /docs/browser-sdk, /changelog, /blog/launching-saas-pro-max;
  • the signup funnel $pageview /pricing → signup_started → identify + signup_completed → purchase with a value of 19/49/149 USD;
  • LCP, CLS and INP vitals for about one session in six;
  • two goals (signup_completed, purchase) and a saved "Signup funnel";
  • every generated day is rolled up before the seed returns.

A typical 30-day run: ~20 000 events, ~9 000 sessions, ~6 500 persons, ~700 identified, ~270 purchases. Re-running replaces the previous demo rows rather than stacking on top of them.


Load#

scripts/ingest-bench.ts posts events through the real endpoint and reports throughput and latency percentiles. Nothing is asserted — the number depends entirely on the machine and the database behind it.

Shell
pnpm --filter @saaspro/web exec tsx scripts/ingest-bench.ts \
  --url http://localhost:3100 --key spm_pub_prod_xxxxxxxxxxxxxxxxxxxxxx \
  --events 20000 --batch 100 --concurrency 8

Reference figure, measured against the pipeline in process (no HTTP) with Postgres 17 in Docker on an Apple laptop: 20 000 events in 47 s, ~424 events/s, with 100-event batches at p50 211 ms, p95 333 ms, p99 433 ms. The endpoint rate limits a key at 600 events per 10 s, so a full HTTP run needs a key created for a throwaway application; the script reports 429s separately rather than hiding them.


Environment#

Variable Meaning
SPM_IP_HASH_SECRET ≥ 32 characters. HMAC key for ip_hash. Rotating it makes previously stored hashes uncomparable, which is the point.
SPM_INGEST_MAX_BODY_BYTES Request body ceiling, default 262144. Over it: 413.
SPM_INGEST_MAX_BATCH Events per envelope, default and hard ceiling 100.
SPM_DATABASE_URL Where all of the above is stored.

Per-application settings that change analytics behaviour: timezone (which day a number belongs to), allowed_origins (which browsers may write), retention_days (how long rows survive).


Limits and known trade-offs#

  • Range visitor totals are daily uniques summed. Use activeUsers for true distinct users over a window.
  • A filtered read never uses the rollup, and covers at most 90 days. analytics_daily has a row per dimension value, not per combination, so a filter is a raw scan; clampFiltered shortens the window and reports it.
  • Merges re-point 90 days of history. Older events keep the retired person_id and will not appear on the merged dossier.
  • The rate limiter is per process. Several instances multiply the effective budget; it is a runaway-loop guard, not a quota.
  • Deduplication covers 48 hours. A replay older than that is stored again.
  • Backdated events beyond 35 days are stored but not rolled up by the incremental scan; force a recompute with an analytics.rollup payload naming the days.
  • Geo comes from edge headers only. Behind a proxy that does not set them, region and city stay null and country falls back to the browser's time zone; no lookup is ever performed over an IP. analytics.geo.backfill applies the same fallback once a day to stored events that have none and recomputes the days it touched.

Console#

Owner: W5. Source: apps/web/src/app/(console)/o/[org]/apps/[app]/{overview,analytics,events}/**, apps/web/src/components/analytics/**, apps/web/src/app/api/console/analytics/**, apps/web/src/styles/modules/analytics.css.

Every control writes a query parameter and nothing else, so a link is the state: the range, the comparison, the charted metric, the open breakdown tab, the feed cursor and the selected event all survive a copy and paste. The parsing rules live in one place, src/components/analytics/params.ts, and are unit tested.

Two things are printed on every page because both change the numbers and neither is visible otherwise: the environment the page is scoped to, and the application timezone whose midnight decides which day a number belongs to.

Shared chrome#

Component What it does
range-menu.tsx 24h / 7d / 30d / 90d / 12m and the calendar presets as links, a custom range as a plain GET form, the comparison (none, previous period, previous period on the same weekdays, same period last year) and, on the overview and the analytics reports, a "Group by" switch of the bucket sizes the range can take. Changing the range clears cursors and the open inspector.
kpi-row.tsx The six headline tiles. Hints are the definitions from the table above, not a paraphrase — tests/analytics-ui/copy.test.ts fails if the two drift apart. overview() reports deltas as percentages and <StatTile> takes a ratio, so deltaRatio() converts in exactly one place.
breakdown-panel.tsx A tabbed BarList panel, its "View all" full table, and the drill-down rules below.
filter-chips.tsx The active ?filter= values as removable chips under the range picker, plus the 90-day note when the range was clamped.
env-note.tsx Environment, range and timezone as one line.
install-empty-state.tsx Script-tag and npm tracking code built from this application's own public key prefix, host and INGEST_PATH. Shown only when the environment has never received an event — a quiet week stays a quiet week.
live-refresh.tsx A passive router.refresh() every 10 s while the tab is visible, paused while it is hidden, with a visible "Live · updated 3 s ago". The refresh does not extend the idle session, so a tab left on the live view still signs out after 30 minutes.
events-live.tsx The events feed's Live toggle: polls the events live topic every 5 s and adds new rows to the top, or behind an "N new events" button while the feed's head is scrolled away. Pauses while the tab is hidden and after 10 minutes without input ("Paused · Resume"), never extends the idle session, and is offered only on the newest page of a range that includes now.
funnel-editor.tsx The only stateful client component: it serialises a funnel to one JSON payload field, which the server re-validates in full.

Pages and their parameters#

Every page also accepts ?env= (passed to requireApp, sticky through the spm_env_<appId> cookie) and the range parameters ?range= (a preset or custom:YYYY-MM-DD..YYYY-MM-DD) or ?from=&to=, which the custom-range form submits and which win over a stale ?range=.

The overview and the analytics reports compare by default with the previous period; ?compare=weekday moves that window back by whole weeks, and ?compare=year draws the same calendar window one year earlier, with 29 February paired with 28 February. ?interval= overrides the bucket size of the traffic chart only when the range then yields 2 to 400 buckets, and is ignored otherwise; totals, breakdowns and goals keep the range's own buckets, so an hourly chart never turns daily unique visitors into a sum of hourly ones. "Why did this move?" explains whichever comparison the chart draws.

RoutePermissionParameters
/overviewapps.readenv, range/from/to, compare=weekday|year (0 turns it off), interval=hour|day|week|month, filter=<dimension>:<value> (repeatable)
/analyticsanalytics.readfilter=<dimension>:<value> (repeatable), compare=weekday|year (0 turns it off), interval=hour|day|week|month, metric=visitors|pageviews|sessions|events|conversions, pages=path|entry_path|exit_path, sources=source|medium|referrer_domain, campaigns=utm_source|utm_medium|utm_campaign|utm_term|utm_content, locations=country|region|city, devices=device|browser|os, and for the full-table mode dimension=<any rollup dimension>, q=<substring>, limit=1..500
/analytics/funnelsanalytics.readrange only
/analytics/funnels/newanalytics.write—
/analytics/funnels/[id]analytics.read (editor needs analytics.write)range only
/analytics/goalsanalytics.read (editor needs analytics.write)goal=<id> opens that goal in the form
/analytics/retentionanalytics.readcohort=day|week|month, event=<name>, advanced q definition
/analytics/liveanalytics.readenv only; the view refreshes itself
/eventsevents.readname=, search=, person=, session=, before=<cursor>, event=<id>; the Live toggle polls with the same filters

The overview is the one route that is not gated on a module — every application has one — but each of its tiles is: a tile appears only when its module is on and the member holds its read permission (errors.read, metrics.read, people.read, flags.read, revenue.read, destinations.read). Each module's numbers are read through that module's own query layer and each read is isolated, so one broken module degrades its own tile rather than blanking the application's front door.

/analytics picks its bucket from the range, through parseRange: hourly up to two days, daily up to 95 days, weekly up to 400 (which is what the 12-month view uses — 52 columns read the way a 90-day daily chart does, where twelve monthly ones hide every move inside a month), monthly beyond that.

Drill-down and filters#

Clicking a breakdown row re-scopes the page it came from: the link is the same page with ?filter=<dimension>:<value> added, so the KPI row, the chart, the goals panel and every other breakdown answer for that value too. Filters compose — a second click narrows further rather than replacing the first — and each one renders as a removable chip under the range picker.

?filter= is repeatable, one filter per parameter, because a page path, a campaign name and a city all contain the characters any in-parameter separator would have to reserve. A dimension never does, and the first colon ends it, so ?filter=path:/pricing?ref=a:b filters on /pricing?ref=a:b. A dimension may be filtered once; a repeat keeps the first value, which makes appending idempotent. Anything that is not a rollup dimension, and any empty value, is ignored rather than refused.

Setting or clearing a filter drops ?before= and ?event=: a cursor and an open inspector cannot survive a change of what the page is counting.

The event explorer stays reachable as a secondary action in the full breakdown table, for the two kinds of value the feed can express — an event name (/events?name=<value>) and a path (/events?search=<value>) — because "show me the raw events behind this row" is a different question from "scope the report to it". Range and environment travel to the explorer; nothing else does.

/overview honours the same ?filter= parameters, and its top-pages and top-sources lists link into /analytics with the value added.

Goal and funnel definitions#

src/components/analytics/store.ts holds the reads and writes for goals and funnels. It sits beside the components rather than in src/lib/analytics/ because that directory belongs to the ingest and query workstream; it creates no table and alters none.

Mutations go through POST /api/console/analytics?intent=… with the usual console chain (same origin → passkey session → membership and analytics.write → CSRF → zod → audit):

IntentFieldsAudit
goal-savename, kind, match, optional value, optional idgoal.saved
goal-deleteidgoal.deleted
funnel-savepayload (JSON: { id?, name, description?, windowHours, steps[] })funnel.saved
funnel-deleteidfunnel.deleted

Goals are unique per (app, kind, match) — two rows counting the same event would double every conversion — so a clash answers 409 naming the goal that already claims it. Both goal intents call resetGoalCache() so a goal added a second ago counts on the page the operator lands on.

Known approximations#

  • A filtered view covers at most 90 days. It is a raw scan, not a rollup read; the chips say so when the selected range was longer.
  • A filtered breakdown ignores a filter on its own dimension, so the panel the filter was set from stays navigable.
  • Per-goal conversions lag a goal edit by one rollup run for days that were already materialised, exactly as the total conversions do.

Property analysis and formulas#

Open Analytics → Explore. Add up to four series, choose an event (blank means all events), then choose event count, unique visitors, sum, average, minimum or maximum. Numeric metrics accept value or property.<name>. Add shared or per-series filters, an optional breakdown, and arithmetic such as B / A * 100. The chart selector changes the plotted series; exact bucket values remain available below it.

POST /api/v1/apps/{app}/analytics/query?env=production&range=7d&compare=1 accepts the same versioned definition and requires analytics.read with a read-capable Management API token. It does not save state. Example body:

JSON
{"version":1,"series":[{"key":"A","label":"Started","event":"signup_started","operation":"visitors","filters":[]},{"key":"B","label":"Completed","event":"signup_completed","operation":"visitors","filters":[]}],"formula":"B / A * 100","breakdown":"country","filters":[],"interval":"day"}
  • Fields: event, path, source, medium, utm_source, utm_medium, utm_campaign, country, device, browser, os, release, app_version, currency, or property.<name>. A property name is a literal top-level JSON key, not a nested path. Custom property filters/breakdowns require people.pii.read; the server enforces this before querying.
  • Filters: {field, op, value}; op is eq, neq, contains, exists, gt, gte, lt or lte. Five shared and five per-series filters maximum. All filters within a set are ANDed; contains is a literal case-insensitive substring. Missing values match neq and fail numeric comparisons.
  • Formulas: existing series letters A–D, decimal constants, parentheses, and + - * /; 200 characters, 100 tokens and 12 nesting levels maximum. No code or SQL execution. Null inputs, division by zero and non-finite results return null.
  • Totals are computed over the full effective window, independently of time buckets and the top-20 breakdown. Visitors are true distinct identities per bucket/window, namespaced by person/anonymous/distinct identity. A visitor can count in several breakdown values; summing rows is not a distinct visitor total.
  • Numeric operations ignore missing/nonnumeric values. Empty sums are zero; empty averages/minima/maxima are null. Event value is not verified provider revenue; filter one currency when measuring money. No implicit currency conversion occurs.
  • Queries are scoped to organization, application and environment, use retained raw events, exclude future timestamps and cover at most 90 days. Hourly queries cover at most 14 days. Daily/weekly buckets follow the application timezone; repeated DST hours remain distinct. The response includes actual dates and a capped flag. Comparisons use the equally long immediately preceding window.
  • Breakdowns return up to 20 values ranked by matching event count, plus groupCount; totals include every value. Each query has a 12-second statement budget and 16 MB work-memory setting within a read-only transaction. Suggestions use a separate 3-second query and return at most 100 names. Query settings do not alter pooled connection defaults.

Saved reports and dashboards#

After running an analysis in Explore, choose Save this analysis. Give it a name, select its dashboard metric and choose a trend chart, number or breakdown table. Analytics → Saved lists reports and dashboards for the current environment. Definitions and data stay behind existing application membership and analytics permissions; custom-property privacy checks also apply to history and dashboard panels.

Create a dashboard from up to six saved reports. Reorder panels, choose half/full widths and either follow the latest report definition or pin an existing revision. One date range and comparison control apply across the dashboard. Each report keeps its event/property filters. Reports run in batches of two; a query-budget or access refusal produces a panel explanation instead of invented data.

Edits require the current revision. A concurrent edit returns a conflict and requires a reload. Version history retains every revision; restoring a historical definition creates a new revision. Archive/recover is reversible. An archived report stops rendering in dashboards, including pinned panels. Duplicate a view to create an independent definition. Limits: 200 views per environment including archives, 1,000 revisions per view, 50 revisions shown in history. Database triggers reject revision updates/deletes; each write and its audit event commit atomically.

Copy team link shares an authenticated link; recipients retain their own permissions. Export definition downloads the JSON definition with its format version, source ID and source revision. Dashboard exports reference report IDs in the same application/environment. These are team-sharing features; anonymous public sharing remains the existing overview/funnel capability.

Management API endpoints (all accept ?env=):

MethodPath after /apps/{app}/analyticsBehavior
GET/views?archived=0Active reports/dashboards; archived=1 lists archived views
GET/views/{id}?revision=3Current or historical definition and visible history
POST/viewsCreate; or edit with id and expectedRevision
POST/views/{id}/archive{expectedRevision, archived}; true archives, false recovers
POST/views/{id}/restore{expectedRevision, restoreRevision}; restores the definition as a new revision

Reads require analytics.read; writes require analytics.write and a write-capable token. The request body schema is generated in OpenAPI. Console mutations additionally enforce same-origin requests, a passkey session, CSRF and server-resolved environment membership.

Event catalog and tracking plans#

Open Events → Event catalog to discover event names, document their meaning and assign an owner or team. Choose Define event to start with observed property types, or New tracking plan before any events arrive. Review inferred types and required fields before marking a plan active.

A plan belongs to one organization, application and environment. It records a description, owner, draft/active/archived status, and up to 50 top-level property expectations. Each property has a JSON type, a required-key rule, optional null allowance and description. any accepts every JSON value. Optional keys may be absent; null still must satisfy the type or null rule. Unexpected properties are allowed by default and can be flagged explicitly.

Tracking plans are diagnostic: they never reject, remove or rewrite ingested events. A quality check compares the newest 2,000 events for the exact name in the last 30 days with the displayed plan revision. The page shows matching events, missing required fields, incorrect types and unexpected keys. An event with multiple violations counts once in Need attention. Property values are never returned by catalog diagnostics. At most 200 observed key/type combinations are listed; compliance and required/type violation counts cover the entire bounded sample.

Discovery uses the newest 10,000 events in the last 30 days and lists up to 200 names by sampled frequency, alongside all saved plans. These are sample counts, not lifetime totals. Future events are excluded. Catalog queries have a five-second database timeout.

Plans retain immutable revision history. An update requires the expected revision and commits its plan, revision and audit event atomically. Open Version history to inspect an older definition and restore it as a new revision. Event names stay fixed; a renamed event gets its own plan. Each environment supports 500 plans and each plan 1,000 revisions. Archived plans retain their descriptions and history.

The catalog and diagnostics require events.read; editing requires analytics.write. The Analytics module must be enabled. Management API endpoints under /apps/{app}/analytics/catalog support list/create/update, with /{id} for a plan, diagnostics and its last 50 revisions. Use ?env=production to select the environment and ?revision=1 to inspect an older definition. SDK ingestion contracts are unchanged.

What the assistant can do here#

Pilot answers from this module's query layer, never from SQL of its own: the overview and its deltas, a timeseries for one metric, a breakdown by page, source, campaign, country or device, active users, retention, engagement, path exploration and the raw event feed, for whatever range you name in words. Asked from an analytics page it already knows which page and range you are looking at, so "why did this drop?" needs no preamble.

It can also save work. Creating a funnel or a goal, writing a chart note, archiving a saved view or changing replay settings are proposed as actions: the assistant shows what it is about to write and nothing happens until you approve it. Deleting a funnel, a goal or a recording is destructive, so it additionally asks for a typed reason and a fresh passkey. Every figure it states comes from a tool result and it names the tool inline, so any number in an answer can be checked against the page it came from.

A chart an answer drew from a timeseries, breakdown, overview or query result can be pinned to a dashboard from Pilot with Pin to dashboard: it is saved as a report and added as a panel, which needs analytics.write. A result the report builder cannot reproduce exactly is refused rather than approximated.