Diagnostic Reports, Logging & Observability

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. Debugging with an inspector and profiling a hot path are what you do on your own machine; this page is about what a process can tell you after the fact and while it runs in production, with no debugger attached. It covers the built-in diagnostic report (Stability 2, Stable), the node:diagnostics_channel module (Stable, with TracingChannel Stable only from v26.8.0 and Experimental before that), the node:trace_events module (Experimental), and then the userland half of observability: structured logs with pino, request correlation with AsyncLocalStorage (Stable), traces through OpenTelemetry, and health and metrics endpoints. The profilers are in Profiling & Performance and heap analysis is in Memory & Leaks; the same ideas seen through a framework are in the NestJS pages Logging and Health Checks & Observability. The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture.

The stability and version map

Table 1. APIs and tools covered on this page
API or tool Stability (Node.js 26 docs) Version notes

Diagnostic report: --report-* flags, process.report

2 Stable

Added in v11.8.0. --report-exclude-network and process.report.excludeNetwork since v22.0.0 and v20.13.0; --report-exclude-env and process.report.excludeEnv since v23.3.0 and v22.13.0. The report format is at reportVersion 5 (observed on all three lines).

node:diagnostics_channel: channel(), subscribe(), hasSubscribers, publish()

2 Stable

Added in v15.1.0 and v14.17.0; Stable since v19.2.0 and v18.13.0. The documentation-only deprecation of channel.subscribe() and channel.unsubscribe() was revoked in v24.8.0 and v22.20.0.

TracingChannel and diagnostics_channel.tracingChannel()

2 Stable on 26, 1 Experimental on 24 and 22

Added in v19.9.0 and v18.19.0; marked Stable in v26.8.0. The v24 and v22 documentation still labels it Experimental, although it ran without a warning in every test for this page.

channel.bindStore(), runStores(), withStoreScope(), BoundedChannel

1 Experimental

boundedChannel() was added in v26.1.0.

Built-in channels (http., net., child_process, sqlite.db.query, console.* and others)

1 Experimental

Names and payloads may change in any release; sqlite.db.query arrived in v26.8.0.

node:trace_events, --trace-event-categories, --trace-event-file-pattern

1 Experimental

Module added in v10.0.0. The flag --trace-events-enabled is no longer required. Not available in Worker threads.

AsyncLocalStorage

2 Stable

Added in v13.10.0 and v12.17.0; Stable since v16.4.0. defaultValue and name options since v24.0.0. enterWith(), exit() and disable() remain Experimental.

perf_hooks.monitorEventLoopDelay(), performance.eventLoopUtilization()

2 Stable

Used for the metrics endpoint below.

--experimental-loader

Discouraged (flag documentation)

The documentation recommends --import with register() instead.

module.register()

Runtime deprecation (DEP0205)

Documentation-only in v25.9.0, runtime deprecation in v26.0.0; module.registerHooks() is the replacement.

A map of the tools

Diagnostic data comes from three different places, and picking the right one saves time. The Node.js Learn guide on diagnostics starts from symptoms: unexpected behaviour points to live debugging, growing memory points to the memory guides, and high latency or CPU use points to the poor-performance guides. It also asks you to record the Node.js version, the operating system, the symptoms, how to reproduce them and what changed recently before you start, which is exactly what the tools below capture for you.

Table 2. Choosing a diagnostic or observability tool
Question Tool Where it lives

Why did the process die, or what is it doing right now?

Diagnostic report: a JSON snapshot of stacks, heap, libuv handles, resource usage and limits.

Built into Node.js, no dependency.

What is a library doing internally, without patching it?

diagnostics_channel and TracingChannel events.

Built into Node.js; libraries publish, tools subscribe.

What did the runtime itself spend time on (V8, fs, async work)?

Trace events written in the Chrome trace format.

Built into Node.js, Experimental.

What happened during one request, in order?

Structured logs carrying a request identifier.

Userland (pino) plus AsyncLocalStorage.

Where did the time go across several services?

Distributed traces.

OpenTelemetry SDK and instrumentations.

Is the process alive, ready for traffic and healthy over time?

Health endpoints and metrics.

Your code, optionally prom-client.

Diagnostic reports

A diagnostic report is a JSON document with a summary of the process state: the JavaScript and native stack, V8 heap statistics, the libuv handles that keep the event loop alive, resource usage, platform information and the system limits that apply. It is documented as intended for development, test and production use, which makes it the cheapest first response to a crash you cannot reproduce: the file is written at the moment of failure and you read it later. The runtime builds it in its C++ layer, so a report can be produced even when JavaScript can no longer run, for example after the heap is exhausted.

