UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

188 lines 8.73 kB
// packages/core/src/ai/diagnosis-coordinator.ts import { Logger } from '@nestjs/common'; import { NewExceptionTracker } from '../alerts/new-exception-tracker.js'; import { durationToMs } from '../config/parse-duration.js'; import { EntryType } from '../entry/entry.js'; import { buildDiagnoseContext } from './diagnose-context-builder.js'; import { DiagnosisCache } from './diagnosis-cache.js'; /** * Window for auto-mode's "is this a NEW family?" dedup. Mirrors the default * `new-exception` alert window so an exception that auto-diagnosed once doesn't * re-diagnose on every later occurrence within the same window (the cache also * guards this, but the tracker stops us even ENQUEUEING a redundant diagnosis). */ const AUTO_NEW_FAMILY_WINDOW = '1h'; /** * Default grace an alert waits for an in-flight auto-mode diagnosis before * dispatching WITHOUT it. Kept short: the alert is the time-critical artifact, so * we'd rather page promptly with no AI note than hold the page waiting on a model. */ const DEFAULT_ALERT_GRACE_MS = 10_000; /** * Central AI-diagnosis coordinator. Owns the diagnoser, the per-family cache, and * the auto-mode wiring. Constructed only when the host configures `ai`; the * service injects nothing AI-related otherwise, so the feature is zero-cost off. * * Two entry points: * - {@link diagnose}: on-demand, behind the dashboard endpoint. Cache-first * (unless `force`), and a diagnoser rejection PROPAGATES so the controller can * map it to a safe 5xx. * - {@link onNewFamily}: auto-mode. Called from the alerter's first-seen signal. * Fire-and-forget — it NEVER throws into the flush path; a failure is swallowed * and logged. The in-flight promise is tracked so an alert firing for the same * family can briefly await it ({@link awaitForAlert}) and attach the result. */ export class DiagnosisCoordinator { storage; logger; diagnoser; mode; cache; /** In-flight auto-mode diagnoses keyed by familyHash (so alerts can await). */ inFlight = new Map(); alertGraceMs; /** First-seen tracker backing auto-mode (independent of the alerter's). */ newFamilyTracker; newFamilyWindowMs; now; constructor(options, storage, deps) { this.storage = storage; this.diagnoser = options.diagnoser; this.mode = options.mode ?? 'on-demand'; this.cache = deps?.cache ?? new DiagnosisCache(); this.logger = deps?.logger ?? new Logger(DiagnosisCoordinator.name); this.alertGraceMs = deps?.alertGraceMs ?? DEFAULT_ALERT_GRACE_MS; this.now = deps?.now ?? Date.now; this.newFamilyTracker = new NewExceptionTracker(); this.newFamilyWindowMs = durationToMs(AUTO_NEW_FAMILY_WINDOW); } /** * Auto-mode flush hook, wired into the Recorder's `onFlushStored` path (the * SAME path the new-exception alert evaluates on). For each just-stored * exception, do a cheap first-seen check; on a genuinely NEW family kick off a * fire-and-forget diagnosis. Independent of whether a `new-exception` ALERT * rule is configured — auto-diagnosis is its own feature. No-op in on-demand * mode. NEVER throws into the flush path. */ observeFlush(storedEntries) { if (!this.isAuto) return; const nowMs = this.now(); for (const entry of storedEntries) { if (entry.type !== EntryType.Exception && entry.type !== EntryType.ClientException) { continue; } if (entry.familyHash === null) continue; const isNew = this.newFamilyTracker.observe(entry.familyHash, nowMs, this.newFamilyWindowMs); if (!isNew) continue; // Occurrence count is 1 at first-seen by definition; the on-demand path // recomputes a precise count, but auto-mode triggers exactly here. void this.onNewFamily(entry, 1); } } /** * On-demand diagnosis for an exception entry. Serves from cache unless `force`. * A diagnoser rejection propagates to the caller (the endpoint turns it into a * safe 502). On success the result is cached by family for the next reader. * * @throws whatever the diagnoser rejects with (timeout/model error). */ async diagnose(entry, occurrenceCount, force = false) { const familyHash = entry.familyHash; if (!force && familyHash !== null) { const cached = this.cache.get(familyHash); if (cached !== null) return { markdown: cached, cached: true }; } const context = await buildDiagnoseContext(this.storage, entry, occurrenceCount); const markdown = await this.diagnoser.diagnose(context); if (familyHash !== null) this.cache.set(familyHash, markdown); return { markdown, cached: false }; } /** * Read-only, cache-ONLY lookup for an entry's diagnosis. Returns the cached * markdown for the entry's family, or `null` on a miss / when the entry has no * family hash. NEVER builds context and NEVER calls the diagnoser — so it costs * nothing and can be safely fetched on every detail-page open. This is what * makes an auto-mode (or previously on-demand) diagnosis visible immediately: * the cache was populated by `observeFlush`/`onNewFamily` (auto) or a prior * `diagnose` (on-demand), and this just surfaces it without re-running. */ peekCached(entry) { const familyHash = entry.familyHash; if (familyHash === null) return null; return this.cache.get(familyHash); } /** * Auto-mode hook: a NEW exception family was just seen. Kick off diagnosis * fire-and-forget and cache the result. NEVER throws — this runs inside the * flush/alert path. If a diagnosis for this family is already in flight or * already cached, do nothing (at most once per family per cache window). * * Returns the tracked promise (resolving to the markdown, or `null` on * failure) so the alert path can await it; callers on the flush path ignore it. */ onNewFamily(entry, occurrenceCount) { const familyHash = entry.familyHash; if (familyHash === null) return Promise.resolve(null); if (this.cache.has(familyHash)) return Promise.resolve(this.cache.get(familyHash)); const existing = this.inFlight.get(familyHash); if (existing !== undefined) return existing; const task = this.runDiagnosis(entry, occurrenceCount, familyHash); this.inFlight.set(familyHash, task); return task; } /** * Briefly await an in-flight (or cached) diagnosis for `familyHash`, for the * alert path to attach it. Returns the markdown if ready within the grace cap, * else `null` (the alert dispatches without it). Never throws. */ async awaitForAlert(familyHash, graceMs = this.alertGraceMs) { const cached = this.cache.get(familyHash); if (cached !== null) return cached; const pending = this.inFlight.get(familyHash); if (pending === undefined) return null; // Race the in-flight diagnosis against a short timer; whichever wins, the // alert proceeds. A timeout here does NOT cancel the diagnosis — it keeps // running and lands in the cache for the next reader. return Promise.race([ pending.catch(() => null), new Promise((resolve) => { const timer = setTimeout(() => resolve(null), graceMs); timer.unref?.(); }), ]); } /** True when running in `'auto'` mode (the alerter wires the first-seen hook). */ get isAuto() { return this.mode === 'auto'; } /** Run + cache one diagnosis, swallowing every failure. Clears the in-flight slot. */ async runDiagnosis(entry, occurrenceCount, familyHash) { try { const context = await buildDiagnoseContext(this.storage, entry, occurrenceCount); const markdown = await this.diagnoser.diagnose(context); this.cache.set(familyHash, markdown); return markdown; } catch (error) { // Auto-mode must never break flush/alerting — log once and move on. this.logger.warn(`Telescope AI auto-diagnosis failed for family ${familyHash}: ${error.message}`); return null; } finally { this.inFlight.delete(familyHash); } } } export { DEFAULT_ALERT_GRACE_MS }; //# sourceMappingURL=diagnosis-coordinator.js.map