@telorun/debug-ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/debug-ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Browser-safe debug event UI for Telo: wire types, filtering, SSE client, and React components for watching a running app's event stream.",
5
5
  "keywords": [
6
6
  "telo",
@@ -46,7 +46,7 @@
46
46
  "@dagrejs/dagre": "^3.0.0",
47
47
  "@xyflow/react": "^12.10.2",
48
48
  "lucide-react": "^1.8.0",
49
- "@telorun/debug-wire": "0.1.0"
49
+ "@telorun/debug-wire": "0.2.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": ">=18",
@@ -114,12 +114,12 @@ function TraceFlowNode({ data }: NodeProps<Node<TraceNodeData>>) {
114
114
  <Handle type="target" position={Position.Left} className="tdbg-node-handle" />
115
115
  <div className="tdbg-node-head">
116
116
  {node.isRoot && <span className="tdbg-node-roottag">root</span>}
117
- <span className="tdbg-node-name">{node.name}</span>
117
+ <span className="tdbg-node-name">{node.label ?? node.name}</span>
118
118
  <span className={badgeClass(last.outcome)}>
119
119
  {node.invocations.length > 1 ? `${node.invocations.length}×` : last.suffix}
120
120
  </span>
121
121
  </div>
122
- <div className="tdbg-node-kind">{node.kind}</div>
122
+ <div className="tdbg-node-kind">{node.label ? `${node.kind} · ${node.name}` : node.kind}</div>
123
123
  <Handle type="source" position={Position.Right} className="tdbg-node-handle" />
124
124
  </div>
125
125
  );
@@ -420,6 +420,12 @@ function TraceNodeDetail({
420
420
  <span className="tdbg-muted">{inv.timestamp.slice(11, 23)}</span>
421
421
  </div>
422
422
  <InOut inv={inv} resolveUrl={resolveUrl} />
423
+ {inv.context !== undefined && (
424
+ <>
425
+ <div className="tdbg-graph-detail-label">Available context</div>
426
+ <PayloadInspector value={inv.context} resolveUrl={resolveUrl} />
427
+ </>
428
+ )}
423
429
  </div>
424
430
  ))}
425
431
  </div>
