Structured Logging in Node.js with Pino
console.log stops scaling the day you have two servers. How we set up Pino for structured, searchable, low-overhead logging in production Node services.
When we inherit a Node.js codebase, the state of its logging tells us most of what we need to know about its operational maturity. Scattered console.log calls with free-text messages mean that when something breaks at 2am, someone will be grepping through interleaved text hoping the relevant line includes an order ID. Structured logging fixes this: every log line is a JSON object, so your log platform can filter, aggregate and alert on fields rather than substrings.
Why Pino specifically
Pino is the fastest mainstream logger in the Node ecosystem, and it achieves that by doing less in-process: it writes newline-delimited JSON and pushes formatting, pretty-printing and transport concerns out of the hot path. Logging is one of the few things your app does on every single request, so its overhead compounds. Pino keeps that cost close to negligible.
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: ['req.headers.authorization', '*.password'],
formatters: {
level: (label) => ({ level: label }),
},
});In development, pipe the output through pino-pretty for human-readable colourised logs. Crucially, do that in the shell, not in code: production should always emit raw JSON to stdout and let the platform collect it.
Child loggers carry request context
The single most valuable habit is attaching a request ID to every line a request produces. Pino makes this cheap with child loggers, which inherit configuration and merge extra fields into everything they log.
app.addHook('onRequest', async (req) => {
req.log = logger.child({
reqId: req.id,
route: req.routeOptions.url,
});
});
// later, in any handler:
req.log.info({ orderId }, 'order created');Fastify ships with Pino built in and does this wiring for you. On Express, pino-http provides the same pattern. Either way, the payoff is enormous: one query in your log platform returns the complete story of a single failed request across every layer of the app.
Where the logs go next is deliberately not Pino's problem: write to stdout and let the environment decide. In containers, the runtime collects the stream; on bare servers, systemd or a shipper such as Vector tails it. If you must move logs from inside the process, use Pino transports, which run in a worker thread so serialisation and shipping never block the event loop. Resist writing log files from the application itself — rotation, permissions and disk-full handling are all problems the platform already solves better than your code will.
Redaction is not optional
Structured logging makes it dangerously easy to log entire objects, and entire objects contain tokens, passwords and personal data. Pino's redact option censors named paths before serialisation, which makes it the right place to enforce policy — it works even when a developer logs a whole request object in a hurry. Maintain the redact list in one shared logger module, review it whenever new secrets enter the system, and treat a secret in the logs as an incident, because your log platform is almost certainly the least protected place that data could live.
A few conventions worth adopting
- Log the object first, message second: log.info({ userId }, 'login succeeded') keeps fields queryable.
- Use levels honestly: error means a human should eventually look, warn means degraded, info means business events.
- Never log inside tight loops at info level; use debug and sample if you must.
- Set a serialiser for errors so stack traces survive JSON encoding.
None of this takes more than a day to set up, and it changes the character of every incident that follows. Good logs turn debugging from guesswork into lookup.
Struggling to see what your Node services are doing in production? STRCLI helps teams put observability in place — drop us a line.