UNPKG

@dudousxd/nestjs-telescope

Version:

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

165 lines 8.98 kB
import { type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; import type { ResolvedCoreConfig } from '../config/options.js'; import { type TelescopeModuleOptions } from './telescope.options.js'; import { TelescopeService } from './telescope.service.js'; /** * Which process-level event produced a captured crash. Kept as the literal Node * event names so the recorded `content.context.source` reads the same as the * thing you'd grep for in Node's docs. */ export type ProcessCrashKind = 'unhandledRejection' | 'uncaughtException'; /** Resolved exit behaviour — see {@link TelescopeCrashCapture} for the contract. */ type CrashExitMode = 'exit' | 'passthrough'; /** * 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. */ export declare class TelescopeCrashCapture implements OnModuleInit, OnModuleDestroy { private readonly service; private readonly options; private readonly config; private readonly logger; /** True once this instance owns the process listeners (drives teardown). */ private installed; private exitMode; private flushTimeoutMs; private exitCode; /** * 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. */ private recording; /** * 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. */ private readonly onUnhandledRejection; private readonly onUncaughtException; constructor(service: TelescopeService, options: TelescopeModuleOptions, config: ResolvedCoreConfig); onModuleInit(): void; onModuleDestroy(): void; /** Whether this instance currently owns the process listeners. @internal */ get isInstalled(): boolean; /** The resolved exit behaviour for this instance. @internal */ get resolvedExitMode(): CrashExitMode; /** * 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. */ private resolveExitMode; /** * 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. */ private capture; /** * 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. */ private recordCrash; /** * 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. */ private boundedFlush; /** * 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. */ private finish; } export {}; //# sourceMappingURL=telescope-crash-capture.service.d.ts.map