@nwire/studio 0.12.1 → 0.13.1
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 +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- package/src/pages/__tests__/Queries.test.ts +0 -86
package/src/lib/rca.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root-cause analysis — group raw failure records into incidents and read the
|
|
3
|
+
* story out of each one. A failed chain rarely produces a single record: an
|
|
4
|
+
* action.failed retries, then dlq.recorded, while a sibling projection.failed
|
|
5
|
+
* lands on the same correlation. This groups by correlation, picks the terminal
|
|
6
|
+
* outcome as the headline, gathers every record on the chain as evidence, and
|
|
7
|
+
* derives the blast radius (apps, tenant, retries). Pure — unit-testable, and
|
|
8
|
+
* the Errors view + RcaPanel render straight off these shapes.
|
|
9
|
+
*/
|
|
10
|
+
import { isFailure, type TelemetryRecord } from "./telemetry";
|
|
11
|
+
import {
|
|
12
|
+
explainError,
|
|
13
|
+
severityOf,
|
|
14
|
+
maxSeverity,
|
|
15
|
+
type FriendlyError,
|
|
16
|
+
type Severity,
|
|
17
|
+
} from "./error-friendly";
|
|
18
|
+
|
|
19
|
+
/** One step on the incident timeline — a record plus whether it's a failure. */
|
|
20
|
+
export interface TimelineEntry {
|
|
21
|
+
readonly record: TelemetryRecord;
|
|
22
|
+
readonly failure: boolean;
|
|
23
|
+
/** ms since the first record on the chain, when timestamps are present. */
|
|
24
|
+
readonly offsetMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The blast radius of an incident. */
|
|
28
|
+
export interface Impact {
|
|
29
|
+
readonly tenant?: string;
|
|
30
|
+
readonly userId?: string;
|
|
31
|
+
/** Apps that touched the chain. */
|
|
32
|
+
readonly apps: readonly string[];
|
|
33
|
+
/** Total records on the chain. */
|
|
34
|
+
readonly records: number;
|
|
35
|
+
/** Failure records on the chain. */
|
|
36
|
+
readonly failures: number;
|
|
37
|
+
/** Retries the runtime made before this settled (0 = first try). */
|
|
38
|
+
readonly retries: number;
|
|
39
|
+
/** The chain ended in the dead-letter queue / exhausted retries. */
|
|
40
|
+
readonly deadLettered: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A grouped failure — everything the Errors view needs about one incident. */
|
|
44
|
+
export interface Incident {
|
|
45
|
+
/** Stable id — the correlationId, or `solo:<n>` for envelope-less failures. */
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly correlationId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The failure that represents the incident — the terminal outcome (dead-letter
|
|
50
|
+
* / exhausted) when there is one, else the loudest failure. This is what the
|
|
51
|
+
* card headline + RcaPanel speak to; the full cascade lives in `failures`.
|
|
52
|
+
*/
|
|
53
|
+
readonly root: TelemetryRecord;
|
|
54
|
+
/** Humanised view of the headline failure. */
|
|
55
|
+
readonly friendly: FriendlyError;
|
|
56
|
+
/** Loudest severity across the chain's failures. */
|
|
57
|
+
readonly severity: Severity;
|
|
58
|
+
/** Every failure record on the chain, in arrival order. */
|
|
59
|
+
readonly failures: readonly TelemetryRecord[];
|
|
60
|
+
/** Every record on the chain (failures + context), in arrival order. */
|
|
61
|
+
readonly related: readonly TelemetryRecord[];
|
|
62
|
+
/** How many failures — the card's count badge. */
|
|
63
|
+
readonly count: number;
|
|
64
|
+
/** Arrival index of the latest record — sort newest-first. */
|
|
65
|
+
readonly lastIndex: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
|
|
69
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Group a flat record list into incidents, newest first. Failures sharing a
|
|
73
|
+
* correlationId form one incident (with their whole chain as context); a
|
|
74
|
+
* failure with no correlation becomes its own solo incident.
|
|
75
|
+
*/
|
|
76
|
+
export function groupIncidents(records: readonly TelemetryRecord[]): Incident[] {
|
|
77
|
+
const byCorrelation = new Map<string, number[]>(); // correlationId → record indices
|
|
78
|
+
const solo: number[] = [];
|
|
79
|
+
|
|
80
|
+
records.forEach((r, i) => {
|
|
81
|
+
if (!isFailure(r)) return;
|
|
82
|
+
const cid = r.envelope?.correlationId;
|
|
83
|
+
if (cid) {
|
|
84
|
+
const bucket = byCorrelation.get(cid);
|
|
85
|
+
if (bucket) bucket.push(i);
|
|
86
|
+
else byCorrelation.set(cid, [i]);
|
|
87
|
+
} else {
|
|
88
|
+
solo.push(i);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const incidents: Incident[] = [];
|
|
93
|
+
|
|
94
|
+
for (const [cid, failureIdx] of byCorrelation) {
|
|
95
|
+
const related = records.filter((r) => r.envelope?.correlationId === cid);
|
|
96
|
+
const failures = failureIdx.map((i) => records[i]!);
|
|
97
|
+
incidents.push(makeIncident(cid, cid, failures, related, Math.max(...failureIdx)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const i of solo) {
|
|
101
|
+
const r = records[i]!;
|
|
102
|
+
incidents.push(makeIncident(`solo:${i}`, undefined, [r], [r], i));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return incidents.sort((a, b) => b.lastIndex - a.lastIndex);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeIncident(
|
|
109
|
+
id: string,
|
|
110
|
+
correlationId: string | undefined,
|
|
111
|
+
failures: TelemetryRecord[],
|
|
112
|
+
related: TelemetryRecord[],
|
|
113
|
+
lastIndex: number,
|
|
114
|
+
): Incident {
|
|
115
|
+
const root = pickHeadline(failures);
|
|
116
|
+
const severity = failures.reduce<Severity>((acc, r) => maxSeverity(acc, severityOf(r)), "info");
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
correlationId,
|
|
120
|
+
root,
|
|
121
|
+
friendly: explainError(root),
|
|
122
|
+
severity,
|
|
123
|
+
failures,
|
|
124
|
+
related,
|
|
125
|
+
count: failures.length,
|
|
126
|
+
lastIndex,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The failure that best represents the incident: a terminal outcome
|
|
132
|
+
* (dead-letter / exhausted) when present — that's the actionable headline and
|
|
133
|
+
* carries the final error — otherwise the loudest failure, ties broken by
|
|
134
|
+
* arrival order (earliest).
|
|
135
|
+
*/
|
|
136
|
+
function pickHeadline(failures: TelemetryRecord[]): TelemetryRecord {
|
|
137
|
+
const terminal = failures.find(
|
|
138
|
+
(f) => f.kind === "dlq.recorded" || f.kind === "reaction.exhausted",
|
|
139
|
+
);
|
|
140
|
+
if (terminal) return terminal;
|
|
141
|
+
let best = failures[0]!;
|
|
142
|
+
for (const f of failures) {
|
|
143
|
+
if (
|
|
144
|
+
maxSeverity(severityOf(best), severityOf(f)) === severityOf(f) &&
|
|
145
|
+
severityOf(f) !== severityOf(best)
|
|
146
|
+
) {
|
|
147
|
+
best = f;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return best;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Build the ordered timeline for an incident, with ms offsets when available. */
|
|
154
|
+
export function buildTimeline(incident: Incident): TimelineEntry[] {
|
|
155
|
+
const times = incident.related
|
|
156
|
+
.map((r) => (r.ts ? Date.parse(r.ts) : NaN))
|
|
157
|
+
.filter((t) => !Number.isNaN(t));
|
|
158
|
+
const base = times.length ? Math.min(...times) : NaN;
|
|
159
|
+
return incident.related.map((record) => {
|
|
160
|
+
const t = record.ts ? Date.parse(record.ts) : NaN;
|
|
161
|
+
const offsetMs = !Number.isNaN(t) && !Number.isNaN(base) ? t - base : undefined;
|
|
162
|
+
return { record, failure: isFailure(record), offsetMs };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Derive the blast radius of an incident from its records. */
|
|
167
|
+
export function computeImpact(incident: Incident): Impact {
|
|
168
|
+
const apps = new Set<string>();
|
|
169
|
+
let attemptsMax = 1;
|
|
170
|
+
let deadLettered = false;
|
|
171
|
+
let tenant: string | undefined;
|
|
172
|
+
let userId: string | undefined;
|
|
173
|
+
|
|
174
|
+
for (const r of incident.related) {
|
|
175
|
+
const app = str(r.appName);
|
|
176
|
+
if (app) apps.add(app);
|
|
177
|
+
attemptsMax = Math.max(attemptsMax, num(r.attempt) ?? 0, num(r.attempts) ?? 0);
|
|
178
|
+
if (r.kind === "dlq.recorded" || r.kind === "reaction.exhausted") deadLettered = true;
|
|
179
|
+
// tenant/user ride the whole chain — take the first record that carries them.
|
|
180
|
+
tenant ??= r.envelope?.tenant;
|
|
181
|
+
userId ??= r.envelope?.userId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
tenant,
|
|
186
|
+
userId,
|
|
187
|
+
apps: [...apps],
|
|
188
|
+
records: incident.related.length,
|
|
189
|
+
failures: incident.failures.length,
|
|
190
|
+
retries: Math.max(0, attemptsMax - 1),
|
|
191
|
+
deadLettered,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed telemetry — the live behavior feed.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the record union the runtime + forge push onto `runtime.onTelemetry`
|
|
5
|
+
* (`@nwire/runtime` base kinds + `@nwire/forge` domain kinds). Kept as a local
|
|
6
|
+
* mirror (not a runtime import) so Studio stays a browser bundle with no kernel
|
|
7
|
+
* dependency; the shape is the contract. Pure helpers (parse, group, tree) live
|
|
8
|
+
* here so they're unit-testable without a DOM.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Correlation envelope carried on every domain record. */
|
|
12
|
+
export interface TelemetryEnvelope {
|
|
13
|
+
readonly messageId: string;
|
|
14
|
+
readonly correlationId: string;
|
|
15
|
+
readonly causationId: string;
|
|
16
|
+
readonly tenant?: string;
|
|
17
|
+
readonly userId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A serialized thrown value, as the runtime stamps it. */
|
|
21
|
+
export interface SerializedError {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly stack?: string;
|
|
25
|
+
readonly [k: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The known telemetry kinds. Open-ended (`string & {}`) — the UI tolerates kinds it doesn't enumerate. */
|
|
29
|
+
export type TelemetryKind =
|
|
30
|
+
| "hook.step"
|
|
31
|
+
| "source.stage"
|
|
32
|
+
| "sink.stage"
|
|
33
|
+
| "action.dispatched"
|
|
34
|
+
| "action.completed"
|
|
35
|
+
| "action.failed"
|
|
36
|
+
| "event.published"
|
|
37
|
+
| "event.deduped"
|
|
38
|
+
| "event.emitted"
|
|
39
|
+
| "event.listener.failed"
|
|
40
|
+
| "actor.transitioned"
|
|
41
|
+
| "projection.folded"
|
|
42
|
+
| "projection.failed"
|
|
43
|
+
| "reaction.fired"
|
|
44
|
+
| "reaction.failed"
|
|
45
|
+
| "reaction.exhausted"
|
|
46
|
+
| "dlq.recorded"
|
|
47
|
+
| "timer.fired"
|
|
48
|
+
| "timer.scheduled"
|
|
49
|
+
| "query.executed"
|
|
50
|
+
| "listener.fired"
|
|
51
|
+
| "external.call.started"
|
|
52
|
+
| "external.call.completed"
|
|
53
|
+
| "external.call.failed"
|
|
54
|
+
| "enqueue.failed";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One telemetry record. The discriminant is `kind`; common fields are typed,
|
|
58
|
+
* kind-specific fields stay open. The UI narrows on `kind` where it needs more.
|
|
59
|
+
*/
|
|
60
|
+
export interface TelemetryRecord {
|
|
61
|
+
readonly kind: TelemetryKind | (string & {});
|
|
62
|
+
readonly ts?: string;
|
|
63
|
+
readonly appName?: string;
|
|
64
|
+
readonly envelope?: TelemetryEnvelope;
|
|
65
|
+
readonly durationMs?: number;
|
|
66
|
+
readonly error?: SerializedError;
|
|
67
|
+
readonly [k: string]: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Kinds that represent a failure — the Errors surface keys off these. */
|
|
71
|
+
const FAILURE_KINDS: ReadonlySet<string> = new Set([
|
|
72
|
+
"action.failed",
|
|
73
|
+
"projection.failed",
|
|
74
|
+
"reaction.failed",
|
|
75
|
+
"reaction.exhausted",
|
|
76
|
+
"dlq.recorded",
|
|
77
|
+
"external.call.failed",
|
|
78
|
+
"event.listener.failed",
|
|
79
|
+
"enqueue.failed",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** True when a record represents a failure (for Errors / RCA grouping). */
|
|
83
|
+
export function isFailure(r: TelemetryRecord): boolean {
|
|
84
|
+
return FAILURE_KINDS.has(r.kind) || r.error != null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse a raw SSE payload into a record, or `null` if it isn't one. Never throws. */
|
|
88
|
+
export function parseRecord(data: unknown): TelemetryRecord | null {
|
|
89
|
+
let obj: unknown = data;
|
|
90
|
+
if (typeof data === "string") {
|
|
91
|
+
try {
|
|
92
|
+
obj = JSON.parse(data);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (obj && typeof obj === "object" && typeof (obj as { kind?: unknown }).kind === "string") {
|
|
98
|
+
return obj as TelemetryRecord;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Group records by their correlation id (chain). Records without an envelope go under `""`. */
|
|
104
|
+
export function groupByCorrelation(
|
|
105
|
+
records: readonly TelemetryRecord[],
|
|
106
|
+
): Map<string, TelemetryRecord[]> {
|
|
107
|
+
const out = new Map<string, TelemetryRecord[]>();
|
|
108
|
+
for (const r of records) {
|
|
109
|
+
const cid = r.envelope?.correlationId ?? "";
|
|
110
|
+
const bucket = out.get(cid);
|
|
111
|
+
if (bucket) bucket.push(r);
|
|
112
|
+
else out.set(cid, [r]);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A node in a causation tree — one record + the records it caused. */
|
|
118
|
+
export interface TraceNode {
|
|
119
|
+
readonly record: TelemetryRecord;
|
|
120
|
+
readonly children: TraceNode[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build causation trees from a flat record list, one tree per chain head.
|
|
125
|
+
* Links by `envelope`: a record's parent is whichever record's `messageId`
|
|
126
|
+
* equals this record's `causationId`. A chain head (`causationId === messageId`,
|
|
127
|
+
* or a parent not present in the set) becomes a root. Records lacking an
|
|
128
|
+
* envelope are skipped (they have no place in a causal tree).
|
|
129
|
+
*
|
|
130
|
+
* Stable: roots + children keep first-seen order. Records that share a
|
|
131
|
+
* `messageId` (rare — multiple stages of one dispatch) attach to the first.
|
|
132
|
+
*/
|
|
133
|
+
export function buildCorrelationTree(records: readonly TelemetryRecord[]): TraceNode[] {
|
|
134
|
+
const byMessage = new Map<string, TraceNode>();
|
|
135
|
+
const ordered: TraceNode[] = [];
|
|
136
|
+
for (const record of records) {
|
|
137
|
+
const id = record.envelope?.messageId;
|
|
138
|
+
if (!id) continue;
|
|
139
|
+
if (byMessage.has(id)) continue; // first record per messageId owns the node
|
|
140
|
+
const node: TraceNode = { record, children: [] };
|
|
141
|
+
byMessage.set(id, node);
|
|
142
|
+
ordered.push(node);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const roots: TraceNode[] = [];
|
|
146
|
+
for (const node of ordered) {
|
|
147
|
+
const env = node.record.envelope!;
|
|
148
|
+
const parentId = env.causationId;
|
|
149
|
+
const isHead = !parentId || parentId === env.messageId;
|
|
150
|
+
const parent = isHead ? undefined : byMessage.get(parentId);
|
|
151
|
+
if (parent) parent.children.push(node);
|
|
152
|
+
else roots.push(node);
|
|
153
|
+
}
|
|
154
|
+
return roots;
|
|
155
|
+
}
|