ReviewOS

stacks/analyticshq

[feature] Funnels (aggregate, privacy-safe)

#21
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Gap

No funnels. Plausible and Umami offer aggregate funnels; done at the aggregate level they stay privacy-safe (no per-person timelines).

Proposed

Multi-step funnel definition over pageview/event goals with aggregate step conversion/drop-off. Keep it strictly aggregate — no individual paths.

Priority: P2.


From the 2026-07-20 privacy-first competitive audit (vs. Plausible / Fathom / Simple Analytics / Umami).

9 days ago

Shipped in 34ce7f0.

What landed

Multi-step funnels over goals (2–8 steps), with per-step count, conversion from start, conversion from the previous step, and drop-off. CRUD at /api/sites/{siteId}/funnels, results at /api/sites/{siteId}/funnels/{funnelId}/results?startDate&endDate.

"Privacy-safe" turned out to be structural here, not a policy

A funnel needs an identity that survives between steps. This product has two, and both are deliberately short-lived: session_id lasts one visit, and visitor_id is sha256(ip | ua | site | secret salt) where the salt is random, per site, per UTC day, and deleted at retention.

So a funnel spanning more than a day is not something we decline to compute — it is something the data cannot express, for us as much as for anyone. Once a day's salt is deleted, its hashes are unlinkable to any input permanently. A visitor who lands at 23:50 and converts at 00:10 is two people, and a 30-day day-scoped funnel is the sum of 30 one-day funnels.

The response reports that (spansMultipleDays) rather than letting the reader assume a continuous journey. A number that quietly means something narrower than it appears is worse than no number.

scope chooses the identity — session (strict, right for checkout) or day. Verified they genuinely differ: the same visitor returning in a second session advances the day-scoped funnel and not the session-scoped one.

"Strictly aggregate, no individual paths" is enforced by the SQL

The identity is needed inside the CTEs to join steps together and must not reach the result, so the outer projection is count subqueries and nothing else. That is asserted against the generated SQL with an anchored pattern, for 2, 3 and 8 steps in both scopes — so adding a debugging column fails the suite rather than quietly shipping a per-person export. There is no code path that carries an identity out.

Order is the whole point

Without it a funnel is three goal counts in a row, and reports checkouts completed by people who hit the thank-you page first. Each step joins the previous and requires a timestamp at or after it. Proven against real Postgres with a session that fires step 2 before step 1: it counts at step 1 and is correctly excluded from step 2 — [5,3,1] across six seeded sessions.

>= rather than >, deliberately: one beacon can satisfy two goals (a pageview matching both /pricing and "any page"), writing rows with identical timestamps, and > would report a 100% drop at step 2 for a funnel both of whose steps were genuinely reached.

Steps are goals, not raw paths. /collect already matches goals on the hot path and writes a conversion row, so a funnel and the goal report can never disagree about whether something happened — a parallel matching engine would be a second opinion, and second opinions drift. That model also settles the ordering semantics: conversions carry a deterministic id (sha256(session|goal) + ON CONFLICT DO NOTHING), so there is exactly one row per session per goal holding the first occurrence.

Reading is a viewer right, writing is admin — matching goals rather than alerts, since a funnel definition holds no delivery credential. Every step must be a goal on this site, checked on create and update.

Verification. 16 checks against real Postgres; six sabotages fail the suite by name (dropping the ordering constraint, > for >=, leaking an identity into the projection, dropping the day from the day-scope identity, downgrading create to viewer, and removing the cross-site goal check from the update path alone). 219 tests pass.

Deliberately not included

  • No UI — API-only, like alerts and digests.
  • No small-count floor. A step reading "1" names a single person's sequence. Harmless on its own, but it becomes identifying once saved segments (#23) can narrow the population first. That guard belongs with segments, and I have noted it there rather than half-building it here.

Sign in to comment on this issue.