@telorun/debug-ui 0.3.0 → 0.5.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/app-dist/assets/{index-CWG3ZSzH.css → index-C9ZwjlYK.css} +1 -1
- package/app-dist/assets/index-D2M9P905.js +126 -0
- package/app-dist/index.html +2 -2
- package/app-single/index.html +29 -29
- package/package.json +2 -2
- package/src/components/EventGraph.tsx +223 -17
- package/src/components/styles.css +165 -1
- package/src/filter.ts +6 -2
- package/src/graph.test.ts +270 -39
- package/src/graph.ts +302 -79
- package/app-dist/assets/index-DS4SK8dD.js +0 -126
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
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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,
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
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
|
|
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). */
|
|
@@ -40,11 +41,20 @@ export interface InvokeRecord {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export interface GraphNode {
|
|
43
|
-
/** Stable id — the resource
|
|
44
|
+
/** Stable id — the resource's full hierarchical id (`<owner.id>/<kind>.<name>`),
|
|
45
|
+
* unique across instances of the same templated kind. Falls back to the bare
|
|
46
|
+
* name on a legacy stream that carries no `id`. */
|
|
44
47
|
id: string;
|
|
45
48
|
kind: string;
|
|
46
49
|
name: string;
|
|
47
50
|
module?: string;
|
|
51
|
+
/** The owning resource's id, when this resource was spawned by another (a
|
|
52
|
+
* templated kind's child). Drives the collapsible parent/child grouping. */
|
|
53
|
+
ownerId?: string;
|
|
54
|
+
/** The resource's resolved config "after templating" (from the `Created`
|
|
55
|
+
* payload) — concrete values for compile-time CEL, `{kind,name}` for refs,
|
|
56
|
+
* `${{ … }}` for deferred runtime expressions, secrets scrubbed. */
|
|
57
|
+
properties?: unknown;
|
|
48
58
|
status: NodeStatus;
|
|
49
59
|
/** Total invocations seen across the stream. */
|
|
50
60
|
invokeCount: number;
|
|
@@ -60,6 +70,10 @@ export interface GraphEdge {
|
|
|
60
70
|
source: string;
|
|
61
71
|
/** Target resource name (the dependency). */
|
|
62
72
|
target: string;
|
|
73
|
+
/** An ownership edge (owner → spawned child), not a dependency. Added when a
|
|
74
|
+
* templated resource is expanded so its revealed children stay attached to it;
|
|
75
|
+
* the renderer draws it distinctly (dashed/muted). */
|
|
76
|
+
ownership?: boolean;
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
export interface GraphState {
|
|
@@ -71,9 +85,23 @@ interface WireResourceRef {
|
|
|
71
85
|
kind: string;
|
|
72
86
|
name: string;
|
|
73
87
|
module?: string;
|
|
88
|
+
/** Full hierarchical id; absent on a legacy stream. */
|
|
89
|
+
id?: string;
|
|
74
90
|
alias?: string;
|
|
75
91
|
}
|
|
76
92
|
|
|
93
|
+
interface WireOwner {
|
|
94
|
+
kind: string;
|
|
95
|
+
name: string;
|
|
96
|
+
id: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The id a node is keyed by: the producer's hierarchical `id`, or the bare name
|
|
100
|
+
* on a legacy stream. Globally unique when `id` is present. */
|
|
101
|
+
function nodeKey(ref: { id?: string; name: string }): string {
|
|
102
|
+
return ref.id ?? ref.name;
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
function asResource(payload: unknown): WireResourceRef | undefined {
|
|
78
106
|
const res = (payload as { resource?: unknown } | undefined)?.resource;
|
|
79
107
|
if (res && typeof res === "object") {
|
|
@@ -85,14 +113,52 @@ function asResource(payload: unknown): WireResourceRef | undefined {
|
|
|
85
113
|
return undefined;
|
|
86
114
|
}
|
|
87
115
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
116
|
+
/** The owning resource a lifecycle/dispatch payload carries under `owner`, when
|
|
117
|
+
* the resource was spawned by another (a templated kind's child). */
|
|
118
|
+
function readOwner(payload: unknown): WireOwner | undefined {
|
|
119
|
+
const owner = (payload as { owner?: unknown } | undefined)?.owner;
|
|
120
|
+
if (owner && typeof owner === "object") {
|
|
121
|
+
const { kind, name, id } = owner as Record<string, unknown>;
|
|
122
|
+
if (typeof kind === "string" && typeof name === "string" && typeof id === "string") {
|
|
123
|
+
return { kind, name, id };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
93
126
|
return undefined;
|
|
94
127
|
}
|
|
95
128
|
|
|
129
|
+
/** The resolved ref a dispatch event's payload carries under `ref` — `id` keys
|
|
130
|
+
* the node (a templated child's calls land on the right instance). */
|
|
131
|
+
function readRef(
|
|
132
|
+
payload: Record<string, unknown> | undefined,
|
|
133
|
+
): { kind: string; name: string; id?: string } | undefined {
|
|
134
|
+
const ref = payload?.ref;
|
|
135
|
+
if (ref && typeof ref === "object") {
|
|
136
|
+
const { kind, name, id } = ref as Record<string, unknown>;
|
|
137
|
+
if (typeof kind === "string" && typeof name === "string") {
|
|
138
|
+
return { kind, name, id: typeof id === "string" ? id : undefined };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Narrow a payload `outcome` to a known {@link InvokeOutcome}. Absent on
|
|
145
|
+
* `phase:"start"` events, so they are naturally skipped by callers. */
|
|
146
|
+
function asOutcome(value: unknown): InvokeOutcome | undefined {
|
|
147
|
+
return value === "ok" || value === "failed" || value === "rejected" || value === "cancelled"
|
|
148
|
+
? value
|
|
149
|
+
: undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function num(value: unknown): number | undefined {
|
|
153
|
+
return typeof value === "number" ? value : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function asCapability(value: unknown): SpanCapability | undefined {
|
|
157
|
+
return value === "invoke" || value === "run" || value === "provide" || value === "request"
|
|
158
|
+
? value
|
|
159
|
+
: undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
96
162
|
/**
|
|
97
163
|
* Fold a frame buffer into the resource graph. Logs are ignored; events are
|
|
98
164
|
* applied in arrival order so the returned status / `lastInvoke` reflect the
|
|
@@ -112,15 +178,18 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
|
|
|
112
178
|
if (suffix === "Created") {
|
|
113
179
|
const res = asResource(event.payload);
|
|
114
180
|
if (!res) return;
|
|
115
|
-
const
|
|
181
|
+
const key = nodeKey(res);
|
|
182
|
+
const existing = nodes.get(key);
|
|
116
183
|
if (existing) {
|
|
117
184
|
existing.lastActivitySeq = seq;
|
|
118
185
|
} else {
|
|
119
|
-
nodes.set(
|
|
120
|
-
id:
|
|
186
|
+
nodes.set(key, {
|
|
187
|
+
id: key,
|
|
121
188
|
kind: res.kind,
|
|
122
189
|
name: res.name,
|
|
123
190
|
module: res.module,
|
|
191
|
+
ownerId: readOwner(event.payload)?.id,
|
|
192
|
+
properties: (event.payload as { properties?: unknown }).properties,
|
|
124
193
|
status: "created",
|
|
125
194
|
invokeCount: 0,
|
|
126
195
|
lastActivitySeq: seq,
|
|
@@ -128,16 +197,17 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
|
|
|
128
197
|
}
|
|
129
198
|
const deps = (event.payload as { dependencies?: WireResourceRef[] }).dependencies ?? [];
|
|
130
199
|
for (const dep of deps) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
200
|
+
const depKey = dep?.name ? nodeKey(dep) : undefined;
|
|
201
|
+
if (!depKey || depKey === key) continue;
|
|
202
|
+
const id = `${key}->${depKey}`;
|
|
203
|
+
if (!edges.has(id)) edges.set(id, { id, source: key, target: depKey });
|
|
134
204
|
}
|
|
135
205
|
return;
|
|
136
206
|
}
|
|
137
207
|
|
|
138
208
|
if (suffix === "Initialized" || suffix === "Teardown") {
|
|
139
209
|
const res = asResource(event.payload);
|
|
140
|
-
const node = res ? nodes.get(res
|
|
210
|
+
const node = res ? nodes.get(nodeKey(res)) : undefined;
|
|
141
211
|
if (node) {
|
|
142
212
|
node.status = suffix === "Teardown" ? "torndown" : "initialized";
|
|
143
213
|
node.lastActivitySeq = seq;
|
|
@@ -145,19 +215,19 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
|
|
|
145
215
|
return;
|
|
146
216
|
}
|
|
147
217
|
|
|
148
|
-
//
|
|
149
|
-
// are
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const outcome =
|
|
154
|
-
|
|
218
|
+
// Dispatch (trace) events: the payload's `ref.id` resolves the target node
|
|
219
|
+
// directly (nodes are id-keyed). `phase:"start"` events carry no `outcome`
|
|
220
|
+
// and are skipped — only terminal events record a result.
|
|
221
|
+
const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
|
|
222
|
+
const ref = readRef(p);
|
|
223
|
+
const outcome = asOutcome(p?.outcome);
|
|
224
|
+
const node = ref ? nodes.get(nodeKey(ref)) : undefined;
|
|
225
|
+
if (!node || !outcome) return;
|
|
155
226
|
node.invokeCount += 1;
|
|
156
227
|
node.lastActivitySeq = seq;
|
|
157
|
-
const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
|
|
158
228
|
node.lastInvoke = {
|
|
159
229
|
outcome,
|
|
160
|
-
suffix:
|
|
230
|
+
suffix: eventSuffix(event.event),
|
|
161
231
|
timestamp: event.timestamp,
|
|
162
232
|
inputs: p && "inputs" in p ? p.inputs : undefined,
|
|
163
233
|
outputs: p && "outputs" in p ? p.outputs : undefined,
|
|
@@ -168,17 +238,175 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
|
|
|
168
238
|
return { nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
169
239
|
}
|
|
170
240
|
|
|
241
|
+
// ── Owner grouping (collapsible parents) ────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** A topology node augmented with its grouping state — how many direct children
|
|
244
|
+
* it owns and whether they are currently revealed. */
|
|
245
|
+
export interface GroupedGraphNode extends GraphNode {
|
|
246
|
+
/** Number of resources directly owned by this node (spawned children). */
|
|
247
|
+
childCount: number;
|
|
248
|
+
/** Whether this node's children are currently shown (only meaningful when
|
|
249
|
+
* `childCount > 0`). */
|
|
250
|
+
expanded: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface GroupedGraphState {
|
|
254
|
+
nodes: GroupedGraphNode[];
|
|
255
|
+
edges: GraphEdge[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Count direct children per owner id — how many resources each node spawned. */
|
|
259
|
+
function countChildren(nodes: readonly GraphNode[]): Map<string, number> {
|
|
260
|
+
const counts = new Map<string, number>();
|
|
261
|
+
for (const n of nodes) {
|
|
262
|
+
if (n.ownerId) counts.set(n.ownerId, (counts.get(n.ownerId) ?? 0) + 1);
|
|
263
|
+
}
|
|
264
|
+
return counts;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Collapse a topology by owner: a resource spawned by another (a templated kind's
|
|
269
|
+
* child) is hidden unless every ancestor in its owner chain is in `expanded`. A
|
|
270
|
+
* collapsed parent absorbs its hidden descendants' edges — each edge endpoint is
|
|
271
|
+
* remapped to its nearest visible ancestor — so the parent stays wired to the
|
|
272
|
+
* rest of the graph as one node. Pure: drives the Graph view's collapse toggles
|
|
273
|
+
* and is independently testable. With an empty `expanded` set, every owner is
|
|
274
|
+
* collapsed (the default), so the top-level topology stays readable.
|
|
275
|
+
*/
|
|
276
|
+
export function collapseTopology(
|
|
277
|
+
graph: GraphState,
|
|
278
|
+
expanded: ReadonlySet<string>,
|
|
279
|
+
): GroupedGraphState {
|
|
280
|
+
const byId = new Map<string, GraphNode>(graph.nodes.map((n) => [n.id, n] as [string, GraphNode]));
|
|
281
|
+
const childCount = countChildren(graph.nodes);
|
|
282
|
+
|
|
283
|
+
// A node is visible when no present ancestor in its owner chain is collapsed.
|
|
284
|
+
const visible = (node: GraphNode): boolean => {
|
|
285
|
+
let cur: GraphNode | undefined = node;
|
|
286
|
+
while (cur?.ownerId) {
|
|
287
|
+
const parent = byId.get(cur.ownerId);
|
|
288
|
+
if (!parent) return true; // owner not in this stream — don't hide under an unknown
|
|
289
|
+
if (!expanded.has(parent.id)) return false;
|
|
290
|
+
cur = parent;
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// The nearest visible ancestor an edge endpoint folds into.
|
|
296
|
+
const resolveVisible = (id: string): string => {
|
|
297
|
+
const start = byId.get(id);
|
|
298
|
+
if (!start) return id;
|
|
299
|
+
let node: GraphNode = start;
|
|
300
|
+
while (!visible(node)) {
|
|
301
|
+
const parent = node.ownerId ? byId.get(node.ownerId) : undefined;
|
|
302
|
+
if (!parent) return node.id;
|
|
303
|
+
node = parent;
|
|
304
|
+
}
|
|
305
|
+
return node.id;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const nodes = graph.nodes
|
|
309
|
+
.filter(visible)
|
|
310
|
+
.map((n) => ({ ...n, childCount: childCount.get(n.id) ?? 0, expanded: expanded.has(n.id) }));
|
|
311
|
+
|
|
312
|
+
const edges = new Map<string, GraphEdge>();
|
|
313
|
+
for (const e of graph.edges) {
|
|
314
|
+
const source = resolveVisible(e.source);
|
|
315
|
+
const target = resolveVisible(e.target);
|
|
316
|
+
if (source === target) continue;
|
|
317
|
+
const id = `${source}->${target}`;
|
|
318
|
+
if (!edges.has(id)) edges.set(id, { id, source, target });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Attach each revealed child to its owner: when a parent is expanded both it
|
|
322
|
+
// and its children are visible, but no dependency edge ties them together, so
|
|
323
|
+
// they would float as a detached cluster. An ownership edge keeps them grouped
|
|
324
|
+
// (and pulls the children next to the parent in the layout).
|
|
325
|
+
const visibleIds = new Set(nodes.map((n) => n.id));
|
|
326
|
+
for (const n of nodes) {
|
|
327
|
+
if (n.ownerId && visibleIds.has(n.ownerId)) {
|
|
328
|
+
const id = `owner:${n.ownerId}->${n.id}`;
|
|
329
|
+
edges.set(id, { id, source: n.ownerId, target: n.id, ownership: true });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { nodes, edges: [...edges.values()] };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The drill-down view of one templated resource: the parent node plus the direct
|
|
338
|
+
* children it spawned. Children are wired by the dependency edges that run *among*
|
|
339
|
+
* them (e.g. an Http.Api → its SQL handlers); anything pointing outside the
|
|
340
|
+
* subtree is dropped (that wiring is visible at the level above). The parent
|
|
341
|
+
* connects by an ownership edge only to children that aren't already reached by a
|
|
342
|
+
* sibling's dependency edge — so a handler reached through the Http.Api isn't also
|
|
343
|
+
* tied directly to the parent, leaving a clean tree instead of a redundant fan.
|
|
344
|
+
* Each child that itself owns children reports its `childCount` for a further
|
|
345
|
+
* drill-in. Pure and depth-independent: drilling into a child calls this again.
|
|
346
|
+
*/
|
|
347
|
+
export function subtreeGraph(topology: GraphState, ownerId: string): GroupedGraphState {
|
|
348
|
+
const childCount = countChildren(topology.nodes);
|
|
349
|
+
|
|
350
|
+
const parent = topology.nodes.find((n) => n.id === ownerId);
|
|
351
|
+
const children = topology.nodes.filter((n) => n.ownerId === ownerId);
|
|
352
|
+
|
|
353
|
+
const nodes: GroupedGraphNode[] = [];
|
|
354
|
+
if (parent) nodes.push({ ...parent, childCount: childCount.get(parent.id) ?? 0, expanded: true });
|
|
355
|
+
for (const c of children) {
|
|
356
|
+
nodes.push({ ...c, childCount: childCount.get(c.id) ?? 0, expanded: false });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const present = new Set(nodes.map((n) => n.id));
|
|
360
|
+
const edges = new Map<string, GraphEdge>();
|
|
361
|
+
|
|
362
|
+
// Dependency edges among the shown nodes; track which are reached by one so the
|
|
363
|
+
// parent doesn't also tie to a child already connected through a sibling.
|
|
364
|
+
const reachedByDep = new Set<string>();
|
|
365
|
+
for (const e of topology.edges) {
|
|
366
|
+
if (e.source !== e.target && present.has(e.source) && present.has(e.target) && !edges.has(e.id)) {
|
|
367
|
+
edges.set(e.id, e);
|
|
368
|
+
reachedByDep.add(e.target);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Ownership edge parent → child, only for children not already wired in by a
|
|
373
|
+
// sibling's dependency edge.
|
|
374
|
+
for (const c of children) {
|
|
375
|
+
if (reachedByDep.has(c.id)) continue;
|
|
376
|
+
const id = `owner:${ownerId}->${c.id}`;
|
|
377
|
+
edges.set(id, { id, source: ownerId, target: c.id, ownership: true });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { nodes, edges: [...edges.values()] };
|
|
381
|
+
}
|
|
382
|
+
|
|
171
383
|
// ── Invocation traces ──────────────────────────────────────────────────────
|
|
172
384
|
|
|
173
|
-
/**
|
|
385
|
+
/** Which capability the span represents. `"request"` is an inbound-boundary span. */
|
|
386
|
+
export type SpanCapability = "invoke" | "run" | "provide" | "request";
|
|
387
|
+
|
|
388
|
+
/** One invocation — a single dispatched call, identified by its `spanId`. */
|
|
174
389
|
export interface Invocation {
|
|
175
390
|
id: number;
|
|
176
391
|
parentId?: number;
|
|
392
|
+
/** Trace this span belongs to — present while tracing; groups the call tree. */
|
|
393
|
+
traceId?: string;
|
|
177
394
|
kind: string;
|
|
178
395
|
name: string;
|
|
396
|
+
/** The dispatched resource's full hierarchical id; absent on a legacy stream.
|
|
397
|
+
* Keys the scoped trace graph so a templated child collapses per instance. */
|
|
398
|
+
resourceId?: string;
|
|
399
|
+
capability?: SpanCapability;
|
|
179
400
|
outcome: InvokeOutcome;
|
|
180
|
-
/** The event suffix (`Invoked`, `InvokeFailed`, …). */
|
|
401
|
+
/** The terminal event's suffix (`Invoked`, `InvokeFailed`, …) — for display only. */
|
|
181
402
|
suffix: string;
|
|
403
|
+
/** Human label (e.g. a route `"POST /feedback"`), for `request` spans. */
|
|
404
|
+
label?: string;
|
|
405
|
+
/** Structured span attributes (e.g. `{ method, path }`). */
|
|
406
|
+
attributes?: Record<string, unknown>;
|
|
407
|
+
/** On a trace root: the redacted CEL root scope (variables / secrets / resources
|
|
408
|
+
* / ports) the trace could reference. */
|
|
409
|
+
context?: Record<string, unknown>;
|
|
182
410
|
timestamp: string;
|
|
183
411
|
inputs?: unknown;
|
|
184
412
|
outputs?: unknown;
|
|
@@ -195,39 +423,12 @@ export interface TraceState {
|
|
|
195
423
|
roots: Invocation[];
|
|
196
424
|
}
|
|
197
425
|
|
|
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
426
|
/**
|
|
228
|
-
* Fold the
|
|
229
|
-
*
|
|
230
|
-
*
|
|
427
|
+
* Fold the dispatch events that carry a `spanId` into a call tree. Each
|
|
428
|
+
* invocation appears once — keyed by `spanId`, the first terminal event wins, so
|
|
429
|
+
* the `InvokeRejected.Undeclared` echo (same id, emitted after `InvokeRejected`)
|
|
430
|
+
* is deduped. `phase:"start"` events carry no `outcome` and are skipped. Empty
|
|
431
|
+
* when the producer isn't tracing (no `spanId`).
|
|
231
432
|
*/
|
|
232
433
|
export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
|
|
233
434
|
const byId = new Map<number, Invocation>();
|
|
@@ -237,22 +438,34 @@ export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
|
|
|
237
438
|
for (const frame of frames) {
|
|
238
439
|
if (isLogFrame(frame)) continue;
|
|
239
440
|
const event = frame as DebugEvent;
|
|
240
|
-
const
|
|
441
|
+
const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
|
|
442
|
+
const id = num(p?.spanId);
|
|
241
443
|
if (id === undefined || byId.has(id)) continue;
|
|
242
|
-
const
|
|
243
|
-
if (!parsed) continue;
|
|
244
|
-
const outcome = classify(parsed.suffix);
|
|
444
|
+
const outcome = asOutcome(p?.outcome);
|
|
245
445
|
if (!outcome) continue;
|
|
446
|
+
const ref = readRef(p);
|
|
447
|
+
if (!ref) continue;
|
|
246
448
|
|
|
247
|
-
const parentId =
|
|
248
|
-
const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
|
|
449
|
+
const parentId = num(p?.parentSpanId);
|
|
249
450
|
const invocation: Invocation = {
|
|
250
451
|
id,
|
|
251
452
|
parentId,
|
|
252
|
-
|
|
253
|
-
|
|
453
|
+
traceId: typeof p?.traceId === "string" ? p.traceId : undefined,
|
|
454
|
+
kind: ref.kind,
|
|
455
|
+
name: ref.name,
|
|
456
|
+
resourceId: ref.id,
|
|
457
|
+
capability: asCapability(p?.capability),
|
|
254
458
|
outcome,
|
|
255
|
-
suffix:
|
|
459
|
+
suffix: eventSuffix(event.event),
|
|
460
|
+
label: typeof p?.label === "string" ? p.label : undefined,
|
|
461
|
+
attributes:
|
|
462
|
+
p?.attributes && typeof p.attributes === "object"
|
|
463
|
+
? (p.attributes as Record<string, unknown>)
|
|
464
|
+
: undefined,
|
|
465
|
+
context:
|
|
466
|
+
p?.context && typeof p.context === "object"
|
|
467
|
+
? (p.context as Record<string, unknown>)
|
|
468
|
+
: undefined,
|
|
256
469
|
timestamp: event.timestamp,
|
|
257
470
|
inputs: p && "inputs" in p ? p.inputs : undefined,
|
|
258
471
|
outputs: p && "outputs" in p ? p.outputs : undefined,
|
|
@@ -278,6 +491,8 @@ export interface TraceNode {
|
|
|
278
491
|
id: string;
|
|
279
492
|
kind: string;
|
|
280
493
|
name: string;
|
|
494
|
+
/** Human label from the span (e.g. a route `"POST /feedback"`), if any. */
|
|
495
|
+
label?: string;
|
|
281
496
|
isRoot: boolean;
|
|
282
497
|
invocations: Invocation[];
|
|
283
498
|
}
|
|
@@ -303,7 +518,7 @@ export function traceSubgraph(trace: TraceState, rootId: number): TraceSubgraph
|
|
|
303
518
|
const root = trace.byId.get(rootId);
|
|
304
519
|
if (!root) return { nodes: [], edges: [] };
|
|
305
520
|
|
|
306
|
-
const resourceKey = (inv: Invocation) => `${inv.kind}.${inv.name}`;
|
|
521
|
+
const resourceKey = (inv: Invocation) => inv.resourceId ?? `${inv.kind}.${inv.name}`;
|
|
307
522
|
const nodes = new Map<string, TraceNode>();
|
|
308
523
|
const edges = new Map<string, TraceEdge>();
|
|
309
524
|
|
|
@@ -313,7 +528,15 @@ export function traceSubgraph(trace: TraceState, rootId: number): TraceSubgraph
|
|
|
313
528
|
const key = resourceKey(inv);
|
|
314
529
|
const node = nodes.get(key);
|
|
315
530
|
if (node) node.invocations.push(inv);
|
|
316
|
-
else
|
|
531
|
+
else
|
|
532
|
+
nodes.set(key, {
|
|
533
|
+
id: key,
|
|
534
|
+
kind: inv.kind,
|
|
535
|
+
name: inv.name,
|
|
536
|
+
label: inv.label,
|
|
537
|
+
isRoot: id === rootId,
|
|
538
|
+
invocations: [inv],
|
|
539
|
+
});
|
|
317
540
|
|
|
318
541
|
for (const childId of trace.childrenOf.get(id) ?? []) {
|
|
319
542
|
const child = trace.byId.get(childId);
|