package/src/filter.ts CHANGED
@@ -23,8 +23,12 @@ export function matchesFilter(event: DebugEvent, filter: EventFilter): boolean {
23
23
  if (suffixes.length > 0 && !suffixes.includes(eventSuffix(event.event))) {
24
24
  return false;
25
25
  }
26
- if (filter.kind && !event.event.toLowerCase().includes(filter.kind.toLowerCase())) {
27
- return false;
26
+ if (filter.kind) {
27
+ // Lifecycle events carry the kind in the dotted name; dispatch (trace) events
28
+ // carry it in `payload.ref.kind` (the name dropped the kind prefix). Match either.
29
+ const refKind = (event.payload as { ref?: { kind?: unknown } } | undefined)?.ref?.kind;
30
+ const haystack = `${event.event} ${typeof refKind === "string" ? refKind : ""}`.toLowerCase();
31
+ if (!haystack.includes(filter.kind.toLowerCase())) return false;
28
32
  }
29
33
  if (filter.text) {
30
34
  const needle = filter.text.toLowerCase();
package/src/graph.test.ts CHANGED
@@ -12,6 +12,19 @@ const ev = (event: string, payload?: unknown): DebugFrame => ({
12
12
  const created = (kind: string, name: string, deps: { kind: string; name: string }[] = []) =>
13
13
  ev(`${kind}.${name}.Created`, { resource: { kind, name }, dependencies: deps });
14
14
 
15
+ type Outcome = "ok" | "failed" | "rejected" | "cancelled";
16
+
17
+ /** A terminal dispatch (trace) event: the name drops the kind, the payload carries
18
+ * the structured trace contract `{ capability, phase, outcome, ref, … }`. */
19
+ const disp = (
20
+ name: string,
21
+ kind: string,
22
+ outcome: Outcome,
23
+ suffix: string,
24
+ detail: Record<string, unknown> = {},
25
+ ): DebugFrame =>
26
+ ev(`${name}.${suffix}`, { capability: "invoke", phase: "end", outcome, ref: { kind, name }, ...detail });
27
+
15
28
  describe("deriveGraph", () => {
16
29
  it("creates a gray (created) node on Created", () => {
17
30
  const { nodes } = deriveGraph([created("Http.Server", "api")]);
@@ -52,7 +65,7 @@ describe("deriveGraph", () => {
52
65
  it("records an ok invocation with inputs and outputs", () => {
53
66
  const { nodes } = deriveGraph([
54
67
  created("Js.Script", "echo"),
55
- ev("Js.Script.echo.Invoked", { inputs: { msg: "hi" }, outputs: { echoed: "hi" } }),
68
+ disp("echo", "Js.Script", "ok", "Invoked", { inputs: { msg: "hi" }, outputs: { echoed: "hi" } }),
56
69
  ]);
57
70
  expect(nodes[0].invokeCount).toBe(1);
58
71
  expect(nodes[0].lastInvoke).toMatchObject({
@@ -63,18 +76,27 @@ describe("deriveGraph", () => {
63
76
  });
64
77
  });
65
78
 
66
- it("classifies failure, rejection and cancellation outcomes", () => {
79
+ it("skips a phase:start event (no outcome) — only terminal events count", () => {
80
+ const { nodes } = deriveGraph([
81
+ created("Js.Script", "echo"),
82
+ ev("echo.Invoking", { capability: "invoke", phase: "start", ref: { kind: "Js.Script", name: "echo" }, inputs: {} }),
83
+ ]);
84
+ expect(nodes[0].invokeCount).toBe(0);
85
+ expect(nodes[0].lastInvoke).toBeUndefined();
86
+ });
87
+
88
+ it("records failure, rejection and cancellation outcomes from the payload", () => {
67
89
  const base = created("Js.Script", "x");
68
90
  expect(
69
- deriveGraph([base, ev("Js.Script.x.InvokeFailed", { inputs: {}, name: "Error", message: "boom" })])
91
+ deriveGraph([base, disp("x", "Js.Script", "failed", "InvokeFailed", { inputs: {}, name: "Error", message: "boom" })])
70
92
  .nodes[0].lastInvoke?.outcome,
71
93
  ).toBe("failed");
72
94
  expect(
73
- deriveGraph([base, ev("Js.Script.x.InvokeRejected", { inputs: {}, code: "E", message: "no" })])
95
+ deriveGraph([base, disp("x", "Js.Script", "rejected", "InvokeRejected", { inputs: {}, code: "E", message: "no" })])
74
96
  .nodes[0].lastInvoke?.outcome,
75
97
  ).toBe("rejected");
76
98
  expect(
77
- deriveGraph([base, ev("Js.Script.x.InvokeCancelled", { inputs: {}, reason: "stop" })])
99
+ deriveGraph([base, disp("x", "Js.Script", "cancelled", "InvokeCancelled", { inputs: {}, reason: "stop" })])
78
100
  .nodes[0].lastInvoke?.outcome,
79
101
  ).toBe("cancelled");
80
102
  });
@@ -82,29 +104,27 @@ describe("deriveGraph", () => {
82
104
  it("treats InvokeRejected.Undeclared as a rejection on the right node", () => {
83
105
  const { nodes } = deriveGraph([
84
106
  created("Js.Script", "x"),
85
- ev("Js.Script.x.InvokeRejected.Undeclared", { inputs: {}, code: "E", message: "no" }),
107
+ disp("x", "Js.Script", "rejected", "InvokeRejected.Undeclared", { inputs: {}, code: "E", message: "no" }),
86
108
  ]);
87
- expect(nodes[0].lastInvoke).toMatchObject({ outcome: "rejected", suffix: "InvokeRejected.Undeclared" });
109
+ expect(nodes[0].lastInvoke).toMatchObject({ outcome: "rejected", suffix: "Undeclared" });
88
110
  });
89
111
 
90
112
  it("counts repeated invocations and advances the activity index", () => {
91
113
  const { nodes } = deriveGraph([
92
114
  created("Js.Script", "x"),
93
- ev("Js.Script.x.Invoked", { inputs: {}, outputs: 1 }),
94
- ev("Js.Script.x.Invoked", { inputs: {}, outputs: 2 }),
115
+ disp("x", "Js.Script", "ok", "Invoked", { inputs: {}, outputs: 1 }),
116
+ disp("x", "Js.Script", "ok", "Invoked", { inputs: {}, outputs: 2 }),
95
117
  ]);
96
118
  expect(nodes[0].invokeCount).toBe(2);
97
119
  expect(nodes[0].lastInvoke?.outputs).toBe(2);
98
120
  expect(nodes[0].lastActivitySeq).toBeGreaterThan(0);
99
121
  });
100
122
 
101
- it("attributes an invocation to the longest matching resource prefix", () => {
102
- // `Http.Server.api` and `Http.Server.api.sub` share a prefix; the event must
103
- // land on the deeper node, not the shorter one.
123
+ it("attributes an invocation to the resource named in the payload ref", () => {
104
124
  const { nodes } = deriveGraph([
105
125
  created("Http.Server", "api"),
106
126
  created("Http.Server.api", "sub"),
107
- ev("Http.Server.api.sub.Invoked", { inputs: {}, outputs: "ok" }),
127
+ disp("sub", "Http.Server.api", "ok", "Invoked", { inputs: {}, outputs: "ok" }),
108
128
  ]);
109
129
  expect(nodes.find((n) => n.name === "sub")?.invokeCount).toBe(1);
110
130
  expect(nodes.find((n) => n.name === "api")?.invokeCount).toBe(0);
@@ -121,46 +141,54 @@ describe("deriveGraph", () => {
121
141
  });
122
142
  });
123
143
 
144
+ /** A terminal dispatch event carrying trace ids — `spanId` / `parentSpanId`. */
124
145
  const inv = (
125
- event: string,
126
- invocationId: number,
127
- parentInvocationId: number | undefined,
128
- payload: unknown,
129
- ): DebugFrame => ({
130
- timestamp: `2026-01-01T00:00:00.${String(seq++).padStart(3, "0")}Z`,
131
- event,
132
- payload,
133
- metadata: { invocationId, ...(parentInvocationId !== undefined ? { parentInvocationId } : {}) },
134
- });
146
+ name: string,
147
+ kind: string,
148
+ spanId: number,
149
+ parentSpanId: number | undefined,
150
+ outcome: Outcome,
151
+ suffix: string,
152
+ detail: Record<string, unknown> = {},
153
+ ): DebugFrame =>
154
+ ev(`${name}.${suffix}`, {
155
+ spanId,
156
+ parentSpanId,
157
+ capability: "invoke",
158
+ phase: "end",
159
+ outcome,
160
+ ref: { kind, name },
161
+ ...detail,
162
+ });
135
163
 
136
164
  describe("deriveInvocations", () => {
137
- it("is empty when events carry no invocation metadata (tracing off)", () => {
138
- const t = deriveInvocations([ev("Js.Script.x.Invoked", { outputs: 1 })]);
165
+ it("is empty when events carry no span ids (tracing off)", () => {
166
+ const t = deriveInvocations([disp("x", "Js.Script", "ok", "Invoked", { outputs: 1 })]);
139
167
  expect(t.roots).toHaveLength(0);
140
168
  expect(t.byId.size).toBe(0);
141
169
  });
142
170
 
143
171
  it("collects a root invocation with its resource, inputs and outputs", () => {
144
- const t = deriveInvocations([inv("Http.Server.api.Invoked", 1, undefined, { inputs: { a: 1 }, outputs: "ok" })]);
172
+ const t = deriveInvocations([inv("api", "Http.Server", 1, undefined, "ok", "Invoked", { inputs: { a: 1 }, outputs: "ok" })]);
145
173
  expect(t.roots).toHaveLength(1);
146
174
  expect(t.roots[0]).toMatchObject({ id: 1, kind: "Http.Server", name: "api", outcome: "ok", inputs: { a: 1 }, outputs: "ok" });
147
175
  });
148
176
 
149
177
  it("links children to parents and keeps only roots at the top", () => {
150
178
  const t = deriveInvocations([
151
- inv("A.root.Invoked", 1, undefined, { outputs: 1 }),
152
- inv("B.child.Invoked", 2, 1, { outputs: 2 }),
153
- inv("C.grand.Invoked", 3, 2, { outputs: 3 }),
179
+ inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
180
+ inv("child", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
181
+ inv("grand", "C", 3, 2, "ok", "Invoked", { outputs: 3 }),
154
182
  ]);
155
183
  expect(t.roots.map((r) => r.id)).toEqual([1]);
156
184
  expect(t.childrenOf.get(1)).toEqual([2]);
157
185
  expect(t.childrenOf.get(2)).toEqual([3]);
158
186
  });
159
187
 
160
- it("dedupes the InvokeRejected.Undeclared echo by id", () => {
188
+ it("dedupes the InvokeRejected.Undeclared echo by id, keeping the primary", () => {
161
189
  const t = deriveInvocations([
162
- inv("A.x.InvokeRejected", 1, undefined, { code: "E" }),
163
- inv("A.x.InvokeRejected.Undeclared", 1, undefined, { code: "E" }),
190
+ inv("x", "A", 1, undefined, "rejected", "InvokeRejected", { code: "E" }),
191
+ inv("x", "A", 1, undefined, "rejected", "InvokeRejected.Undeclared", { code: "E" }),
164
192
  ]);
165
193
  expect(t.byId.size).toBe(1);
166
194
  expect(t.byId.get(1)).toMatchObject({ outcome: "rejected", suffix: "InvokeRejected" });
@@ -170,9 +198,9 @@ describe("deriveInvocations", () => {
170
198
  describe("traceSubgraph", () => {
171
199
  it("returns only the participating resources wired by call edges", () => {
172
200
  const t = deriveInvocations([
173
- inv("A.root.Invoked", 1, undefined, { outputs: 1 }),
174
- inv("B.child.Invoked", 2, 1, { outputs: 2 }),
175
- inv("C.other.Invoked", 9, undefined, { outputs: 9 }), // a different trace
201
+ inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
202
+ inv("child", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
203
+ inv("other", "C", 9, undefined, "ok", "Invoked", { outputs: 9 }), // a different trace
176
204
  ]);
177
205
  const g = traceSubgraph(t, 1);
178
206
  expect(g.nodes.map((n) => n.id).sort()).toEqual(["A.root", "B.child"]);
@@ -182,9 +210,9 @@ describe("traceSubgraph", () => {
182
210
 
183
211
  it("collapses repeated calls to one node holding every invocation", () => {
184
212
  const t = deriveInvocations([
185
- inv("A.root.Invoked", 1, undefined, { outputs: 1 }),
186
- inv("B.svc.Invoked", 2, 1, { outputs: 2 }),
187
- inv("B.svc.Invoked", 3, 1, { outputs: 3 }),
213
+ inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
214
+ inv("svc", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
215
+ inv("svc", "B", 3, 1, "ok", "Invoked", { outputs: 3 }),
188
216
  ]);
189
217
  const g = traceSubgraph(t, 1);
190
218
  const svc = g.nodes.find((n) => n.id === "B.svc");
package/src/graph.ts CHANGED
@@ -7,18 +7,19 @@ import { type DebugEvent, type DebugFrame, eventSuffix, isLogFrame } from "./wir
7
7
  * tracks each node's lifecycle status and most-recent invocation. The component
8
8
  * layer adds positions (dagre) and rendering.
9
9
  *
10
- * Three event families drive it, all named `<Kind>.<Name>.<Suffix>`:
11
- * - `.Created` → a node appears (status `created`); its payload carries the
12
- * resolved `{kind,name}` and the `dependencies` (the edges).
13
- * - `.Initialized` → the node flips to `initialized`.
14
- * - `.Teardown` → the node flips to `torndown`.
15
- * - invocation suffixes (`Invoked`, `InvokeFailed`, `InvokeRejected[.Undeclared]`,
16
- * `InvokeCancelled`, `RunCancelled`) → record the call's outcome, inputs and
17
- * outputs on the matching node.
10
+ * Two event families drive it:
11
+ * - Lifecycle events `<Kind>.<Name>.{Created,Initialized,Teardown}` — a node
12
+ * appears (its payload carries the resolved `{kind,name}` and `dependencies`),
13
+ * then flips to `initialized` / `torndown`.
14
+ * - Dispatch (trace) events — every capability call emits a structured payload
15
+ * `{ spanId, parentSpanId, capability, phase, outcome, ref:{kind,name}, … }`.
16
+ * The consumer reads the *payload*, never the dotted name: `ref` locates the
17
+ * node, `outcome` records the result. Terminal events carry an `outcome`;
18
+ * `phase:"start"` events carry none and are skipped here.
18
19
  *
19
- * When the producer has tracing on, invocation events also carry
20
- * `metadata.invocationId` / `metadata.parentInvocationId`; {@link deriveInvocations}
21
- * folds those into the call tree behind the invocation list + scoped trace graph.
20
+ * When the producer has tracing on, dispatch events also carry `spanId` /
21
+ * `parentSpanId`; {@link deriveInvocations} folds those into the call tree behind
22
+ * the invocation list + scoped trace graph.
22
23
  */
23
24
 
24
25
  export type NodeStatus = "created" | "initialized" | "torndown";
@@ -28,7 +29,7 @@ export type InvokeOutcome = "ok" | "failed" | "rejected" | "cancelled";
28
29
  /** The most recent invocation observed for a node. */
29
30
  export interface InvokeRecord {
30
31
  outcome: InvokeOutcome;
31
- /** The suffix part after `<Kind>.<Name>.` (e.g. `Invoked`, `InvokeFailed`). */
32
+ /** The terminal event's suffix (`Invoked`, `InvokeFailed`, …) — for display only. */
32
33
  suffix: string;
33
34
  timestamp: string;
34
35
  /** The dispatched inputs (present on every invocation event the kernel emits). */
@@ -85,14 +86,34 @@ function asResource(payload: unknown): WireResourceRef | undefined {
85
86
  return undefined;
86
87
  }
87
88
 
88
- function classify(suffix: string): InvokeOutcome | undefined {
89
- if (suffix === "Invoked") return "ok";
90
- if (suffix.startsWith("InvokeFailed")) return "failed";
91
- if (suffix.startsWith("InvokeRejected")) return "rejected";
92
- if (suffix === "InvokeCancelled" || suffix === "RunCancelled") return "cancelled";
89
+ /** The resolved `{kind,name}` a dispatch event's payload carries under `ref`. */
90
+ function readRef(payload: Record<string, unknown> | undefined): { kind: string; name: string } | undefined {
91
+ const ref = payload?.ref;
92
+ if (ref && typeof ref === "object") {
93
+ const { kind, name } = ref as Record<string, unknown>;
94
+ if (typeof kind === "string" && typeof name === "string") return { kind, name };
95
+ }
93
96
  return undefined;
94
97
  }
95
98
 
99
+ /** Narrow a payload `outcome` to a known {@link InvokeOutcome}. Absent on
100
+ * `phase:"start"` events, so they are naturally skipped by callers. */
101
+ function asOutcome(value: unknown): InvokeOutcome | undefined {
102
+ return value === "ok" || value === "failed" || value === "rejected" || value === "cancelled"
103
+ ? value
104
+ : undefined;
105
+ }
106
+
107
+ function num(value: unknown): number | undefined {
108
+ return typeof value === "number" ? value : undefined;
109
+ }
110
+
111
+ function asCapability(value: unknown): SpanCapability | undefined {
112
+ return value === "invoke" || value === "run" || value === "provide" || value === "request"
113
+ ? value
114
+ : undefined;
115
+ }
116
+
96
117
  /**
97
118
  * Fold a frame buffer into the resource graph. Logs are ignored; events are
98
119
  * applied in arrival order so the returned status / `lastInvoke` reflect the
@@ -145,19 +166,19 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
145
166
  return;
146
167
  }
147
168
 
148
- // Invocation events share `parseInvocationEvent` with the trace fold — nodes
149
- // are name-keyed, so the parsed resource name resolves the target directly.
150
- const parsed = parseInvocationEvent(event.event);
151
- const node = parsed ? nodes.get(parsed.name) : undefined;
152
- if (!parsed || !node) return;
153
- const outcome = classify(parsed.suffix);
154
- if (!outcome) return;
169
+ // Dispatch (trace) events: the payload's `ref.name` resolves the target node
170
+ // directly (nodes are name-keyed). `phase:"start"` events carry no `outcome`
171
+ // and are skipped — only terminal events record a result.
172
+ const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
173
+ const ref = readRef(p);
174
+ const outcome = asOutcome(p?.outcome);
175
+ const node = ref ? nodes.get(ref.name) : undefined;
176
+ if (!node || !outcome) return;
155
177
  node.invokeCount += 1;
156
178
  node.lastActivitySeq = seq;
157
- const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
158
179
  node.lastInvoke = {
159
180
  outcome,
160
- suffix: parsed.suffix,
181
+ suffix: eventSuffix(event.event),
161
182
  timestamp: event.timestamp,
162
183
  inputs: p && "inputs" in p ? p.inputs : undefined,
163
184
  outputs: p && "outputs" in p ? p.outputs : undefined,
@@ -170,15 +191,28 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
170
191
 
171
192
  // ── Invocation traces ──────────────────────────────────────────────────────
172
193
 
173
- /** One invocation — a single dispatched call, identified by its `invocationId`. */
194
+ /** Which capability the span represents. `"request"` is an inbound-boundary span. */
195
+ export type SpanCapability = "invoke" | "run" | "provide" | "request";
196
+
197
+ /** One invocation — a single dispatched call, identified by its `spanId`. */
174
198
  export interface Invocation {
175
199
  id: number;
176
200
  parentId?: number;
201
+ /** Trace this span belongs to — present while tracing; groups the call tree. */
202
+ traceId?: string;
177
203
  kind: string;
178
204
  name: string;
205
+ capability?: SpanCapability;
179
206
  outcome: InvokeOutcome;
180
- /** The event suffix (`Invoked`, `InvokeFailed`, …). */
207
+ /** The terminal event's suffix (`Invoked`, `InvokeFailed`, …) — for display only. */
181
208
  suffix: string;
209
+ /** Human label (e.g. a route `"POST /feedback"`), for `request` spans. */
210
+ label?: string;
211
+ /** Structured span attributes (e.g. `{ method, path }`). */
212
+ attributes?: Record<string, unknown>;
213
+ /** On a trace root: the redacted CEL root scope (variables / secrets / resources
214
+ * / ports) the trace could reference. */
215
+ context?: Record<string, unknown>;
182
216
  timestamp: string;
183
217
  inputs?: unknown;
184
218
  outputs?: unknown;
@@ -195,39 +229,12 @@ export interface TraceState {
195
229
  roots: Invocation[];
196
230
  }
197
231
 
198
- /** Known invocation suffixes, longest first so `InvokeRejected.Undeclared` wins
199
- * over `InvokeRejected`. */
200
- const INVOKE_SUFFIXES = [
201
- "InvokeRejected.Undeclared",
202
- "InvokeCancelled",
203
- "InvokeRejected",
204
- "InvokeFailed",
205
- "Invoked",
206
- "RunCancelled",
207
- ] as const;
208
-
209
- /** Split a dotted invocation event into `{ kind, name, suffix }` by stripping a
210
- * known trailing suffix — self-contained, no node registry needed. */
211
- function parseInvocationEvent(event: string): { kind: string; name: string; suffix: string } | undefined {
212
- for (const suffix of INVOKE_SUFFIXES) {
213
- if (event.endsWith(`.${suffix}`)) {
214
- const head = event.slice(0, event.length - suffix.length - 1);
215
- const dot = head.lastIndexOf(".");
216
- if (dot <= 0) return undefined;
217
- return { kind: head.slice(0, dot), name: head.slice(dot + 1), suffix };
218
- }
219
- }
220
- return undefined;
221
- }
222
-
223
- function metaId(value: unknown): number | undefined {
224
- return typeof value === "number" ? value : undefined;
225
- }
226
-
227
232
  /**
228
- * Fold the invocation events that carry `metadata.invocationId` into a call tree.
229
- * Each invocation appears once (the `.Undeclared` rejection echo is deduped by id,
230
- * keeping the primary event). Empty when the producer isn't tracing.
233
+ * Fold the dispatch events that carry a `spanId` into a call tree. Each
234
+ * invocation appears once — keyed by `spanId`, the first terminal event wins, so
235
+ * the `InvokeRejected.Undeclared` echo (same id, emitted after `InvokeRejected`)
236
+ * is deduped. `phase:"start"` events carry no `outcome` and are skipped. Empty
237
+ * when the producer isn't tracing (no `spanId`).
231
238
  */
232
239
  export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
233
240
  const byId = new Map<number, Invocation>();
@@ -237,22 +244,33 @@ export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
237
244
  for (const frame of frames) {
238
245
  if (isLogFrame(frame)) continue;
239
246
  const event = frame as DebugEvent;
240
- const id = metaId((event.metadata as Record<string, unknown> | undefined)?.invocationId);
247
+ const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
248
+ const id = num(p?.spanId);
241
249
  if (id === undefined || byId.has(id)) continue;
242
- const parsed = parseInvocationEvent(event.event);
243
- if (!parsed) continue;
244
- const outcome = classify(parsed.suffix);
250
+ const outcome = asOutcome(p?.outcome);
245
251
  if (!outcome) continue;
252
+ const ref = readRef(p);
253
+ if (!ref) continue;
246
254
 
247
- const parentId = metaId((event.metadata as Record<string, unknown>).parentInvocationId);
248
- const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
255
+ const parentId = num(p?.parentSpanId);
249
256
  const invocation: Invocation = {
250
257
  id,
251
258
  parentId,
252
- kind: parsed.kind,
253
- name: parsed.name,
259
+ traceId: typeof p?.traceId === "string" ? p.traceId : undefined,
260
+ kind: ref.kind,
261
+ name: ref.name,
262
+ capability: asCapability(p?.capability),
254
263
  outcome,
255
- suffix: parsed.suffix,
264
+ suffix: eventSuffix(event.event),
265
+ label: typeof p?.label === "string" ? p.label : undefined,
266
+ attributes:
267
+ p?.attributes && typeof p.attributes === "object"
268
+ ? (p.attributes as Record<string, unknown>)
269
+ : undefined,
270
+ context:
271
+ p?.context && typeof p.context === "object"
272
+ ? (p.context as Record<string, unknown>)
273
+ : undefined,
256
274
  timestamp: event.timestamp,
257
275
  inputs: p && "inputs" in p ? p.inputs : undefined,
258
276
  outputs: p && "outputs" in p ? p.outputs : undefined,
@@ -278,6 +296,8 @@ export interface TraceNode {
278
296
  id: string;
279
297
  kind: string;
280
298
  name: string;
299
+ /** Human label from the span (e.g. a route `"POST /feedback"`), if any. */
300
+ label?: string;
281
301
  isRoot: boolean;
282
302
  invocations: Invocation[];
283
303
  }
@@ -313,7 +333,15 @@ export function traceSubgraph(trace: TraceState, rootId: number): TraceSubgraph
313
333
  const key = resourceKey(inv);
314
334
  const node = nodes.get(key);
315
335
  if (node) node.invocations.push(inv);
316
- else nodes.set(key, { id: key, kind: inv.kind, name: inv.name, isRoot: id === rootId, invocations: [inv] });
336
+ else
337
+ nodes.set(key, {
338
+ id: key,
339
+ kind: inv.kind,
340
+ name: inv.name,
341
+ label: inv.label,
342
+ isRoot: id === rootId,
343
+ invocations: [inv],
344
+ });
317
345
 
318
346
  for (const childId of trace.childrenOf.get(id) ?? []) {
319
347
  const child = trace.byId.get(childId);