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
| API or tool | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
Diagnostic report: |
2 Stable |
Added in v11.8.0. |
|
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 |
|
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. |
|
1 Experimental |
|
Built-in channels ( |
1 Experimental |
Names and payloads may change in any release; |
|
1 Experimental |
Module added in v10.0.0. The flag |
|
2 Stable |
Added in v13.10.0 and v12.17.0; Stable since v16.4.0. |
|
2 Stable |
Used for the metrics endpoint below. |
|
Discouraged (flag documentation) |
The documentation recommends |
|
Runtime deprecation (DEP0205) |
Documentation-only in v25.9.0, runtime deprecation in v26.0.0; |
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.
| 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? |
|
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 |
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 |
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
| Trigger | Flag and process.report property |
Notes |
|---|---|---|
Uncaught exception |
|
The report’s JavaScript stack points at the error. |
Fatal error such as running out of heap |
|
Useful when no JavaScript catch block can ever run. |
A signal |
|
Default |
A call from your code |
|
|
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.
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);
}
{
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:
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.
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);
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.
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());
});
});
subscribers before: false
bookshelf:stock.low { isbn: '978-0-00-000000-2', left: 1 }
inbound: GET /books?limit=2
Source: Node.js API: Diagnostics Channel, Public API, Node.js API: Diagnostics Channel, Built-in channels
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.
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(() => {});
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.
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();
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
Source: Node.js API: Trace events
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.
| Topic | Practice |
|---|---|
Levels |
|
Fields |
Log data as fields ( |
Redaction |
The |
Errors |
Pass the error as |
Pretty output |
Use |
Child loggers |
|
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.
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(); });
});
}
});
{"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"}
Source: pino: API, Node.js API: AsyncLocalStorage
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.
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
Source: OpenTelemetry: JavaScript zero-code instrumentation, OpenTelemetry JS: ECMAScript Modules vs. CommonJS
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:
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)));
Source: OpenTelemetry: Node.js getting started, Setup, Node.js API: module.register(), OpenTelemetry JS: ECMAScript Modules vs. CommonJS
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.)
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());
});
});
{"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.
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);
});
/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
Source: Node.js API: Performance measurement APIs, Prometheus: Exposition formats, Kubernetes: Configure liveness, readiness and startup probes
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.
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'));
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
| Version | Change |
|---|---|
v26.9.0, v26.8.0 |
|
v26.8.0 |
|
v26.5.0, v26.0.0 |
|
v26.1.0 |
|
v26.0.0, v25.9.0 |
|
v24.8.0, v22.20.0 |
The documentation-only deprecation of |
v24.0.0 |
|
v23.3.0, v22.13.0 |
|
v22.0.0, v20.13.0 |
|
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.
| Topic | Current behaviour |
|---|---|
|
Fine for tutorials, but production services should emit structured JSON with levels and a request identifier, as above. |
Tracing flags in |
|
No post-mortem story |
Diagnostic reports (Stable) fill the gap: |
Instrumenting by monkey-patching |
Prefer |
Tracing and APM |
OpenTelemetry is the vendor-neutral route; for ESM applications it still needs a module-customization hook, and |
Health checks |
Neither book separates liveness from readiness; do, and fail readiness before draining on |
References
-
Node.js Learn: Diagnostics user journey, https://nodejs.org/learn/diagnostics/user-journey
-
Node.js API: Diagnostic report, https://nodejs.org/docs/latest/api/report.html
-
Node.js API: process.report, https://nodejs.org/docs/latest/api/process.html#processreport
-
Node.js API: Diagnostics Channel, https://nodejs.org/docs/latest/api/diagnostics_channel.html
-
Node.js API: Diagnostics Channel, Class TracingChannel, https://nodejs.org/docs/latest/api/diagnostics_channel.html#class-tracingchannel
-
Node.js API: Diagnostics Channel, Built-in channels, https://nodejs.org/docs/latest/api/diagnostics_channel.html#built-in-channels
-
Node.js API: Trace events, https://nodejs.org/docs/latest/api/tracing.html
-
Node.js API: Asynchronous context tracking, https://nodejs.org/docs/latest/api/async_context.html
-
Node.js API: AsyncLocalStorage, https://nodejs.org/docs/latest/api/async_context.html#class-asynclocalstorage
-
Node.js API: Performance measurement APIs, https://nodejs.org/docs/latest/api/perf_hooks.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Deprecations, https://nodejs.org/docs/latest/api/deprecations.html
-
Node.js API: module.register(), https://nodejs.org/docs/latest/api/module.html#moduleregisterspecifier-parenturl-options
-
OpenTelemetry: JavaScript, https://opentelemetry.io/docs/languages/js/
-
OpenTelemetry: Node.js getting started, https://opentelemetry.io/docs/languages/js/getting-started/nodejs/
-
OpenTelemetry: Node.js getting started, Setup, https://opentelemetry.io/docs/languages/js/getting-started/nodejs/#setup
-
OpenTelemetry: JavaScript instrumentation, https://opentelemetry.io/docs/languages/js/instrumentation/
-
OpenTelemetry: JavaScript zero-code instrumentation, https://opentelemetry.io/docs/zero-code/js/
-
OpenTelemetry: Zero-code instrumentation configuration, https://opentelemetry.io/docs/zero-code/js/configuration/
-
OpenTelemetry JS: ECMAScript Modules vs. CommonJS, https://github.com/open-telemetry/opentelemetry-js/blob/main/doc/esm-support.md
-
OpenTelemetry: Semantic conventions, https://opentelemetry.io/docs/specs/semconv/
-
pino, https://getpino.io/
-
pino: API, https://getpino.io/#/docs/api
-
pino: Redaction, https://getpino.io/#/docs/redaction
-
Prometheus: Exposition formats, https://prometheus.io/docs/instrumenting/exposition_formats/
-
prom-client, https://github.com/siimon/prom-client
-
Kubernetes: Configure liveness, readiness and startup probes, https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/