Logging & Observability Rules
Structured logging and correlation IDs make Node services debuggable in production without SSH and grep of unstructured strings.
Search across all documentation pages
Structured logging and correlation IDs make Node services debuggable in production without SSH and grep of unstructured strings.
Quick-reference recipe card - copy-paste ready.
import pino from "pino";
import { randomUUID } from "node:crypto";
export const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
app.use((req, res, next) => {
const requestId = req.headers["x-request-id"]?.toString() ?? randomUUID();
res.setHeader("X-Request-Id", requestId);
req.log = logger.child({ requestId });
next();
});When to reach for this:
// src/logging/logger.ts
import pino from "pino";
export const rootLogger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: ["req.headers.authorization", "password", "creditCard"],
});
export type AppLogger = pino.Logger;// src/middleware/request-context.ts
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { rootLogger } from "../logging/logger.js";
declare global {
namespace Express {
interface Request {
log: import("pino").Logger;
requestId: string;
}
}
}
export function requestContext(req: Request, res: Response, next: NextFunction) {
const requestId = (req.headers["x-request-id"] as string) ?? randomUUID();
req.requestId = requestId;
res.setHeader("X-Request-Id", requestId);
req.log = rootLogger.child({ requestId, method: req.method, path: req.path });
req.log.info("request started");
res.on("finish", () => req.log.info({ status: res.statusCode }, "request finished"));
next();
}// src/services/billing.ts
export async function charge(log: AppLogger, customerId: string, cents: number) {
log.info({ customerId, cents }, "charge started");
// ...
log.info({ customerId }, "charge completed");
}What this demonstrates:
X-Request-Id respected; generated if absent.requestId through service layer.error for failures needing action, warn for degraded, info for request lifecycle, debug dev-only.| Field | Source |
|---|---|
requestId | Header or UUID |
level | Pino default |
msg | Human-readable event |
time | ISO timestamp automatic |
service | name in logger config |
req.log augmentation for Express; Fastify uses built-in req.log with pino integration.req.body - log ids and action names only.no-console in src/.requestId in job payload from HTTP handler.LOG_LEVEL=info default; debug via dynamic toggle with expiry.| Alternative | Use When | Don't Use When |
|---|---|---|
| OpenTelemetry traces | Full distributed tracing | Tiny single service MVP |
| Winston | Team legacy standard | Greenfield (Pino faster on Node) |
| AsyncLocalStorage context | Implicit requestId without req.log | Simple Express apps (middleware enough) |
No, but JSON structured logging is required. Pino is the common Node 24 choice; Fastify integrates natively.
await fetch(url, { headers: { "X-Request-Id": requestId } });Use nestjs-pino or OTel; same correlation header rules apply.
Yes in error logs server-side; never return stacks to API clients in production.
Sample debug/info on high-RPS health checks; never sample error logs.
Same JSON schema with jobId and requestId fields for trace join.
Metrics for SLO dashboards; logs for incident debugging. Both required for mature ops.
Org policy; typically 30-90 days hot, longer cold archive for audit domains.
Yes in Kubernetes - collectors scrape stdout; no file rotation in container.
Do not log every /health at info in prod; use debug or exclude path in middleware.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 18, 2026