Structured Logging in Node.js: Stop Using console.log in Production

Structured Logging in Node.js: Stop Using console.log in Production
Brandon Perfetti

Technical PM + Software Engineer

Topics:Node.jsProduction ReliabilityDeveloper Tooling
Tech:Structured LoggingSentry / Monitoring Tools

Structured Logging in Node.js: Stop Using console.log in Production

Confession: I used to pepper production Node.js processes with console.log. It worked—until it didn’t. Console output hides important signals, fragments context across services, and turns debugging into a scavenger hunt. If you’re responsible for shipping and operating real systems, upgrading from console.log to structured logging is one of the highest-leverage changes you can make.

This guide is a practical, implementation-focused walkthrough for experienced full‑stack JavaScript developers. We’ll cover real code, tradeoffs, pitfalls, and decision criteria so you can replace noisy, unstructured logging with a production-grade solution that improves incident response, observability, and engineering velocity.

Why console.log fails at scale (and what “scale” means)

console.log is great for quick local debugging. In production it fails for four practical reasons:

  1. Fragmented context: plain text loses stable keys (requestId, userId, operation), forcing ad-hoc string parsing.
  2. Poor machine semantics: search/alerting systems expect JSON fields; free-text requires regex and large indexes to query.
  3. No cross-service correlation: without a shared identifier, distributed traces become guesswork.
  4. Operational risk: console logs often leak secrets, have inconsistent levels, and aren't flushed on crash.

“Scale” doesn’t mean millions of requests per second; it means multiple services, multiple engineers, and real incidents. Once you care about MTTR (mean time to resolution) and repeatable alerting, console.log becomes a liability.

Decision criteria: If you need reliable alerting, cross-service debugging, or automated dashboards — stop using console.log in production.

What structured logging means (minimal practical schema)

Structured logging emits machine-readable events (JSON objects are the de facto standard). The goal is consistent keys and predictable types so storage and queries are reliable.

Minimal useful schema (required fields):

  • timestamp (ISO 8601 or epoch ms)
  • level (debug/info/warn/error)
  • message (human-friendly summary)
  • service (name)
  • env (production/staging)
  • traceId/requestId (string)
  • durationMs (when applicable)

Useful additional fields:

  • operation, handler, route
  • userId, accountId (only if needed — see redaction)
  • component or dependency
  • errorCode and category
  • latency buckets or numeric metrics

Consistency beats completeness. Pick a small, stable schema and evolve it with change control.

Choosing a logger: tradeoffs and decision criteria

Popular Node loggers: pino, bunyan, winston. Quick decision criteria:

  • Performance-sensitive, JSON-first: pino
  • Mature ecosystem and readable output: bunyan
  • Highly pluggable and feature-rich: winston

Recommendation: choose pino unless you have a strong reason otherwise. It is low overhead, supports child loggers, has built-in serializers and redaction, and can stream to external transports efficiently.

Tradeoffs:

  • pino favors stdout streaming to a collector (Fluentd/Vector/Datadog). If you need complex in-process transport plugins, winston is friendlier.
  • Bunyan has a slightly older ecosystem; pino is the newer performance choice.
  • Avoid heavy sync file writes; prefer streaming to stdout with a sidecar collector.

Example: pino basic setup

// logger.js
const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  base: { service: 'basket-service', env: process.env.NODE_ENV },
  timestamp: pino.stdTimeFunctions.isoTime,
  redact: {
    // redact full request bodies and auth headers by default
    paths: ['req.headers.authorization', 'req.body.password', 'req.body.creditCard'],
    censor: 'REDACTED'
  }
});

module.exports = logger;

Tradeoff note: redaction is costly if applied to large objects frequently. Apply serialize/redact selectively on hot paths.

Correlation IDs and context propagation (AsyncLocalStorage pattern)

Correlation IDs are non-negotiable. They allow you to follow a request across HTTP handlers, queues, workers, and other services. Use traceId/requestId conventions and forward them in headers (e.g., `x-request-id`, `traceparent` / W3C tracecontext).

Implementing request-scoped log context in Node.js:

  • For HTTP servers, accept incoming request IDs or generate one.
  • Attach to a context store that is accessible by any code running in that request’s call stack.
  • Use AsyncLocalStorage (Node >= 12.17) carefully (memory leaks are possible with long-lived tasks).

Express example using AsyncLocalStorage:

// context.js
const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();

function runWithId(req, res, next) {
  const requestId = req.headers['x-request-id'] || generateId();
  asyncLocalStorage.run({ requestId }, () => next());
}

function getContext() {
  return asyncLocalStorage.getStore() || {};
}

