Skip to content

Product events from annotated spans - #710

Open
Makisuo wants to merge 7 commits into
mainfrom
feat/product-events-from-annotated-spans
Open

Product events from annotated spans#710
Makisuo wants to merge 7 commits into
mainfrom
feat/product-events-from-annotated-spans

Conversation

@Makisuo

@Makisuo Makisuo commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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",  // presence is the predicate
  "maple.product_event.user_id": user.id,            // optional identity
})

That becomes a product_events row with Source='trace', carrying its TraceId/SpanId — so it steps in a funnel like any track() 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 traces retention 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.

"maple.product_event.include": "plan,seats"   // ONLY these span keys (whitespace trimmed)
"maple.product_event.prop.plan": "pro"        // explicit prop, merged over the base, wins ties
"maple.product_event.include": ""             // and together: full overwrite
include prop.* Attributes
absent every span attribute
absent set every span attribute, props overriding on collision
"plan,seats" only plan and seats
"" set only the props

include narrows the base, prop.* merges over it, and an empty include narrows 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:

  • include switches 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_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 in the first place.

Direction Query Surface
trace → its product events productEventsForTraceQuery trace detail, under the anatomy strip; clicking selects the annotated span
event → the traces behind it productEventTraceSamplesQuery /analytics, when the event filter is set

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.

Schema surfaces

All three, backfilling the trace half from traces (bounded by its 30-day retention against product_events' 365):

  • BYO ClickHouse — migration 0024. requiredForIngest: false: the gateway writes neither new column, so ingest routing is not un-readied over a read-path feature.
  • Managedproduct_events_traces_mv, deployed with the rest of the Tinybird project.
  • Local CLI — schema v13 → v14 with its migration edge.

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 typecheck green 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 Attributes expression was executed rather than assumed — mapUpdate, trimBoth and the outer-column lambda capture are all things worth checking. Against ClickHouse 26.2:

Scenario Span attributes Result
default 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=pro http.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. include is 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 in docs/product-events-funnels.md.

Ingest cost. The MV predicate is one Map value read per incoming span, on the same block every other traces MV already fires on. An MV sees the insert block, not the table, so idx_span_attr_keys doesn't help it. The expensive Attributes expression only evaluates for rows that pass the WHERE, 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:

  • No MCP tool. list_product_events still returns names only and inspect_trace doesn't surface a trace's events, so an agent can't walk the link yet. The queries and routes it would sit on exist.
  • No SDK helper. Teams set the attributes by hand. A markProductEvent(span, name, { props, include }) is a wrapper over setAttributes, and it's where the empty-string overwrite idiom would get a real name instead of being a documented convention.

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

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