@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
332 lines • 16.4 kB
JavaScript
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