When a report is produced

Table 3. Report triggers
Trigger Flag and process.report property Notes

Uncaught exception

--report-uncaught-exception, reportOnUncaughtException

The report’s JavaScript stack points at the error.

Fatal error such as running out of heap

--report-on-fatalerror, reportOnFatalError

Useful when no JavaScript catch block can ever run.

A signal

--report-on-signal, reportOnSignal; --report-signal, signal

Default SIGUSR2; not supported on Windows.

A call from your code

process.report.writeReport(), process.report.getReport()

getReport() returns an object and writes nothing.

The output is shaped with four more flags, each with a matching property: --report-directory (directory), --report-filename (filename, where the special values stdout and stderr write to the standard streams and ignore the directory), --report-compact (compact, single-line JSON, easier for log processors) and --report-exclude-network (excludeNetwork). --report-exclude-env (excludeEnv) removes the environment variables, which should be your default: they often hold secrets, and a report is a file that people attach to tickets. All of these can also be set through NODE_OPTIONS, so you can switch reports on for a container without touching its command line.

The first program crashes a child process on purpose and reads the report it left behind.

Crash a child process and read its diagnostic report
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';

// Child: enable reports on an uncaught exception, then crash on purpose.
if (process.argv[2] === 'child') {
  process.report.reportOnUncaughtException = true;
  process.report.directory = process.argv[3];
  process.report.filename = 'crash.json';
  process.report.compact = true;
  process.report.excludeEnv = true;
  setTimeout(() => { throw new Error('boom'); }, 10);
} else {
  const dir = new URL('../rep/', import.meta.url).pathname;
  spawnSync(process.execPath, [import.meta.filename, 'child', dir], { stdio: 'ignore' });
  const report = JSON.parse(readFileSync(`${dir}crash.json`, 'utf8'));
  const { event, trigger, reportVersion, nodejsVersion } = report.header;
  console.log({ event, trigger, reportVersion, nodejsVersion });
  console.log(Object.keys(report));
  console.log(report.javascriptStack.message, '|', 'environmentVariables' in report);
}
Output on Node.js 26.10.0 (24.21.0 and 22.23.3 differ only in the version string)
{
  event: 'Exception',
  trigger: 'Exception',
  reportVersion: 5,
  nodejsVersion: 'v26.10.0'
}
[
  'header',
  'javascriptStack',
  'javascriptHeap',
  'nativeStack',
  'resourceUsage',
  'uvthreadResourceUsage',
  'libuv',
  'workers',
  'userLimits',
  'sharedObjects'
]
Error: boom | false

The header.event value was Exception with a capital letter in these runs, while the example report in the documentation shows a lower-case spelling; treat the strings as informational and key off reportVersion and the sections that exist. environmentVariables is gone because of excludeEnv. The same setup from the command line, for a long-running service, is:

Report flags for a production service
node --report-uncaught-exception --report-on-fatalerror --report-on-signal \
     --report-compact --report-exclude-env --report-directory=/var/log/bookshelf \
     server.js

To check that --report-on-fatalerror really works, run a process with a tiny heap limit and an allocation loop: node --report-on-fatalerror --report-directory=reports --report-filename=oom.json --max-old-space-size=24 -e "const a=[];while(true)a.push(new Array(1e5).fill(1))" exited with code 134 and left a report whose header.event was Allocation failed - JavaScript heap out of memory and header.trigger was OOMError (observed on Node.js 26.10.0). The heap limit flag is covered in Memory & Leaks.

Reports from running code and from signals

process.report.getReport() returns the same document as an object, which is handy for self-diagnosis, and process.report.writeReport() writes it and returns the file name. Setting reportOnSignal lets an operator (or a sidecar) ask a live process for a report with kill -USR2 <pid>, with no restart.

Take a report on demand, from code and from a signal
import { setTimeout as sleep } from 'node:timers/promises';

const report = process.report.getReport();           // plain object, nothing written
console.log(report.header.event, report.header.trigger);
console.log('libuv handles:', report.libuv.length, '| heap used:', report.javascriptHeap.usedMemory > 0);

const file = process.report.writeReport(new URL('../rep/manual.json', import.meta.url).pathname);
console.log('written:', file.endsWith('manual.json'));