module.exports = { runWithId, getContext };
// use in logger wrapper
const { getContext } = require('./context');
const baseLogger = require('./logger');

function log() {
  const ctx = getContext();
  return baseLogger.child({ requestId: ctx.requestId || null });
}

module.exports = log;

Pitfalls:

  • AsyncLocalStorage can leak if you attach timers or long-lived promises that keep the context alive. Test under load.
  • For serverless/AWS Lambda, context is per-invocation; use injection instead of global stores.

Decision criteria: use AsyncLocalStorage when you need implicit context everywhere; prefer explicit passing of context in libraries that are invoked off the main request path.

Logging levels with real semantics (enforceable rules)

Define level semantics and enforce them in code reviews:

  • debug: Verbose diagnostic info, never used for normal operation (can be sampled).
  • info: Expected lifecycle events: migrations applied, service started, request accepted.
  • warn: Anomalies that are recoverable or unusual: retries, slow dependency, feature fallback.
  • error: Operational failure that needs attention; record stack traces and error codes.

Practical rule: every uncaught exception must be logged at error level with a consistent shape. Track errorCode and category to power programmatic alerts.

Tradeoffs:

  • Too many errors create alert fatigue. Use errorCode and grouping to avoid noisy alerts.
  • Debug level in production should be off or sampled. Sampling at ingestion avoids overloading storage.

Error handling: operational vs programmer errors

Distinguish the two and act differently:

  • Operational errors: timeouts, dependency unavailability, rate limits. These are transient and should be logged with structured fields: dependency, endpoint, retryable, duration, and metric increments. These are often alert-worthy based on rate or impact.
  • Programmer errors: invariant violations, TypeError due to unexpected null. These usually indicate a bug in code and should surface as high-severity incidents; send to an error-tracking tool like Sentry and investigate.

Example: normalize errors with metadata

class OperationalError extends Error {
  constructor(message, { code, retryable = false, metadata = {} } = {}) {
    super(message);
    this.name = 'OperationalError';
    this.code = code || 'OP_ERR';
    this.retryable = retryable;
    this.metadata = metadata;
  }
}

When you catch errors at the boundary:

try {
  await doRemoteCall();
} catch (err) {
  const log = getLogger().child({ operation: 'fetch-user', userId });
  if (err instanceof OperationalError) {
    log.warn({ err, errorCode: err.code, retryable: err.retryable }, 'remote call failed');
    // increment metric, possibly retry
  } else {
    log.error({ err, errorCode: 'UNHANDLED' }, 'unexpected failure');
    Sentry.captureException(err); // link to Sentry with traceId
    // escalate or terminate depending on severity
  }
}

Tradeoff: Logging full error stacks everywhere is noisy; log stacks only at boundaries and send essential metadata deeper in the stack.

Redaction, privacy, and compliance (practical rules)

Logging can become a compliance risk. Centralize a redaction policy and enforce it in the logger configuration — not by ad hoc developer discipline.

Rules:

  • Redact tokens, passwords, SSNs, credit card numbers by default.
  • Store only identifiers that are necessary to debug (e.g., userId but not PII).
  • Maintain an allowlist of endpoints where request bodies are logged (admin or debug-only).
  • Audit logs periodically for accidental secrets.

Example: pino serializers and redact combined

const pino = require('pino');

const logger = pino({
  redact: {
    paths: ['req.headers.authorization', 'req.body.*.password', 'resp.headers.set-cookie'],
    censor: '[REDACTED]'
  },
  serializers: {
    // convert Error objects to structured shape
    err: pino.stdSerializers.err
  }
});

Pitfalls:

  • Redaction can be computationally expensive on large objects.
  • Redaction that’s silent (replacing with placeholder) may mask important fields; pair with alerting on redaction metrics.

Decision criteria: if your logs are ingested centrally, add redaction at the ingestion layer too. Defense in depth.

Request logging pattern and what to log (examples and volumes)

Useful lifecycle logging for HTTP:

  1. Request start: method, url, requestId, headers (only safe ones), source IP, userId (if available)
  2. Request end: status, durationMs, response size, route, handler
  3. Branch decisions: cache hit/miss, fallback used, retry attempted
  4. Errors: as described above, with classification and stack traces at boundary

Express middleware example:

app.use((req, res, next) => {
  const start = Date.now();
  const log = logger.child({ reqId: req.id, method: req.method, path: req.path });
  log.info('request-start');

  res.on('finish', () => {
    const duration = Date.now() - start;
    log.info({ status: res.statusCode, durationMs: duration }, 'request-end');
  });

  next();
});

Volume considerations:

  • Each request producing multiple logs multiplies volume. Keep INFO logs minimal, and shift noisy instrumentation to metrics.
  • Use sampling for debug-level traces or for high-throughput endpoints.

