Skip to content

feat: product events + funnels (web_events → product_events, /v1/events, identity stitching) - #510

Open
Makisuo wants to merge 13 commits into
mainfrom
feat/product-events-funnels
Open

feat: product events + funnels (web_events → product_events, /v1/events, identity stitching)#510
Makisuo wants to merge 13 commits into
mainfrom
feat/product-events-funnels

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Why

We couldn't answer "referral → signed up → started a plan". web_events was 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_events replaces web_events: same time-first fact table plus Source (browser|server|mobile), VisitorId/UserId/GroupId, ServiceName; 365-day TTL; dual-fed (MV from session_events + direct ingest). Backfill deletes WHERE Source='browser' instead of truncating so directly ingested rows survive a re-run.
  • identity_links (VisitorId, UserId) MV over session_replays — stitches a person's anonymous marketing visit to their identified/server-side events.
  • session_events gains VisitorId/UserId/GroupId DEFAULT '', stamped per event by the SDKs.

IngestPOST /v1/events (NDJSON, ingest-key auth, org from key, per-row sanitising, $screenKind='screen'), new TelemetrySignal::ProductEvents on 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.

APIProductEventsService; POST /webhooks/clerk (user.createdsignup_completed) and POST /webhooks/autumn (billing.updatedplan_started/changed/cancelled), Svix-verified with WebCrypto (no new dep); inline attach success also emits plan_started. Web fires plan_checkout_started before the Stripe redirect.

Query enginewindowFunnel/sequenceMatch in clickhouse-builder; productEventsFunnelQuery, productEventsFunnelBreakdownQuery, productEventNamesQuery with a person key if(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/analytics gets an Overview | Funnels toggle (step builder, key-by, window, breakdown, URL-persisted); the existing funnel widget gains an additive display.funnel steps block (mobile wire unchanged); MCP query_funnel + list_product_events.

Rollout notes (see doc §"Rollout checklist")

  • Tinybird deploy + explicit MV populate for product_events/identity_links.
  • 0016 bumps clickHouseSchemaVersion → 16 (BYO-CH orgs must apply schema before ingest re-routes) — deliberate, the gateway writes the new columns.
  • New api secrets: CLERK_WEBHOOK_SECRET, AUTUMN_WEBHOOK_SECRET (routes 503 until set); optional MAPLE_PRODUCT_EVENTS_INGEST_KEY.
  • Billing: product_events is its own Autumn metered feature (unit = events), separate from browser_sessions. Free and unlimited during beta — the Startup item is unlimited: true with 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 in apps/api/autumn.config.ts shows how to switch to $0.05 / 1,000 past 1M when pricing lands. Push with bun run --cwd apps/api atmn push. Gateway meters /v1/events rows + browser track() rows; /v1/events is gated on product_events, session transcripts stay gated on browser_sessions. Daily spend series includes it. Retention is 365 days.

Verification

  • bun typecheck — 40/40 green; bun run lint clean.
  • Scoped Vitest across domain/query-engine/widgets/api/web/effect-sdk/browser-session; cargo check --all-targets + scoped cargo test in apps/ingest; bun test in apps/cli (21 migration tests + a real chDB v5→v6 replay).
  • ClickHouse e2e: sql-catalog sweep 152/152 and web-analytics parity + funnels 47/47 against local ClickHouse.
  • Not run: apps/cli/test/native-local-store-migration.sh (needs the full binary bundle); no browser walkthrough of the new Funnels tab.
  • Pre-existing, not from this branch: packages/browser eager size budget (56.5 kB vs 37 kB) already fails on main.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Open in Devin Review

Makisuo added 10 commits August 17, 2026 13:14
…_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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +1757 to +1761
.handle("productEventsFunnelBreakdown", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* validateFunnelDefinition(productEventsFunnelOpts(payload))
const rows = yield* runQuery(Queries.productEventsFunnelBreakdown, tenant, payload)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:165before 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.

Suggested change
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(),
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +55 to +57
if (Result.isFailure(verified)) {
return yield* reject(401, verified.failure.reason, "Invalid signature")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

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['']`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant