Aviary

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 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.

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 ioredis

Supply 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:

app.module.ts
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.

On this page