feat: product events + funnels (web_events → product_events, /v1/events, identity stitching) - #510
feat: product events + funnels (web_events → product_events, /v1/events, identity stitching)#510Makisuo wants to merge 13 commits into
Conversation
…_events; identity_links Migration 0016 + Tinybird datasources/MVs. product_events is dual-fed (session_events MV for browser rows, direct ingest for server/mobile) and carries Source + VisitorId/UserId/GroupId; 365-day TTL. identity_links stitches visitor→user pairs out of session_replays.
NDJSON, ingest-key auth, org from the key. Rows are rebuilt from a sanitized allowlist (name/timestamp/source/identity/url/attributes); malformed rows are dropped individually. New TelemetrySignal::ProductEvents rides the existing native-rows pipeline, so BYO-ClickHouse export works via the generated product_events mapping.
…b_events v5→v6 store migration mirrors CH migration 0016: adds the session_events identity columns, creates product_events/identity_links (+MVs), backfills browser rows and identity pairs, drops web_events.
…track() Browser/effect-sdk sinks emit visitor_id/user_id/group_id on every session event so product_events carries the person key without a join. effect-sdk server entry gains MapleEvents (batched NDJSON POST /v1/events, never throws to the caller).
…lan events signup_completed from Clerk user.created; plan_started/changed/cancelled from Autumn billing.updated (Svix-verified, WebCrypto, no new dep) plus the inline attach path. Web fires plan_checkout_started before the redirect.
…_events windowFunnel/sequenceMatch in clickhouse-builder; productEventsFunnelQuery, productEventsFunnelBreakdownQuery, productEventNamesQuery with a person key stitched through identity_links. Page-view SQL byte-identical apart from the table. Catalog baseline regenerated; parity e2e extended with funnel cases.
…get block, query_funnel MCP tool Internal query-engine endpoints productEventsFunnel/-Breakdown/productEventNames; shared FunnelStep schema in @maple/query-model; additive display.funnel block on the existing funnel widget (mobile wire unchanged); query_funnel + list_product_events MCP tools.
…fect code in MapleEvents
| .handle("productEventsFunnelBreakdown", ({ payload }) => | ||
| Effect.gen(function* () { | ||
| const tenant = yield* CurrentTenant.Context | ||
| yield* validateFunnelDefinition(productEventsFunnelOpts(payload)) | ||
| const rows = yield* runQuery(Queries.productEventsFunnelBreakdown, tenant, payload) |
There was a problem hiding this comment.
🟡 Asking for too many funnel breakdown groups returns a server error instead of a validation message
The number of breakdown groups requested is passed straight to the query builder (runQuery(Queries.productEventsFunnelBreakdown, …) at apps/api/src/routes/internal/query-engine.http.ts:1761) without being checked first, so a request asking for more than 20 groups fails as an internal server error rather than a clear rejection.
Impact: A caller that requests more than 20 breakdown groups (or a fractional number) gets an opaque 500 with no explanation instead of a 400 telling them the allowed range.
How the unchecked limit becomes a defect instead of a 400
ProductEventsFunnelBreakdownRequest declares limit: Schema.optional(Schema.Number) with no bounds (packages/domain/src/http/query-engine.ts:1325). The handler validates only the base funnel definition via validateFunnelDefinition(productEventsFunnelOpts(payload)), which calls CH.productEventsFunnelQuery — the non-breakdown builder — so the limit is never seen.
CH.productEventsFunnelBreakdownQuery throws ProductEventsFunnelError({ reason: "InvalidLimit" }) for a limit outside 1..20 or a non-integer (packages/query-engine/src/ch/queries/product-events.ts:587-592). That builder runs inside definition.compile(...), which runQueryDefinition invokes eagerly and synchronously (packages/query-engine/src/runtime/query-definition-runner.ts:62) while building the effect inside the handler's Effect.gen body. A synchronous throw there is a defect, not a typed failure, so it bypasses the validatedQueryEndpointErrors envelope and surfaces as a 500.
The MCP tool clamps the limit (clampLimit in apps/api/src/mcp/tools/query-funnel.ts:229-232) and the browser view hardcodes 10, so only direct API callers hit this — but the endpoint contract advertises a 400 for bad definitions.
Prompt for agents
The productEventsFunnelBreakdown handler in apps/api/src/routes/internal/query-engine.http.ts validates only the base funnel definition (validateFunnelDefinition with productEventsFunnelOpts), which compiles CH.productEventsFunnelQuery and therefore never sees the breakdown `limit`. CH.productEventsFunnelBreakdownQuery throws ProductEventsFunnelError with reason "InvalidLimit" when the limit is not an integer in 1..20, and because definition.compile runs synchronously while the effect is being constructed (packages/query-engine/src/runtime/query-definition-runner.ts), that throw is a defect and produces a 500 rather than the QueryEngineValidationError 400 the endpoint declares. Either extend the validation helper in apps/api/src/routes/query-helpers.ts with a breakdown variant that compiles CH.productEventsFunnelBreakdownQuery (so InvalidLimit lands in the 400 envelope), or bound/clamp `limit` in the request schema (packages/domain/src/http/query-engine.ts, ProductEventsFunnelBreakdownRequest) against FUNNEL_BREAKDOWN_MAX_GROUPS.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const attributes = sanitizeAttributes(event.attributes) | ||
| return { | ||
| name: event.name.slice(0, MAX_NAME_LENGTH), | ||
| timestamp: new Date(event.timestamp ?? nowMs).toISOString(), |
There was a problem hiding this comment.
🟡 A webhook carrying an out-of-range event time crashes the request instead of being recorded or ignored
An event time supplied by an external provider is converted to text without any range check (new Date(...).toISOString() at apps/api/src/services/product-events/ProductEventsService.ts:100), so an implausible value makes the whole webhook request fail even though recording the event is documented as never able to fail the caller.
Impact: One bad delivery from Clerk or Autumn returns a server error, the provider never gets an acknowledgement, and it keeps retrying the same failing payload.
Where the unguarded conversion runs and why the surrounding safety net misses it
ClerkUserCreatedData.created_at and Autumn's started_at / occurred_at are plain Schema.Number (apps/api/src/services/product-events/clerk-events.ts:29, apps/api/src/services/product-events/autumn-events.ts:25,52), so any finite number decodes. signupCompletedEvent / planEventsFromBillingUpdated forward it as ProductEventInput.timestamp.
In track, toProductEventLine(event, now) is called at apps/api/src/services/product-events/ProductEventsService.ts:165 — before the post(...).pipe(Effect.retry, Effect.catchCause(...)) chain that provides the "never fails" guarantee. new Date(v) with |v| > 8.64e15 yields an Invalid Date and .toISOString() throws RangeError; inside an Effect.fn generator body that becomes a defect that propagates out of productEvents.track(...) in the webhook handler (apps/api/src/routes/webhooks/clerk.http.ts:57), turning an otherwise-200 delivery into a 500.
The same unguarded conversion exists in the server SDK (packages/effect-sdk/src/server/events.ts:129-130), where the value comes from the host application.
| timestamp: new Date(event.timestamp ?? nowMs).toISOString(), | |
| timestamp: new Date( | |
| event.timestamp !== undefined && | |
| Number.isFinite(event.timestamp) && | |
| Math.abs(event.timestamp) <= 8.64e15 | |
| ? event.timestamp | |
| : nowMs, | |
| ).toISOString(), |
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (Result.isFailure(verified)) { | ||
| return yield* reject(401, verified.failure.reason, "Invalid signature") | ||
| } |
There was a problem hiding this comment.
🟨 Misconfigured webhook secret is rejected as an invalid signature (401) rather than as a configuration problem
receiveSvixWebhook maps every verification failure, including bad_secret, to a 401 Invalid signature (apps/api/src/routes/webhooks/svix-receiver.ts:55-57). bad_secret is raised by signSvix when the configured CLERK_WEBHOOK_SECRET / AUTUMN_WEBHOOK_SECRET is not valid base64 (apps/api/src/services/product-events/svix.ts:85-91) — i.e. an operator misconfiguration, not an untrusted-caller problem. Providers treat a 401 as a permanent rejection and eventually disable the endpoint, so every signup/plan event is silently and permanently dropped, whereas the unset-secret path deliberately answers 503 so the provider retries once configured.
Was this helpful? React with 👍 or 👎 to provide feedback.
Autumn feature product_events (Startup: 1M included, $0.05/1,000 — placeholder price). Ingest gates /v1/events on it and meters direct rows plus browser track() rows; session transcripts stay gated on browser_sessions. Web/landing billing UI, spend model (rate normalised per billingUnits), daily spend series, and pricing page carry the new feature. Retention already 365d.
Autumn item is unlimited with no price; usage is still metered/tracked. Landing pricing renders 'Unlimited · free during beta' (en/ja/ko, HTML + /pricing.md); web billing already handles unlimited/no-rate items.
- ingest: `metered_enqueue` takes an `OnDenied` policy. `/v1/sessionEvents` meters `product_events` fail-open, so a denied reservation (a customer with no balance for the feature yet, or a future hard cap) no longer 402s a whole session batch and takes the clicks and navigations beside the `track()` rows down with it. Session starts and `/v1/events` keep rejecting: there the metered feature IS the payload. - api: validate a funnel breakdown through the BREAKDOWN builder, so an out-of-range `limit` is a 400 instead of a defect thrown inside `compile`. - query-engine: a funnel whose only step is a `session` step is answered by the session-entry branch alone. The events branch had no predicate to filter on and read every `product_events` row in range to project zeros. - web: debounce the funnel definition before it reaches the atoms (every keystroke was its own `windowFunnel`), and only emit `attribute:<key>` once the key is non-empty instead of breaking down by `Attributes['']`.
Why
We couldn't answer "referral → signed up → started a plan".
web_eventswas built as the funnel substrate but nothing queried it as one, it had no person key, and plan-start happens on a webhook path the browser never sees. This makes funnels a real product feature — for browser, backend and mobile events — and renames the table because it's no longer web-only.Full design + rollout checklist:
docs/product-events-funnels.md.What
Schema (
0016_product_events, Tinybird datasources/MVs, local CLI v6)product_eventsreplacesweb_events: same time-first fact table plusSource(browser|server|mobile),VisitorId/UserId/GroupId,ServiceName; 365-day TTL; dual-fed (MV fromsession_events+ direct ingest). Backfill deletesWHERE Source='browser'instead of truncating so directly ingested rows survive a re-run.identity_links(VisitorId, UserId) MV oversession_replays— stitches a person's anonymous marketing visit to their identified/server-side events.session_eventsgainsVisitorId/UserId/GroupId DEFAULT '', stamped per event by the SDKs.Ingest —
POST /v1/events(NDJSON, ingest-key auth, org from key, per-row sanitising,$screen→Kind='screen'), newTelemetrySignal::ProductEventson the existing native-rows pipeline (BYO-CH export works via the generated mapping).SDKs — browser-session/effect-sdk sinks stamp identity on every session event; effect-sdk server entry gains
MapleEvents.track()(batched, never throws to the caller). Docs updated.API —
ProductEventsService;POST /webhooks/clerk(user.created→signup_completed) andPOST /webhooks/autumn(billing.updated→plan_started/changed/cancelled), Svix-verified with WebCrypto (no new dep); inlineattachsuccess also emitsplan_started. Web firesplan_checkout_startedbefore the Stripe redirect.Query engine —
windowFunnel/sequenceMatchin clickhouse-builder;productEventsFunnelQuery,productEventsFunnelBreakdownQuery,productEventNamesQuerywith a person keyif(UserId!='', UserId, coalesce(link.UserId, VisitorId)); page-view SQL byte-identical apart from the table (parity e2e extended with 9 funnel cases and run against a real ClickHouse). Catalog baseline regenerated.Surfaces —
/analyticsgets an Overview | Funnels toggle (step builder, key-by, window, breakdown, URL-persisted); the existingfunnelwidget gains an additivedisplay.funnelsteps block (mobile wire unchanged); MCPquery_funnel+list_product_events.Rollout notes (see doc §"Rollout checklist")
product_events/identity_links.clickHouseSchemaVersion→ 16 (BYO-CH orgs must apply schema before ingest re-routes) — deliberate, the gateway writes the new columns.CLERK_WEBHOOK_SECRET,AUTUMN_WEBHOOK_SECRET(routes 503 until set); optionalMAPLE_PRODUCT_EVENTS_INGEST_KEY.product_eventsis its own Autumn metered feature (unit = events), separate frombrowser_sessions. Free and unlimited during beta — the Startup item isunlimited: truewith no price, so usage is tracked in Autumn but nothing is charged; the billing page and pricing page read "Unlimited · free during beta". A commented example inapps/api/autumn.config.tsshows how to switch to $0.05 / 1,000 past 1M when pricing lands. Push withbun run --cwd apps/api atmn push. Gateway meters/v1/eventsrows + browsertrack()rows;/v1/eventsis gated onproduct_events, session transcripts stay gated onbrowser_sessions. Daily spend series includes it. Retention is 365 days.Verification
bun typecheck— 40/40 green;bun run lintclean.cargo check --all-targets+ scopedcargo testinapps/ingest;bun testinapps/cli(21 migration tests + a real chDB v5→v6 replay).apps/cli/test/native-local-store-migration.sh(needs the full binary bundle); no browser walkthrough of the new Funnels tab.packages/browsereager size budget (56.5 kB vs 37 kB) already fails onmain.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.