// SIGUSR2 (POSIX only) triggers a report while the process runs.
process.report.reportOnSignal = true;
process.report.directory = new URL('../rep/', import.meta.url).pathname;
process.report.filename = 'signal.json';
process.kill(process.pid, 'SIGUSR2');
await sleep(300);
Output (reports are also announced on stderr)
JavaScript API GetReport
libuv handles: 9 | heap used: true

Writing Node.js report to file: .../rep/manual.json
Node.js report completed
written: true

Writing Node.js report to file: signal.json
Node.js report completed

Reading a report is mostly about four sections. javascriptStack and nativeStack say where the process was; libuv lists the active handles (open sockets, timers, file watchers) and answers "what is keeping this process alive?"; javascriptHeap and resourceUsage show memory and CPU; userLimits shows limits such as the open-file limit. Worker threads generate their own reports in the same format, listed under workers. Because a report can contain file paths, host names, command lines and (unless excluded) environment variables, store it with the access rules of a log file. Report generation also takes time (it interrupts JavaScript and the event loop and waits for workers), so do not trigger it on a hot path.

diagnostics_channel

node:diagnostics_channel lets code publish named messages that nobody has to consume. A library creates a channel once, checks hasSubscribers before building a payload, and publishes; a monitoring tool subscribes by name. That inverts the older approach of monkey-patching a library from the outside, and it is how Node.js core exposes internals (HTTP, networking, child processes) to APM agents. A channel is just an object registered under a string name, so names should carry the module name (bookshelf:stock.low) to avoid collisions.

Publish on a custom channel and subscribe to a built-in one
import dc from 'node:diagnostics_channel';
import { createServer, get } from 'node:http';

// 1. Your own channel: publishers pay almost nothing while nobody subscribes.
const stock = dc.channel('bookshelf:stock.low');
console.log('subscribers before:', stock.hasSubscribers);
dc.subscribe('bookshelf:stock.low', (message, name) => {
  console.log(name, message);
});
if (stock.hasSubscribers) stock.publish({ isbn: '978-0-00-000000-2', left: 1 });

// 2. Built-in channels (Experimental): see every inbound HTTP request, with no wrapper.
dc.subscribe('http.server.request.start', ({ request }) => {
  console.log('inbound:', request.method, request.url);
});

const server = createServer((req, res) => res.end('ok')).listen(0, () => {
  get({ port: server.address().port, path: '/books?limit=2' }, (res) => {
    res.resume();
    res.on('end', () => server.close());
  });
});
Output
subscribers before: false
bookshelf:stock.low { isbn: '978-0-00-000000-2', left: 1 }
inbound: GET /books?limit=2

Two rules keep this cheap and safe. Acquire a channel once at module level (looking channels up per call costs more), and guard expensive payload construction with hasSubscribers. Subscribers run synchronously in the publisher’s call stack, so a slow or throwing subscriber is the publisher’s problem: keep them short and wrap their bodies in try/catch if they do anything fallible. Since v19.2.0 the module itself is Stable, but the catalogue of channels that Node.js publishes (http.client., http.server., http2., net., udp.socket, child_process, process.execve, locks., worker_threads, module.require and module.import tracing, console., sqlite.db.query) is Experimental, so pin them in tests.

TracingChannel

A TracingChannel is a bundle of five channels that describe the lifecycle of one traceable operation: start, end, asyncStart, asyncEnd and error, named tracing:<name>:<event>. The producer wraps a function with traceSync(), tracePromise() or traceCallback(); all five events of one call share a single context object, so a subscriber can stash a start time on it at start and read it at asyncEnd. end fires when the synchronous part returns (not when the promise settles), asyncStart and asyncEnd bracket the continuation, and error carries the thrown or rejected error.

Trace an asynchronous operation with a TracingChannel
import dc from 'node:diagnostics_channel';
import { setTimeout as sleep } from 'node:timers/promises';

const tc = dc.tracingChannel('bookshelf:db.query');

// A subscriber (an APM agent, a logger) attaches to the five lifecycle channels.
tc.subscribe({
  start: (ctx) => { ctx.startedAt = performance.now(); console.log('start   ', ctx.sql); },
  end: () => console.log('end      (synchronous part returned)'),
  asyncStart: (ctx) => console.log('asyncStart result =', ctx.result),
  asyncEnd: (ctx) =>
    console.log('asyncEnd  took >=', Math.round(performance.now() - ctx.startedAt), 'ms'),
  error: (ctx) => console.log('error   ', ctx.error.message),
});

