A request id is only useful if every line produced while serving that request carries it, including lines from a data-access function that never received req. Request Context builds that plumbing with AsyncLocalStorage; here a pino 18,227 child logger goes into the store, so context and logging become one.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import pino from 'pino';
const base = pino({ base: null, timestamp: false }); // trimmed so the output below fits
const store = new AsyncLocalStorage();
export const log = () => store.getStore()?.log ?? base;
export const withContext = (req, res, next) => {
const id = req.get('x-request-id')?.replace(/[^\w-]/g, '').slice(0, 64) ?? randomUUID();
res.set('X-Request-Id', id);
store.run({ requestId: id, log: base.child({ requestId: id, route: req.path }) }, next);
};withContext goes first in the stack, ahead of the body parser, so even a malformed-JSON rejection is logged with an id, and any module can now call log() for a logger bound to the current request. Two overlapping requests interleave — req-b finishes while req-a waits — and every line still carries the right id:
{"level":30,"requestId":"req-a","route":"/api/books/1","id":1,"msg":"store lookup"}
{"level":30,"requestId":"req-b","route":"/api/books/2","id":2,"msg":"store lookup"}
{"level":30,"requestId":"req-b","route":"/api/books/2","id":2,"msg":"store hit"}
{"level":30,"requestId":"req-a","route":"/api/books/1","id":1,"msg":"store hit"}Sanitize the incoming header, as the listing does: X-Request-Id is attacker-controlled, an unbounded value bloats every record, and a newline injected into a text-format logger forges entries. Echo the id back in the response and forward it on every outbound call — one identifier, generated at the edge, in every record any service writes about that unit of work. Health and Metrics replaces it with traceparent.