Aviary

Getting Started

Install the package, emit diagnostic events, and correlate them with a trace id.

Install

pnpm add @dudousxd/nestjs-diagnostics

There's nothing to register — no module, no provider. The package is a set of functions over Node's diagnostics_channel.

Emitting events

Call emit(lib, event, payload) from your provider wherever something interesting happens:

import { Injectable } from '@nestjs/common';
import { emit } from '@dudousxd/nestjs-diagnostics';

@Injectable()
export class BillingService {
  async markInvoicePaid(invoiceId: string, amount: number) {
    // … your domain logic …
    emit('billing', 'invoice-paid', { invoiceId, amount });
  }
}
  • lib identifies the emitting library ('billing', 'authz', 'jobs'…).
  • event is the event within it ('invoice-paid', 'decision'…).
  • payload is your own data.

The event is published on the channel aviary:billing:invoice-paid.

Free when unobserved

emit builds and publishes the envelope only when the channel has subscribers (channel.hasSubscribers). A production process with no observer attached pays essentially nothing per call — so libraries can emit by default. emit also never throws: observability must never break the code path that produced the event.

Trace correlation

If your app uses @dudousxd/nestjs-context, register its accessor once and every emit auto-fills traceId from the active request:

import { setContextAccessor, CONTEXT_ACCESSOR } from '@dudousxd/nestjs-diagnostics';

// e.g. in a Nest module, after resolving the optional CONTEXT_ACCESSOR provider:
setContextAccessor(accessor);

CONTEXT_ACCESSOR is Symbol.for('@dudousxd/nestjs-context:accessor') — the same token nestjs-context publishes under, so any object structurally matching the accessor works. nestjs-context is an optional peer; it is never imported here.

Need an explicit id instead? Pass it in opts — e.g. from a method that already holds a correlation id:

@Injectable()
export class BillingService {
  async markInvoicePaid(payload: InvoicePaid, traceId: string) {
    emit('billing', 'invoice-paid', payload, { traceId });
  }
}

Timing an operation

If the event describes something that took measurable time — an outbound call, a query, a job run — stamp the wall-clock duration onto it with opts.durationMs. emit copies it onto the envelope as durationMs, letting downstream consumers build duration histograms instead of only counting events:

import { Injectable } from '@nestjs/common';
import { emit } from '@dudousxd/nestjs-diagnostics';

@Injectable()
export class AiService {
  constructor(private readonly client: ChatClient) {}

  async chat(prompt: string) {
    const start = performance.now();
    const reply = await this.client.complete(prompt);
    emit('ai', 'chat-request', { model: 'gpt-4o', tokens: reply.tokens }, {
      durationMs: performance.now() - start,
    });
    return reply;
  }
}

Optional by design

durationMs is set on the envelope only when you pass it — omit it for events that aren't tied to a timed operation, and the key won't appear at all. There's no need to send 0 or a placeholder.

Tracing an operation

emit is for a single fire-and-forget event. When the thing you want to observe has a real start/end/error shape — an outbound call, a durable step — wrap it in trace(lib, event, fn, payload?, opts?) instead of hand-rolling matching emit calls at both ends:

import { trace } from '@dudousxd/nestjs-diagnostics';

async function chargeCard(orderId: string) {
  return trace('billing', 'charge', () => paymentProvider.charge(orderId), { orderId });
}

trace publishes on five sub-channels named aviary:<lib>:<event>:<phase> (start, end, asyncStart, asyncEnd, error) rather than the base channel emit uses — mirroring Node's own tracingChannel() semantics. A synchronous fn publishes start then end (or error on throw). An async fn (one returning a promise) publishes start, an end marking the synchronous portion completing, then asyncStart followed by asyncEnd (or error) once the promise settles. Every phase event of one call shares a spanId, and each terminal phase (end/asyncEnd/error) carries durationMs, measured from start with performance.now().

Trace id precedence mirrors emit: pass opts.traceId for an explicit id (resolved once at span start and stamped on every phase), otherwise it falls back to the registered context accessor.

Like emit, trace costs almost nothing when unobserved — if none of the five sub-channels has a subscriber, it just calls fn() directly: no span id, no envelope built. tracingChannel(lib, event) returns a reusable { name, channels, trace(fn, payload?, opts?) } bound to one (lib, event) pair, for a hot call site that always traces the same operation.

See Consumers → Telescope for how the generic watcher turns a completed span into a single recorded entry.

API surface

ExportDescription
emit(lib, event, payload, opts?)Build + publish a DiagnosticEvent on aviary:<lib>:<event> (only when subscribed). opts accepts traceId, durationMs, and sample.
trace(lib, event, fn, payload?, opts?)Wrap an operation and publish span-like start/end/asyncStart/asyncEnd/error events (see Tracing an operation). opts accepts traceId.
tracingChannel(lib, event)A trace bound to one (lib, event) pair, reused across calls.
traceChannelNames(lib, event)The five aviary:<lib>:<event>:<phase> sub-channel names for a pair.
channelName(lib, event)The aviary:<lib>:<event> string.
getChannel(lib, event)The memoized diagnostics_channel for a pair (also registers its name).
CHANNEL_PREFIX'aviary'.
registeredChannels()Snapshot of every registered channel name.
onChannelRegistered(cb)Notified once per future channel registration; returns an unsubscribe.
setContextAccessor(accessor | null)Register the accessor emit/trace read traceId from.
CONTEXT_ACCESSORShared DI token for the optional context accessor.
claimDiagnostics(lib, events)Register lib:event keys in the process-global claim registry so a generic consumer skips them by default; returns a release function. See Consumers → the claim registry.
isDiagnosticClaimed(lib, event)Whether lib:event is currently claimed.

The registeredChannels / onChannelRegistered pair is what makes generic, wildcard-style observing possible — see Consumers.

On this page