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:

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_mediumutm_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

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=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:

  • BrowserContent-Type: text/plain with the key inside the envelope, for every request including sendBeacon, so no CORS preflight ever happens.
  • ServerContent-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 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

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

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 /pricingsignup_startedidentify + signup_completedpurchase 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.

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 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, 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 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:

{"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=):

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.