Logging Best Practices
Logs are the first thing you reach for when production misbehaves, yet most Node.js apps treat logging as an afterthought — a scatter of console.log calls printing unparseable strings. Good logging is a deliberate practice: it emits machine-readable structured records, picks the right severity for every message, threads a correlation ID through each request, never leaks secrets, and ships everything to one place you can query. This page walks through those practices with runnable examples built around Pino, the de facto high-performance logger for Node.
Prefer structured JSON logs
Free-text log lines are fine for a human watching a terminal and useless for a machine searching ten million records. Structured logging emits each event as a JSON object with typed fields, so your aggregation platform can filter by userId, group by statusCode, or alert on level >= 50 without fragile regex parsing. Pino does this by default and is fast because it serializes asynchronously and avoids the overhead of pretty formatting in production.
// src/logger.js
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
// Rename the noisy defaults to conventional field names.
base: { service: "checkout-api", env: process.env.NODE_ENV },
timestamp: pino.stdTimeFunctions.isoTime,
});
import { logger } from "./logger.js";
logger.info({ userId: "u_123", plan: "pro" }, "subscription activated");
Output:
{"level":30,"time":"2026-06-14T10:42:01.115Z","service":"checkout-api","env":"production","userId":"u_123","plan":"pro","msg":"subscription activated"}
Note the first argument is an object of structured fields and the second is the human message — that ordering is Pino’s convention. In CommonJS the only change is const pino = require("pino").
In development, pipe output through
pino-prettyfor colorized, readable lines:node app.js | npx pino-pretty. Keep raw JSON in production so your log pipeline can index it — never runpino-prettyas a transport in prod, it costs CPU.
Use log levels deliberately
Levels let you tune verbosity per environment and route alerts. The mistake is logging everything at info (so you can’t lower the noise) or everything at error (so nothing is actionable). Assign levels by what the reader should do with the message.
| Level | Numeric | When to use |
|---|---|---|
trace | 10 | Very fine-grained flow, usually off |
debug | 20 | Diagnostic detail useful while investigating |
info | 30 | Normal lifecycle events worth recording |
warn | 40 | Recoverable problem; degraded but working |
error | 50 | A request or operation failed |
fatal | 60 | Process cannot continue; about to exit |
Set LOG_LEVEL=debug in staging and info in production. Because Pino checks the level before serializing, suppressed debug calls cost almost nothing, so you can leave them in the code.
logger.debug({ query, durationMs: 18 }, "db query executed");
logger.warn({ retryIn: 500, attempt: 2 }, "upstream slow, retrying");
logger.error({ err }, "failed to send receipt email");
Thread a correlation ID through every request
When one user request fans out across middleware, services, and async callbacks, a correlation (or request) ID lets you reconstruct the whole story from interleaved logs. Generate it once per request, then make a child logger that automatically stamps every line. AsyncLocalStorage carries that child logger through async boundaries without passing it as an argument everywhere.
// src/request-context.js
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { logger } from "./logger.js";
const storage = new AsyncLocalStorage();
export function requestContext(req, res, next) {
const requestId = req.headers["x-request-id"] ?? randomUUID();
res.setHeader("x-request-id", requestId);
const child = logger.child({ requestId, method: req.method, path: req.path });
storage.run({ logger: child }, () => next());
}
export const log = () => storage.getStore()?.logger ?? logger;
import { log } from "./request-context.js";
async function placeOrder(order) {
log().info({ orderId: order.id }, "order received");
// ...later, deep in an async chain, still the same requestId:
log().info({ orderId: order.id }, "order confirmed");
}
Output:
{"level":30,"time":"2026-06-14T10:42:03.901Z","service":"checkout-api","requestId":"6f1c...","method":"POST","path":"/orders","orderId":"o_88","msg":"order received"}
{"level":30,"time":"2026-06-14T10:42:04.210Z","service":"checkout-api","requestId":"6f1c...","method":"POST","path":"/orders","orderId":"o_88","msg":"order confirmed"}
Never log secrets
Logs are widely accessible — they flow to aggregators, get tailed in terminals, and end up in screenshots. A password, token, or full credit-card number written to a log is a breach waiting to happen. Redact sensitive fields at the logger level so you cannot forget at the call site. Pino’s redact option masks paths before serialization.
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"password",
"*.password",
"card.number",
],
censor: "[REDACTED]",
},
});
logger.info({ user: "u_9", password: "hunter2" }, "login attempt");
Output:
{"level":30,"time":"2026-06-14T10:42:05.004Z","user":"u_9","password":"[REDACTED]","msg":"login attempt"}
Redaction is a safety net, not a license to log carelessly. Treat any personally identifiable information as sensitive, and never log entire request or response bodies for auth, payment, or profile endpoints.
Centralize logs for observability
A log that only exists on one ephemeral container is gone the moment the pod restarts. The standard pattern in containerized environments is to write JSON to stdout and let the platform — Docker, Kubernetes, a sidecar — collect and forward it to a central store (Loki, Elasticsearch, Datadog, CloudWatch). Your app should not own log shipping; it should emit clean structured lines and exit. Correlate logs with traces by including the same requestId (or an OpenTelemetry trace_id) so a single search spans logging, metrics, and distributed traces.
// Production: just write JSON to stdout — the platform handles the rest.
// Avoid logging to files inside containers; they vanish on restart.
const logger = pino(); // defaults to process.stdout
Best Practices
- Emit structured JSON, not free-text strings, so logs are queryable; reserve
pino-prettyfor local development only. - Choose levels by intent (
debug/info/warn/error/fatal) and drive verbosity from aLOG_LEVELenv var per environment. - Attach a correlation ID with a child logger and
AsyncLocalStorageso every line of a request is traceable end to end. - Log the whole
errobject (Pino serializes stack andcause), never justerr.message. - Configure
redactto mask passwords, tokens, cookies, and PII at the logger, so secrets can’t slip through at a call site. - Write to stdout and let the platform ship logs to a central, searchable store; do not log to files inside containers.
- Keep messages stable and put the variable data in fields — stable messages are easy to alert on and group.