/** * Typed telemetry — the live behavior feed. * * Mirrors the record union the runtime + forge push onto `runtime.onTelemetry` * (`@nwire/runtime` base kinds + `@nwire/forge` domain kinds). Kept as a local * mirror (not a runtime import) so Studio stays a browser bundle with no kernel * dependency; the shape is the contract. Pure helpers (parse, group, tree) live * here so they're unit-testable without a DOM. */ /** Correlation envelope carried on every domain record. */ export interface TelemetryEnvelope { readonly messageId: string; readonly correlationId: string; readonly causationId: string; readonly tenant?: string; readonly userId?: string; } /** A serialized thrown value, as the runtime stamps it. */ export interface SerializedError { readonly name: string; readonly message: string; readonly stack?: string; readonly [k: string]: unknown; } /** The known telemetry kinds. Open-ended (`string & {}`) — the UI tolerates kinds it doesn't enumerate. */ export type TelemetryKind = | "hook.step" | "source.stage" | "sink.stage" | "action.dispatched" | "action.completed" | "action.failed" | "event.published" | "event.deduped" | "event.emitted" | "event.listener.failed" | "actor.transitioned" | "projection.folded" | "projection.failed" | "reaction.fired" | "reaction.failed" | "reaction.exhausted" | "dlq.recorded" | "timer.fired" | "timer.scheduled" | "query.executed" | "listener.fired" | "external.call.started" | "external.call.completed" | "external.call.failed" | "enqueue.failed"; /** * One telemetry record. The discriminant is `kind`; common fields are typed, * kind-specific fields stay open. The UI narrows on `kind` where it needs more. */ export interface TelemetryRecord { readonly kind: TelemetryKind | (string & {}); readonly ts?: string; readonly appName?: string; readonly envelope?: TelemetryEnvelope; readonly durationMs?: number; readonly error?: SerializedError; readonly [k: string]: unknown; } /** Kinds that represent a failure — the Errors surface keys off these. */ const FAILURE_KINDS: ReadonlySet = new Set([ "action.failed", "projection.failed", "reaction.failed", "reaction.exhausted", "dlq.recorded", "external.call.failed", "event.listener.failed", "enqueue.failed", ]); /** True when a record represents a failure (for Errors / RCA grouping). */ export function isFailure(r: TelemetryRecord): boolean { return FAILURE_KINDS.has(r.kind) || r.error != null; } /** Parse a raw SSE payload into a record, or `null` if it isn't one. Never throws. */ export function parseRecord(data: unknown): TelemetryRecord | null { let obj: unknown = data; if (typeof data === "string") { try { obj = JSON.parse(data); } catch { return null; } } if (obj && typeof obj === "object" && typeof (obj as { kind?: unknown }).kind === "string") { return obj as TelemetryRecord; } return null; } /** Group records by their correlation id (chain). Records without an envelope go under `""`. */ export function groupByCorrelation( records: readonly TelemetryRecord[], ): Map { const out = new Map(); for (const r of records) { const cid = r.envelope?.correlationId ?? ""; const bucket = out.get(cid); if (bucket) bucket.push(r); else out.set(cid, [r]); } return out; } /** A node in a causation tree — one record + the records it caused. */ export interface TraceNode { readonly record: TelemetryRecord; readonly children: TraceNode[]; } /** * Build causation trees from a flat record list, one tree per chain head. * Links by `envelope`: a record's parent is whichever record's `messageId` * equals this record's `causationId`. A chain head (`causationId === messageId`, * or a parent not present in the set) becomes a root. Records lacking an * envelope are skipped (they have no place in a causal tree). * * Stable: roots + children keep first-seen order. Records that share a * `messageId` (rare — multiple stages of one dispatch) attach to the first. */ export function buildCorrelationTree(records: readonly TelemetryRecord[]): TraceNode[] { const byMessage = new Map(); const ordered: TraceNode[] = []; for (const record of records) { const id = record.envelope?.messageId; if (!id) continue; if (byMessage.has(id)) continue; // first record per messageId owns the node const node: TraceNode = { record, children: [] }; byMessage.set(id, node); ordered.push(node); } const roots: TraceNode[] = []; for (const node of ordered) { const env = node.record.envelope!; const parentId = env.causationId; const isHead = !parentId || parentId === env.messageId; const parent = isHead ? undefined : byMessage.get(parentId); if (parent) parent.children.push(node); else roots.push(node); } return roots; }