Product events from annotated spans - #710
Open
Makisuo wants to merge 7 commits into
Open
Conversation
Adds a fourth feed into `product_events`, alongside browser (session_events
MV), server and mobile (POST /v1/events): a span the customer annotated in
their own code.
span.setAttributes({
"maple.product_event.name": "checkout_completed",
"maple.product_event.user_id": user.id,
})
The span's other attributes become the event's properties by default, so
nothing has to be declared to get a useful event. Two optional controls narrow
or replace that: `maple.product_event.include` is a comma-separated allow-list,
`maple.product_event.prop.*` are explicit props merged over the base and
winning ties, and an EMPTY `include` narrows the base to nothing so the props
are all that survive — one mechanism, three tiers, no separate replace flag.
Both are span attributes because a materialized view is static SQL per cluster
with no per-org config to read.
An attribute rather than a UI action because a product event has to be emitted
by the code path that performed the thing, at the moment it performed it.
Marking a trace by hand marks one sampled trace, cannot be replayed over
history, and puts a mutable user-authored row into an append-only fact table.
There is no new store and no write path from the dashboard: the span is the
record, the product event is its projection.
`product_events` gains TraceId/SpanId (DEFAULT '', appended) plus an
idx_trace_id bloom filter. Real columns rather than Attributes keys because
both directions filter on them, and a Map lookup on this table reads the whole
map per row — the cost product_events was split out of session_events to
avoid. That column is the link:
trace -> its product events productEventsForTraceQuery (trace detail)
event -> the traces behind it productEventTraceSamplesQuery (/analytics)
Both panels render nothing when empty. Most traces produce no events and a
browser track() call has no trace, so silence is the design rather than an
empty state on every page.
Shipped across all three schema surfaces: ClickHouse migration 0024 (BYO,
requiredForIngest: false — the gateway writes neither new column, so ingest
routing is not un-readied), product_events_traces_mv for managed orgs, and
local schema v13 -> v14. All three backfill the trace half from `traces`,
bounded by its 30-day retention against product_events' 365.
Known trade, taken deliberately: copying the whole attribute map by default
means an annotated span's attributes outlive the span by a factor of twelve,
and attribute pickers list the span's full semconv surface until a team sets
`include`. Documented in docs/product-events-funnels.md along with the lever.
The Attributes expression was executed against ClickHouse 26.2 rather than
assumed — mapUpdate, trimBoth and the outer-column lambda capture all behave,
and all three tiers produce the intended map.
Not in this cut: no MCP tool (an agent cannot walk the link yet) and no SDK
helper. Both noted in the doc.
Self-review with four adversarial passes (warehouse SQL, migration safety, API boundary, UI) turned up two bugs that would have shipped. 1. BYO ingest would have dropped every /v1/events batch for unmigrated orgs. Widening the product_events datasource regenerated the Rust gateway's insert mapping to name TraceId/SpanId, but the readiness gate stayed at schema version 21 because 0024 is requiredForIngest: false. A BYO org stamped 21-23 is therefore still routed to its own cluster, where the INSERT fails on the unknown column, retries, trips the breaker and drops the batch. Reproduced against a pre-0024 table: Code 16, NO_SUCH_COLUMN_IN_TABLE. Fixed at the declaration rather than the flag: TraceId/SpanId now carry no jsonPath, so generate-clickhouse-insert-mappings skips them and the gateway never names them — the same shape service_usage's MV-only columns already use. requiredForIngest: false is honest again, and no BYO org is un-readied over columns none of their writers touch. The migration comment now says which fact it depends on, since that fact lives in another file. 2. Dropping product_events_mv across the whole trace backfill was a permanent hole in page views. The ordering was copied from 0021, where the bracket was forced — there the backfill WAS the browser feed. Here the backfill reads traces and the view reads session_events, so the bracket bought nothing while the chunked backfill ran for up to 400 workflow steps, and every navigation row ingested meanwhile was never projected. The view is now recreated immediately after its drop; the outage is one statement wide. Also from the review: - The idempotency DELETE is now scoped to the backfill's own source window (Timestamp >= (SELECT min(Timestamp) FROM traces)) in both 0024 and the local edge. Unbounded, a late re-apply cleared 365 days of trace rows and rebuilt only the 30 that traces still holds. Verified: 200 rows -> 170 kept, browser rows untouched. - limit is constrained at the HTTP boundary (RowLimit: int, 1..1000). The builder inlines a limit into the SQL text, so limit: -1 and limit: 1e21 were 500s rather than 400s and limit: 1e9 an unbounded scan — the bucket_seconds mistake in a second costume, one field over from a comment citing that rule. - Deleted the two exported row schemas: both were byte-identical to what the builder derives and neither was passed to compile, so they were a contract nothing enforced. Declared schemas earn their place by narrowing. - Removed toStringRecord. Rows are decoded before reaching it, so the driver quirk it defended against cannot occur. - The trace panel no longer mounts an atom with empty time bounds before its own guard runs — that manufactured a swallowed decode error and exported a failure span per render, and only missed the network because TinybirdDateTime rejects "". Window resolution now happens before the child that queries. - Row keys include the index: SpanId is '' on any row that reached the table without a span, so two same-named events in one trace collided. - A row with no span to select is a plain row, not a disabled button, which had removed its whole content from the tab order with no visual cue. - The analytics panel renders an error state instead of silence. It mounts because the user asked for it and empty is a meaningful answer there, so swallowing a failure answered their question wrongly. - Docs: the falsified requiredForIngest claim, plus a warning that the managed populate is one-shot and overlap-prone (no DELETE step exists on Tinybird, so BYO risks a gap where managed risks duplicates). Unchanged and verified clean by the review: the Attributes expression (merge direction, substring offset, all three include tiers, key types, lambda capture), column order, OrgId scoping and time bounds on both queries, and cache keys — the identity embeds the full payload and is prefixed with orgId.
Dropping the jsonPath from product_events.TraceId/SpanId changed the project revision, so both local-schema.sql and its v14 snapshot carry a new header line. The DDL is byte-identical — which is why the local schema identity hash did not move — but clickhouse:schema:check compares the whole file, and the regenerated versions were left unstaged in the previous commit.
Carried in from main, which is red at 59db862 for this reason: IngestAttributeMappingForbiddenError was added to the ingest-attribute-mappings HTTP schema without rerunning `gen:anticipated-errors`, so the checked-in literal list (115) no longer matched what reflection derives (116) and anticipated-errors.test.ts failed. Not this branch's bug — it is inherited because CI builds the merge commit — but the merge cannot go green without it. The list is generated, so this is purely the output of `bun run --cwd packages/domain gen:anticipated-errors`. Consequence of the gap, for the record: the identifier gates whether a span failing entirely with that error records as OTLP status Ok rather than Error. Missing from the list, a plain 403 from that route would have counted as a real error in error_events_mv.
Second half of main's breakage at 59db862 (the first was the anticipated- error list). Main is red for exactly `TypeScript (effect-lint)` and `TypeScript (test-packages)`; CI builds the merge commit, so this branch inherits both and cannot go green without them. All three sites landed with #717-#719 and none are this branch's code. One is a real violation: - PlanetScaleConnectionService: two consecutive `catchTag` calls collapse into a single `catchTags`, which is what the rule asks for and what the rest of the repo does. Two are the heuristic firing on correct code, suppressed with a reason rather than "fixed" into something worse: - ElectricClient `shape` is Electric's own domain term — a shape is its unit of subscription — and the value is written to the `maple.electric.shape` span attribute under that exact name. Renaming it to satisfy no-shape-in-symbol-names would make the code describe Electric less accurately. Suppressed across the function rather than at one line, since the parameter and its use both trip it. - WarehouseQueryService's two fetch test doubles use `as unknown as typeof fetch` because `typeof fetch`'s overload set is not satisfiable by a bare async function. The narrowing is local to a test. Same shape as the existing anti-slop suppressions in the v2 OpenAPI contract tests. Directives are placed on the line immediately above the offending code with the prose above them — an `oxlint-disable-next-line` whose justification wraps onto a second comment line targets that comment, not the code, and reports as an unused directive while the original error stands. Verified: full `bun run lint` clean, apps/api WarehouseQueryService 36 passed, apps/electric-sync 76 passed, both packages typecheck clean.
Main fixed its own effect-lint breakage in #687, so the three fixes this branch was carrying to stay green are superseded. Main's versions are better in every case and win the resolution: - PlanetScaleConnectionService: main replaced the call with a new `deleteManaged(orgId, target.id)` that drops the `allowManaged` flag entirely, so the ScrapeTargetValidationError branch no longer exists and there is only one catchTag left. The catchTags collapse this branch made is moot; took main's refactor whole. - WarehouseQueryService.test: main typed the fetch doubles as `const requestFetch: typeof fetch = …`, removing the chained assertion instead of suppressing it. Strictly better than the suppression here; took main's. - ElectricClient: main left `shape` alone but #687 dropped no-shape-in-symbol-names from the config, so the suppression became an unused directive — its own error. Reverted the file to main's. No product decision was needed: every conflict was two solutions to one lint error, and main's is the one that survives. Verified on the merged tree: full `bun run lint` clean, `bun typecheck` 39/39, apps/api 2594 passed, packages/domain 698, query-engine 1366, apps/cli 517, electric-sync 76, plus the ClickHouse schema, local-manifest and Tinybird generated-artifact gates all up to date.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a fourth feed into
product_events, alongside browser (session_eventsMV), server and mobile (POST /v1/events): a span the customer annotated in their own code.That becomes a
product_eventsrow withSource='trace', carrying itsTraceId/SpanId— so it steps in a funnel like anytrack()call and links back to the request that performed it.Why an attribute, not a UI action
The first cut of this was going to be "mark a trace as a product event" from the trace view, backed by a Postgres annotations table. That's the wrong shape. A product event has to be emitted by the code path that performed the thing, at the moment it performed it. Marking a trace by hand marks one sampled trace, can't be replayed over history, and puts a mutable user-authored row into an append-only fact table.
An attribute marks every trace the path produces, applies retroactively across the whole
tracesretention window, and is reviewable in the customer's own diff. There is no new store and no write path from the dashboard: the span is the record, the product event is its projection.Attribute selection — three tiers, one mechanism
The span's other attributes become the event's properties by default, so nothing has to be declared to get a useful event. Two optional controls narrow or replace that. Both are themselves span attributes, because a materialized view is static SQL per cluster with no per-org config to read.
includeprop.*Attributes"plan,seats"planandseats""includenarrows the base,prop.*merges over it, and an emptyincludenarrows the base to nothing so only the props survive. No separate replace flag to get wrong.Two things are load-bearing here, and both have a test pinning them:
includeswitches on key presence (mapContains), not a non-empty value. A!= ''would silently turn the documented overwrite back into copy-everything — the exact opposite of what the caller asked for.mapUpdate(base, props)argument order is the override rule. Swapped, an override gets discarded precisely when the key it meant to correct was already present.The link
product_eventsgainsTraceId/SpanId(DEFAULT '', appended) plus anidx_trace_idbloom filter. Real columns rather thanAttributeskeys because both directions filter on them, and aMaplookup on this table reads the whole map per row — the costproduct_eventswas split out ofsession_eventsto avoid in the first place.productEventsForTraceQueryproductEventTraceSamplesQuery/analytics, when the event filter is setBoth panels render nothing when empty. Most traces produce no events and a browser
track()call has no trace, so silence is the design rather than an empty state on every page.Schema surfaces
All three, backfilling the trace half from
traces(bounded by its 30-day retention againstproduct_events' 365):requiredForIngest: false: the gateway writes neither new column, so ingest routing is not un-readied over a read-path feature.product_events_traces_mv, deployed with the rest of the Tinybird project.The projection has one live definition and two frozen copies (0024 and the local edge), deliberately not sharing a constant: a delta migration describes one step in history, and a shared constant would silently rewrite what it did the next time the live projection changes.
Verified
bun typecheckgreen across 39 packages. Domain 662, query-engine 1366, cli 517, api 2547, web 2339 — all passing. Schema, local-manifest and Tinybird gates up to date; lint and format clean.The
Attributesexpression was executed rather than assumed —mapUpdate,trimBothand the outer-column lambda capture are all things worth checking. Against ClickHouse 26.2:http.method,plan=free,seats=5,prop.plan=pro{http.method, seats, plan:'pro'}include: "plan, seats"noise{plan:'free', seats:'5'}include: ""+prop.plan=prohttp.method,plan=free{plan:'pro'}Reviewer notes
The known trade. Copying the whole attribute map by default is the deliberate expensive choice: an annotated span's attributes outlive the span by a factor of twelve (365d vs 30d), and attribute pickers over product events will list the span's full semconv surface until a team sets
include. The alternative — opt-in props only — was rejected because it means nothing works until you declare something.includeis the lever, and it's a one-line change on the span rather than a schema migration. If this becomes the dominant cost across orgs rather than for one of them, the next lever is a per-org key denylist at the MV. Written up indocs/product-events-funnels.md.Ingest cost. The MV predicate is one
Mapvalue read per incoming span, on the same block every othertracesMV already fires on. An MV sees the insert block, not the table, soidx_span_attr_keysdoesn't help it. The expensiveAttributesexpression only evaluates for rows that pass theWHERE, i.e. annotated spans, so its cost is per product event rather than per span.Rollout. The managed Tinybird side of the original product-events work was never deployed — Tinybird CD is disabled and deploys are a manual operator step. This inherits that; BYO ClickHouse and local mode migrate on their own.
Not in this cut, both noted in the doc:
list_product_eventsstill returns names only andinspect_tracedoesn't surface a trace's events, so an agent can't walk the link yet. The queries and routes it would sit on exist.markProductEvent(span, name, { props, include })is a wrapper oversetAttributes, and it's where the empty-string overwrite idiom would get a real name instead of being a documented convention.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.