Origin decoding: show which frontend action started each chain - #43
Open
robnewton wants to merge 18 commits into
Open
Origin decoding: show which frontend action started each chain#43robnewton wants to merge 18 commits into
robnewton wants to merge 18 commits into
Conversation
Adds a pure core codec for a structured 32-hex trace/correlation id and a plugin that decodes it into observability context_data. The codec (src/core/origin-id.ts) packs a magic marker, a version, an opaque minting-surface number (originId, 0-255), an env, and 84 random bits into a 32-hex string. A frontend mints one and sends it as x-b3-traceid; with Hasura's OTel config on it becomes the invocation's correlation id. Names for originId numbers are consumer config, not baked into the id. No timestamp and no user identity on purpose, and the id is display-only (spoofable). The originDecoder plugin (src/plugins/origin-decoder) decodes the chain's final correlation id in configureInvocation (after loop-guard/correlation-resolver recover it) and injects an origin object into request meta, which observability persists as context_data.origin. Supports a custom decode function and an originNames map. No-op on any id it can't decode; inert until registered. Includes tests for the codec and plugin, a docs page (docs/origin-id.md), README entries, and a minor changeset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
…node The Hasura DB-event source no longer adopts the inbound event.trace_context.trace_id as the chain's correlation id by default. A client controls that trace id (a browser sends it as x-b3-traceid), so using it as chain identity let a client dictate the correlation id and, worse, merged every unrelated chain that reused a static action id into one. The correlation id is now minted fresh at the root, and the raw trace id is surfaced on meta.sourceTraceId as a conveyance channel. Set correlationFromTraceId: true on the source to restore the old behavior. The origin-decoder plugin now reads meta.sourceTraceId (not the correlation id) and runs a required, consumer-supplied decode function over it, injecting the returned object verbatim into request.meta.origin (observability persists it as context_data.origin). eventkit ships no codec anymore: the packed origin-id codec in core is removed, and decoding policy (a registry lookup or a packed-format decoder) lives in the consumer. Only chain roots decode; downstream hops carry Hasura-minted trace ids the decoder returns null for. Console: when a chain root has context_data.origin, the flow canvas draws a synthetic node to the left of the root with an edge into it. The node type is "userAction" to avoid colliding with the InvocationNode ORIGIN badge (that marks the chain root itself; this marks the client action that started it). It renders the conventional action/site/purpose keys prominently, any other keys as compact key-value lines, plus the root's user/role and event time, and never crashes on a malformed origin. The seed puts an origin on a slice of roots (always the first) so the node is demoable without a live frontend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
The card had only a min-width, and NodeShell's meta line is nowrap, so a long purpose sentence stretched the node past the 450px column spacing and under the invocation node. Fixed width at 300px, site alone on the meta line, and the purpose wraps (clamped to 3 lines) in the body instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Three upgrades to the user-action node, per review of the live demo: - Who acted, as a person: an optional host-injected userResolver(email) on the console config turns the root's source_user_email into a display profile (name + avatar). The console stays schema-agnostic about identity; without a resolver it falls back to the raw email and an initials avatar. Resolved lazily per distinct email and cached; the dev harness shows the reference wiring against a users table on the same endpoint. - Click-through details: the node now opens a drawer like every other node, with the profile card, site/role/when/action-id facts, the purpose, any extra decoded keys, the raw origin JSON, and cross-navigation to the chain root. NodeRow only renders a status chip when a status is actually passed. - Correct ORIGIN semantics: when a user action exists, it carries the ORIGIN badge instead of the root invocation (the click started the chain, not the row write), and the flow breadcrumb marks and labels it accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
The card was a box full of words (purpose sentence, action id, extra keys). The canvas version now shows only what orients you at a glance: the action slug as the title (its site prefix says where) and one slim who/when row (avatar, name, relative time, full timestamp on hover). Purpose, action id, role, and additional decoded keys live in the detail drawer only. Width drops to 240px, matching the visual weight of the other nodes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
…node HopDrive's trace id is becoming PREFIX+SEED, so the decoded origin now carries a clickId that is identical across every chain one user click caused. Each chain still keeps its own server-minted correlation id; clickId is a separate, display-only grouping key (client-supplied, so never chain identity). When the focused chain's root has an origin clickId, the flow view fetches the other root chains that share it (SiblingChainsByClick, a jsonb containment query bounded by created_at and a small limit) and merges them onto the canvas. The layout now groups roots by clickId and draws ONE user-action node that fans an edge into every root in the group. Roots without a clickId keep the existing one-node-per-root behavior. The node card gains a tiny "N chains" count chip next to the ORIGIN badge when a click started more than one chain; the drawer carries the detail as a "This click started N chains" section that lists the sibling roots as cross-navigable rows, with a note that the grouping is display only. The generated hook for the new query was hand-scoped into generated.ts (the console's schema has heavy drift, so a blind regen is not safe); the additions are exactly what codegen produces for the query, nothing else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Describe what origin decoding is and how to use it, without the design history. Same content, plainer words, no em-dashes: the docs page, the changeset, and the two README mentions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
…rollup The unit of debugging is the chain (all invocations sharing a correlation_id). This makes the chain root and its rollup first-class on the invocation record so the console can query chains as plain root rows. Writer side: - Sources expose meta.correlationMinted: true when the invocation minted its correlation id, false when it inherited one. loopGuard clears it when it adopts an inbound token's correlation, so token-chained hops read as non-root. Root is never inferred from timestamps and is independent of any decoded origin. - The observability writer stamps is_chain_root from that flag, promotes context_data.origin.action to origin_action (string only), and on root rows seeds chain_status='ok' and chain_last_activity_at=started_at. - The graphql sink rolls each member write up onto the chain's root row: one indexed UPDATE bumps chain_last_activity_at (greatest-wins), and an errored member flips chain_status to 'errored' (never back). Both are best-effort and swallow all failures so telemetry never blocks event handling. Tests cover minted-vs-inherited root flagging, origin_action promotion (and the non-string skip), the last-activity bump, the error flip, and the best-effort swallow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Add origin_action, is_chain_root, chain_status, and chain_last_activity_at to the canonical schema, with partial indexes on root rows (default order by last activity, plus a smaller errored-roots index) and an origin_action index. A schema-delta file carries the same change for existing observability databases, with a note that it must be applied before deploying the writer that sets these columns. The seeder populates all four columns and rolls the chain summary up onto root rows in a post-COPY pass (last activity = latest member start; chain_status flips to 'errored' when any member failed). It also generates one deterministic showcase chain: a completed root carrying an origin_action whose non-root child errored, so the errors-only chains view, the dashboard needs-attention feed, and the "root succeeded but chain errored" case always have content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Make the chain the primary object across the console. Chains index (InvocationsTable / new ChainsTable): the default view is now "Chains" — one row per chain, queried as plain root rows (is_chain_root = true, no group-by), sorted by last activity. Each row leads with the origin action chip when present (amber, matching the flow canvas' user-action node) or the root's event/function otherwise, and shows user, chain status (row color follows the chain, so a root that itself succeeded under an errored chain reads errored), last activity, and per-chain invocation count and span fetched for the visible page only. A persistent errors-only toggle and an origin-action filter are URL params (?errors=1, ?action=), and a view toggle keeps the flat all-invocations table. Rows open the chain permalink. Permalinks: the flow page accepts ?correlationId= alongside ?invocationId=, resolving the chain root (or earliest member) and rendering the same diagram — the stable deep-link contract for external links. The search box resolves a full correlation id, a bare invocation UUID, or a fuzzy fragment straight to the right flow, and gains an origin-action typeahead group over the indexed column. Dashboard: leads with health, not volume — an errored-chain count with trend vs the previous window, a "needs attention" feed of recent errored chains (each one click from its flow), and a top-origin-actions table with per-action errored rate; the existing charts move below. New/extended GraphQL operations are hand-scoped into generated.ts (the console's schema has heavy drift, so a blind regen is unsafe); the additions are exactly what codegen emits for the new queries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Document the chain root definition (minted correlation id), the rollup columns and the writer invariant, the flow?correlationId= permalink contract, and the chains index behavior. Note explicitly that origin is optional decoration: header-less and non-frontend chains get the same chain and health treatment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
A decoded origin is any registered entry point, not only a user action: inbound webhooks, schedules, and system triggers are origins too. The console now derives the origin node's kind label from the decoded object's optional `trigger` field (user → "User action", webhook → "Webhook", schedule → "Scheduled", system → "System", absent/unknown → a neutral "Origin"), via one shared helper so the node, the detail drawer, and the breadcrumb all agree. Everything still derives from the decoded object; eventkit never assumes a specific consumer's shape. The origin node and drawer only render the avatar/name row when the origin carries a person (a signed-in user); webhook and system origins have no identity, so that row is omitted. The drawer shows `trigger` as a fact when present, and keeps the trust note. The amber tone and layout are unchanged. The seed gains a webhook-triggered chain (origin with trigger:'webhook', an integration source, no user) so the non-user card renders in dev, and the existing user origins now carry trigger:'user'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Add the chains index, errors-only view, origin-action search, health-led dashboard, correlation permalink flow, and webhook-origin card screenshots under docs/images/console/ so the PR can embed them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Adds a host-injection point so the console can overlay the expected flow for
each invocation without knowing where flows come from (same pattern as
userResolver). The host wrapper injects `expectedFlowResolver({ sourceFunction,
at })` and returns an ExpectedFlowGraph.
The contract type is `toFlowGraph()`'s exact output ({ nodes, edges } in the
FlowNode/FlowEdge vocabulary) plus optional metadata (title, generatedAt, hash),
so a producer like the registry codegen can emit it as plain JSON with no
console-specific fields. Exported from the package barrel.
- useExpectedFlows: resolves one flow per distinct sourceFunction, cached for the
life of the page like useOriginUser. `at` is passed through to the resolver but
does not split the cache today.
- expectedFlowToGraph: adapts the wire contract into the console's internal
FlowGraph (adds the display label; ids carry through unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
… section Adds a resolver-driven overlay on the flow canvas. For each invocation, its source_function resolves to an expected graph, compared against that invocation's own observed records with the existing compareFlow matcher and the shared name-derived node ids: - expected-but-missing steps render as ghost nodes (dashed, muted, same shell as the real nodes) attached under the invocation, tooltip "expected by the flow definition, not observed". - observed-but-unexpected steps get a small off-contract badge on the existing node (no new node). - matched steps render unchanged. - an invocation with no resolved flow (no resolver, null, or error) renders exactly as today. Absence of a flow is never a judgement. A new "Expected flow" toggle (checkbox + the X hotkey) shows/hides it and is remembered in localStorage. The toggle only appears when a resolver is wired, so the generic console is unchanged when none is. Ghost ids are prefixed ghost-flow so replay hides them like the other overlays' ghosts. The user-action drawer gains an "Expected events" section: when the decoded origin carries expectedEvents (string names the cause expects somewhere in the chain), each is listed with a check (observed anywhere in the loaded chain) or a muted dashed miss marker. The origin card is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Wires an example expectedFlowResolver into the standalone dev entry (the same file the create-eventkit-console template copies). It returns one static flow for source_function 'event-handlers', derived from the seeded showcase chain's shape (move.create.requested -> requestVehicleMove) plus one step the seed never records (move.audit.recorded -> writeMoveAuditLog), so the overlay always has an expected-but-missing ghost to show in dev. Adds expectedEvents to the showcase root's decoded origin: two of them are observed in the chain and one (driver.assigned) never fires, so the user-action drawer demonstrates both a check and a miss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Documents the expectedFlowResolver contract (ExpectedFlowGraph = toFlowGraph output + optional metadata, name-derived node ids), the per-invocation overlay semantics (display only, ghost nodes for missing, off-contract badge for unexpected, no flow means no judgement), and the expectedEvents drawer section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Overlay on (ghost expected-but-missing node + off-contract badges), overlay off (identical to today), and the user-action drawer's expected-events section (check + miss). Captured against the seeded showcase chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
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.
What this adds
A way to label each chain of invocations with the frontend action that started it, so observability and the flow console can show "a dealer created a move" next to the database write instead of just the write.
Two parts:
originDecoderplugin, which reads an id the frontend sends and turns it into a small object for display.How it works
A frontend sends a short id for the action a user took (say, "dealer creates a move") as the
x-b3-traceidheader on its Hasura mutation. With Hasura's OpenTelemetry config on, that id arrives in the event payload atevent.trace_context.trace_id, and the Hasura source puts it onmeta.sourceTraceId.That id is only for display. It is never the chain's id. Every chain gets its own correlation id (the id that ties a chain of invocations together) minted fresh at the start, whether or not an id came in. This matters for two reasons:
Using it
You write a
decodefunction that turns the incoming id into an object to show, and register the plugin with it.decodeis required; eventkit does not ship one, because the meaning of your ids is yours.The usual way to write
decodeis a list of the actions your frontends can start, kept in your own code and shared by the frontend that sends the id and the function that decodes it:decodereturns any JSON object to show, ornullfor an id it does not know (then the plugin does nothing). The object lands atcontext_data.originon the first invocation of the chain. Only the first invocation gets one: later steps carry a Hasura-generated trace id, not your action id, sodecodereturns null for them.The docs cover the list pattern, a pack-it-into-the-id alternative, sending the header from the frontend, and the safety notes.
The console
When the first invocation of a chain has a
context_data.origin, the flow canvas draws a node to its left showing the action, the person who did it (name and role), and when. It carries the ORIGIN marker, since the click started the chain, not the row write. Click it for a drawer with the full decoded object and a link back to the invocation it started. If you give the console auserResolver(email)that returns a name and avatar, it shows those; otherwise it shows the email and initials.If the decoded object has a
clickId(an id the frontend can send that is the same for every chain one click set off), the console groups those chains: one action node stands in front of all the chains that click started, with an edge into each, a small "N chains" count on the card, and a drawer section listing them so you can jump between them. Chains without aclickIdeach get their own node. TheclickIdis display-only grouping, like the rest of the object, and each chain keeps its own correlation id.Options
originDecoder({ decode }):decodeis required. Your function from the incoming id to an object, or null.correlationFromTraceIdon thehasuraEventsource, default off. Off keeps the trace id out of the chain id (the safe default above). Turn it on only for a trusted caller that sends a real, unique trace id per request that you want to be the chain id.Try it locally
console/, runnode db/local-setup.mjsthennpm run seed. The seed puts an origin on some root invocations.npm run dev, open a root invocation's flow, and the action node sits to its left. When two roots share aclickId, one node fans into both chains and its drawer lists them.Checks
Tests, typecheck, typecheck:contracts, build, bundle check, and docs compile all pass, plus the console app build and library build.
Related
Works with sdk PR #662 (Hasura sends the
x-b3-traceidthrough totrace_context.trace_id) and sdk PRs #663 and #664 (the frontend sending the header, and the shared list of actions).Chain usability round
A second scope on this branch makes the CHAIN (all invocations sharing a correlation id) the primary object in observability and the console. Origin is optional decoration on top: header-less and non-frontend chains get the same chain and health treatment.
Writer and schema
Sources expose
meta.correlationMinted, true when an invocation minted its correlation id and false when it inherited one (loop-guard clears it when it adopts an inbound token's correlation). The observability writer reads the settled flag to setis_chain_roottruthfully. Root is never inferred from timestamps and does not depend on a decoded origin.The writer also promotes
context_data.origin.actionto an indexedorigin_action, and on every member write rolls a summary onto the chain's root row:chain_last_activity_at(greatest-wins) andchain_status(flips toerroredwhen any member errored, never back). The rollup is best-effort and never blocks event handling, so a chain's root can readerroredeven though the root invocation itself completed, because a downstream member failed.New
invocationscolumns (origin_action,is_chain_root,chain_status,chain_last_activity_at) and their indexes are inconsole/db/schema.sql. Existing databases applyconsole/db/schema-delta-chain-rollup.sqlbefore deploying the writer (it lists the columns in its invocation upsert).Console
?errors=1) and a per-action filter (?action=) are URL params, and a view toggle keeps the flat all-invocations table.?correlationId=alongside?invocationId=, resolving the chain root and rendering the same diagram. This is the stable deep link external tools point at.Docs:
docs/chain-observability.md. The new GraphQL operations are hand-scoped intoconsole/src/types/generated.ts(the console schema drifts, so a blind regen is unsafe).Verified
Root typecheck and 333 backend tests pass. Console builds. Verified headless (puppeteer + local Chrome) against the seeded local obs DB: chains default view, errors-only view, per-action URL filter, reordered dashboard, and a
?correlationId=permalink that renders a chain whose root completed but whose child failed as errored.Origin node generalized
A decoded origin is now any registered entry point, not only a human click. The console derives the origin node's kind label from the decoded object's optional
triggerfield (user→ "User action",webhook→ "Webhook",schedule→ "Scheduled",system→ "System", absent/unknown → a neutral "Origin"), through one shared helper used by the node, the detail drawer, and the breadcrumb. The avatar/name row renders only for origins that carry a person; webhook and system origins have no identity, so that row is omitted, and the drawer showstriggeras a fact. Everything still derives from the decoded object. The seed gains a webhook-triggered chain so the non-user card renders in dev. Verified headless: the webhook chain renders a "Webhook" origin card with no avatar row.What it looks like
Chains index, the default view: one row per chain, with the origin action chip when present, chain status, last activity, and per-chain invocation count and span.
Errors-only toggle: the persistent, URL-addressable filter to chains that errored anywhere.
Search resolves origins too: typing an origin action shows an origin-action suggestion group over the indexed column.
The dashboard leads with health: an errored-chain count with trend, then the needs-attention feed of recent errored chains.
The flow permalink: opening
?correlationId=resolves the chain root and renders the whole chain. Here the root completed but a downstream member failed, so the chain reads errored.Origins are entry points generally, not only clicks: a webhook-triggered chain renders a "Webhook" origin card with no user row.
Expected-flow overlay (per-invocation)
The flow canvas can now overlay what a run was supposed to do on top of what it did do. The host wrapper injects an
expectedFlowResolver(same pattern asuserResolver); it returns anExpectedFlowGraph, which istoFlowGraph()'s output plus optional metadata, so a producer (the registry codegen) can emit it as plain JSON with no console-specific fields. Node ids stay name-derived so observed and expected line up.Toggle it with "Expected flow" in the toolbar or the
Xhotkey; the choice is remembered in localStorage, and the toggle only appears when a resolver is wired. It is display only, per invocation, and never a pass/fail gate. An invocation with no resolved flow (no resolver, null, or error) renders exactly as today.off-contractbadge.The user-action drawer also gains an "Expected events" section: when a decoded origin carries
expectedEvents, each name is checked against the events observed anywhere in the loaded chain (a check when seen, a muted dashed marker when not).Contract type and semantics are in
docs/console-expected-flow-overlay.md.Overlay on: the root's observed
move.create.requestedsits above a dashed ghost for the expected-but-missingmove.audit.recorded, and downstream steps the flow doesn't declare carry an off-contract badge.Overlay off: the same chain, identical to today.
The user-action drawer's expected-events section: two observed (check), one never observed (dashed miss).
🤖 Generated with Claude Code
https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW