Skip to content

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.

3 min read
JSON log entries flowing from a Node.js service through a pipeline into a search interface with filtered results

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.

tstypescript
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 };
tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
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?"

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX