Structured Logging
Swap console.log for a fast JSON logger like pino, attach a request id to every line, and never log secrets.
console.log is a development tool. In production you want structured, JSON logs that a log aggregator can index, filter and alert on. pino is the community standard: extremely fast, JSON by default, and it barely touches your event loop.
Correlation is the whole point
The magic is a child logger per request, bound to a request id. Every line emitted while handling that request carries the id, so when something breaks you filter your aggregator by one id and see the entire story — across services, if you propagate the id in a header.
import pino from 'pino';
const logger = pino();
logger.info({ userId: 42, route: '/orders' }, 'order placed');
// -> {"level":30,"userId":42,"route":"/orders","msg":"order placed"}Pretty in dev, JSON in prod
Pipe through pino-pretty locally for human-readable colors; emit raw JSON in production so machines can parse it.
Example
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { randomUUID } from 'node:crypto';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
// Strip secrets structurally — before anything is serialized.
redact: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
});
const app = express();
app.use(
pinoHttp({
logger,
// Reuse an inbound id or mint one; it flows to every child log line.
genReqId: (req, res) => {
const id = req.headers['x-request-id'] ?? randomUUID();
res.setHeader('x-request-id', id);
return id;
},
}),
);
app.get('/orders/:id', (req, res) => {
// req.log is a child logger already bound to this request's id.
req.log.info({ orderId: req.params.id }, 'fetching order');
res.json({ id: req.params.id });
});When to use it
- A production Express app uses Pino to write structured JSON logs so a log aggregator like Datadog can index and query log fields.
- A developer configures separate log levels for development (debug) and production (warn) using Winston transports.
- A request-scoped logger attaches a correlation ID to every log line so all entries for a single HTTP request can be traced together.
More examples
Structured logging with Pino
Integrates Pino with pino-http middleware so every request generates a structured JSON log line and req.log is available in handlers.
const express = require('express');
const pino = require('pino');
const pinoHttp = require('pino-http');
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
const app = express();
app.use(pinoHttp({ logger }));
app.get('/health', (req, res) => {
req.log.info('health check hit');
res.json({ status: 'ok' });
});
app.listen(3000);Winston with transports
Creates a Winston logger that writes to the console at debug level in development and to an error log file in production.
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'warn' : 'debug',
format: winston.format.json(),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
],
});
module.exports = logger;Request correlation ID logger
Creates a child logger per request with a correlation ID so every log line for the same request shares the same requestId field.
const { randomUUID } = require('crypto');
const pino = require('pino');
const logger = pino();
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || randomUUID();
req.log = logger.child({ requestId: req.id, method: req.method, path: req.path });
res.setHeader('X-Request-Id', req.id);
next();
});
app.get('/data', (req, res) => {
req.log.info('fetching data');
res.json({ ok: true });
});
Discussion