DevOps & observability / Tutorial
Structured Logging in Node.js That Pays Off During Incidents
Build structured JSON logging in Node.js with correlation IDs, redaction, and error serialization so you can trace failures quickly during incidents.

You will build a structured logging setup for a Node.js service that lets you follow a single failing request from its first log line to the downstream call that broke, without leaking secrets or drowning in noise. You will configure a JSON logger with stable fields, propagate correlation IDs with AsyncLocalStorage, create child loggers, serialize errors correctly, redact sensitive values, and connect the output to alerts and dashboards.
What structured logging actually means
An unstructured line is a sentence written for a human watching a terminal:
[INFO] User 4821 checkout failed because payment timed out after retryIt reads well once. It fails when you need every checkout failure for one user, grouped by downstream service: you end up writing fragile patterns against wording that changes whenever someone edits a message.
A structured line is a JSON object written for a query first:
{"level":30,"time":1720000000000,"service":"checkout-api","msg":"checkout completed","requestId":"req_9f2c","userId":"4821","durationMs":184}Every line carries the same core fields: level, time, msg, and service. Everything else is a named field you can filter on. During an incident you do not read logs top to bottom. You slice them: this service, this request, this error, this window. Structure makes that reliable.
Keep two rules. First, keep the message stable and put changing values in fields, not interpolated into the sentence. Second, treat field names as an API: decide on requestId, traceId, userId, and err once and use them everywhere. Renaming a field breaks dashboards and alerts.
Choose your logger: pino vs winston
Both libraries emit JSON, redact fields, and create child loggers. They differ in philosophy and in where they spend CPU.
| Concern | pino | winston |
|---|---|---|
| Design focus | Low overhead, JSON by default | Flexibility, broad transport ecosystem |
| Output model | JSON lines to stdout; transport handled separately | Built-in transports for files, HTTP, and external services |
| Child loggers | First-class, cheap per request | Supported via child() |
| Development formatting | pino-pretty as a transport | Built-in format pipeline |
| Best fit | Containers and aggregation pipelines | Varied in-process destinations or custom formats |
Pick pino when your service runs in containers and the platform already collects stdout, the common modern case: letting the platform handle shipping, rotation, and retention keeps the application simpler and avoids blocking requests on log delivery. Pick winston when a concrete requirement is solved directly by its transport or format system. Do not build a homegrown logger on console.log; you will eventually need levels, redaction, serialization, and context, and you will reimplement a library badly under pressure.
Set up a base logger with stable fields
Create one shared base logger. Every module and request logger derives from it, so every line inherits the same service name, timestamp, redaction, and serializers.
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: { service: process.env.SERVICE_NAME ?? "checkout-api" },
timestamp: pino.stdTimeFunctions.epochTime,
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"password",
"*.password",
"token",
"*.token",
"creditCard",
"*.creditCard",
],
censor: "[Redacted]",
},
serializers: {
err: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
});Control the level from the environment, not code edits, so you can raise a service to debug during an investigation without a deploy. Treat that as temporary: debug output creates volume, cost, and risk if it captures payloads.
Always call the logger with the object first and the message second, as in logger.error({ err, orderId }, "payment request failed"). Interpolating values into the message destroys queryable fields. Make the correct pattern the easy pattern, and review for it.
Propagate correlation IDs with AsyncLocalStorage
A service name tells you where a line came from. A correlation ID tells you which lines belong together. When a request touches five functions and one downstream service, that ID is the thread you pull.
Generate an ID at the edge if the caller supplied none, store it in AsyncLocalStorage, and attach it to every line in that context.
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
export const requestContext = new AsyncLocalStorage();
export function getRequestContext() {
return requestContext.getStore();
}
export function createRequestContext(incoming = {}) {
return {
requestId: incoming.requestId ?? randomUUID(),
traceId: incoming.traceId ?? randomUUID(),
};
}AsyncLocalStorage follows execution across await points and callbacks originating inside the context, so you do not pass an ID through every signature. The edge case is work that escapes the context: background tasks, intervals, and queued jobs lose it. Capture and restore the context explicitly there.
Wire it into HTTP handling. This uses node:http; the same logic adapts to Express-style middleware:
import { createServer } from "node:http";
import { requestContext, createRequestContext } from "./context.js";
import { logger } from "./logger.js";
const server = createServer((req, res) => {
const context = createRequestContext({
requestId: req.headers["x-request-id"],
traceId: req.headers["traceparent"],
});
requestContext.run(context, () => {
const log = logger.child(context);
log.info({ method: req.method, url: req.url }, "request received");
res.on("finish", () => {
log.info({ statusCode: res.statusCode }, "request completed");
});
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, requestId: context.requestId }));
});
});
server.listen(process.env.PORT ?? 3000);Return the request ID in the response so support can match a report to your logs. Propagate it outbound too, otherwise the trail stops at your boundary:
import { getRequestContext } from "./context.js";
export async function chargePayment(payload) {
const context = getRequestContext();
const response = await fetch("https://payments.internal/charge", {
method: "POST",
headers: {
"content-type": "application/json",
"x-request-id": context?.requestId ?? "unknown",
...(context?.traceId ? { traceparent: context.traceId } : {}),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`payment service returned ${response.statusCode}`);
}
return response.json();
}If you use a tracing standard, pass its trace context header in the correct format rather than inventing a parallel one, so logs, traces, and metrics share one identifier.
Use child loggers for request and module context
Passing the same fields on every call is repetitive and error-prone. A child logger bakes context in once:
import { logger } from "./logger.js";
import { getRequestContext } from "./context.js";
const moduleLog = logger.child({ module: "orders" });
export async function createOrder(input) {
const context = getRequestContext();
const log = moduleLog.child({
requestId: context?.requestId,
traceId: context?.traceId,
userId: input.userId,
});
log.debug({ itemCount: input.items.length }, "validating order");
log.info({ orderId: "ord_123" }, "order created");
return { id: "ord_123" };
}Create children at meaningful boundaries: per request, per job, per module. Keep a practical hierarchy: service fields on the root, module on a long-lived child, request and entity IDs on a short-lived child. Child loggers also prevent a concurrency bug: each carries its own bindings, so concurrent requests cannot overwrite each other’s IDs, as they would if you stored the current ID in a module-level variable.
Log errors so you can actually debug them
The most expensive mistake is an error line that says a failure happened and nothing else. Logging only err.message, or converting the error to a string, discards the stack, the type, and any chained cause.
Always pass the error under the err key and let the serializer work:
import { logger } from "./logger.js";
export async function loadOrder(orderId) {
try {
throw new Error("connection reset by database");
} catch (cause) {
const err = new Error(`failed to load order ${orderId}`, { cause });
logger.error({ err, orderId }, "order load failed");
throw err;
}
}The standard serializer records message, stack, and type consistently. Wrapping with { cause } preserves why the higher-level operation failed, which matters when a database error surfaces as a checkout error several layers up. Verify in real output that the deepest cause survives your serializer and pipeline; implementations handle nested causes differently.
Add decision-relevant fields alongside the error: the operation, the entity ID, the downstream service, whether it will be retried. Do not add the entire payload by default; it bloats the line and risks capturing sensitive data.
Decide where errors are logged, too. Logging the same error at every layer makes counts meaningless. Log where you handle it, or where you hold context no other layer has, and let the outermost handler record the final request failure. Low-level helpers should usually throw, not log and throw.
Redact secrets and PII by default
Logs are copied, retained, exported, and read by more people than your database. Treat anything logged as viewed widely.
Redact at the logger level, as in the base configuration, so common paths are censored even when someone logs a whole request object. Understand the limits: pino redaction matches exact paths and simple wildcards, so *.password catches a first-level nested password but not every depth or naming variation. Redaction is a safety net, not a licence to log sensitive objects. The stronger rule is to not log the object at all: log an identifier instead of an email address when an identifier is enough, and never log passwords, tokens, authorization headers, private keys, full card numbers, or environment secrets, even at debug.
Watch indirect routes. Errors from third-party clients sometimes embed the request URL, headers, or body. Trigger a failed downstream call outside production, inspect the emitted line, and confirm no credential or personal data appears, including nested fields.
Use log levels with discipline
Levels help only if the team agrees what each one promises, because alerts are built on that promise.
Use error for a failure that caused an operation to fail or needs attention. Use warn for something abnormal the system handled, such as a retry that succeeded, where the pattern matters more than a single occurrence. Use info for significant state changes you will want later: service started, order created, job completed. Reserve debug for diagnostic detail you enable while investigating, and fatal for a process that cannot continue, not for an important but recoverable failure.
Two habits destroy discipline: logging routine successes at warn or error, which trains everyone to ignore those levels, and catching a failure, logging it at info, and continuing. Set production to info, and move lines that fire on every request and add no value to debug or delete them.
Control noise and decide what not to log
Useful logs are defined by what you leave out.
Do not log health check successes or high-frequency polling at info. Do not log inside a tight loop; count iterations and emit one summary line. Do not log full request and response bodies by default, or the same event in both middleware and a handler.
For high-volume events, sample deliberately: keep every error, a representative subset of successes, and the full trail for any request that errors. Mark sampled lines as sampled so nobody mistakes a partial view for a complete one. Be cautious sampling security, billing, and audit events; those often need completeness and may belong in a separate audit record.
Cardinality is the other quiet problem. requestId is for filtering to one request, not grouping. Do not build dashboards grouped by unique IDs, full URLs with embedded identifiers, or free-text messages. Normalize routes to patterns such as /orders/:id for aggregation, and keep the specific ID as its own field.
Make local development readable without hurting production
JSON is the right production format and a poor laptop reading experience. Solve that with a development-only transport, not by changing what production emits:
import pino from "pino";
const isProduction = process.env.NODE_ENV === "production";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: { service: process.env.SERVICE_NAME ?? "checkout-api" },
transport: isProduction
? undefined
: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
ignore: "pid,hostname",
},
},
});Install pino-pretty as a development dependency and keep it out of production. Keep field names and levels identical across environments, so instincts built while developing transfer during an incident.
Connect logs to alerts and dashboards
Logging pays off when it drives decisions without a human watching a stream. Build alerts on patterns in your structured fields, not on individual dramatic lines.
Start with error rate by service and operation, from level and a stable operation field. Add an alert for a new or rising error type, using the serialized error type or a deliberate error code rather than matching message text. Record duration and downstream status codes as fields, so slow requests and provider failures show as their own patterns.
During an incident, the workflow should be mechanical. An alert fires on error rate. You open a dashboard filtered to the service and window, group errors by code and downstream target, pick one failing requestId, pull every line for it in order, and follow its traceId downstream. Every step depends on earlier decisions: stable fields, honest levels, propagated IDs, serialized errors.
Write that workflow into a short runbook beside the dashboard links, with exact field names and example queries. Test it with a deliberately triggered failure outside production, while finding a missing field or broken redaction path is cheap.
Verdict
Use pino as your default for a new Node.js service, emit JSON to stdout in production, and reserve pino-pretty for local development. Build one base logger with service name, redaction paths, and error serializer, then derive child loggers per module and per request. Propagate request and trace IDs with AsyncLocalStorage, return the request ID to callers, and forward it on outbound calls. Log each error once, with the full serialized error and its cause, at the layer that handles it.
Choose winston only when you have a specific transport or formatting requirement it meets directly. Either way, the library is the smallest part of the work. The value is the discipline around it: stable fields, honest levels, minimal sensitive data, and noise low enough that an alert still means something.