async function query(sql) {
  await sleep(20);
  if (sql.includes('DROP')) throw new Error('refused');
  return [{ id: 1 }];
}

// The instrumented library wraps its own function; the context object is shared by all events.
await tc.tracePromise(query, { sql: 'SELECT * FROM books' }, null, 'SELECT * FROM books');
await tc.tracePromise(query, { sql: 'DROP TABLE books' }, null, 'DROP TABLE books').catch(() => {});
Output (identical on Node.js 26, 24 and 22, apart from timings)
start    SELECT * FROM books
end      (synchronous part returned)
asyncStart result = [ { id: 1 } ]
asyncEnd  took >= 22 ms
start    DROP TABLE books
end      (synchronous part returned)
error    refused
asyncStart result = undefined
asyncEnd  took >= 21 ms

The bindStore(), runStores() and withStoreScope() methods connect a channel to an AsyncLocalStorage, so that a subscriber’s data is visible in the traced function’s own async context. They and the new BoundedChannel (v26.1.0) are Experimental. The lifecycle events follow the naming pattern tracing:module.function:start, and the documentation asks publishers to emit events only when subscribers were present before the trace began, so a subscription added mid-operation sees only later traces. In v26.0.0 tracePromise() started warning for non-thenable return values, and in v26.5.0 it returns non-native thenables as they are; keep both in mind when wrapping code that does not always return a native promise.

Trace events

node:trace_events is the runtime’s own tracing, written in the Chrome trace format so that chrome://tracing or Perfetto can open the file. It is Experimental and unavailable in Worker threads. Categories include v8, node.fs.sync, node.fs.async, node.http, node.perf, node.console, node.dns.native, node.net.native, node.threadpoolwork.sync, node.async_hooks, node.promises.rejections and node.vm.script; by default node, node.async_hooks and v8 are enabled. Enable them with --trace-event-categories, choose the output name with --trace-event-file-pattern (it supports ${rotation} and ${pid}), or switch categories on and off from code with createTracing(). The default file is node_trace.<rotation>.log in the working directory. To be sure the file is complete after a signal, handle the signal yourself and exit, as the signals page Process, Signals & Exit Codes shows.

Capture synchronous fs calls and console timers with trace events
import { createTracing, getEnabledCategories } from 'node:trace_events';
import { readFileSync } from 'node:fs';

const tracing = createTracing({ categories: ['node.fs.sync', 'node.console'] });
tracing.enable();
console.log('enabled:', getEnabledCategories());

readFileSync(import.meta.filename);   // a synchronous fs call
console.time('import-books');         // console.time() is captured by node.console
console.timeEnd('import-books');

tracing.disable();
Events found in node_trace.1.log (a JSON object with a traceEvents array; metadata events omitted)
B node,node.fs,node.fs.sync fs.sync.open
E node,node.fs,node.fs.sync fs.sync.open
B node,node.fs,node.fs.sync fs.sync.fstat
E node,node.fs,node.fs.sync fs.sync.fstat
B node,node.fs,node.fs.sync fs.sync.read
E node,node.fs,node.fs.sync fs.sync.read
B node,node.fs,node.fs.sync fs.sync.close
E node,node.fs,node.fs.sync fs.sync.close
b node,node.console time::import-books
e node,node.console time::import-books

The same events can be captured without code changes: node --trace-event-categories node.fs.sync,node.perf --trace-event-file-pattern '${pid}-${rotation}.log' app.js wrote <pid>-1.log in a run on Node.js 26. One observed limitation: enabling node.perf or node.perf.usertiming (by flag or by createTracing()) and then calling performance.mark() and performance.measure() produced no events in the output on any of the three lines in the runs for this page, so check your own category choice before relying on it for user timings; perf_hooks itself is covered in Profiling & Performance.

Structured logging with pino

A log line is for a machine first: one JSON object per line, with a level, a timestamp, a message and fields, written to standard output where the platform (Docker, Kubernetes, a systemd journal) collects it. console.log() is fine for scripts, but it has no levels, no fields, and its output is formatted for people rather than parsed by tools. pino is the logger Fastify embeds by default; it writes newline-delimited JSON, keeps the hot path small and moves heavier work (pretty-printing, shipping) into separate transports and processes. The examples were run with pino 10.4.0, the version that resolved on 2026-10-03; check the pino documentation for current options.

Table 4. Common pino choices
Topic Practice

Levels

fatal, error, warn, info, debug, trace; set the minimum from an environment variable such as LOG_LEVEL, never hard-code it.

