Home / Articles / Structured Logging in Node.js: Turning Production Debugging Chaos into Clarity

This article is published in English.

Structured Logging in Node.js: Turning Production Debugging Chaos into Clarity

Learn why console.log fails in production Node.js apps and how structured logging, log levels, and correlation IDs turn hard bugs into fast fixes.

1912 words

A customer complains that checkout failed for them. Your monitoring dashboard flags an unhandled exception: TypeError: Cannot read properties of undefined (reading 'id').

You pull up the file referenced in the stack trace, and the offending line looks entirely unremarkable:

const customerId = session.user.id;

So why was session.user undefined at that moment?

You try to recreate the failure locally. You sign in, walk through the checkout flow, and nothing breaks. You check the database and find that every test record has a complete user object attached. Without visibility into what data actually reached that line of code in production, you're stuck guessing.

This kind of investigation can eat up entire days of engineering time. But if you've set up logging with intention and structure, the very same bug is often resolved in minutes.

Why Debugging Node.js in Production Is Hard

While developing locally, you have interactive debuggers, instant terminal feedback, and typically just one user hitting the app — yourself. If a route throws, you simply rerun it with different inputs until the cause becomes clear.

Production doesn't offer any of that comfort:

  • Asynchronous execution: Node.js handles many operations at once through its event loop. While one request is waiting on a database call, another is already executing its own logic. Plain console output from concurrent requests gets interleaved and becomes unreadable.
  • Transient data: The exact payload a user submitted only lives in memory for a brief moment. Once an unhandled exception crashes the process or the server responds with a 500, that specific data is unrecoverable.
  • Thin stack traces: A JavaScript stack trace tells you where execution failed, but almost never why. It won't reveal which tenant made the call, which options were chosen, or what an upstream service actually returned.

Without intentional logging, your running application behaves like a sealed black box. Solid logging practices function as the dashboard that reveals what's happening inside it.

The Console Log Trap

Most developers reach for console.log() first when debugging Node.js apps. It ships with the runtime, needs no setup, and writes straight to stdout.

But relying on it in production introduces three distinct problems.

1. It Produces Unstructured Text

Take a line like this:

console.log("Payment processed for user: " + userId + " amount: " + amount);

Once a log aggregation platform such as Datadog, CloudWatch, or Grafana Loki ingests this line, it's stored as a plain string with no indexing. There's no efficient way to query for "every payment above 1000," since the platform would need costly regex parsing just to extract the values.

2. It Lacks Execution Context

Imagine an error log that simply reads:

Database timeout on query

There's nothing here telling you which route triggered the query, which user was involved, or when the request came in.

3. It Can Degrade Event Loop Performance

Under certain conditions — notably when writing to files or piped output on some systems — console.log() executes synchronously. When traffic is high, frequent calls to console.log() end up blocking Node.js's event loop, which drives up latency across the whole application.

The Three Pillars of Production-Grade Logging

To make logs genuinely useful for debugging, you need three core practices in place: structured output, consistent severity levels, and correlation IDs.

1. Structured Logging (JSON)

Rather than writing plain strings, your app should emit each log entry as a structured JSON object. JSON keys can be indexed automatically by log platforms.

{
  "timestamp": "2026-03-24T14:32:01.412Z",
  "level": "error",
  "message": "Payment processing failed",
  "userId": "usr_9912",
  "orderId": "ord_5521",
  "attempt": 3,
  "error": "Gateway timeout"
}

With this format, there's no guesswork involved when something breaks — you can just search for orderId: "ord_5521" and instantly pull up every log tied to that order.

2. Meaningful Log Levels

Not every entry deserves the same level of attention. Applying consistent log levels keeps your production output manageable:

  • DEBUG: Detailed diagnostic output meant for development, such as full payload dumps or internal branching decisions. This is typically suppressed in production.
  • INFO: Routine operational messages confirming the app is healthy, like Server started on port 3000 or User account created.
  • WARN: Situations the system recovered from on its own but that might signal a growing issue — for example, a cache miss forcing a database fallback, or a client hitting a deprecated endpoint.
  • ERROR: Failures that prevented an operation from finishing and need a human to look into them, such as a failed payment or an unhandled promise rejection.

3. Correlation IDs (Tracing Requests)

Since Node.js runs operations asynchronously, you need some way to follow a single request as it moves through controllers, services, database calls, and outbound API requests.

A correlation ID — commonly called requestId or traceId — is a unique identifier created when an HTTP request first arrives. That identifier gets attached to every log line produced while that request is being processed.

Practical Implementation: Structured Logging in Express

Now let's put this into practice by building a production-ready logging setup using Pino, a very fast JSON logger for Node.js, paired with the built-in AsyncLocalStorage API to handle correlation IDs.

Step 1: Set Up the Context Store and Logger

Node.js ships with AsyncLocalStorage, exposed through the core node:async_hooks module. Think of it as an equivalent to thread-local storage found in multi-threaded languages: it keeps a piece of context alive across asynchronous calls without forcing you to thread parameters through every function signature by hand.

