import type { NextFunction, Request, Response } from 'express';
import crypto from 'node:crypto';
import pino from 'pino';
const baseLogger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: ['req.headers.authorization', 'req.headers.cookie']
});
declare global {
namespace Express {
interface Request {
requestId: string;
log: pino.Logger;
}
}
}
export function requestIdAndLogger(req: Request, res: Response, next: NextFunction) {
const incoming = req.header('x-request-id');
const requestId = incoming && incoming.length < 128 ? incoming : crypto.randomUUID();
req.requestId = requestId;
res.setHeader('x-request-id', requestId);
req.log = baseLogger.child({ requestId });
req.log.info({ req: { method: req.method, path: req.path } }, 'request.start');
res.on('finish', () => {
req.log.info(
{ res: { statusCode: res.statusCode }, durationMs: Date.now() - (res.locals._startMs ?? Date.now()) },
'request.finish'
);
});
res.locals._startMs = Date.now();
next();
}
Debugging distributed requests is miserable without a stable request id, so I generate it once at the edge, echo it back via x-request-id, and attach it to every log line with a child logger. I keep this intentionally boring because it delivers 80% of the value without a full tracing stack. I like pino because it’s fast and the structured JSON logs ship cleanly to most backends. The real payoff shows up when I’m chasing a production 500: I can grep by requestId and reconstruct the request lifecycle (start → downstream calls → finish) instead of guessing. Once this is in, I can also correlate backend errors to frontend reports by request id.