UNPKG

@dudousxd/nestjs-telescope

Version:

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

332 lines 16.4 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var TelescopeService_1; // packages/core/src/nest/telescope.service.ts import { Inject, Injectable, Logger, Optional, } from '@nestjs/common'; import { v7 } from 'uuid'; import { DiagnosisCoordinator } from '../ai/diagnosis-coordinator.js'; import { resolveAlerts } from '../alerts/resolve-alerts.js'; import { TelescopeAlerter } from '../alerts/telescope-alerter.js'; import { samplingRates } from '../config/sampling.js'; import { createBatch } from '../context/batch.js'; import { CONTEXT_ACCESSOR } from '../context/context-accessor.js'; import { TelescopeContext } from '../context/telescope-context.js'; import { setTelescopeDump } from '../dump/telescope-dump.js'; import { EntryType } from '../entry/entry.js'; import { ExtensionRegistry } from '../extension/registry.js'; import { setTelescopeRecordSink } from '../record/telescope-record.js'; import { Recorder } from '../recorder/recorder.js'; import { EntryEvents } from '../sse/entry-events.js'; import { TELESCOPE_CONFIG, TELESCOPE_DASHBOARD_AUTH, TELESCOPE_EXTENSIONS, TELESCOPE_OPTIONS, TELESCOPE_STORAGE, } from './telescope.options.js'; /** Iterations used by the on-demand capture-cost micro-benchmark. */ const CAPTURE_COST_BENCHMARK_ITERATIONS = 1000; let TelescopeService = TelescopeService_1 = class TelescopeService { config; storage; options; dashboardAuth; extensions; contextAccessor; entryEvents; context = new TelescopeContext(); recorder; logger = new Logger(TelescopeService_1.name); flushTimer = null; watcherTypes = []; /** Resolved (boot-validated) alerting config, or `null` when unconfigured. */ alerts; alerter = null; /** AI exception-diagnosis coordinator, or `null` when `ai` is unconfigured. */ diagnosis; constructor(config, storage, options, dashboardAuth = null, extensions = new ExtensionRegistry([], {}), contextAccessor = undefined, entryEvents = new EntryEvents()) { this.config = config; this.storage = storage; this.options = options; this.dashboardAuth = dashboardAuth; this.extensions = extensions; this.contextAccessor = contextAccessor; this.entryEvents = entryEvents; // Boot-validate alerts FIRST (fail-closed at provider instantiation, like // dashboardAuth): no destination / empty rules / bad duration throws. this.alerts = resolveAlerts(this.options.alerts); // Build the AI coordinator (if configured) BEFORE the Recorder so its // auto-mode flush hook can be wired into `onFlushStored` below. The // coordinator is a no-op on the flush path in on-demand mode. this.diagnosis = this.options.ai !== undefined ? new DiagnosisCoordinator(this.options.ai, this.storage) : null; this.recorder = new Recorder({ storage, context: this.context, instanceId: config.instanceId, taggers: config.taggers, redact: config.redact, sampling: config.sampling, bufferSize: config.recorder.bufferSize, flushBatchSize: config.recorder.flushBatchSize, retryDelayMs: config.recorder.retryDelayMs, idFactory: () => v7(), ...(config.filter ? { filter: config.filter } : {}), ...(config.traceContext ? { traceContext: config.traceContext } : {}), // Additive, opt-in context enrichment (traceId fallback + user/tenant // tags). Present only when nestjs-context bound the shared accessor token. ...(this.contextAccessor ? { contextAccessor: this.contextAccessor } : {}), // Complete-count metrics tap: fan every record out to extension observers // (e.g. the OTel exporter) before sampling, so exported counters are honest. onRecorded: (input) => this.extensions.notifyRecord(input), // Per-flush new-exception evaluation: cheap map lookup per stored exception, // batch-context fetch only on a real fire. Delegates to the alerter built // just below; the closure reads `this.alerter` at flush time (always set by // then). Never throws into the flush — the alerter swallows failures. // The same path drives AI auto-mode: a NEW family kicks off a fire-and- // forget diagnosis (no-op in on-demand mode / when AI is off). onFlushStored: (entries) => { this.diagnosis?.observeFlush(entries); this.entryEvents.emitTypes(entries.map((e) => e.type)); // Span/trace export and any other extension flush consumers. Awaited so // the flush chain settles them; the registry isolates each (never throws). const observers = this.extensions.notifyFlush(entries); const alert = this.alerter?.evaluateFlush(entries); return Promise.all([observers, alert]).then(() => undefined); }, }); // Construct the alerter AFTER the Recorder so its `droppedCount` baseline can // be read. The interval is started later in onModuleInit; the new-exception // hook above is already wired and becomes live as soon as this is assigned. if (this.alerts !== null) { this.alerter = new TelescopeAlerter({ alerts: this.alerts, storage: this.storage, instanceId: this.config.instanceId, droppedCount: () => this.recorder.droppedCount, // In auto-mode, let a firing new-exception alert briefly await the AI // diagnosis and attach it. on-demand / off → no hook, no enrichment. ...(this.diagnosis?.isAuto ? { diagnosisFor: (familyHash) => this.diagnosis?.awaitForAlert(familyHash) ?? Promise.resolve(null), } : {}), }); } // Wire the global dump sink so `telescopeDump(value, label)` can be called // anywhere without injecting this service. Cleared on shutdown. setTelescopeDump((value, label) => this.dump(value, label)); // Wire the global record sink so `telescopeRecord(input)` can be called // from boot-time integrations (e.g. a MikroORM query logger, constructed // at `MikroORM.init()` before Nest DI has this service) without // injecting it. Cleared on shutdown. setTelescopeRecordSink((input) => this.record(input)); } /** * Record a developer debug dump into the Dumps tab. The value is redacted by * the Recorder and correlated to the active batch automatically. Prefer the * free `telescopeDump()` at call sites that don't already inject this service. */ dump(value, label) { this.record({ type: EntryType.Dump, content: { label: label ?? null, value } }); } /** Normalized mount segment (no leading/trailing slash). Default `'telescope'`. */ get path() { return this.config.path; } /** * Host-supplied hook to resolve the authenticated user from a raw request * (used by the request middleware). `undefined` when the host didn't supply * one — the middleware then falls back to reading `request.user`. */ get resolveUser() { return this.options.resolveUser; } async onModuleInit() { if (!this.config.enabled) return; // Retention guardrail: without `prune`, the entry table grows unbounded and // the windowed analytics scans (pulse/timeseries/stats) get slower over time. // Warn once at boot so hosts opt into a retention window (and/or `sampling` // for noisy request floods) rather than discovering the slowdown in prod. if (this.config.prune === undefined) { this.logger.warn('No `prune` configured — Telescope entries accumulate without bound, ' + 'slowing analytics over time. Set e.g. `prune: { after: "1h" }` ' + '(and/or `sampling` to down-sample noisy request volume).'); } else if (Object.keys(this.config.sampling).length === 0) { // Retention is set, but with no `sampling` every captured entry is kept — // the retained working set is `prune.after × ingest rate × entry size`. // High-volume streams (cache hits especially) dominate that product, so // nudge hosts toward per-type sampling. INFO, single line, non-noisy. this.logger.log('No `sampling` configured — high-volume streams (e.g. cache) are kept in full. ' + 'Add per-type rates to bound store volume, e.g. `sampling: { cache: 0.1 }`.'); } // Let the storage acquire resources / ensure its schema before first use. await this.storage.init?.(); this.flushTimer = setInterval(() => { this.recorder.flush().catch((error) => { this.logger.warn(`Telescope flush failed: ${error.message}`); }); }, this.config.recorder.flushIntervalMs); // Don't keep the event loop alive solely for the flush timer. this.flushTimer.unref?.(); // Start alerting when configured (unref'd interval for the rate rules; the // new-exception rule already runs via the Recorder's onFlushStored hook). // Never crashes the host — failures are swallowed inside the alerter. this.alerter?.start(); } async onApplicationShutdown() { // Detach the global dump sink so stray post-shutdown calls become no-ops. setTelescopeDump(null); // Detach the global record sink so stray post-shutdown calls become no-ops. setTelescopeRecordSink(null); if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } if (this.alerter) { this.alerter.stop(); this.alerter = null; } await this.recorder.flush(); // Always close after the final flush. Providers that borrow a host resource no-op close(). await this.storage.close?.(); } /** Register the set of active watcher type names (for meta). */ /** @internal Used by TelescopeWatcherRegistrar; not part of the public API. */ setWatchers(types) { this.watcherTypes = types; } record(input) { if (!this.config.enabled) return; this.recorder.record(input); } runInBatch(origin, fn) { // When disabled, do zero work — no batch, no ALS context. if (!this.config.enabled) return fn(); const batch = createBatch(origin, () => v7()); return this.context.run(batch, fn); } /** * Open a batch and make it active for the current async execution (no * callback scope). Returns a handle; `end()` is a no-op today (the async * scope ends naturally) but is part of the contract for future cleanup. */ beginBatch(origin) { const batch = createBatch(origin, () => v7()); if (this.config.enabled) { this.context.enterWith(batch); } return { id: batch.id, end: () => { } }; } async flush() { await this.recorder.flush(); } /** * Pause capture (overload protection). While paused the Recorder drops new * `record()` calls; flushing continues so the buffer drains. Driven by the * OverloadGuard when event-loop lag crosses its threshold. */ pause() { this.recorder.pause(); } /** Resume capture after a {@link pause}. */ resume() { this.recorder.resume(); } /** Whether capture is currently paused by overload protection. */ get isPaused() { return this.recorder.isPaused; } async getMeta() { return { enabled: this.config.enabled, droppedCount: this.recorder.droppedCount, watchers: [...this.watcherTypes], traceLink: this.config.traceLink ?? null, tracesEnabled: this.config.traceContext !== undefined, retention: this.config.prune ? { afterMs: this.config.prune.afterMs, keepLast: this.config.prune.keepLast ?? null, } : null, pruneEnabled: this.config.prune !== undefined && Boolean(this.options.authorizeAction), explainEnabled: Boolean(this.options.explainQuery), sampling: samplingRates(this.config.sampling), auth: { enabled: this.dashboardAuth !== null, modes: this.dashboardAuth ? [...this.dashboardAuth.modes] : [], }, alerts: { enabled: this.alerts !== null, ruleCount: this.alerts?.rules.length ?? 0, }, ai: { enabled: this.diagnosis !== null, mode: this.diagnosis?.mode ?? null, }, profiling: { enabled: this.config.profiling.enabled, sampleRate: this.config.profiling.sampleRate, }, entryTypes: this.extensions.entryTypes(), dashboards: this.extensions.dashboards().map((d) => ({ id: d.id, label: d.label, panels: d.panels, ...(d.navGroup ? { navGroup: d.navGroup } : {}), // Forward the sectioned layout too: extensions that declare their panels // under `sections` (with a flat `panels: []`) — e.g. the durable Workflows // dashboard — would otherwise render blank, because the UI only falls back // to `panels` when `sections` is absent. Dropping `sections` here was why // those dashboards showed nothing. ...(d.sections ? { sections: d.sections } : {}), })), }; } /** * AI exception-diagnosis coordinator, or `null` when `ai` is unconfigured. The * gated controller reads this to run the on-demand `diagnose` endpoint (and to * 404 when AI is off). */ get diagnosisCoordinator() { return this.diagnosis; } /** * Self-observability snapshot: the Recorder's cheap self-metrics plus an * on-demand micro-benchmark of the per-capture cost. The benchmark runs the * synchronous enrich path on a representative input WITHOUT enqueuing, so it * never pollutes the live buffer or taxes real records. */ getHealth() { return { ...this.recorder.getSelfMetrics(), enabled: this.config.enabled, captureCostNanos: this.recorder.benchmarkRecordCost(CAPTURE_COST_BENCHMARK_ITERATIONS), }; } }; TelescopeService = TelescopeService_1 = __decorate([ Injectable(), __param(0, Inject(TELESCOPE_CONFIG)), __param(1, Inject(TELESCOPE_STORAGE)), __param(2, Inject(TELESCOPE_OPTIONS)), __param(3, Inject(TELESCOPE_DASHBOARD_AUTH)), __param(4, Inject(TELESCOPE_EXTENSIONS)), __param(5, Optional()), __param(5, Inject(CONTEXT_ACCESSOR)), __param(6, Inject(EntryEvents)), __metadata("design:paramtypes", [Object, Object, Object, Object, ExtensionRegistry, Object, EntryEvents]) ], TelescopeService); export { TelescopeService }; //# sourceMappingURL=telescope.service.js.map