Fields

Log data as fields (logger.info({ id }, 'book loaded')), not interpolated strings, so it can be filtered and aggregated.

Redaction

The redact option removes or censors listed paths, such as req.headers.authorization, before serialization.

Errors

Pass the error as err: logger.error({ err }, 'save failed'), which pino serializes with its stack.

Pretty output

Use pino-pretty only in development, as a transport or a pipe; production should emit raw JSON.

Child loggers

logger.child({ component: 'catalog' }) adds fixed fields to every record without a new logger configuration.

Correlating a request across async calls

The usual complaint with concurrent servers is that the log lines of two requests interleave and nothing ties a line to its request. Passing a logger or a requestId through every function signature works but is invasive. AsyncLocalStorage keeps a store that follows the asynchronous flow of a request, and pino’s mixin option runs a function on every log call, so the correlation field is added without any function taking a parameter. The Node.js documentation itself demonstrates this pattern with a simple logger, and the full API (including cancellation and context propagation pitfalls) is in Async Context & Cancellation. On Node.js 24 and later the implementation is backed by AsyncContextFrame; --no-async-context-frame restores the older async_hooks based behaviour, and in the runs below all three lines produced the same correlation.

Request-scoped logging with pino and AsyncLocalStorage
import { createServer, get } from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import pino from 'pino';

const requestContext = new AsyncLocalStorage();

// mixin() runs on every log call and merges its result into the record.
const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  base: { service: 'bookshelf' },
  redact: ['req.headers.authorization'],
  mixin: () => ({ requestId: requestContext.getStore()?.requestId }),
});

async function findBook(id) {
  logger.debug({ id }, 'loading book');       // no requestId parameter is passed down
  await sleep(10);
  logger.info({ id }, 'book loaded');
  return { id, title: 'Example' };
}

const server = createServer(async (req, res) => {
  const requestId = req.headers['x-request-id'] ?? randomUUID();
  await requestContext.run({ requestId }, async () => {
    logger.info({ req: { method: req.method, url: req.url } }, 'request received');
    res.setHeader('x-request-id', requestId);
    res.end(JSON.stringify(await findBook(1)));
  });
}).listen(0, () => {
  const port = server.address().port;
  let pending = 2;
  for (const id of ['req-a', 'req-b']) {
    get({ port, path: '/books/1', headers: { 'x-request-id': id } }, (res) => {
      res.resume();
      res.on('end', () => { if (--pending === 0) server.close(); });
    });
  }
});
Output on Node.js 26 (the order of the two requests varies between runs and lines)
{"level":30,"time":1791068878065,"service":"bookshelf","requestId":"req-a","req":{"method":"GET","url":"/books/1"},"msg":"request received"}
{"level":30,"time":1791068878065,"service":"bookshelf","requestId":"req-b","req":{"method":"GET","url":"/books/1"},"msg":"request received"}
{"level":30,"time":1791068878076,"service":"bookshelf","requestId":"req-a","id":1,"msg":"book loaded"}
{"level":30,"time":1791068878076,"service":"bookshelf","requestId":"req-b","id":1,"msg":"book loaded"}

Running the same file with LOG_LEVEL=debug adds the loading book records, each still tagged with its requestId. Two cautions: trust an incoming x-request-id only as an opaque label (validate its length and characters, or always generate your own), and never log whole request or response bodies, headers with credentials or personal data; redaction is a safety net, not a design. Logging an uncaught error is part of Errors & Error Handling, and server-side request pipelines that use this logger are built in Building a REST API.

OpenTelemetry for Node.js

OpenTelemetry is the vendor-neutral standard for traces, metrics and logs: your process emits OTLP data and a collector or backend (Jaeger, Tempo, Prometheus, a commercial APM) stores it. The JavaScript implementation is split into the API (@opentelemetry/api, which libraries depend on and which does nothing by itself), the SDK (@opentelemetry/sdk-node) that actually records and exports, and instrumentation packages that patch libraries such as http, Express, pg and many others. The "zero-code" route bundles the SDK and a large set of instrumentations in @opentelemetry/auto-instrumentations-node, and it must be loaded before your application code, which is exactly what --import (or --require for CommonJS) is for. The examples used @opentelemetry/sdk-node 0.222.0, @opentelemetry/auto-instrumentations-node 0.80.0 and @opentelemetry/api 1.9.1; several of these packages are still pre-1.0 or fast-moving, so pin them.

Zero-code instrumentation

