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 withCREATE 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 assessionswherever 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:
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 }):
- A known distinct id wins, always.
- Otherwise a known anonymous id wins. If the event also carries an unclaimed
distinct id, that person adopts it — the same outcome an
identifywould produce, so the pipeline does not depend on anidentifyever arriving. - 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,eventsandpageviewscounters are summed;- the earliest
first_seen_atsurvives, 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_idandanalytics_sessions.person_idare 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=cancelledrequiresanalytics.read.GET /api/v1/apps/{app}/funnels/{id}/participants?range=7d&step=1&outcome=dropped&page=1also requirespeople.readand the people module. Outcomes areconverted,waiting, anddropped; 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_dailyand the partial leading and trailing windows — most importantly today — from rawevents.
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
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) // { id?, name?, personId?, sessionId?, search?, before?, 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?)
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.
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:
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:
| Dimension | On the event | On the session |
|---|---|---|
path, event |
events.path, events.name |
the sessions those events belong to |
entry_path, exit_path |
the sessions the events belong to | analytics_sessions.entry_path / exit_path |
| everything else | its own events column |
its own analytics_sessions column |
Two consequences worth knowing:
- A breakdown ignores a filter on its own dimension. Filtering by
source=searchand 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/plainwith the key inside the envelope, for every request includingsendBeacon, so no CORS preflight ever happens. - Server —
Content-Type: application/jsonwithAuthorization: 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 non-ingest key | 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
- Envelope is validated as a whole; a malformed envelope is a 400.
- Each event is validated separately against
ingestEventSchema, so one bad event costs only itself — it comes back inrejectedwith a reason. - Public keys have
context.ipandcontext.userAgentstripped 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. - Bots are dropped for public keys (
isBotUserAgentfrom@saaspro/shared); the events come back rejected rather than silently vanishing. Secret keys keep them — a server may legitimately describe a bot's request. - Enrichment:
received_at;ip_hash= HMAC-SHA256 of the request IP (first hop ofX-Forwarded-For, elseX-Real-IP) withSPM_IP_HASH_SECRET— the raw address is never stored; geo fromx-vercel-ip-country,x-vercel-ip-country-region,x-vercel-ip-city,cf-ipcountry,cf-region,cf-ipcity; user-agent parsing; referrer domain and source classification. - Deduplication: an
events.idalready stored for that application and environment within the last 48 hours counts asacceptedand is not written again. - 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 anidentifyand thetrackafter it in the order the SDK sent them. - After the commit, if the application has an enabled destination,
destinations.dispatchis 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
| Kind | Interval | What it does |
|---|---|---|
analytics.rollup |
15 min | Recomputes 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.close |
1 min | Closes sessions idle for more than 30 minutes: ended_at, duration_ms, bounced. |
analytics.partitions |
daily | Keeps events partitioned from last month through two months ahead. |
analytics.retention.purge |
daily | Deletes 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
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→purchasewith a value of 19/49/149 USD; LCP,CLSandINPvitals 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.
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 minted 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
activeUsersfor true distinct users over a window. - A filtered read never uses the rollup, and covers at most 90 days.
analytics_dailyhas a row per dimension value, not per combination, so a filter is a raw scan;clampFilteredshortens the window and reports it. - Merges re-point 90 days of history. Older events keep the retired
person_idand 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.rolluppayload naming the days. - Geo comes from edge headers only. Behind a proxy that does not set them, country/region/city stay null; no lookup is performed over a stored IP.
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-picker.tsx |
24h / 7d / 30d / 90d / 12m as links, a custom range as a plain GET form, and a comparison toggle. 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 snippets 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 |
router.refresh() every 10 s while the tab is visible, paused while it is hidden, with a visible "Live · updated 3 s ago". |
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=.
| Route | Permission | Parameters |
|---|---|---|
/overview |
apps.read |
env, range/from/to, compare=1, filter=<dimension>:<value> (repeatable) |
/analytics |
analytics.read |
filter=<dimension>:<value> (repeatable), compare=1, 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/funnels |
analytics.read |
range only |
/analytics/funnels/new |
analytics.write |
— |
/analytics/funnels/[id] |
analytics.read (editor needs analytics.write) |
range only |
/analytics/goals |
analytics.read (editor needs analytics.write) |
goal=<id> opens that goal in the form |
/analytics/retention |
analytics.read |
cohort=day|week|month, event=<name>, advanced q definition |
/analytics/live |
analytics.read |
env only; the view refreshes itself |
/events |
events.read |
name=, search=, person=, session=, before=<cursor>, event=<id> |
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):
| Intent | Fields | Audit |
|---|---|---|
goal-save |
name, kind, match, optional value, optional id |
goal.saved |
goal-delete |
id |
goal.deleted |
funnel-save |
payload (JSON: { id?, name, description?, windowHours, steps[] }) |
funnel.saved |
funnel-delete |
id |
funnel.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
totalconversions 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:
{"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, orproperty.<name>. A property name is a literal top-level JSON key, not a nested path. Custom property filters/breakdowns requirepeople.pii.read; the server enforces this before querying. - Filters:
{field, op, value};opiseq,neq,contains,exists,gt,gte,ltorlte. Five shared and five per-series filters maximum. All filters within a set are ANDed;containsis a literal case-insensitive substring. Missing values matchneqand 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 returnnull. - 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
valueis 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
cappedflag. 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=):
| Method | Path after /apps/{app}/analytics |
Behavior |
|---|---|---|
| GET | /views?archived=0 |
Active reports/dashboards; archived=1 lists archived views |
| GET | /views/{id}?revision=3 |
Current or historical definition and visible history |
| POST | /views |
Create; 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
Ask 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.