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