/** * Node metrics — fold the live telemetry feed into per-primitive and per-app * rollups the Topology and Map canvases overlay on their nodes. * * Pure + framework-free so it unit-tests without a DOM. The runtime + forge * push records keyed by *which* primitive each is about under different field * names (`action` / `query` / `projection` / `event` / `actor` / `workflow` / * `call`); `nodeIdOf` normalizes any record to the graph node id * (`"${kind}:${name}"`) the manifest uses, so a record lights up exactly the * node it belongs to. `appName` carries the owning app for the Map rollup. * * Metrics are derived, not stored: same records in → same metrics out, so a * telemetry refresh updates counts in place without moving any node. */ import type { TelemetryRecord } from "./telemetry"; import { isFailure } from "./telemetry"; /** Per-node rollup overlaid on a Topology wiring node (or a Map BC row). */ export interface NodeMetrics { /** Total records seen for this node (throughput). */ readonly count: number; /** Of those, how many were failures. */ readonly errors: number; /** Median duration in ms, when any record carried one (else undefined). */ readonly p50?: number; /** ISO timestamp of the most recent record. */ readonly lastTs?: string; } /** Per-app rollup overlaid on a Map bounded-context card. */ export interface AppMetrics { /** Dispatches (actions + queries) attributed to this app. */ readonly dispatches: number; /** Failure records attributed to this app. */ readonly errors: number; /** Total records (any kind) — drives the "live" pulse. */ readonly total: number; /** ISO timestamp of the most recent record for this app. */ readonly lastTs?: string; } /** Recent-activity windows feeding edge animation + counts. */ export interface MetricsRollup { /** node id → metrics. */ readonly byNode: ReadonlyMap; /** app name → metrics. */ readonly byApp: ReadonlyMap; /** Total records folded — `0` means "no run yet" (neutral placeholders). */ readonly total: number; } const EMPTY: MetricsRollup = { byNode: new Map(), byApp: new Map(), total: 0 }; /** Kinds the Map counts as a "dispatch" (the everyday request count). */ const DISPATCH_KINDS: ReadonlySet = new Set(["action.dispatched", "query.executed"]); function str(v: unknown): string | undefined { return typeof v === "string" && v ? v : undefined; } /** * The graph node id (`"${kind}:${name}"`) a telemetry record is about, or * `null` when the record names no primitive (stage / hook records). Reads the * kind-specific name field the runtime/forge stamp: * action.* → `action` query.* → `query` projection.* → `projection` * event.* / listener.* → `event` (string name, or `event.name` object) * actor.* / timer.* → `actor` reaction.* → `workflow` * external.call.* → `externalCall:` */ export function nodeIdOf(rec: TelemetryRecord): string | null { const base = rec.kind.split(".")[0] ?? rec.kind; switch (base) { case "action": { // dlq.recorded also carries `action`; routed here only via its own kind. const name = str(rec.action); return name ? `action:${name}` : null; } case "query": { const name = str(rec.query); return name ? `query:${name}` : null; } case "projection": { const name = str(rec.projection); return name ? `projection:${name}` : null; } case "actor": case "timer": { const name = str(rec.actor); return name ? `actor:${name}` : null; } case "reaction": { const name = str(rec.workflow); return name ? `workflow:${name}` : null; } case "dlq": { const name = str(rec.action); return name ? `action:${name}` : null; } case "event": case "listener": { // `event` is a string (event.emitted / listener.fired) or an // EventMessage object (event.published / .deduped) carrying its name // under `eventName` (forge's EventMessage) or `name`. const ev = rec.event; const obj = ev as { eventName?: unknown; name?: unknown } | undefined; const name = str(ev) ?? str(obj?.eventName) ?? str(obj?.name); return name ? `event:${name}` : null; } case "external": { const name = str(rec.call); return name ? `externalCall:${name}` : null; } default: return null; } } /** Median of a numeric list (sorted copy). Empty → undefined. */ function median(ns: number[]): number | undefined { if (ns.length === 0) return undefined; const s = [...ns].sort((a, b) => a - b); const mid = Math.floor(s.length / 2); return s.length % 2 ? s[mid]! : Math.round((s[mid - 1]! + s[mid]!) / 2); } type NodeAccum = { count: number; errors: number; durations: number[]; lastTs?: string }; type AppAccum = { dispatches: number; errors: number; total: number; lastTs?: string }; function laterTs(a: string | undefined, b: string | undefined): string | undefined { if (!a) return b; if (!b) return a; return a >= b ? a : b; } /** * Fold a telemetry record list into per-node + per-app metrics. Deterministic: * the same records always yield the same rollup, so the canvas updates counts * without re-laying-out. Tolerant of unknown kinds (they just don't attribute). */ export function rollupMetrics(records: readonly TelemetryRecord[]): MetricsRollup { if (records.length === 0) return EMPTY; const nodeAcc = new Map(); const appAcc = new Map(); for (const rec of records) { const failed = isFailure(rec); const ts = str(rec.ts); const id = nodeIdOf(rec); if (id) { let n = nodeAcc.get(id); if (!n) { n = { count: 0, errors: 0, durations: [] }; nodeAcc.set(id, n); } n.count++; if (failed) n.errors++; if (typeof rec.durationMs === "number") n.durations.push(rec.durationMs); n.lastTs = laterTs(n.lastTs, ts); } const app = str(rec.appName); if (app) { let a = appAcc.get(app); if (!a) { a = { dispatches: 0, errors: 0, total: 0 }; appAcc.set(app, a); } a.total++; if (DISPATCH_KINDS.has(rec.kind)) a.dispatches++; if (failed) a.errors++; a.lastTs = laterTs(a.lastTs, ts); } } const byNode = new Map(); for (const [id, n] of nodeAcc) { byNode.set(id, { count: n.count, errors: n.errors, p50: median(n.durations), lastTs: n.lastTs, }); } const byApp = new Map(); for (const [name, a] of appAcc) { byApp.set(name, { dispatches: a.dispatches, errors: a.errors, total: a.total, lastTs: a.lastTs, }); } return { byNode, byApp, total: records.length }; } /** * Per-edge activity for the wiring graph: an edge is "active" when both its * endpoints saw a record within the recency window, so the canvas can animate * live edges and label them with the lighter end's throughput. Keyed by the * `"${from}=>${to}"` ids the wiring graph mints. */ export interface EdgeActivity { /** Records attributed to the edge's destination node (its throughput). */ readonly volume: number; /** True when the destination saw a record this window — animate it. */ readonly active: boolean; } /** * Resolve per-edge activity from the node rollup: an edge carries the volume * of its target node and is active when that node has any throughput. Pure; * derived entirely from `byNode` so it never thrashes the layout. */ export function edgeActivity( byNode: ReadonlyMap, edges: ReadonlyArray<{ id: string; from: string; to: string }>, ): Map { const out = new Map(); for (const e of edges) { const m = byNode.get(e.to); const volume = m?.count ?? 0; out.set(e.id, { volume, active: volume > 0 }); } return out; } /** Format a metric count for display — a dim em dash before any run. */ export function fmtCount(n: number | undefined): string { return n == null || n === 0 ? "—" : String(n); } /** Format a p50 latency — "—" when none yet, "<1ms" / "12ms" / "1.2s" otherwise. */ export function fmtLatency(ms: number | undefined): string { if (ms == null) return "—"; if (ms < 1) return "<1ms"; if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(1)}s`; }