Configuration is by environment variables, so the same command works in a container. OTEL_SERVICE_NAME names the service, OTEL_TRACES_EXPORTER selects the exporter (the examples use console so the output is visible; the default sends OTLP), OTEL_EXPORTER_OTLP_ENDPOINT points at a collector, and OTEL_NODE_ENABLED_INSTRUMENTATIONS limits which instrumentations load. For an ESM application the OpenTelemetry documentation additionally requires a module-customization hook, because ES module imports cannot be intercepted by the CommonJS patching the instrumentations rely on.

Zero-code tracing of an ESM application
OTEL_SERVICE_NAME=bookshelf \
OTEL_TRACES_EXPORTER=console \
OTEL_NODE_ENABLED_INSTRUMENTATIONS=http \
node --experimental-loader=@opentelemetry/instrumentation/hook.mjs \
     --import @opentelemetry/auto-instrumentations-node/register \
     app.js

Run against a small node:http server and client (app.js), this printed, on Node.js 26, 24 and 22, an HTTP server span (kind: 1) and an HTTP client span (kind: 2) from @opentelemetry/instrumentation-http, all in one trace. Node.js printed an ExperimentalWarning for --experimental-loader; the flag’s own documentation says it is discouraged in favour of --import with register(). Without the hook, loading only --import @opentelemetry/auto-instrumentations-node/register on an ESM application produced no HTTP spans in the runs for this page, and no error either, which is the typical failure mode: nothing is instrumented and nothing says so.

A telemetry file you control

When you need to choose exporters, resource attributes or which instrumentations run, write the SDK setup in its own file and preload it. The following telemetry.js registers the loader hook through module.register() itself, so the command line is just --import:

telemetry.js, loaded with node --import ./telemetry.js app.js
import { register } from 'node:module';
register('@opentelemetry/instrumentation/hook.mjs', import.meta.url);

import { NodeSDK } from '@opentelemetry/sdk-node';
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({
  serviceName: 'bookshelf',
  traceExporter: new ConsoleSpanExporter(),            // swap for an OTLP exporter in production
  instrumentations: [getNodeAutoInstrumentations({
    '@opentelemetry/instrumentation-fs': { enabled: false },   // very chatty
  })],
});
sdk.start();

// Flush buffered spans before the process goes away.
process.once('beforeExit', () => sdk.shutdown());
process.once('SIGTERM', () => sdk.shutdown().finally(() => process.exit(0)));
app.js: a manual span nested in the automatic server span
import { createServer, get } from 'node:http';
import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('bookshelf');

const server = createServer((req, res) => {
  tracer.startActiveSpan('load-book', (span) => {      // a manual span nested in the automatic one
    span.setAttribute('book.id', 1);
    res.end('ok');
    span.end();
  });
}).listen(0, () => {
  get({ port: server.address().port, path: '/books/1' }, (res) => {
    res.resume();
    res.on('end', () => { server.close(); });
  });
});

On Node.js 26 this setup works, but module.register() raises the runtime deprecation DEP0205 ("use module.registerHooks() instead"); on 24 and 22 it is silent. In the console output the load-book span had the HTTP server span as its parent and the same traceId. The OpenTelemetry ESM guide says it expects to move to register() eventually, so either form may change; on a Node.js 26 deployment the supported-today answer is the loader flag with its warning, and the practical advice is to follow the package’s release notes when you upgrade. The --experimental-loader form and the register() form were both run on Node.js 26 and 24 and the loader form on 22, with the same spans. Exporters buffer spans, so flush on shutdown as above: without the beforeExit hook the first run for this page ended without printing the manual span at all.

Connecting logs and traces

The most useful link between the two signals is putting the trace and span identifiers into every log record, so that a trace in your backend opens the logs of that request and the other way round. @opentelemetry/api exposes the active span, and pino’s mixin is again the hook. (The auto-instrumentation bundle also contains a pino instrumentation that injects these fields for you; writing the mixin yourself keeps it explicit.)

Add trace and span identifiers to every pino record
import { createServer, get } from 'node:http';
import pino from 'pino';
import { trace } from '@opentelemetry/api';

const logger = pino({
  mixin() {
    const ctx = trace.getActiveSpan()?.spanContext();
    return ctx ? { trace_id: ctx.traceId, span_id: ctx.spanId } : {};
  },
});

