/** * Pure logic behind the live telemetry table — subject extraction, time * parsing, text filtering, sorting, and KPI roll-ups. Kept DOM-free so the * firehose's behavior is unit-tested without mounting a component; `LiveTable` * and the Streams page render what these return. */ import type { TelemetryRecord } from "./telemetry"; import { isFailure } from "./telemetry"; /** Sortable columns. `time` is the default (newest first). */ export type SortKey = "time" | "kind" | "subject" | "duration"; export type SortDir = "asc" | "desc"; /** Epoch millis for a record's `ts` (0 when absent/unparseable — sorts last desc). */ export function recordTime(r: TelemetryRecord): number { if (!r.ts) return 0; const t = Date.parse(r.ts); return Number.isNaN(t) ? 0 : t; } /** * The primary noun a record is about — the handler/event/actor it concerns. * Reads the per-kind field the runtime + forge stamp (`action`, `event`, * `actor`, …); `event` may be a string or an `EventMessage`-shaped object. */ export function recordSubject(r: TelemetryRecord): string { const ev = r.event; const eventName = typeof ev === "string" ? ev : ev && typeof ev === "object" ? ((ev as { name?: string; type?: string }).name ?? (ev as { name?: string; type?: string }).type) : undefined; const pick = (r.action as string | undefined) ?? (r.query as string | undefined) ?? (r.projection as string | undefined) ?? (r.actor as string | undefined) ?? (r.workflow as string | undefined) ?? (r.call as string | undefined) ?? (r.timer as string | undefined) ?? (r.sourceEvent as string | undefined) ?? eventName ?? (r.listener as string | undefined); return pick ?? "—"; } /** Duration in ms when the record carries one, else `undefined`. */ export function recordDuration(r: TelemetryRecord): number | undefined { return typeof r.durationMs === "number" ? r.durationMs : undefined; } /** Lower-cased haystack of everything a text filter should match against. */ function haystack(r: TelemetryRecord): string { const env = r.envelope; return [ r.kind, recordSubject(r), r.appName, env?.correlationId, env?.causationId, env?.messageId, env?.tenant, env?.userId, r.error?.message, ] .filter(Boolean) .join(" ") .toLowerCase(); } /** Free-text match across kind, subject, ids, tenant, user, app, and error. */ export function matchesFilter(r: TelemetryRecord, query: string): boolean { const q = query.trim().toLowerCase(); if (!q) return true; return haystack(r).includes(q); } export interface FilterSpec { /** Free text. */ readonly text?: string; /** Restrict to a single record kind (exact). */ readonly kind?: string | null; /** Restrict to one correlation chain. */ readonly correlationId?: string | null; /** Only failures (the Errors-family kinds, or a stamped `error`). */ readonly failuresOnly?: boolean; } /** Apply every active filter clause. Order-independent; all must pass. */ export function filterRecords( records: readonly TelemetryRecord[], spec: FilterSpec, ): TelemetryRecord[] { return records.filter((r) => { if (spec.kind && r.kind !== spec.kind) return false; if (spec.correlationId && r.envelope?.correlationId !== spec.correlationId) return false; if (spec.failuresOnly && !isFailure(r)) return false; if (spec.text && !matchesFilter(r, spec.text)) return false; return true; }); } /** Stable sort by the chosen column + direction (input array is not mutated). */ export function sortRecords( records: readonly TelemetryRecord[], key: SortKey, dir: SortDir, ): TelemetryRecord[] { const sign = dir === "asc" ? 1 : -1; return records .map((r, i) => [r, i] as const) .sort(([a, ai], [b, bi]) => { const d = compare(a, b, key); return d !== 0 ? d * sign : ai - bi; // stable on ties }) .map(([r]) => r); } function compare(a: TelemetryRecord, b: TelemetryRecord, key: SortKey): number { switch (key) { case "time": return recordTime(a) - recordTime(b); case "duration": return (recordDuration(a) ?? -1) - (recordDuration(b) ?? -1); case "kind": return a.kind.localeCompare(b.kind); case "subject": return recordSubject(a).localeCompare(recordSubject(b)); } } /** Clamp-and-wrap the selection index when the user presses ↑/↓. */ export function nextIndex(current: number, length: number, delta: number): number { if (length === 0) return -1; if (current < 0) return delta > 0 ? 0 : length - 1; const next = current + delta; if (next < 0) return 0; if (next >= length) return length - 1; return next; } export interface StreamKpis { /** Total records in the buffer. */ readonly total: number; /** How many are failures. */ readonly failures: number; /** Distinct record kinds seen. */ readonly kinds: number; /** Distinct correlation chains seen. */ readonly chains: number; /** Records per second over the buffer's time span (0 when < 2 records). */ readonly perSecond: number; } /** Roll the buffer up into the header strip's numbers. */ export function computeKpis(records: readonly TelemetryRecord[]): StreamKpis { const kinds = new Set(); const chains = new Set(); let failures = 0; let min = Infinity; let max = -Infinity; for (const r of records) { kinds.add(r.kind); const cid = r.envelope?.correlationId; if (cid) chains.add(cid); if (isFailure(r)) failures++; const t = recordTime(r); if (t > 0) { if (t < min) min = t; if (t > max) max = t; } } const spanSec = max > min ? (max - min) / 1000 : 0; const perSecond = spanSec > 0 ? Math.round((records.length / spanSec) * 10) / 10 : 0; return { total: records.length, failures, kinds: kinds.size, chains: chains.size, perSecond, }; } /** The distinct kinds present, in first-seen order — drives the kind filter. */ export function distinctKinds(records: readonly TelemetryRecord[]): string[] { const seen = new Set(); const out: string[] = []; for (const r of records) { if (!seen.has(r.kind)) { seen.add(r.kind); out.push(r.kind); } } return out; } /** Short id for display (first dash-segment, else first 8 chars). */ export function shortId(id: string | undefined): string { if (!id) return "—"; if (id.includes("-")) return id.split("-")[0] || id.slice(0, 8); return id.slice(0, 8); }