/** * Root-cause analysis — group raw failure records into incidents and read the * story out of each one. A failed chain rarely produces a single record: an * action.failed retries, then dlq.recorded, while a sibling projection.failed * lands on the same correlation. This groups by correlation, picks the terminal * outcome as the headline, gathers every record on the chain as evidence, and * derives the blast radius (apps, tenant, retries). Pure — unit-testable, and * the Errors view + RcaPanel render straight off these shapes. */ import { isFailure, type TelemetryRecord } from "./telemetry"; import { explainError, severityOf, maxSeverity, type FriendlyError, type Severity, } from "./error-friendly"; /** One step on the incident timeline — a record plus whether it's a failure. */ export interface TimelineEntry { readonly record: TelemetryRecord; readonly failure: boolean; /** ms since the first record on the chain, when timestamps are present. */ readonly offsetMs?: number; } /** The blast radius of an incident. */ export interface Impact { readonly tenant?: string; readonly userId?: string; /** Apps that touched the chain. */ readonly apps: readonly string[]; /** Total records on the chain. */ readonly records: number; /** Failure records on the chain. */ readonly failures: number; /** Retries the runtime made before this settled (0 = first try). */ readonly retries: number; /** The chain ended in the dead-letter queue / exhausted retries. */ readonly deadLettered: boolean; } /** A grouped failure — everything the Errors view needs about one incident. */ export interface Incident { /** Stable id — the correlationId, or `solo:` for envelope-less failures. */ readonly id: string; readonly correlationId?: string; /** * The failure that represents the incident — the terminal outcome (dead-letter * / exhausted) when there is one, else the loudest failure. This is what the * card headline + RcaPanel speak to; the full cascade lives in `failures`. */ readonly root: TelemetryRecord; /** Humanised view of the headline failure. */ readonly friendly: FriendlyError; /** Loudest severity across the chain's failures. */ readonly severity: Severity; /** Every failure record on the chain, in arrival order. */ readonly failures: readonly TelemetryRecord[]; /** Every record on the chain (failures + context), in arrival order. */ readonly related: readonly TelemetryRecord[]; /** How many failures — the card's count badge. */ readonly count: number; /** Arrival index of the latest record — sort newest-first. */ readonly lastIndex: number; } const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined); const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); /** * Group a flat record list into incidents, newest first. Failures sharing a * correlationId form one incident (with their whole chain as context); a * failure with no correlation becomes its own solo incident. */ export function groupIncidents(records: readonly TelemetryRecord[]): Incident[] { const byCorrelation = new Map(); // correlationId → record indices const solo: number[] = []; records.forEach((r, i) => { if (!isFailure(r)) return; const cid = r.envelope?.correlationId; if (cid) { const bucket = byCorrelation.get(cid); if (bucket) bucket.push(i); else byCorrelation.set(cid, [i]); } else { solo.push(i); } }); const incidents: Incident[] = []; for (const [cid, failureIdx] of byCorrelation) { const related = records.filter((r) => r.envelope?.correlationId === cid); const failures = failureIdx.map((i) => records[i]!); incidents.push(makeIncident(cid, cid, failures, related, Math.max(...failureIdx))); } for (const i of solo) { const r = records[i]!; incidents.push(makeIncident(`solo:${i}`, undefined, [r], [r], i)); } return incidents.sort((a, b) => b.lastIndex - a.lastIndex); } function makeIncident( id: string, correlationId: string | undefined, failures: TelemetryRecord[], related: TelemetryRecord[], lastIndex: number, ): Incident { const root = pickHeadline(failures); const severity = failures.reduce((acc, r) => maxSeverity(acc, severityOf(r)), "info"); return { id, correlationId, root, friendly: explainError(root), severity, failures, related, count: failures.length, lastIndex, }; } /** * The failure that best represents the incident: a terminal outcome * (dead-letter / exhausted) when present — that's the actionable headline and * carries the final error — otherwise the loudest failure, ties broken by * arrival order (earliest). */ function pickHeadline(failures: TelemetryRecord[]): TelemetryRecord { const terminal = failures.find( (f) => f.kind === "dlq.recorded" || f.kind === "reaction.exhausted", ); if (terminal) return terminal; let best = failures[0]!; for (const f of failures) { if ( maxSeverity(severityOf(best), severityOf(f)) === severityOf(f) && severityOf(f) !== severityOf(best) ) { best = f; } } return best; } /** Build the ordered timeline for an incident, with ms offsets when available. */ export function buildTimeline(incident: Incident): TimelineEntry[] { const times = incident.related .map((r) => (r.ts ? Date.parse(r.ts) : NaN)) .filter((t) => !Number.isNaN(t)); const base = times.length ? Math.min(...times) : NaN; return incident.related.map((record) => { const t = record.ts ? Date.parse(record.ts) : NaN; const offsetMs = !Number.isNaN(t) && !Number.isNaN(base) ? t - base : undefined; return { record, failure: isFailure(record), offsetMs }; }); } /** Derive the blast radius of an incident from its records. */ export function computeImpact(incident: Incident): Impact { const apps = new Set(); let attemptsMax = 1; let deadLettered = false; let tenant: string | undefined; let userId: string | undefined; for (const r of incident.related) { const app = str(r.appName); if (app) apps.add(app); attemptsMax = Math.max(attemptsMax, num(r.attempt) ?? 0, num(r.attempts) ?? 0); if (r.kind === "dlq.recorded" || r.kind === "reaction.exhausted") deadLettered = true; // tenant/user ride the whole chain — take the first record that carries them. tenant ??= r.envelope?.tenant; userId ??= r.envelope?.userId; } return { tenant, userId, apps: [...apps], records: incident.related.length, failures: incident.failures.length, retries: Math.max(0, attemptsMax - 1), deadLettered, }; }