const server = createServer((req, res) => {
  logger.info('serving request');                      // inside the automatic server span
  res.end('ok');
}).listen(0, () => {
  get({ port: server.address().port, path: '/' }, (res) => {
    res.resume();
    res.on('end', () => server.close());
  });
});
Output on Node.js 26 (the trace_id matches the exported spans)
{"level":30,"time":1791069004426,"pid":16744,"hostname":"example-host","trace_id":"91af6dadb396dd87efd78ba30620e6ab","span_id":"e5341ea25c6d5afe","msg":"serving request"}

OpenTelemetry’s own context propagation also rides on AsyncLocalStorage (the Node.js context manager is built on it), so spans and your request context flow through the same awaits. Instrumentation has a cost: sampling, batching and attribute limits are the tools for controlling it, and the semantic conventions (OpenTelemetry semantic conventions) tell you which attribute names to use so that dashboards work across services.

Health and metrics endpoints

Orchestrators need two different answers. Liveness asks whether the process is alive enough to keep running (if not, restart it); readiness asks whether it should receive traffic right now (if not, take it out of rotation). Mixing them causes outages: a liveness probe that checks the database turns a database blip into a restart storm, while a readiness probe that never fails during shutdown sends requests to a draining instance. The probe types and their timing are covered in the Kubernetes health probes page, and the NestJS equivalent is in Health Checks & Observability; this section shows the framework-free version.

Metrics are numbers sampled over time. For Node.js the ones worth exposing first are event-loop delay (monitorEventLoopDelay()), event-loop utilization (performance.eventLoopUtilization()), heap and resident memory (process.memoryUsage()), garbage-collection time, request counts and durations, and open handles. The Prometheus text exposition format is a plain-text page that a scraper reads from /metrics; it is simple enough to emit by hand for a handful of values.

Liveness, readiness and metrics endpoints with built-ins only
import { createServer } from 'node:http';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';

const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
let lastElu = performance.eventLoopUtilization();
let ready = true;
let requests = 0;

function metrics() {
  const elu = performance.eventLoopUtilization(lastElu);   // utilisation since the last scrape
  lastElu = performance.eventLoopUtilization();
  const mem = process.memoryUsage();
  const lines = [
    '# TYPE bookshelf_http_requests_total counter',
    `bookshelf_http_requests_total ${requests}`,
    '# TYPE nodejs_eventloop_utilization gauge',
    `nodejs_eventloop_utilization ${elu.utilization.toFixed(4)}`,
    '# TYPE nodejs_eventloop_delay_p99_seconds gauge',
    `nodejs_eventloop_delay_p99_seconds ${delay.percentile(99) / 1e9}`,
    '# TYPE nodejs_heap_used_bytes gauge',
    `nodejs_heap_used_bytes ${mem.heapUsed}`,
    '# TYPE nodejs_resident_memory_bytes gauge',
    `nodejs_resident_memory_bytes ${mem.rss}`,
  ];
  delay.reset();
  return lines.join('\n') + '\n';
}

const server = createServer((req, res) => {
  requests++;
  const { pathname } = new URL(req.url, 'http://localhost');
  if (pathname === '/livez') {                       // is the process alive at all?
    res.writeHead(200).end('ok');
  } else if (pathname === '/readyz') {               // should the load balancer send traffic?
    res.writeHead(ready ? 200 : 503).end(ready ? 'ready' : 'draining');
  } else if (pathname === '/metrics') {
    res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4' }).end(metrics());
  } else {
    res.writeHead(404).end();
  }
});

process.on('SIGTERM', () => {
  ready = false;                                     // fail readiness first, then drain
  setTimeout(() => server.close(), 100);             // stand-in for the grace period
});

server.listen(0, async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  for (const path of ['/livez', '/readyz', '/metrics']) {
    const res = await fetch(base + path);
    console.log(path, res.status, path === '/metrics' ? '' : await res.text());
    if (path === '/metrics') console.log(await res.text());
  }
  process.kill(process.pid, 'SIGTERM');
  await new Promise((resolve) => setTimeout(resolve, 20));
  console.log('/readyz after SIGTERM', (await fetch(base + '/readyz')).status);
});
Output on Node.js 26, 24 and 22 (metric values shortened: the process lived for milliseconds, so they are not meaningful)
/livez 200 ok
/readyz 200 ready
/metrics 200
bookshelf_http_requests_total 3
nodejs_eventloop_utilization ...
nodejs_eventloop_delay_p99_seconds ...
nodejs_heap_used_bytes ...
nodejs_resident_memory_bytes ...

/readyz after SIGTERM 503

