Capture & correlation
How watchers, batches, and AsyncLocalStorage turn scattered events into one navigable flow — the request and everything it caused, in capture order.
The unit of value in Telescope is the batch — one entry-point (a request, a queue job, a scheduled tick) and everything it caused. Correlation is the differentiator: it's the view metrics and logs can't give you, where a single request expands into the exact queries, jobs, exceptions, and mails it produced, in the order they happened.
Watchers
A watcher captures one kind of activity and records it as an Entry. Watchers come in two flavours:
- Entry-point watchers open a batch: the HTTP request watcher, the queue job watcher, the schedule watcher, and manual
Telescope.batch(). - Sub-watchers record into whatever batch is already active: query, mail, cache, event, log, Redis-command, model, and outbound-HTTP watchers.
Request and exception capture are wired automatically by TelescopeModule.forRoot(). Every other watcher is a value you add to the watchers array (most live in their own package). The same Watcher SPI backs the built-ins and your own watchers — community watchers are first-class, not second-class hooks.
interface Watcher {
readonly type: string; // entry type it produces
register(ctx: WatcherContext): void | Promise<void>; // wire NestJS hooks
shouldRecord?(candidate: unknown): boolean; // cheap pre-filter
}Watchers never touch storage and never block. They call ctx.record(...), which returns immediately.
Batches and AsyncLocalStorage
A TelescopeContext lives in AsyncLocalStorage. When an entry-point watcher calls beginBatch(origin), it seeds a Batch { id, origin, startedAt } into the ALS store for the duration of that async flow. Every record() made inside that flow then inherits the batch's id and gets a monotonic sequence — so entries reassemble in capture order with no manual plumbing.
This is why adapters like MikroORM and TypeORM correlate each query to its request: their loggers run inside the query's async context, so the active batch is already there. (Prisma is the exception — its query events fire detached from the caller's context, so Prisma queries are captured but orphaned. See the Prisma package.)
Non-HTTP entry points get their own batch:
- a queue worker opens a batch per job,
@nestjs/scheduleopens one per cron / interval / timeout tick,Telescope.batch(origin, fn)wraps an arbitrary script or CLI run.
Entries recorded outside any batch get a synthetic per-entry batch, so nothing is ever lost.
traceId / spanId from an active OpenTelemetry span are stamped onto entries (via the -otel provider), so a Telescope batch maps 1:1 to a trace and the correlation survives across the OTel bridge.
traceId precedence — explicit wins
Stamping isn't only ambient. RecordInput.traceId lets whatever calls record() state its own trace id explicitly, and precedence is: explicit input.traceId wins over everything, else the active OTel span's traceId, else (when @dudousxd/nestjs-context is present) the context accessor's traceId() as a last-resort fallback. An existing OTel trace id is never clobbered by the fallback — the fallback only fires when OTel yielded nothing for that entry.
This is the hook a library integration reaches for when it already knows its own correlation id but has no active OTel span to inherit one from — e.g. a diagnostics package that emits its own span envelope for a background run and wants those spans to land in the same trace as the request that kicked the run off. It states traceId on the RecordInput it hands to record(), and that entry joins the trace exactly as if an OTel span had stamped it — including showing up in TracesService.getWaterfall(traceId) and the dashboard's #/traces/:traceId waterfall view, alongside the request/query/job entries that share the same id.
The Traces nav item still needs traceContext
Explicit traceId gets an entry onto the right trace, but it doesn't turn the top-level Traces nav link on by itself — that's gated on meta.tracesEnabled, which is true only when a traceContext provider (e.g. -otel's) is configured. In practice this is rarely a real constraint: a host running a trace-aware integration has almost always already wired traceContext for its own HTTP requests, so the lib-emitted spans just need to reuse the same traceId those requests are already stamped with.
The Entry
Every watcher produces the same universal record. Type-specific data lives in content; everything else is uniform, so the API, dashboard, pruner, and OTel bridge treat all entry types identically:
interface Entry<TContent = unknown> {
id: string; // uuid v7 — time-sortable, globally unique
batchId: string; // correlation key; all entries in one batch share it
type: string; // 'request' | 'query' | 'job' | 'exception' | 'mail' | <custom>
familyHash: string | null; // groups "the same thing" (query template, exception class+message)
content: TContent; // redacted, type-specific payload
tags: string[]; // cross-cutting filters: 'status:500', 'user:42', 'slow'
sequence: number; // order within the batch (capture order)
durationMs: number | null;
origin: BatchOrigin; // 'http' | 'queue' | 'schedule' | 'cli' | 'manual'
instanceId: string; // hostname / pod id — multi-instance aggregation
createdAt: Date;
}familyHash is what powers "show me every occurrence of this exception" and the slow-query / duplicate-query views without scanning content. Queries normalize to a SQL template before hashing; exceptions hash on class + message.
Exception capture
Exceptions thrown out of a route handler are captured automatically (no watcher to register) and recorded as exception entries — which is what opens an error family, drives the new-exception alert, and feeds AI diagnosis.
By default, expected 4xx control flow is not recorded as an exception. A NestJS HttpException whose status is a 4xx — a 403 ForbiddenException, a 404 NotFoundException, a 400 from the validation pipe — is the framework doing its job (permission denied, resource missing, bad input), not an incident. Recording each one would open a new exception family (the family hash keys on class + message + top frame, so every call site is distinct), fire the new-exception alert, and — in AI auto-mode — spend model tokens diagnosing intended behaviour. In production every permission denial would page on-call and burn a diagnosis. (This default changed after exactly that incident: Telescope's own client-errors authorize gate threw a 403, which was captured as a brand-new family and paged Slack.)
The 4xx is not lost — the request watcher still records the 4xx statusCode (and a status:NNN tag, e.g. status:404) on its own request entry. You still see the 4xx in the dashboard and in error-rate metrics; it just doesn't spawn an exception family, can't fire new-exception, and can't trigger diagnosis.
Always recorded: 5xx HttpExceptions (real server errors) and any non-HttpException error (a plain Error, TypeError, etc.). Untouched: browser-reported client_exception entries — those are deliberate reports recorded directly by the ingestion endpoint, never through this filter.
To opt 4xx back in (restore the pre-change behaviour), set exceptions.captureHttp4xx:
TelescopeModule.forRoot({
exceptions: { captureHttp4xx: true }, // default false — 4xx is control flow, not an incident
});Process-level crashes — exceptions.processCrashes
Exception capture rides the Nest pipeline, so it only ever sees errors thrown out of a route handler. A promise rejected with nobody awaiting it, or a throw that escapes to the event loop from a timer, a stream callback or an 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.
exceptions.processCrashes closes that gap by recording process.on('unhandledRejection') and process.on('uncaughtException') as ordinary exception entries — same family hash, so a crash and a route-handler throw of the same error group together.
TelescopeModule.forRoot({
exceptions: {
processCrashes: {
enabled: true, // default false — this is opt-in, see below
onCrash: 'auto', // 'auto' (default) | 'exit' | 'passthrough'
flushTimeoutMs: 2000, // bounded best-effort flush before the exit contract
exitCode: 1, // used by 'exit'
},
},
});Why it is opt-in. Registering a process.on('uncaughtException') listener suppresses Node's default fatal exit — the process keeps running instead of dying. The same holds for unhandledRejection under Node's default --unhandled-rejections=throw. A library that attached these behind your back would silently convert "crashed, restarted clean by the orchestrator" into "limping along with half-initialised state", which is a worse failure than the blind spot it fixes. So you have to ask for it, and Telescope reproduces the crash it suppressed.
The exit contract. After the entry is recorded and the bounded flush settles:
onCrash | What happens |
|---|---|
'auto' (default) | Decided once at bootstrap from the number of pre-existing uncaughtException + unhandledRejection listeners. Zero ⇒ 'exit' (nothing else was deciding, Node would have crashed). One or more ⇒ 'passthrough' (your handler, or an APM agent's, was already deciding — don't yank the exit out from under it). The resolved mode is logged once at boot. |
'exit' | Reproduce Node's default: write the stack to stderr, then process.exit(exitCode). |
'passthrough' | Record only, then return. Use this only when something else exits — otherwise every crash becomes a zombie process. |
Because 'auto' samples at bootstrap, a host that registers its own handler after Nest boots must pass onCrash explicitly.
To keep Node's original crash behaviour exactly: leave onCrash at 'auto' and register no competing handler. Telescope then records the crash and exits 1, which is what Node would have done.
Recording is bounded, never blocking. The flush is raced against flushTimeoutMs on an unref'd timer rather than awaited — a wedged storage provider delays a dying process by at most that budget instead of hanging it. Exceeding the budget loses the entry; that is the right trade for a process that is leaving. Every step is wrapped so a failure inside the recording path can never mask the original error.
Correlation. The active batch survives into the process handler via AsyncLocalStorage, so a rejection raised inside a request, a BullMQ job or a @Cron tick inherits that batch's batchId, origin and traceId and lands in the same trace. With no active batch the crash is genuinely unattributable, and is recorded as such — tagged orphaned, origin manual — rather than dropped. Every entry from this path also carries unhandled plus unhandled-rejection / uncaught-exception, so you can filter the whole family in the dashboard.
Registration is idempotent per process (two TelescopeModule instances, or a duplicate copy of the package, install one set of listeners, not two), and onModuleDestroy removes them.
Request body capture gate — requestCapture
The request-capture middleware used to hand the raw, already-decoded body straight to record(), so a multi-MB JSON/string body always paid for the Recorder's synchronous redaction walk before its size bounds could kick in. requestCapture gates the body before record() ever sees it — the walk never runs over a skipped body:
TelescopeModule.forRoot({
requestCapture: {
maxBodyBytes: 131_072, // default: 128 KiB. `false` disables the size gate.
skipBodyContentTypes: [
'application/offset+octet-stream', // tus resumable uploads
'application/octet-stream',
'multipart/form-data',
], // defaults shown — string-prefix or RegExp match against content-type
skipBody: (request) => request.url.startsWith('/uploads'), // escape hatch for route-based skips
},
});maxBodyBytes(default131_072, 128 KiB) — measured from thecontent-lengthheader when present, else a string/Buffer body's own length. A parsed object body with nocontent-lengthis never measured (noJSON.stringify— that would be the exact walk this gate exists to avoid) and passes the size gate untouched. Setfalseto disable.skipBodyContentTypes— string-prefix orRegExpmatch againstcontent-type. Defaults to the three binary/streamed types above.skipBody(request)— a predicate escape hatch for route-based skips (e.g. a tus resumable-upload endpoint whose content-type doesn't tell the whole story), checked in addition to the two gates above.requestis a typedTelescopeHttpRequest(method,url,headers,user, plus an index signature) — the same minimal shapeclientErrors.authorizeanddashboardAuth.sessionhooks receive, exported astoTelescopeHttpRequest()for narrowing a raw platform request yourself.
Every gate is on by default (the 128 KiB cap + the binary content-type list) — this is a safe-by-default fix, not opt-in. When a gate trips, the request entry is still recorded in full — method/path/status/duration/user/headers are untouched; only payload becomes a marker string: '[Skipped: N bytes > maxBodyBytes]', '[Skipped: <content-type>]', or '[Skipped: skipBody predicate]'.
The Recorder pipeline
ctx.record(input) hands the entry to the Recorder — a bounded, async, backpressure-safe pipe between watchers and storage. The application thread never waits on it:
record(input)
→ enrich (attach batchId from ALS, instanceId, sequence, createdAt)
→ tag (run registered Taggers; built-in + user-provided)
→ redact (deep-redact configured paths + default sensitive keys)
→ sample (per-type sampling + filter() hook; drop early)
→ buffer (push to a bounded ring buffer)
→ flush (drain in batches on a timer / size threshold → StorageProvider.store)The guarantees that make this safe to run in production:
- Non-blocking.
record()is synchronous and O(1); all I/O is deferred to the flush timer. - Bounded memory. The ring buffer has a hard cap; on overflow it drops the oldest entry and increments a dropped counter — it never grows unboundedly and never blocks the app.
- Batched writes. Flushes coalesce many entries into one
store()call. - Graceful shutdown.
onApplicationShutdowndrains the buffer with a timeout. - Failure isolation. A storage error is logged and the batch is dropped — a broken telescope never breaks the host app.
Redaction is load-bearing
The synchronous redact() step is not just for privacy: it snapshots each entry's content into a plain, reference-free object at record() time. That releases live object graphs (e.g. a hydrated ORM entity captured off req.user, which references its EntityManager and identity map). Keeping redaction synchronous doubles as a detach that bounds memory — deferring it retains those graphs until flush and can OOM the host. See Performance.
The request flow, end to end:
GET /orders/42→ 5 queries (1 flagged slow, 2 duplicates) → 1 job dispatched (SendReceipt) → 1 outbound HTTP call → 1 exception — all sharing onebatchId, reassembled insequenceorder when you open the request in the dashboard.
Getting Started
Mount the Telescope dashboard in an existing NestJS app — install core + ui, import two modules, and open /telescope. Zero-config SQLite by default; swap the storage adapter when you're ready.
Storage
The StorageProvider SPI, the zero-config SQLite default, self-healing schema, and the adapter table — your DB, your store, the same contract everywhere.