UNPKG

@dudousxd/nestjs-telescope

Version:

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

362 lines 18 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 TelescopeCrashCapture_1; // packages/core/src/nest/telescope-crash-capture.service.ts import { Inject, Injectable, Logger, } from '@nestjs/common'; import { toExceptionRecordInput } from './exception-capture.js'; import { TELESCOPE_CONFIG, TELESCOPE_OPTIONS, } from './telescope.options.js'; import { TelescopeService } from './telescope.service.js'; /** Tag on every process-level crash entry, so the dashboard can filter the family. */ const CRASH_TAG = 'unhandled'; /** * Tag added when the crash could not be attributed to any batch. Recorded * EXPLICITLY (rather than dropped or silently attached to a synthetic batch) * because "this error belongs to no request/job" is itself the finding. */ const ORPHANED_TAG = 'orphaned'; /** Per-kind dashboard tag; kebab-case to match the existing tag vocabulary. */ const KIND_TAG = { unhandledRejection: 'unhandled-rejection', uncaughtException: 'uncaught-exception', }; /** Default budget for the pre-exit flush. See `flushTimeoutMs`. */ const DEFAULT_FLUSH_TIMEOUT_MS = 2000; /** Node's own fatal exit code, reproduced by `onCrash: 'exit'`. */ const DEFAULT_EXIT_CODE = 1; /** * Process-wide install slot, keyed by a `Symbol.for` on `globalThis` rather than * a module-scoped `let`. * * WHY the global registry and not a module variable: pnpm workspaces, a * transitive dependency pinning a different `@dudousxd/nestjs-telescope`, or a * test runner that re-imports the ESM graph can all give you TWO live copies of * this module — each with its own module scope, each convinced it is the first * to install. Two copies means two `process.on` listeners means every crash is * recorded TWICE (two entries, two family occurrences, two `new-exception` * evaluations). `Symbol.for` is interned per realm, so the second copy sees the * first copy's claim and stands down. */ const INSTALL_SLOT = Symbol.for('@dudousxd/nestjs-telescope.processCrashCapture'); /** * Claim the process-wide install slot for `owner`. Returns false when another * instance (possibly from a duplicate copy of this module) already holds it. */ function claimInstallSlot(owner) { const current = Reflect.get(globalThis, INSTALL_SLOT); if (current !== undefined && current !== null) return false; return Reflect.set(globalThis, INSTALL_SLOT, owner); } /** Release the slot, but only if `owner` is the one holding it. */ function releaseInstallSlot(owner) { const current = Reflect.get(globalThis, INSTALL_SLOT); if (current === owner) { Reflect.set(globalThis, INSTALL_SLOT, undefined); } } /** * Render an arbitrary rejection reason as a string WITHOUT ever throwing. * `Promise.reject(x)` accepts any value, including an object whose `toString` * throws — and a throw from inside the crash handler is the worst possible * place to have one, because it would replace the host's real error with ours. */ function describeReason(reason) { try { return String(reason); } catch { return '<unstringifiable rejection reason>'; } } /** Coerce whatever came out of the process event into an `Error`. */ function toError(raw) { return raw instanceof Error ? raw : new Error(describeReason(raw)); } /** * Build the `exception` RecordInput for a captured crash. * * The shape and the family-hash inputs come from the shared * `toExceptionRecordInput`, the same builder `TelescopeExceptionInterceptor` and * the watchers use, so a crash and a route-handler throw of the same error land * in the SAME family and the `new-exception` alert dedups across both sources. * This function is now only the crash-specific tags and context. * * NOT routed through `captureException`: that applies the 4xx control-flow skip, * and a 4xx is expected control flow only while something is handling it. A * `NotFoundException` that reached `unhandledRejection` is a bug wherever its * status came from, and dropping it would put the crash with the least * observability back where it started. */ function crashRecordInput(error, kind, orphaned) { const tags = [CRASH_TAG, KIND_TAG[kind], 'failed']; if (orphaned) tags.push(ORPHANED_TAG); return toExceptionRecordInput(error, { context: { source: kind, orphaned }, tags, }); } /** * Records `process.on('unhandledRejection')` and `process.on('uncaughtException')` * as telescope `exception` entries. **Opt-in** via * `exceptions.processCrashes.enabled` — off by default. * * WHY this exists: before it, the ONLY server-side exception source was * {@link TelescopeExceptionInterceptor}, which lives on the Nest pipeline. A * promise rejected with nobody awaiting it, or a throw from a timer / stream * callback / event emitter, never touches that pipeline — so it produced no * entry, no exception family, and no `new-exception` alert. Those are precisely * the failures that take the process down: the incident with the least * observability was the one that ended the process. * * WHY opt-in and not on by default: attaching a process-level listener CHANGES * THE HOST'S CRASH SEMANTICS. Node's default for an `uncaughtException` is to * print the stack and exit(1); the moment ANY listener is registered that * default is suppressed and the process keeps running. The same is true for * `unhandledRejection` under Node's default `--unhandled-rejections=throw`. A * library that attached these behind the host's back would silently convert * "crashed, restarted clean by the orchestrator" into "limping along with * half-initialised state" — a strictly worse failure mode than the one it was * trying to observe. So the host has to ask for it, in writing. * * ## The exit contract * * Telescope never decides on its own whether your process dies. After the entry * is recorded and the bounded flush has settled: * * - `onCrash: 'exit'` — reproduce Node's default: write the stack to stderr and * `process.exit(1)` (`exitCode` is configurable). Use this when Telescope is * the only process-level listener, i.e. when the process WOULD have died. * - `onCrash: 'passthrough'` — record only, then return. The host's own handler * (or an APM agent's) decides what happens next. Use this ONLY when something * else already owns the crash, otherwise you have converted a crash into a * zombie. * - `onCrash: 'auto'` (the default) — decide at registration time by counting * PRE-EXISTING listeners for the two events. Zero listeners means nothing else * was deciding and Node would have crashed, so Telescope reproduces that * (`'exit'`). One or more means the host was already deciding, so Telescope * defers (`'passthrough'`) rather than yanking the exit out from under an * existing handler. The decision is logged at boot, once. * * `'auto'` samples the listener count at `onModuleInit`. A host that registers * its own handler AFTER Nest bootstrap must therefore pass `onCrash` explicitly * — auto will already have picked `'exit'` and will race the late handler to * the exit. The boot log line tells you which mode is live. * * To keep Node's ORIGINAL crash behaviour exactly: leave `onCrash` at `'auto'` * (or set `'exit'`) and register no competing handler, or — the belt-and-braces * version — register your own handler that exits, and let Telescope run in * `'passthrough'`. * * ## Recording is best-effort and bounded * * The process may be milliseconds from death, so the flush is raced against * `flushTimeoutMs` (default 2s) on an unref'd timer: a wedged storage provider * delays the exit by at most that budget instead of hanging a dying process * forever. Every step is wrapped so that a failure INSIDE the recording path can * never mask or replace the host's original error — the worst case is a missing * entry, never a swallowed crash. */ let TelescopeCrashCapture = TelescopeCrashCapture_1 = class TelescopeCrashCapture { service; options; config; logger = new Logger(TelescopeCrashCapture_1.name); /** True once this instance owns the process listeners (drives teardown). */ installed = false; exitMode = 'passthrough'; flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS; exitCode = DEFAULT_EXIT_CODE; /** * Re-entrancy latch around the SYNCHRONOUS record call. A tagger, redactor or * host `filter` that throws — or anything else that raises a crash from * inside `record()` — would otherwise re-enter this handler and recurse until * the stack gives out, burying the original error under its own failure. The * latch is deliberately NOT held across the flush: two genuinely unrelated * crashes in the same tick must both be recorded, and holding a mutex for the * whole flush budget would silently drop the second one. */ recording = false; /** * Bound instance arrow functions, kept as fields so `onModuleDestroy` can pass * the SAME references to `removeListener`. A fresh `.bind()` at teardown time * would silently remove nothing and leak the listener into the next test file * — the classic way this kind of code poisons an unrelated suite. */ /** * The `.catch()` on each is the terminator of the loop this class could * otherwise become: an error escaping `capture()` would reject a promise * nobody awaits, which IS an unhandled rejection, which re-enters this very * handler. Swallowing at the boundary means the worst case is a lost entry. */ onUnhandledRejection = (reason) => { void this.capture(reason, 'unhandledRejection').catch(() => undefined); }; onUncaughtException = (error) => { void this.capture(error, 'uncaughtException').catch(() => undefined); }; constructor(service, options, config) { this.service = service; this.options = options; this.config = config; } onModuleInit() { const settings = this.options.exceptions?.processCrashes; if (settings?.enabled !== true) return; // Capture disabled globally ⇒ every entry would be dropped anyway, and // attaching an exit-altering listener purely to no-op is exactly the // behind-your-back semantics change this whole design is avoiding. if (!this.config.enabled) { this.logger.warn('exceptions.processCrashes is enabled but Telescope capture is disabled — ' + 'process-level crash handlers were NOT registered.'); return; } if (!claimInstallSlot(this)) { // A second TelescopeModule in the same process (or a duplicate copy of // this package) already owns the listeners. Standing down is what keeps // one crash from producing two entries. this.logger.warn('Process-level crash capture is already installed in this process — ' + 'this TelescopeModule instance will not register a second set of handlers.'); return; } this.flushTimeoutMs = settings.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS; this.exitCode = settings.exitCode ?? DEFAULT_EXIT_CODE; this.exitMode = this.resolveExitMode(settings.onCrash ?? 'auto'); process.on('unhandledRejection', this.onUnhandledRejection); process.on('uncaughtException', this.onUncaughtException); this.installed = true; this.logger.log(`Process-level crash capture enabled (onCrash: '${this.exitMode}'` + `${this.exitMode === 'exit' ? `, exitCode: ${this.exitCode}` : ''}, ` + `flushTimeoutMs: ${this.flushTimeoutMs}).`); } onModuleDestroy() { if (!this.installed) return; process.removeListener('unhandledRejection', this.onUnhandledRejection); process.removeListener('uncaughtException', this.onUncaughtException); this.installed = false; releaseInstallSlot(this); } /** Whether this instance currently owns the process listeners. @internal */ get isInstalled() { return this.installed; } /** The resolved exit behaviour for this instance. @internal */ get resolvedExitMode() { return this.exitMode; } /** * Resolve `'auto'` against the listeners already on the process. Counting * BOTH events together is deliberate: a host that handles only * `uncaughtException` still demonstrably owns crash policy, and pre-empting it * on the rejection side alone would produce two different exit behaviours for * what is, under Node's defaults, the same fatal path. */ resolveExitMode(configured) { if (configured !== 'auto') return configured; const preexisting = process.listenerCount('uncaughtException') + process.listenerCount('unhandledRejection'); return preexisting === 0 ? 'exit' : 'passthrough'; } /** * Record the crash, flush on a bounded budget, then apply the exit contract. * * Everything up to the first `await` runs SYNCHRONOUSLY inside the process * event handler, which is load-bearing: `AsyncLocalStorage` still holds the * batch that was active when the promise rejected / the callback threw, so * `service.record()` inherits its `batchId`, `origin` and ambient `traceId` * for free. Move the `record()` call after an await and every crash becomes * orphaned. */ async capture(raw, kind) { const error = toError(raw); this.recordCrash(error, kind); try { await this.boundedFlush(); } catch { // boundedFlush already swallows; belt-and-braces so no path from here can // throw back into the process handler. } this.finish(error, kind); } /** * The synchronous half of the capture: read the active batch and hand the * entry to the Recorder. Never throws — a failure in OUR path must never * replace or hide the host's error, so the worst case is a warning and a * missing entry, and the exit contract still runs. */ recordCrash(error, kind) { if (this.recording) return; this.recording = true; try { // Read the active batch through the SAME accessor the Recorder will use a // line later, so `orphaned` can never disagree with the `batchId` actually // stamped on the entry. const orphaned = this.service.context.current() === undefined; this.service.record(crashRecordInput(error, kind, orphaned)); } catch (recordingError) { const message = recordingError instanceof Error ? recordingError.message : describeReason(recordingError); this.logger.warn(`Failed to record ${kind} entry: ${message}`); } finally { this.recording = false; } } /** * Race the recorder flush against `flushTimeoutMs` on an UNREF'D timer. * * Two failure modes are being avoided at once. First, awaiting an unbounded * `flush()` in a process that is about to exit turns a crash into a hang if * the storage backend is wedged — the timeout caps that at a known budget and * we exit with the entry possibly unwritten, which is the right trade for a * dying process. Second, the `.catch()` on the flush promise itself is NOT * decoration: once the race resolves via timeout, that promise is abandoned, * and an abandoned rejecting promise is an unhandled rejection — which would * re-enter this very handler. The catch is what stops crash capture from * feeding itself. */ boundedFlush() { const flushed = this.service.flush().catch(() => undefined); const budget = new Promise((resolve) => { const timer = setTimeout(resolve, this.flushTimeoutMs); timer.unref?.(); }); return Promise.race([flushed, budget]); } /** * Apply the exit contract. In `'exit'` mode this reproduces what Node would * have done with no listener attached: the stack on stderr, then exit(1). * `process.stderr.write` rather than the Nest `Logger` so the output survives * a custom logger that buffers, filters by level, or ships asynchronously — * the last thing written before a fatal exit has to be unconditional. */ finish(error, kind) { if (this.exitMode === 'passthrough') return; process.stderr.write(`${kind}: ${error.stack ?? `${error.name}: ${error.message}`}\n`); process.exit(this.exitCode); } }; TelescopeCrashCapture = TelescopeCrashCapture_1 = __decorate([ Injectable(), __param(0, Inject(TelescopeService)), __param(1, Inject(TELESCOPE_OPTIONS)), __param(2, Inject(TELESCOPE_CONFIG)), __metadata("design:paramtypes", [TelescopeService, Object, Object]) ], TelescopeCrashCapture); export { TelescopeCrashCapture }; //# sourceMappingURL=telescope-crash-capture.service.js.map