typescript 89 lines · 3 tabs

Request ID + structured logging (Express + pino)

Shared by codesnips Jan 2026
3 tabs
import pino, { Logger } from 'pino';
import { AsyncLocalStorage } from 'node:async_hooks';

export interface Store {
  requestId: string;
  logger: Logger;
}

export const als = new AsyncLocalStorage<Store>();

export const baseLogger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  timestamp: pino.stdTimeFunctions.isoTime,
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie'],
    censor: '[redacted]',
  },
});

export function log(): Logger {
  const store = als.getStore();
  return store ? store.logger : baseLogger;
}
3 files · typescript Explain with highlit

This snippet wires a per-request correlation ID through an Express application and attaches a child logger to every log line so that all output for a single request can be grouped after the fact. The core problem it solves is that in a concurrent server, log lines from different in-flight requests interleave; without a shared identifier there is no reliable way to reconstruct what happened during one request. The approach uses Node's AsyncLocalStorage so the logger and request ID follow the async call chain without being threaded through every function argument.

In logger.ts, a base pino instance is created with ISO timestamps and a redaction rule so authorization headers never reach the logs. An AsyncLocalStorage holds a Store containing a requestId and a bound logger. The exported log() helper returns the request-scoped logger when called inside a request, and falls back to the base logger otherwise, which keeps startup and background code working without a store.

In requestContext.ts, the requestContext middleware reads an incoming x-request-id header or mints a fresh UUID with randomUUID(). Honoring an inbound ID lets an upstream gateway or another service propagate its own correlation ID, so a trace spans multiple hops. It echoes the ID back on the response header and creates a child logger bound to that ID via pino.child(), then runs the rest of the pipeline inside als.run() so downstream handlers observe the store.

The requestLogger middleware records a monotonic start time with process.hrtime.bigint() and logs a single completion line on the response finish event, including method, path, status, and duration in milliseconds. Logging once on completion rather than per line keeps volume low while still capturing latency.

In app.ts, ordering matters: requestContext must run before requestLogger and before any route so the store exists. The /orders/:id route calls log() with no plumbing and still emits lines tagged with the correct requestId. The error handler also uses log(), so failures are correlated too. The main trade-off is that AsyncLocalStorage adds slight overhead and requires discipline to never capture the logger outside its async scope.


Related snips

Share this code

Here's the card — post it anywhere.

Request ID + structured logging (Express + pino) — share card
Link copied