Structured Logging for Production Node.js Services
Structured logging in Node.js that produces machine-parsable, searchable entries with correlation IDs, consistent severity levels and context propagation.

Why Structured Logging
Unstructured logs—console.log("User logged in:", userId)—are human-readable but machine-hostile. When you have thousands of requests per second across dozens of services, you need to filter, aggregate, and alert on log data programmatically. Structured logging outputs JSON objects with consistent fields, making every log entry searchable and parsable.
The Logger Foundation
Use a logging library that produces JSON with consistent fields. Pino is the standard for Node.js—fast, structured, and designed for production.
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
formatters: {
level(label) {
return { level: label };
},
},
timestamp: pino.stdTimeFunctions.isoTime,
base: {
service: process.env.SERVICE_NAME ?? "api-server",
environment: process.env.NODE_ENV ?? "development",
version: process.env.APP_VERSION ?? "unknown",
},
});
export { logger };// ❌ Unstructured — impossible to search or filter
console.log("Payment processed for user " + userId + " amount: $" + amount);
console.log("ERROR: Payment failed", error.message);
// ✅ Structured — every field is searchable
logger.info(
{ userId, amount, currency: "USD", paymentId },
"Payment processed successfully"
);
logger.error(
{ userId, amount, paymentId, errorCode: error.code, err: error },
"Payment processing failed"
);Correlation IDs Across Requests
Every incoming request gets a unique correlation ID that propagates to all downstream calls. When debugging an issue, you filter logs by this ID to see the complete request journey across services.
import { randomUUID } from "node:crypto";
import { AsyncLocalStorage } from "node:async_hooks";
interface RequestContext {
correlationId: string;
userId?: string;
requestPath: string;
requestMethod: string;
}
const contextStorage = new AsyncLocalStorage<RequestContext>();
// Middleware: set correlation ID for every request
function correlationMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const correlationId =
(req.headers["x-correlation-id"] as string) ?? randomUUID();
res.setHeader("x-correlation-id", correlationId);
const context: RequestContext = {
correlationId,
requestPath: req.path,
requestMethod: req.method,
};
contextStorage.run(context, () => next());
}
// Context-aware logger that automatically includes correlation ID
function getLogger(): pino.Logger {
const context = contextStorage.getStore();
if (context) {
return logger.child({
correlationId: context.correlationId,
userId: context.userId,
path: context.requestPath,
});
}
return logger;
}
// Usage anywhere in the request lifecycle
function processOrder(order: Order): void {
const log = getLogger();
log.info({ orderId: order.id, items: order.items.length }, "Processing order");
// Log automatically includes correlationId, userId, path
}Log Levels and When to Use Them
Consistent severity levels across the team prevent both log noise and missing critical information.
interface LogLevelGuidance {
level: string;
when: string;
example: string;
}
const guidelines: LogLevelGuidance[] = [
{
level: "fatal",
when: "Application cannot continue — process will exit",
example: "Database connection failed after all retries on startup",
},
{
level: "error",
when: "Operation failed — requires investigation but process continues",
example: "Payment processing failed for a specific transaction",
},
{
level: "warn",
when: "Unexpected condition that may indicate a problem",
example: "Cache miss rate exceeding threshold, falling back to database",
},
{
level: "info",
when: "Significant business events and operational milestones",
example: "Order completed, user registered, deployment started",
},
{
level: "debug",
when: "Detailed technical information for troubleshooting",
example: "SQL query executed, cache key checked, retry attempt 2 of 5",
},
{
level: "trace",
when: "Very verbose — function entry/exit, data payloads",
example: "Request body parsed, response serialized, middleware chain",
},
];Sensitive Data Redaction
Production logs must never contain passwords, tokens, credit card numbers, or PII. Redact at the logger level so developers do not need to remember.
const sensitiveKeys = new Set([
"password",
"token",
"authorization",
"cookie",
"creditCard",
"ssn",
"secret",
"apiKey",
]);
const redactedLogger = pino({
level: "info",
redact: {
paths: [
"password",
"*.password",
"token",
"*.token",
"headers.authorization",
"headers.cookie",
"body.creditCard",
"body.ssn",
],
censor: "[REDACTED]",
},
});
// Automatic redaction — developers don't need to remember
redactedLogger.info(
{
userId: "u_123",
headers: { authorization: "Bearer eyJ..." }, // → [REDACTED]
body: { email: "user@example.com", password: "secret123" }, // password → [REDACTED]
},
"Request received"
);Request and Response Logging
Log every request and response with timing information. This is the single most useful log for debugging production issues.
function requestLogMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const start = performance.now();
const log = getLogger();
// Log request
log.info(
{
event: "request_received",
method: req.method,
path: req.path,
query: req.query,
userAgent: req.headers["user-agent"],
contentLength: req.headers["content-length"],
},
"Incoming request"
);
// Capture response
const originalEnd = res.end.bind(res);
res.end = function (...args: Parameters<typeof res.end>) {
const duration = Math.round(performance.now() - start);
log.info(
{
event: "request_completed",
method: req.method,
path: req.path,
statusCode: res.statusCode,
durationMs: duration,
contentLength: res.getHeader("content-length"),
},
"Request completed"
);
// Warn on slow requests
if (duration > 1000) {
log.warn(
{ durationMs: duration, path: req.path },
"Slow request detected"
);
}
return originalEnd(...args);
};
next();
}Error Logging with Context
Log errors with full context—stack traces, input data, and the operation that failed. This eliminates the back-and-forth of "what was the user doing when this happened?"
function logError(
error: Error,
context: Record<string, unknown>
): void {
const log = getLogger();
log.error(
{
err: {
message: error.message,
name: error.name,
stack: error.stack,
...(error instanceof AppError && {
code: error.code,
statusCode: error.statusCode,
}),
},
...context,
},
`Operation failed: ${error.message}`
);
}
// Usage
try {
await processPayment(order);
} catch (error) {
logError(error as Error, {
operation: "processPayment",
orderId: order.id,
userId: order.userId,
amount: order.total,
});
throw error;
}Key Takeaways
Structured logging produces JSON objects with consistent fields, making every log entry searchable and filterable. Use a library like Pino that handles JSON formatting, log levels, and child loggers efficiently. Propagate correlation IDs through every request using AsyncLocalStorage so you can trace a single request across all services.
Redact sensitive data at the logger configuration level—never rely on developers remembering to omit passwords or tokens. Log every request and response with timing information for the most valuable debugging data. Use log levels consistently across the team: errors for failures that need investigation, warnings for anomalies, info for business events. The investment in structured logging pays back the first time you debug a production incident in minutes instead of hours.


