Track Every Request Across Node.js Services with pino-correlation-id
You have structured logs. You have pino configured with pretty print in dev and JSON in prod. But when an error fires, you open Grafana Loki and search for the request that caused it — and you're staring at hundreds of log lines from twenty concurrent requests, all mixed together, with zero way to isolate the trace for the one that failed.
This is the correlation ID problem. Every distributed system that takes production seriously solves it. Here's the Node.js solution that takes five minutes to wire up.
Why Correlation IDs Break in Async Node.js
The classic approach — attach a reqId to the request object and pass it around — falls apart the moment you go async. Your route handler calls a service, which calls a repository, which runs a database query. Each layer is a separate function. You either pass req.log as a parameter through every single function signature, or you lose the ID.
Neither option is acceptable at scale.
The modern solution is AsyncLocalStorage, a Node.js built-in that maintains context through the async call chain — no prop drilling required. Every await, every callback, every timer inherits the same store for the lifetime of the request.
pino-correlation-id wraps AsyncLocalStorage into an Express/Fastify middleware that requires exactly one line of setup per route.
Install
npm install pino-correlation-id pino
No additional dependencies. Uses Node.js built-in async_hooks and crypto.
Express Setup
const express = require('express');
const pino = require('pino');
const { expressMiddleware, getCorrelationId } = require('pino-correlation-id');
const logger = pino();
const app = express();
// Mount before all routes — one line
app.use(expressMiddleware({ logger }));
app.get('/orders/:id', async (req, res) => {
// req.log is a pino child with reqId already bound
req.log.info({ orderId: req.params.id }, 'Fetching order');
const order = await orderService.findById(req.params.id);
req.log.info({ order }, 'Order retrieved');
res.json(order);
});
Output in JSON:
{"level":30,"time":1711900000000,"reqId":"01948f2a-2c3b-7c22-9b6e-ee8e5b0b3f1a","msg":"Fetching order","orderId":"ord_xyz"}
{"level":30,"time":1711900000050,"reqId":"01948f2a-2c3b-7c22-9b6e-ee8e5b0b3f1a","msg":"Order retrieved","order":{}}
The same reqId appears on every log line for that request — automatically.
Using getCorrelationId() Anywhere in Your Stack
This is where AsyncLocalStorage earns its keep. You do not need req.log to propagate the ID. Any function called during the request — regardless of how deep the call stack goes — can access it:
const { getCorrelationId } = require('pino-correlation-id');
// In your repository layer — no req passed in
class OrderRepository {
async findById(id) {
const correlationId = getCorrelationId(); // Works!
logger.info({ correlationId, id }, 'DB query: findById');
return db.query('SELECT * FROM orders WHERE id = $1', [id]);
}
}
// In your HTTP client — propagate to downstream services
async function callInventoryService(skuId) {
const correlationId = getCorrelationId();
return fetch(`${INVENTORY_URL}/sku/${skuId}`, {
headers: {
'X-Request-ID': correlationId, // Forward the ID
'Content-Type': 'application/json',
}
});
}
Your entire call stack — Express handler, service layer, repository, HTTP client — carries one consistent ID with zero parameter passing.
Header Reading and Response Echo
By default, the middleware reads from X-Request-ID and X-Correlation-ID headers (in that priority order). If neither exists, it generates a UUID v4. It also echoes the ID back in the response header.
This means: if your API gateway, load balancer, or upstream service already sets a trace ID, pino-correlation-id will use it — maintaining a single ID across your entire distributed system.
// Custom header name + custom ID generator
app.use(expressMiddleware({
logger,
header: 'x-trace-id', // Read from this header
setResponseHeader: true, // Echo back (default: true)
logKey: 'traceId', // Key name in pino bindings
generateId: () => `trace_${Date.now()}_${Math.random().toString(36).slice(2)}`,
}));
Fastify Integration
const fastify = require('fastify')({ logger: false });
const pino = require('pino');
const { fastifyPlugin } = require('pino-correlation-id');
const logger = pino();
fastify.register(fastifyPlugin, {
logger,
header: 'x-request-id',
});
fastify.get('/health', async (request, reply) => {
request.log.info('Health check'); // reqId automatically bound
return { status: 'ok' };
});
Storing Additional Context
setContext lets you enrich the async store with any data you want available throughout the request — user ID, tenant ID, session — without touching your function signatures.
const { setContext, getStore } = require('pino-correlation-id');
// After authentication middleware
app.use(authMiddleware);
app.use((req, res, next) => {
if (req.user) {
setContext('userId', req.user.id);
setContext('tenantId', req.user.tenantId);
}
next();
});
// Deep in your service layer — no req argument needed
function auditLog(action, resourceId) {
const store = getStore();
logger.info({
correlationId: store?.correlationId,
userId: store?.userId,
tenantId: store?.tenantId,
action,
resourceId,
}, 'Audit event');
}
Now every audit log includes the user, tenant, and correlation ID — pulled from async context, not from function parameters.
Querying in Grafana Loki
Once all your log lines share a reqId, Loki queries become trivial:
# All logs for a specific request
{app="order-service"} | json | reqId="01948f2a-2c3b-7c22-9b6e-ee8e5b0b3f1a"
# Errors with their full request context
{app="order-service"} | json | level="error" | line_format "{{.reqId}} {{.msg}}"
# Count requests with errors by correlation ID
{app="order-service"} | json | level="error"
| count_over_time({app="order-service"}[5m])
Every error now shows the full chain of log lines that led to it. Debugging time drops from hours to minutes.
Connecting to OpenTelemetry
If you are using OpenTelemetry, you can bridge the trace context with correlation IDs:
const { trace } = require('@opentelemetry/api');
const { setContext } = require('pino-correlation-id');
// In middleware, after OTel auto-instrumentation runs
app.use((req, res, next) => {
const span = trace.getActiveSpan();
if (span) {
const { traceId, spanId } = span.spanContext();
setContext('traceId', traceId);
setContext('spanId', spanId);
}
next();
});
Now your pino JSON logs carry both the correlation ID (for Loki queries) and the OTel trace ID (for Jaeger/Tempo linking). A single log line lets you jump from log aggregator to distributed trace in one click.
Testing
Testing correlation ID propagation is straightforward:
const { AsyncLocalStorage } = require('async_hooks');
const { getCorrelationId } = require('pino-correlation-id');
// Create a fake request context for unit tests
function withCorrelationId(id, fn) {
const storage = new AsyncLocalStorage();
return storage.run({ correlationId: id }, fn);
}
// In your test
test('orderService logs with correlation ID', async () => {
await withCorrelationId('test-id-123', async () => {
const result = await orderService.process({ id: 'ord_1' });
expect(getCorrelationId()).toBe('test-id-123');
});
});
Production Checklist
- [ ]
pino-correlation-idmiddleware mounted before all routes - [ ] Upstream services forward
X-Request-IDheader on outbound calls - [ ] API gateway configured to inject
X-Request-IDif not present - [ ] Loki/Elasticsearch index includes
reqIdas a structured field - [ ] Error handlers access
getCorrelationId()in error responses - [ ]
setContext('userId', req.user.id)added in auth middleware - [ ] OTel trace IDs bridged via
setContextif using distributed tracing
Why Not winston-correlation-id or cls-hooked?
cls-hooked uses an older continuation local storage implementation that patches Node.js async hooks manually — fragile and not officially supported. AsyncLocalStorage is the official Node.js API since v16.4.0, actively maintained by the Node.js core team.
pino-correlation-id is specifically designed for pino's child logger model — it creates a new child logger per request with reqId pre-bound, which means zero performance overhead vs manually binding per log call.
| Package | Storage | Pino Integration | Maintenance |
|---|---|---|---|
| pino-correlation-id | AsyncLocalStorage (official) | Child logger per request | Active |
| cls-hooked | Custom patching | Manual | Deprecated |
| express-mung | req mutation | None | Archived |
| pino-http | Express only | Full | Active |
For teams already using pino and needing distributed tracing without OpenTelemetry overhead, pino-correlation-id is the minimal, correct solution.
Summary
Correlation IDs are not optional in production. Every serious platform has them. With pino-correlation-id, the entire wiring is:
npm install pino-correlation-idapp.use(expressMiddleware({ logger }))- Use
req.login route handlers,getCorrelationId()everywhere else
Your entire async call stack — from Express handler to database query to outbound HTTP call — inherits the request ID automatically. No prop drilling. No middleware threading req through every function signature. AsyncLocalStorage handles propagation. You handle the logs.
Debugging gets boring — which is exactly what you want in production.
Install: pino-correlation-id on npm
Part of the Node.js Production Series — also see: Structured Logging with Pino, OpenTelemetry in Production, and circuit breakers with opossum-prom.
Top comments (0)