Decision: log every request start/end at INFO; log internal debug at DEBUG with sampling.

Graceful shutdown and log flushing

A classic mistake: process exits before logs are flushed. Docker/Kubernetes rely on clean shutdowns.

Rules:

  • Flush and close log streams on SIGTERM.
  • Stop accepting new requests, wait for in-flight requests with a timeout, then exit.
  • Ensure transports (file handles, TCP streams) are drained.

pino provides helpers (pino.final) to flush logs on exit. Example:

const { createServer } = require('http');
const pino = require('pino');
const logger = pino();

const server = createServer((req, res) => {
  logger.info('incoming');
  res.end('ok');
});

const loggerFinal = pino.final(logger);

function shutdown(signal) {
  logger.info({ signal }, 'shutdown requested');
  server.close(async (err) => {
    if (err) loggerFinal(err, () => process.exit(1));
    // flush and exit
    loggerFinal(null, () => process.exit(0));
  });

  // force exit after timeout
  setTimeout(() => {
    loggerFinal(new Error('shutdown timeout'), () => process.exit(1));
  }, 10000);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Pitfalls:

  • Not draining job queues and in-flight database transactions before exit.
  • Relying on process.exit() without flushing.

Decision: enforce graceful shutdown in framework templates and CI tests that simulate SIGTERM.

Integrating logs with tracing and error monitoring

Structured logs are only useful if they’re searchable and correlated with traces and errors.

Integration checklist:

  • Send logs to a central store (ELK, Datadog, Splunk, Loki, Vector -> S3) via stdout or a transport.
  • Forward trace context (traceId/spanId) into logs so you can correlate traces and logs.
  • Use an error tracker (Sentry, Honeybadger) for programmer errors and high-severity issues; include traceId and relevant log fields in the error event.
  • Instrument dependency calls (HTTP, DB) to emit structured metrics and logs.

Example: include tracecontext in outgoing HTTP headers and logs

// when making an HTTP request
const traceId = getContext().traceId; // from ALS
await fetch(remoteUrl, {
  headers: { 'x-request-id': traceId, 'traceparent': buildTraceparent(traceId) }
});

Decision tradeoffs:

  • Centralized indexing costs money. Limit indexed fields to a small set: traceId, service, level, errorCode, route.
  • Use metrics for aggregate alerting (error rate, latency P95) and logs for debugging.

Migration path from console.log (practical, incremental steps)

You don’t have to rewrite everything at once. Follow an incremental plan:

  1. Add a structured logger as a dependency and export a thin wrapper (logger.js). Replace console.log in new code only.
  2. Add request ID middleware and propagate requestId in a consistent header.
  3. Convert critical paths and error boundaries: authentication, payments, job processing.
  4. Configure redaction and basic dashboards for top routes.
  5. Replace console.log in shared libraries and infra code.
  6. Add sampling for debug-level logs and tighten retention policy.
  7. Train team on level semantics and run a code review checklist.

Pitfalls during migration:

  • Partially migrated services produce mixed formats. Ensure your ingestion can parse both or normalize at the collector.
  • Forgetting to update dashboards to use new key names (e.g., requestId -> traceId).

Concrete incremental code commit example (small patch):

  • Add logger.js with pino configured.
  • Replace console.log('start') with logger.info('start', { route: '/foo' }).

Anti-patterns and team standards

Anti-patterns to avoid:

  • string-only logs: logger.info('user x succeeded') — lacks stable keys.
  • inconsistent key names: request_id vs requestId vs reqId — pick one and enforce it.
  • dumping whole objects (req, res) into logs unredacted.
  • noisy debug logs in production without sampling.

Team standards you must enforce:

  • canonical schema document with examples.
  • required fields for top-level service logs (service, env, traceId).
  • error categories and codes list.
  • redaction policy and a test that fails when secrets are detected in logs pre-deploy.
  • CI lint rule or code review checklist that flags console.log or unstructured logs.

Decision: Make logging standards lightweight and enforceable. The overhead of a one-page spec and linter saves hours during incidents.

Final takeaway: logs are living infrastructure

Structured logging is operational infrastructure, not a developer nicety. Treat logs like APIs: define schemas, version changes, and ensure backward compatibility. Use structured logs to reduce incident MTTR, onboard new team members faster, and produce reliable alerts.

Start with a minimal, consistent schema, pick a performant logger (pino), wire correlation IDs, and protect PII with centralized redaction. Migrate incrementally, instrument graceful shutdown and flushing, and integrate logs with tracing and Sentry for a complete observability stack. Replace console.log in production today—your future on-call self will thank you.