Funnels and cohorts are the two "second-order" analyses that turn the raw event stream into business-readable numbers. Both run against BigQuery directly (no rollup tables — the queries are fast on the partitioned + clustered events table). Both share the same identity-stitching layer so they can be run in either visitor mode (one row per anonymous browser) or user mode (one row per signed-in person across devices).
A funnel measures conversion across an ordered series of events.
Open Web Analytics > Funnels > + New. The builder collects:
| Field | Notes |
|---|---|
name |
Display name. |
steps |
Ordered list of event names. Use Event Definition names for stable references; raw auto-events are fine too. |
stepFilters |
Optional per-step filter — same field/op/value shape as Event Definitions. |
lookbackDays |
Max time between step N and step N+1 for the visitor to be counted as having progressed. Default 30. |
groupBy |
'visitor' (default) or 'user'. See Visitor vs User Mode below. |
segmentId |
Optional pre-filter by Segment. |
funnelQueryService.computeFunnel() issues a single BigQuery query that:
lookbackDays of the prior one.step_1_visitors, step_2_visitors, ..., and per-step time-to-progress percentiles (p50, p90).The result is what you see in the funnel chart — absolute count per step, plus the implicit drop-off between each.
This is where identity stitching matters.
identity_map to resolve visitor_id → user_id for both anonymous visits and signed-in ones. The same person's cross-device activity is unified, so the example above counts as one funnel that completed.User mode requires the visitor to have called karma('identify', ...) at some point in their history. If they never did, user mode falls back to visitor_id for them — so user mode is a superset of visitor mode, not a different population.
User mode SQL adds a join roughly like:
LEFT JOIN identity_map idm
ON events.tenant_id = idm.tenant_id
AND events.site_id = idm.site_id
AND events.visitor_id = idm.visitor_id
GROUP BY COALESCE(idm.user_id, events.visitor_id)
{
"funnelId": "...",
"groupBy": "user",
"computedAt": "2026-05-22T...",
"steps": [
{ "name": "page_view", "count": 12480, "filter": null },
{ "name": "add_to_cart", "count": 1872, "filter": null,
"timeToHere": { "p50_sec": 12, "p90_sec": 38 } },
{ "name": "checkout_start", "count": 942, "filter": null,
"timeToHere": { "p50_sec": 9, "p90_sec": 22 } },
{ "name": "purchase", "count": 311, "filter": null,
"timeToHere": { "p50_sec": 14, "p90_sec": 47 } }
],
"totalConverted": 311,
"topConversionRate": 0.0249
}
The UI renders this as the familiar shrinking-bar funnel with drop-off percentages between each step.
A cohort measures retention — what percentage of users from a starting cohort return on day N, week N, or month N.
Web Analytics > Cohorts > + New collects:
| Field | Notes |
|---|---|
name |
Display name. |
cohortEvent |
The event that defines membership (e.g. page_view for "anyone who visited", signup for "new users"). |
returnEvent |
The event that counts as "returning" (e.g. page_view, or a more specific conversion). |
period |
'day' | 'week' | 'month'. |
numPeriods |
How many periods of retention to compute (default 12). |
groupBy |
'visitor' or 'user', same semantics as funnels. |
A cohort table — rows are cohorts (e.g. "users who signed up week of 2026-05-01"), columns are periods (week 0, week 1, week 2, ...). Each cell is the percentage of cohort members who fired returnEvent at least once in that period.
Cohort week | Size | W0 | W1 | W2 | W3 | ...
2026-05-01 | 1247 | 100% | 42% | 31% | 27% | ...
2026-05-08 | 1418 | 100% | 45% | 33% | — | ...
Period 0 is always 100% by definition (everyone in the cohort fired the cohort event in period 0). Period N% is the retention number.
cohortService.computeCohort() runs one BQ query that:
cohortEvent in the time range, grouping by their cohort period.returnEvent in any period.(visitor_id | user_id) and counts unique IDs per (cohort period, return period).User mode applies the same identity_map LEFT JOIN as funnels.
A Segment is a saved filter you can attach to funnels or cohorts to scope the analysis. Examples:
enterprise UTM campaign"Segments are stored in segmentModel per tenant and reference one or more conditions on the standard event fields + UTM + device + geo. They compose with the funnel/cohort groupBy — a user-mode cohort of mobile users counts unique signed-in mobile humans, not unique signed-in browsers that happened to be mobile.
event_ts (day) and clustered by (tenant_id, site_id, event_name). Funnel and cohort queries are written so the partition pruning + clustering pruning kicks in — typical query is sub-second on small sites, low-single-digit seconds on big ones.lookbackDays = 30 on funnels keeps scans manageable. Increasing to 90 days roughly triples bytes scanned. The per-tenant rate-limit in queryService caps how often these queries can be re-run.identity_map table is clustered on (tenant_id, site_id, visitor_id), so the JOIN is also clustering-pruned and stays cheap.