Agora

Getting Started

Install and configure the package, emit POINT events, trace spans, and correlate everything with a trace id.

Install

terminal
node ace add @adonis-agora/diagnostics

This installs the package and runs node ace configure @adonis-agora/diagnostics for you.

Configuring leaves you with three things:

  1. the service provider @adonis-agora/diagnostics/diagnostics_provider registered in adonisrc.ts;
  2. config/diagnostics.ts, where the otel flag and the cross-process transports live — see the config reference;
  3. start/diagnostics.ts, registered as a preload file, so the handlers you write there are subscribed before the app serves traffic.

Emitting POINT events

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

import { emit } from '@adonis-agora/diagnostics'

emit('billing', 'invoice-paid', { invoiceId: 'inv_123', amount: 4200 })
  • lib identifies the emitting library ('billing', 'authz', 'resilience'…).
  • event is the event within it ('invoice-paid', 'decision'…).
  • payload is your own data.

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

By default payload is unknown — the bus imposes no schema. To get compile-time payload checking for your own channels, augment the ChannelRegistry; see Typed payloads.

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.

Timing a POINT event

emit is a "this happened" fact, not a span — but plenty of facts are about something that took time. Pass durationMs and it is stamped onto the envelope:

const startedAt = performance.now()
const rows = await runReport(params)

emit('reporting', 'report-generated', { reportId, rows: rows.length }, {
  durationMs: performance.now() - startedAt,
})

It exists so an exporter can build a histogram — p50/p95/p99 of how long report generation takes — rather than only counting how many reports were generated. That is the whole reason it sits on the envelope instead of inside payload: a duration buried in your own payload shape is an opaque field to a generic observer, while durationMs is part of the contract every consumer already reads.

Reach for trace() instead when you want the operation's start, end and failure as separate correlated events. Use durationMs when one event is enough and you already have the number.

Load-shedding a hot event

For very hot call sites, pass a sample predicate. It is consulted after the hasSubscribers gate and before the envelope is built, so a dropped event allocates nothing. A throwing sampler is treated as a skip.

// Publish ~10% of decisions, even when something is listening:
emit('authz', 'decision', payload, { sample: () => Math.random() < 0.1 })

Tracing spans

When you want timing and start/end/error pairing — not just a "happened" fact — wrap the operation in trace:

import { trace } from '@adonis-agora/diagnostics'

const decision = trace('authz', 'decision', () => evaluate(req), { subject })

const result = await trace('durable', 'step', () => runStep(), { name })

trace publishes on the five span sub-channels of agora:<lib>:<event>:

ChannelPhaseCarries
…:startstartpayload, spanId, traceId
…:endendsync result + durationMs (also the sync prelude of an async op)
…:asyncStartasyncStartcontinuation began
…:asyncEndasyncEndsettled result + durationMs
…:errorerrorerror + durationMs

Every phase of one call shares a spanId, so observers can pair them without relying on subscription order. The return value (or thrown error) of fn is propagated to the caller unchanged.

Also free when unobserved

When no span sub-channel has a subscriber, trace simply calls fn and returns its value — no span id, no envelope, no performance.now() reads. The hot path is a handful of hasSubscribers checks.

For a hot site that always traces the same operation, bind it once with tracingChannel:

import { tracingChannel } from '@adonis-agora/diagnostics'

const decision = tracingChannel('authz', 'decision')
decision.trace(() => evaluate(req), { subject })

Trace correlation

If your app uses @adonis-agora/context, every emit and trace auto-fills traceId from the active request — with zero config here. @adonis-agora/context's provider soft-detects this package at boot and registers its accessor via setContextAccessor, so correlation just works once both are installed.

Need an explicit id instead? Pass it in opts (it wins over the accessor):

emit('billing', 'invoice-paid', payload, { traceId: ctx.request.id() })
trace('durable', 'step', () => runStep(), { name }, { traceId })

Under the hood, traceId is read through a structural ContextAccessor interface registered on a cross-copy-stable Symbol.for('@agora/context:accessor') slot — exported here as CONTEXT_ACCESSOR. @adonis-agora/context is an optional peer; it is never imported by this package.

