@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
125 lines • 6.13 kB
JavaScript
// packages/core/src/nest/exception-capture.ts
import { HttpException, Logger } from '@nestjs/common';
import { EntryType } from '../entry/entry.js';
import { exceptionFamilyHash } from '../entry/exception-family-hash.js';
/**
* The ONE place a thrown error becomes an `exception` entry.
*
* WHY it is a module and not just the interceptor's body: for a long time the
* only door into the exception family was
* {@link TelescopeExceptionInterceptor}, a `NestInterceptor` — which runs on the
* Nest execution pipeline and NOWHERE else. A BullMQ job body, a `@Cron`
* callback, a durable workflow step, an `@OnEvent` listener: none of those go
* through an interceptor, so a throw inside them never opened an exception
* family, never fired `new-exception`, never got an AI diagnosis, and never
* appeared in the exceptions view. It survived at best as `status: 'failed'` on
* the unit's own job entry — a string in a content field, invisible to every
* mechanism that keys off exceptions.
*
* The fix is not "let each watcher build an exception entry too". Three things
* MUST stay identical across every door or the dashboard silently splits into
* parallel realities: the family hash (two identical errors from the same frame
* must be ONE family whether they were thrown in a request or in a job), the
* 4xx control-flow policy (see {@link isExpectedHttpControlFlow} — the reason
* it exists is an incident, not a preference), and the entry shape. So the
* decision and the record live here, and the interceptor, `TelescopeService`
* and every watcher call in.
*/
/** Logger for the capture path itself. Named for the module, not a class, so a
* swallowed failure is traceable to this file rather than to whichever door
* happened to call in. */
const logger = new Logger('TelescopeExceptionCapture');
/**
* Decides whether a thrown error is expected 4xx control flow that should NOT
* become an exception entry. True only for a NestJS `HttpException` whose
* `getStatus()` is a 4xx (>= 400 and < 500), and only while the
* `captureHttp4xx` escape hatch is off (the default).
*
* WHY the default-skip: expected 4xx control flow is NOT an incident. A
* `ForbiddenException` (403), `NotFoundException` (404) or a validation 400 is
* the framework doing its job — permission denied, resource missing, bad input.
* Recording those as exception entries means every permission denial in
* production opens a NEW exception family (the family hash keys on
* name+message+top-frame, so each call site is its own family), which fires the
* `new-exception` Slack alert and, in AI auto-mode, spends model tokens on a
* "diagnosis" of intended behaviour. We hit exactly this: Telescope's own
* client-errors `authorize` gate threw a 403, it was captured as a brand-new
* family, paged Slack, and burned an AI diagnosis.
*
* This applies to EVERY door, not just HTTP. An `HttpException` thrown inside a
* job body is the same expected control flow (hosts routinely reuse
* `NotFoundException` in services that both a controller and a worker call), so
* a queue retry storm must not be able to page on-call through the back door
* that the front door was hardened against.
*
* Detected via `instanceof HttpException` from `@nestjs/common` (a peer dep),
* which also covers all the built-in subclasses (`ForbiddenException`,
* `NotFoundException`, `BadRequestException`, the validation-pipe 400, …).
*/
export function isExpectedHttpControlFlow(error, options) {
if (options?.captureHttp4xx === true) {
return false;
}
if (!(error instanceof HttpException)) {
return false;
}
const status = error.getStatus();
return status >= 400 && status < 500;
}
/**
* Build the `exception` entry for a thrown value. A non-`Error` throw (a string,
* a rejected object) is normalised into an `Error` so `class`/`message` are
* always populated and the family hash is always computable.
*/
export function toExceptionRecordInput(error, details) {
const err = error instanceof Error ? error : new Error(String(error));
const input = {
type: EntryType.Exception,
// Include the top stack frame so two unrelated call sites that throw the
// same name+message stay distinct families (shared with the client-error
// controller so browser and server errors group identically).
familyHash: exceptionFamilyHash({
name: err.name,
message: err.message,
stack: err.stack ?? null,
}),
content: {
class: err.name,
message: err.message,
stack: err.stack ?? null,
context: details?.context ?? {},
},
};
if (details?.tags !== undefined && details.tags.length > 0) {
input.tags = [...details.tags];
}
return input;
}
/**
* Apply the 4xx policy and, when the error survives it, hand the exception
* entry to `record`. Returns whether an entry was recorded (the watchers ignore
* it; it exists so tests and future doors can assert the decision).
*
* This function NEVER throws. Every caller is on the host's own failure path —
* an rxjs `catchError`, a `catch` block that is about to re-throw a job's error
* — and a throw from here would REPLACE the host's original error with a
* Telescope error, turning an observability bug into a data-loss bug. Building
* the entry (`String(error)` on a hostile `toString`, a getter on `err.stack`)
* runs on the host's thread, so the guard has to be here and not only inside
* the Recorder.
*/
export function captureException(record, error, options, details) {
try {
if (isExpectedHttpControlFlow(error, options)) {
return false;
}
record(toExceptionRecordInput(error, details));
return true;
}
catch (captureError) {
const message = captureError instanceof Error ? captureError.message : String(captureError);
logger.error(`Telescope failed to record an exception entry: ${message}`);
return false;
}
}
//# sourceMappingURL=exception-capture.js.map