Events & Analytics
Device and web funnel events, the one outbox-to-ClickHouse pipeline, the Sessions tab, and the funnel webhooks.
Two producers, one pipeline. A native funnel is presented on a device that
reports what happened; a web funnel runs on the server, which records what
it did. Both end up as rows in the same ClickHouse table, split by the
target dimension — and only some of them ever reach a webhook.
Device events
The device reports four wire names over POST /v1/events, each mapped to
the funnel vocabulary in exactly one place
(apps/api/src/lib/funnel-event-shape.ts:86-91):
| Device wire name | Becomes |
|---|---|
funnel_view | funnel.session.started |
funnel_screen_view | funnel.session.advanced |
funnel_complete | funnel.session.completed |
funnel_dismiss | funnel.session.dismissed |
The list is closed: any other funnel_<x> never enters the session
vocabulary and is kept under funnel.device.<x> instead
(apps/api/src/lib/funnel-event-shape.ts:71,
apps/api/src/lib/funnel-event-shape.ts:86-91). A screen view is an
arrival — there is no from, because the device names only the screen it
landed on (apps/api/src/lib/funnel-event-shape.ts:336-340).
Every device event carries funnelContext, and funnelContext.sessionId
is required and non-empty
(apps/api/src/routes/v1/events.ts:99-113): one presentation mints one
session id, and every event from opening to close or finish carries it. A
funnel_* body without its context is refused at the boundary, never
stored (apps/api/src/routes/v1/events.ts:126-137). The route files the
row under its own aggregate, FUNNEL_DEVICE_EVENT
(apps/api/src/routes/v1/events.ts:144-152) — never folded into
the retired paywall aggregate, so onboarding screens cannot inflate
a paywall conversion rate.
{
"eventType": "funnel_view",
"eventId": "<client-generated idempotency key>",
"subscriberId": "<the device's Rovenue id>",
"occurredAt": "2026-09-24T12:00:00.000Z",
"funnelContext": {
"funnelId": "<the funnels row id from the placement envelope>",
"sessionId": "<one id per presentation>",
"placementId": "<the placement identifier>",
"placementRevision": 7,
"revision": 3,
"screenId": "welcome",
"variantId": "<variant id when drawn from an experiment>",
"experimentKey": "onboarding-copy-test"
}
}funnelId is the served funnel's row id — the placement envelope carries
it because a native funnel has no slug
(apps/api/src/lib/placement-resolution.ts:139-143). Restoring a session
from state_json emits nothing and keeps the same sessionId; dismiss()
emits the one terminal event the device owns, funnel_dismiss.
Web session events
The web runner emits the same vocabulary minus the one event it cannot
produce: everything in FUNNEL_EVENT_TYPE except DISMISSED
(apps/api/src/lib/funnel-event-shape.ts:31-52,
apps/api/src/lib/funnel-event-shape.ts:56-57) — started, advanced,
moved_back, paid, completed, abandoned, and custom_action, plus
the claim_token mechanics. abandoned is a server-side inactivity
timeout written by the funnel-abandoner worker
(apps/api/src/workers/funnel-abandoner.ts:40-46), never a device close;
dismissed is the reverse — a device close, never a timeout
(apps/api/src/services/funnel/analytics.ts:44-53). A backward move is
moved_back, not advanced, so going back to an already-reached screen
never counts as a second arrival
(apps/api/src/services/funnel/analytics.ts:131-156).
Every web payload is stamped target: "web" at emission, unconditionally
(apps/api/src/services/funnel/outbox.ts:33-56) — never read off the
funnel's current, mutable target, so converting a funnel cannot rewrite
which side its old sessions belong to
(apps/api/src/services/funnel/analytics.ts:60-86).
The one pipeline
Both aggregates share the rovenue.funnel topic: FUNNEL and
FUNNEL_DEVICE_EVENT map to it in the same table
(apps/api/src/lib/outbox-topics.ts:25-33), and the dispatcher picks the
shaper by aggregate, never by topic
(apps/api/src/workers/outbox-dispatcher.ts:142-160). A web row goes
verbatim with eventId equal to its outbox id; a device row is reshaped
from its raw client body into the same wire, with a content-hash id so a
retried POST dedups
(apps/api/src/workers/outbox-dispatcher.ts:142-160,
apps/api/src/lib/funnel-event-shape.ts:269-284).
From there the path is fixed: outbox → Redpanda → the Kafka Engine table
(packages/db/clickhouse/migrations/0026_funnel_events_kafka_engine.sql:40-46)
→ the materialized view into raw_funnel_events
(packages/db/clickhouse/migrations/0029_funnel_events_target.sql:63-107).
A message that predates target falls back to 'web' in the view, not by
column default
(packages/db/clickhouse/migrations/0029_funnel_events_target.sql:86-95).
Reads go through FINAL over the deduped raw table, so an at-least-once
redelivery cannot inflate a count
(apps/api/src/services/funnel/analytics.ts:14-21). One event aggregate
with a target dimension, one raw table, one MV — that unification is
spec §5
(docs/superpowers/specs/2026-09-21-funnels-flows-unification-design.md:222-228).
The Sessions tab
The builder's Sessions tab reads this pipeline (shipped in wave 7, Task 8 —
docs/superpowers/plans/2026-09-24-funnels-unification-wave-7-rename.md:477,
docs/superpowers/plans/2026-09-24-funnels-unification-wave-7-rename.md:484).
Each screen row shows two numbers: sessions next to arrivals
(apps/dashboard/src/components/funnel-builder/sessions-tab.tsx:337).
They differ on purpose. An arrival is a row: started names the screen a
session opened on, advanced names the one a forward move landed on
(apps/api/src/services/funnel/analytics.ts:131-156). sessions is a uniq
over session ids over the whole window, read off the raw table rather than
summed from the daily view — adding per-day uniqs would count a session
that spanned midnight twice
(apps/api/src/services/funnel/analytics.ts:181-194).
Two details follow from that. The entry is the document's first page — the
screen the resolver starts every session at — not screens[0], which is
the busiest screen, since the API orders busiest-first
(apps/dashboard/src/components/funnel-builder/sessions-tab.tsx:253-266).
And dismissed appears only when non-zero, so a web-only funnel's history
never grows a permanent "0 dismissed"
(apps/dashboard/src/components/funnel-builder/sessions-tab.tsx:267-271).
With no analytics store configured there is no history at all — the live
numbers above it come from Postgres, which knows where sessions are, not
where they went
(apps/dashboard/src/components/funnel-builder/sessions-tab.tsx:240-250,
apps/api/src/services/funnel/analytics.ts:6-21).
Webhooks
Seven funnel keys reach webhooks, and only the catch-all providers offer
them — Custom Webhook, Slack, Discord
(packages/shared/src/integrations.ts:20-28,
apps/docs/content/docs/integrations/outbound-webhooks.mdx:154):
started, advanced, paid, completed, abandoned, dismissed, and
custom_action. Deliberately absent: moved_back (analytics hygiene, not
a business moment), the claim_token mechanics, and any funnel.device.*
name (packages/shared/src/integrations.ts:7-14,
apps/api/src/services/integrations-fanout/consumer.ts:321-326). paid
carries no amount — money stays on revenue.*
(packages/shared/src/integrations.ts:16-18); abandoned is web-only and
dismissed native-only, the same split as above. The full per-key table
lives on Outbound Webhooks.
data is a curated camelCase object with sessionId taken from the wire's
aggregate id and omitted when empty
(apps/api/src/services/integrations-fanout/consumer.ts:286-296,
apps/docs/content/docs/integrations/outbound-webhooks.mdx:69):
{
"id": "<the wire eventId — the outbox id for web, the content hash for native>",
"type": "funnel.session.started",
"created": "2026-09-24T12:00:00.000Z",
"apiVersion": "2026-08-24",
"projectId": "<the project id>",
"data": {
"sessionId": "<the presentation id>",
"funnelId": "<the funnels row id>",
"target": "web",
"pageId": "welcome"
}
}Native funnel webhooks are client-asserted. A device event is submitted
with the project's public SDK key — the same key that ships inside the
app binary — so anyone holding it can submit an event that never happened
on a real device
(apps/docs/content/docs/integrations/outbound-webhooks.mdx:113). Web
funnel events and every revenue.* event are produced server-side and
carry no such exposure. Never treat a native funnel webhook as proof of
anything a client could fake; for a trustworthy signal use revenue.* or
a web funnel event instead.