The registration is a matched pair: setContextAccessor(accessor) installs it, getContextAccessor() reads it back (returning null when unset), and resolveTraceId() is the exact, never-throwing resolution emit/trace perform per event — a throwing accessor simply yields undefined so observability never breaks the caller. You rarely touch these directly (the context provider calls setContextAccessor for you), but they are the seam for registering a custom accessor, or for reading the resolved id from your own consumer:

import { getContextAccessor, resolveTraceId, setContextAccessor } from '@adonis-agora/diagnostics'

setContextAccessor(myAccessor)      // install a structural ContextAccessor
getContextAccessor()                // → myAccessor (or null)
resolveTraceId()                    // → the current trace id, or undefined — never throws

API surface

ExportDescription
emit(lib, event, payload, opts?)Publish a DiagnosticEvent on agora:<lib>:<event> (only when subscribed). opts: traceId, sample, durationMs.
trace(lib, event, fn, payload?, opts?)Run fn and publish span start/end/error phases with timing. Returns fn's value.
tracingChannel(lib, event)A trace bound to one (lib, event) pair.
onDiagnostic(lib, event?, handler, opts?)Subscribe to one channel or every agora:<lib>:* channel. See Consumers.
unsubscribeAll()Tear down every live onDiagnostic subscription (called on shutdown).
channelName(lib, event)The agora:<lib>:<event> string.
traceChannelNames(lib, event)The five span sub-channel names.
getChannel(lib, event)The memoized diagnostics_channel for a pair (also registers its name).
CHANNEL_PREFIX'agora'.
SCHEMA_VERSION / SPAN_SCHEMA_VERSION1 — envelope schema versions stamped as v.
registeredChannels()Snapshot of every registered base channel name.
registerChannel(name)Record a channel name in the discovery registry (idempotent), firing onChannelRegistered listeners the first time. emit/getChannel call it for you; call it by hand only to pre-announce a channel before its first emit.
onChannelRegistered(cb)Notified once per future channel registration; returns an unsubscribe.
resetRegistry()Test-only: forget every registered channel and listener, and drop the memoized channel cache with them, so the next emit/getChannel re-registers. Not part of the runtime contract.
parseChannelName(name)Parse agora:<lib>:<event> into { lib, event }, or null.
createChannelSelector(selection, forward)Shared engine for relays — subscribe forward to matched channels.
setContextAccessor(accessor | null)Register (or clear, with null) the accessor emit/trace read traceId from.
getContextAccessor()The currently-registered ContextAccessor, or null when none is set — the read side of setContextAccessor.
resolveTraceId()The current trace id from the registered accessor, or undefined. Never throws (a throwing accessor resolves to undefined) — the exact resolution emit/trace use internally.
CONTEXT_ACCESSORShared DI token for the optional context accessor.
capability(lib, name)Mint the Symbol.for('@agora/<lib>:<name>') capability token.
assertCapabilityNaming(lib, tokens)Conformance helper: assert tokens follow the canonical naming.
EMIT_SLOTSymbol.for('@agora/diagnostics:emit') — the global slot emit is published on.
TRACE_SLOTSymbol.for('@agora/diagnostics:trace') — the global slot trace is published on.

The capability protocol

The @adonis-agora/* family wires optional peers through Symbol.for('@agora/<lib>:<name>') slots on globalThis, so a producer and consumer in different repos meet without importing each other (and no-op gracefully when a peer is absent). capability(lib, name) is the canonical factory for those tokens:

import { capability, assertCapabilityNaming } from '@adonis-agora/diagnostics'

const ACCESSOR = capability('context', 'accessor') // Symbol.for('@agora/context:accessor')

// In a contract test, guard against naming drift:
assertCapabilityNaming('context', { ACCESSOR })

This is the same mechanism emit itself uses: it publishes onto EMIT_SLOT at module load, so other Agora libs can republish through globalThis[Symbol.for('@agora/diagnostics:emit')] structurally — never importing this package, no-op when it's absent. trace does the same onto TRACE_SLOT (globalThis[Symbol.for('@agora/diagnostics:trace')]).

Where to go next

  • Consumers — observe events, write your own subscriber, assert in tests.
  • Transports — fan events out across processes over Redis or @adonisjs/queue.
  • OpenTelemetry — the zero-config span bridge.

On this page