// logger.js
import pino from 'pino';
import { AsyncLocalStorage } from 'node:async_hooks';
export const asyncLocalStorage = new AsyncLocalStorage();const baseLogger = pino({
  level: process.env.LOG_LEVEL || 'info',
  timestamp: pino.stdTimeFunctions.isoTime,
});// A proxy that injects the requestId into every log entry automatically
export const logger = new Proxy(baseLogger, {
  get(target, property) {
    const store = asyncLocalStorage.getStore();
    const childLogger = store?.requestId
      ? target.child({ requestId: store.requestId })
      : target;    return childLogger[property];
  }
});

Step 2: Implement the Express Middleware

Next, write a middleware that assigns an identifier to each incoming request and executes the rest of the request lifecycle inside the AsyncLocalStorage context.

// middleware.js
import { randomUUID } from 'node:crypto';
import { asyncLocalStorage, logger } from './logger.js';
export function requestContextMiddleware(req, res, next) {
  // Use existing header if forwarded by a load balancer, or create a new UUID
  const requestId = req.headers['x-request-id'] || randomUUID();

  res.setHeader('x-request-id', requestId);  asyncLocalStorage.run({ requestId }, () => {
    const startTime = Date.now();    logger.info({
      method: req.method,
      url: req.url,
      ip: req.ip
    }, 'Incoming request');    res.on('finish', () => {
      const durationMs = Date.now() - startTime;

      const logData = {
        statusCode: res.statusCode,
        durationMs
      };      if (res.statusCode >= 500) {
        logger.error(logData, 'Request completed with server error');
      } else {
        logger.info(logData, 'Request completed');
      }
    });    next();
  });
}

Step 3: Use the Logger Inside Business Logic

Inside your controllers or service functions, simply import the shared logger. There's no need to manually forward req or the requestId as function parameters.

// orderService.js
import { logger } from './logger.js';
export async function processOrder(user, cart) {
  logger.info({ cartItemsCount: cart.items.length }, 'Validating cart inventory');  if (!user || !user.id) {
    logger.warn({ userState: user }, 'Attempted checkout without a valid user ID');
    throw new Error('User identification is missing');
  }  // Continue checkout logic...
}

If something fails inside processOrder, the resulting log entry looks like this:

{
  "time": "2026-03-24T14:40:12.102Z",
  "level": 40,
  "requestId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "userState": null,
  "msg": "Attempted checkout without a valid user ID"
}

Notice how the requestId shows up automatically in that entry. Armed with that value, you can search your logs and reconstruct the complete sequence of events tied to that one request, from beginning to end.

Anatomy of a 5-Minute Fix

Let's revisit the original problem: session.user turning out to be undefined mid-checkout.

Without Structured Context (The Nightmare Scenario)

  • The stack trace points at const customerId = session.user.id.
  • You inspect the database and find that your test accounts all have perfectly valid sessions.
  • You burn 45 minutes testing various theories: guest checkout, expired sessions, mobile-specific behavior.
  • You push temporary console.log() calls to production, hoping the bug resurfaces so you can catch it live.
  • The issue stays open for days.

With Structured Context (The 5-Minute Scenario)

  • You grab the requestId from the customer's bug report or the 500 error alert.
  • You search your log aggregator for requestId: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d".
  • You pull up the three log lines tied to that ID and read them in sequence. The first shows the request landing at the checkout route. The second reveals that the session belongs to an anonymous, unauthenticated guest rather than a logged-in customer. The third confirms that the checkout logic rejected the attempt precisely because no valid user ID was present.
  • Reading those three entries together makes the root cause obvious right away: guest users are reaching the authenticated checkout route directly, bypassing the intended guest-flow redirect.
  • You fix the authentication check in about five minutes.

The resolution came quickly not because the underlying code got any simpler, but because the running application had already documented its own execution path.

Common Logging Mistakes to Avoid

Even seasoned teams undermine their own log quality in a few predictable ways:

  • Logging Sensitive Data (PII): Never write passwords, auth tokens, full card numbers, or other personal data to logs. Rely on built-in redaction features — Pino's redact option, for instance — to automatically strip fields like password or authorization before anything gets serialized.
  • Dumping Everything in Production: Flooding a live system with DEBUG-level output under real traffic wastes CPU cycles and inflates your log-storage bill. Default production environments to INFO or WARN, and only bump the verbosity temporarily when you actually need it.
  • Discarding Error Details: Avoid patterns like catching an error and logging only a plain string message, since that throws away the actual error object and its stack trace. Instead, always pass the complete error object into the log call, for example logger.error({ err }, "Failed to save data"), so nothing gets lost.

Writing Observability Into Your Code

Adopting solid logging habits reshapes how you think about production systems. Rather than treating an application as something you simply hope behaves correctly, you start treating it as a living process that owes you an explanation whenever something goes wrong.

Getting there doesn't demand much upfront effort: format logs as JSON, tag every incoming request with a correlation ID, and capture the relevant variables whenever an error is caught. The next time production throws an unexpected failure at you, that groundwork means you'll spend your time actually fixing it instead of guessing what happened.