# Funnels & Cohorts

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

## Funnels

A funnel measures conversion across an ordered series of events.

### Defining a Funnel

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

### How the Query Runs

`funnelQueryService.computeFunnel()` issues a single BigQuery query that:

1. Selects every row matching any of the step event names within the lookback + date range.
2. For each visitor/user, sorts events chronologically.
3. Walks the steps in order, requiring each subsequent event to occur within `lookbackDays` of the prior one.
4. Aggregates by step into counts: `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.

### Visitor vs User Mode

This is where identity stitching matters.

- **Visitor mode** treats every browser session as independent. A user who visits on their phone, signs up, then converts on their laptop counts as two separate funnels (one drops out at step 1; the other appears to "start at step 2" — which actually disqualifies them since they didn't do step 1).
- **User mode** LEFT JOINs `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:

```sql
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)
```

### Funnel Output Shape

```json
{
  "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.

## Cohorts

A cohort measures retention — what percentage of users from a starting cohort return on day N, week N, or month N.

### Defining a Cohort

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

### Output Shape

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.

### How It's Computed

`cohortService.computeCohort()` runs one BQ query that:

1. Selects everyone who fired `cohortEvent` in the time range, grouping by their cohort period.
2. Selects everyone who fired `returnEvent` in any period.
3. JOINs the two on `(visitor_id | user_id)` and counts unique IDs per (cohort period, return period).
4. Divides by cohort size to produce the percentage.

User mode applies the same `identity_map` LEFT JOIN as funnels.

## Segments

A **Segment** is a saved filter you can attach to funnels or cohorts to scope the analysis. Examples:

- "Users from the `enterprise` UTM campaign"
- "Mobile users"
- "Users from Germany who arrived via Twitter"

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.

## When to Use Which

- **Funnels**: linear conversion analyses ("how many of the people who saw pricing actually paid?").
- **Cohorts**: retention/loyalty analyses ("of people who signed up in May, what % are still active in August?").
- **Both**: an end-to-end onboarding analysis often uses a funnel for the first session and a cohort for week-over-week return rates afterward.

## Performance Notes

- The events table is partitioned by `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.
- BigQuery is queried directly here (not the Mongo rollups) because rollups are pre-aggregated, and funnels/cohorts need per-event ordering. Mongo serves the realtime + overview dashboards instead.
- The default `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 stitching adds one JOIN per query. The `identity_map` table is clustered on `(tenant_id, site_id, visitor_id)`, so the JOIN is also clustering-pruned and stays cheap.

## What These Analyses Don't Do (Yet)

- **Path / Sankey diagrams**: not in MVP. Planned for the Explore page.
- **Predictive churn**: not built; if there's appetite, the same Mongo rollups + a small Gemini classifier could ship.
- **Real-time funnels**: funnels currently query BQ and so reflect the last successful insert. A real-time variant would have to query the Mongo capped buffer with a much smaller event population.