The sequence in the SIGTERM handler (fail readiness, wait, then close) is the part that matters; the complete shutdown choreography, with in-flight requests and connection draining, is in Process, Signals & Exit Codes. Keep /livez free of dependency checks, give /readyz only the dependencies the instance cannot serve without, and put these endpoints on an internal port or behind network policy, since /metrics and any "detailed health" output reveal internals.

For anything beyond a handful of hand-written values, prom-client (15.1.3 in the run below) provides counters, gauges, histograms and a default collector for process and event-loop metrics, and register.metrics() is asynchronous.

Default process metrics with prom-client
import { collectDefaultMetrics, Counter, register } from 'prom-client';

collectDefaultMetrics();                                  // process_*, nodejs_eventloop_lag_*, nodejs_gc_*...
const books = new Counter({ name: 'bookshelf_books_created_total', help: 'Books created' });
books.inc();

const text = await register.metrics();                    // returns a promise
console.log(register.contentType);
console.log(text.split('\n').filter((l) => l.startsWith('# TYPE')).map((l) => l.split(' ')[2]).slice(0, 8).join('\n'));
Output (identical on Node.js 26 and 22)
text/plain; version=0.0.4; charset=utf-8
process_cpu_user_seconds_total
process_cpu_system_seconds_total
process_cpu_seconds_total
process_start_time_seconds
process_resident_memory_bytes
nodejs_eventloop_lag_seconds
nodejs_eventloop_lag_min_seconds
nodejs_eventloop_lag_max_seconds

Source: prom-client

The same numbers can come from OpenTelemetry’s metrics API instead; choose one pipeline per service, because exporting both doubles the cost for no gain. Metric labels (route, status class, method) must have a small, bounded set of values: a label containing a user id or a raw URL creates a new time series per value and can exhaust the monitoring system’s memory.

What changed recently

Table 5. Changes relevant to this page
Version Change

v26.9.0, v26.8.0

diagnostics_channel gained crypto.fips.indicator (26.9.0) and sqlite.db.query (26.8.0) built-in channels, both Experimental.

v26.8.0

TracingChannel and diagnostics_channel.tracingChannel() marked Stable.

v26.5.0, v26.0.0

tracingChannel.tracePromise() returns non-native thenables as they are (26.5.0); non-thenable return values produce a warning (26.0.0).

v26.1.0

BoundedChannel and diagnostics_channel.boundedChannel() added, Experimental.

v26.0.0, v25.9.0

module.register() is a runtime deprecation (DEP0205) in 26.0.0, documentation-only in 25.9.0, which affects OpenTelemetry-style preload files.

v24.8.0, v22.20.0

The documentation-only deprecation of channel.subscribe() and channel.unsubscribe() was revoked.

v24.0.0

AsyncLocalStorage runs on AsyncContextFrame by default (opt out with --no-async-context-frame) and gained the defaultValue and name options.

v23.3.0, v22.13.0

--report-exclude-env and process.report.excludeEnv added; report version 5 fixed the memory-limit key names (v23.5.0 and v22.13.0).

v22.0.0, v20.13.0

--report-exclude-network added.

Where the books are outdated

Efficient Node.js (Samer Buna, O’Reilly 2025) touches this topic only in its chapter on the runtime and its debugging tools, where it points readers at the profiler flag and at the tracing flags listed by node --help; it does not cover diagnostic reports, diagnostics_channel, structured logging or OpenTelemetry. Node.js Projects (Jonathan Wexler, O’Reilly 2025) logs with console.log() in its examples and mentions the Pino logger that ships with Fastify only in passing. Both are good starting points; apply these updates when you work from them.

Table 6. Points to check when following the books
Topic Current behaviour

console.log() as the logging strategy

Fine for tutorials, but production services should emit structured JSON with levels and a request identifier, as above.

Tracing flags in node --help

--trace-event-categories and its siblings belong to the Experimental trace-events module; --trace-deprecation and friends are warnings, not observability.

No post-mortem story

Diagnostic reports (Stable) fill the gap: --report-uncaught-exception, --report-on-fatalerror and --report-on-signal, with --report-exclude-env on current releases.

Instrumenting by monkey-patching

Prefer diagnostics_channel and TracingChannel; TracingChannel is Stable from v26.8.0.

Tracing and APM

OpenTelemetry is the vendor-neutral route; for ESM applications it still needs a module-customization hook, and module.register() is deprecated at runtime from v26.0.0.

Health checks

Neither book separates liveness from readiness; do, and fail readiness before draining on SIGTERM.

References