Consumers
Observe diagnostic events from anywhere — Telescope, OpenTelemetry, an APM, a logger, or your own subscriber.
A diagnostic event is just a message on a Node channel. Anything can read it. This page shows how to wire the common consumers — and how to write your own in a few lines.
The wildcard problem (and the registry)
Node's diagnostics_channel has no wildcard subscription: you can only subscribe to a channel by its exact name. Since channels are named per event (aviary:authz:decision, aviary:billing:invoice-paid, …), a generic consumer needs to know which channels exist. That's what the registry is for:
import diagnostics_channel from 'node:diagnostics_channel';
import { registeredChannels, onChannelRegistered } from '@dudousxd/nestjs-diagnostics';
import type { DiagnosticEvent } from '@dudousxd/nestjs-diagnostics';
/** Subscribe a handler to every channel (current + future); returns a teardown. */
export function observeAll(handler: (e: DiagnosticEvent) => void): () => void {
const offs: Array<() => void> = [];
const subscribe = (name: string) => {
const listener = (msg: unknown) => handler(msg as DiagnosticEvent);
diagnostics_channel.subscribe(name, listener);
offs.push(() => diagnostics_channel.unsubscribe(name, listener));
};
for (const name of registeredChannels()) subscribe(name); // every channel so far…
offs.push(onChannelRegistered(subscribe)); // …and any registered later
return () => offs.forEach((off) => off());
}observeAll is the whole pattern: every consumer below is just a handler plugged into it from a NestJS provider, subscribed on boot and torn down on shutdown.
Telescope (batteries-included)
The simplest consumer is Telescope via the generic watcher — no code, every event becomes a diagnostic entry:
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { nestjsDiagnosticsTelescope } from '@dudousxd/nestjs-diagnostics-telescope';
TelescopeModule.forRoot({
extensions: [nestjsDiagnosticsTelescope()],
});Each emit-produced event lands tagged lib:<lib> / event:<event>, with content = the payload and the envelope's traceId for correlation. When an emitter stamped a durationMs onto the event, the watcher forwards it to the recorder — so the Telescope OTel exporter can feed it into a duration histogram instead of only a counter.
The same watcher also records trace() spans, tagged kind:span: one entry per completed span, not one per phase. For every (lib, event) pair it subscribes only the three terminal sub-channels — end, asyncEnd, error — and builds the entry from whichever one fires, carrying spanId, phase, and result (success) or error (failure). start/asyncStart are never subscribed, so their envelopes aren't even built for an unwatched span. An async trace() call also fires end early, as a "synchronous portion done" marker with no result key; the watcher filters that premature marker out (it isn't a terminal completion) so a span is never recorded twice. durationMs and traceId are forwarded from the envelope, and startedAt is derived as the terminal timestamp minus the duration, so the entry's recorded start lines up with the span's real start for the Traces waterfall to nest it correctly. That traceId is passed explicitly via RecordInput.traceId, which wins over whatever ambient trace context Telescope's Recorder would otherwise infer — so a span always lands under the producer-chosen trace even without an active OTel span.
Avoiding duplicate recording: the claim registry
If a library ships its own Telescope watcher that already records its aviary:<lib>:<event> traffic as a first-class, typed entry (nestjs-agent's and nestjs-media's watchers do this), the generic DiagnosticWatcher above would otherwise record the same event a second time as a plain diagnostic entry. The claim registry lets the lib-specific watcher opt its events out of that generic recording without any config on the consumer side:
import { claimDiagnostics } from '@dudousxd/nestjs-diagnostics';
// inside the lib-specific watcher's own registration:
const release = claimDiagnostics('agent', ['chat-request', 'tool-call']);
// ... and on cleanup:
release();claimDiagnostics(lib, events) marks each lib:event key as claimed in a process-global, refcounted registry (a Map<string, number> behind Symbol.for('aviary:diagnostics:claims')), and returns a release function that undoes exactly what that call claimed. The generic watcher calls isDiagnosticClaimed(lib, event) at record time — when an event actually publishes, not when the watcher subscribes — which is what makes claiming order-independent: a lib-specific watcher can claim before or after the generic one has registered, and every event recorded from then on is skipped either way. Because the registry is refcounted, two watchers claiming the same key don't fight — releasing one doesn't un-claim it for the other.
By default a claimed key is skipped; pass recordClaimed: true to record everything regardless of claims (e.g. to compare the raw feed against the typed one while debugging):
nestjsDiagnosticsTelescope({ recordClaimed: true })This is independent from exclude (a lib:event list muting noisy events outright, e.g. media:upload.progress): exclude drops events nobody should see in Telescope at all, while claims only concern events another watcher already records elsewhere.
OpenTelemetry
Map each diagnostic event onto the active span as a span event (and attributes). Now your ecosystem's domain events show up inline on your traces. Wire it in a provider so the subscription follows the app lifecycle:
import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
import { trace } from '@opentelemetry/api';
@Injectable()
export class OtelDiagnosticsBridge implements OnModuleInit, OnModuleDestroy {
private stop?: () => void;
onModuleInit() {
this.stop = observeAll((e) => {
trace.getActiveSpan()?.addEvent(`${e.lib}.${e.event}`, {
'aviary.lib': e.lib,
'aviary.event': e.event,
'aviary.trace_id': e.traceId ?? '',
// present only on timed events — feed it into a histogram, not just an attribute
...(e.durationMs !== undefined && { 'aviary.duration_ms': e.durationMs }),
...flatten(e.payload), // your own payload → attribute mapping
});
});
}
onModuleDestroy() {
this.stop?.();
}
}Register OtelDiagnosticsBridge in a module's providers and Nest handles the rest.
Because emit already correlates with traceId (from nestjs-context), you can also stitch events to spans by id when there's no active span on the emitting path — e.g. in a background worker.
An APM or a logger
Forwarding to Datadog, Sentry, an HTTP collector, or just structured logs is the same shape — the same provider, a different handler body:
@Injectable()
export class DiagnosticsLogger implements OnModuleInit, OnModuleDestroy {
private stop?: () => void;
onModuleInit() {
this.stop = observeAll((e) => {
logger.info('aviary.diagnostic', {
lib: e.lib,
event: e.event,
traceId: e.traceId,
ts: e.ts,
...e.payload,
});
// or: apm.captureEvent(...), fetch(collectorUrl, { method: 'POST', body: JSON.stringify(e) }), …
});
}
onModuleDestroy() {
this.stop?.();
}
}Keep consumers cheap and non-blocking. The emit path is guarded (hasSubscribers, never throws), but a subscriber runs inline on the producer's call. Do heavy work (network, disk) off the hot path — batch, queue, or hand off to a worker.
A custom subscriber
You don't need a generic observer at all if you only care about one event. Subscribe to a single channel by name — again from a provider, so you also unsubscribe on shutdown:
import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
import diagnostics_channel from 'node:diagnostics_channel';
import { channelName } from '@dudousxd/nestjs-diagnostics';
import type { DiagnosticEvent } from '@dudousxd/nestjs-diagnostics';
@Injectable()
export class DenialAlerter implements OnModuleInit, OnModuleDestroy {
private readonly channel = channelName('authz', 'decision');
private readonly handler = (msg: unknown) => {
const e = msg as DiagnosticEvent;
if (e.payload.allowed === false) this.alertOnRepeatedDenials(e);
};
onModuleInit() {
diagnostics_channel.subscribe(this.channel, this.handler);
}
onModuleDestroy() {
diagnostics_channel.unsubscribe(this.channel, this.handler);
}
}This is the lowest-overhead way to react to a specific ecosystem event — a denial alert, a metric counter, a webhook.
Across processes (Redis relay)
The diagnostics bus is in-process: a subscriber only sees events emitted in its own Node process. When you run multiple processes — an API pod and a background worker, several replicas — and want a subscriber in one to react to events emitted in another, put the optional @dudousxd/nestjs-diagnostics-redis relay in front of the bus. It forwards selected local aviary:<lib>:<event> channels onto Redis pub/sub and re-emits Redis-received events back onto the local bus, so every subscriber on this page — observeAll, a single-channel handler, Telescope — fires across processes. The diagnostics core stays untouched; this is entirely opt-in.
pnpm add @dudousxd/nestjs-diagnostics-redis @dudousxd/nestjs-diagnostics ioredisSupply a publisher and a separate subscriber connection (a subscribed ioredis connection can't publish, so use redis.duplicate()). Prefer forRootAsync so the relay's clients come from the same DI container as the rest of your app — inject your existing Redis provider and build the options in a factory:
import type Redis from 'ioredis';
import { Module } from '@nestjs/common';
import { DiagnosticsRedisModule } from '@dudousxd/nestjs-diagnostics-redis';
import { REDIS, RedisModule } from './redis.module'; // your app's Redis provider
@Module({
imports: [
DiagnosticsRedisModule.forRootAsync({
imports: [RedisModule], // modules whose providers the factory injects
inject: [REDIS], // tokens passed to useFactory, in order
useFactory: (redis: Redis) => ({
pub: redis,
sub: redis.duplicate(),
libs: ['durable', 'notifications'], // forward all events of these libs
}),
}),
],
})
export class AppModule {}forRootAsync({ imports?, inject?, useFactory }) mirrors the usual Nest convention: imports makes other modules' exported providers available, inject lists the tokens handed to useFactory (in order), and useFactory returns the relay options (pub, sub, and a channel selection — libs, channels, or all). The factory may be async.
Already holding the connections?
The static forRoot({ pub, sub, libs }) takes pre-built clients directly. Reach for forRootAsync whenever your Redis client itself lives in DI — it keeps the relay on the same connection lifecycle as everything else, instead of a top-level new Redis(...).
The relay never opens or closes your Redis connections — you own their lifecycle — and it's loop-safe (a process skips its own echoes, so two pods never ping-pong). Note that payloads cross the boundary as JSON: class instances arrive as plain objects and Date as an ISO string, so cross-process handlers should read fields, not behavior.
In tests
Subscribing makes events assertable. Drop a collector in your test, exercise the code, and check what was emitted:
import diagnostics_channel from 'node:diagnostics_channel';
import { channelName } from '@dudousxd/nestjs-diagnostics';
const seen: any[] = [];
const channel = channelName('authz', 'decision');
const handler = (msg: unknown) => seen.push(msg);
diagnostics_channel.subscribe(channel, handler);
// … exercise the code that emits …
diagnostics_channel.unsubscribe(channel, handler);
expect(seen).toHaveLength(1);
expect(seen[0].payload.allowed).toBe(false);Note that, by design, events are only built when something is subscribed — so asserting on them requires an active subscription, exactly as above.