TL;DR:
console.login production creates unstructured text that’s expensive to search and impossible to correlate across services- Pino is the fastest structured JSON logger for Node.js — low overhead, JSON output by default, and a transport system for routing logs to different destinations
- Injecting OpenTelemetry trace context (traceId, spanId) into every log entry lets you jump from a log to the full distributed trace — which is where debugging complex issues actually lives
There’s a particular kind of production incident that everyone in backend development has experienced at least once. Something is wrong. You know approximately when it started. You have logs — thousands of them, generated by console.log calls scattered across your codebase, timestamped but otherwise unstructured text strings. You’re grepping through them, manually correlating entries from different services by trying to match timestamps, and every minute that passes is costing your users and your team.
Structured logging is the fix. It’s not new — the idea of emitting machine-readable JSON instead of human-readable strings has been around for years — but the tooling around it in the Node.js ecosystem has matured considerably, and the integration with OpenTelemetry makes it genuinely useful for distributed systems in a way it wasn’t before.
Why Pino
There are several structured logging libraries for Node.js. Pino is consistently the fastest. Its architecture separates the hot path (generating JSON log entries) from transport (writing them somewhere), which lets the main thread stay fast while log I/O happens asynchronously.
For a high-throughput service, this matters. Winston, the other popular choice, is synchronous by default and noticeably slower under load. Bunyan is closer to Pino’s architecture but less actively maintained. If you’re starting a new Node.js project or replacing console.log in an existing one, Pino is the current default choice.
Getting started is minimal:
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
});
logger.info({ userId: '123', action: 'login' }, 'User logged in');
logger.error({ err, requestId: req.id }, 'Database query failed');
The key shift from console.log is that structured data goes in the first argument as an object, and the human-readable message goes in the second. Pino serialises the object to JSON. Your log aggregation system (Datadog, Grafana Loki, CloudWatch, whatever you’re using) can now filter and query on any field — userId, action, requestId — without parsing unstructured text.
Log Levels in Production
A common mistake is leaving log level at debug in production or, worse, using console.log for everything with no level concept at all. The overhead of debug-level logging in a high-traffic service is real — both the CPU cost of generating log entries and the storage and ingestion cost of whatever system receives them.
In production, info is usually the right default level, with warn and error for conditions that need attention. debug should be available to enable dynamically when you need it for troubleshooting, but not on all the time.
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// Serialisers define how specific objects are converted to JSON
serializers: {
err: pino.stdSerializers.err, // Properly serialises Error objects including stack traces
req: pino.stdSerializers.req, // Standard HTTP request fields
res: pino.stdSerializers.res,
},
});
The err serialiser is worth calling out — console.log(new Error('something')) produces useless output in JSON; pino.stdSerializers.err gives you message, stack, and type as proper JSON fields.
Adding OpenTelemetry Trace Context
Here’s where structured logging gets significantly more powerful. OpenTelemetry is the standard for distributed tracing — it propagates trace and span IDs across service boundaries, so you can reconstruct the complete path of a request as it moves through your system.
If you inject those IDs into your log entries, you can navigate from a log line directly to the trace that produced it. Most log aggregation platforms (Datadog, Grafana) support this correlation natively when the fields are present.
import { trace, context } from '@opentelemetry/api';
// Pino hook to inject trace context into every log entry
const logger = pino({
level: 'info',
mixin() {
const span = trace.getActiveSpan();
if (!span) return {};
const ctx = span.spanContext();
return {
traceId: ctx.traceId,
spanId: ctx.spanId,
traceFlags: ctx.traceFlags,
};
},
});
The mixin function runs on every log call and merges its return value into the log entry. With this in place, every log entry automatically includes the current trace and span ID if an active OTel span exists. You don’t have to remember to pass them manually.
In your log aggregation UI, you can now click from a specific error log directly to the trace it was part of — seeing exactly which downstream service calls preceded the error, where the latency was, and what the full context was.
Correlation IDs for Incoming Requests
If you’re not using OpenTelemetry yet, or you want belt-and-braces correlation, correlation IDs are the simpler approach. Every incoming request gets a unique ID (generated or extracted from a header like X-Request-ID). That ID is threaded through every log entry produced while handling the request.
With Express or Fastify, the cleanest way to do this is with a request-scoped child logger:
import { randomUUID } from 'crypto';
app.use((req, res, next) => {
const requestId = req.headers['x-request-id'] || randomUUID();
req.log = logger.child({ requestId });
res.setHeader('x-request-id', requestId);
next();
});
// In your route handlers:
req.log.info({ userId: req.user?.id }, 'Processing checkout');
req.log.error({ err, orderId }, 'Payment failed');
Every log entry from this request now includes requestId. Filtering your logs by requestId gives you the complete story of what happened during that request — across middleware, database calls, external API calls, everything.
Pino Transports
Pino’s transport system lets you route logs to different destinations. In development, you want human-readable output. In production, you want raw JSON piped to your log aggregator.
const logger = pino(
{ level: 'info' },
process.env.NODE_ENV === 'development'
? pino.transport({ target: 'pino-pretty' })
: process.stdout // In production, write JSON to stdout; your container runtime handles the rest
);
pino-pretty formats JSON logs into colour-coded, human-readable output for development. In production, writing to stdout and letting your container orchestration layer (Kubernetes, ECS, etc.) capture and forward logs is the standard approach — you stay out of the log routing business and let your infrastructure handle it.
If you need to write to a specific destination (Datadog, Elasticsearch, a file), pino transports run in a worker thread so they don’t block your main application thread.
What You Actually Gain
The investment in switching from console.log to structured logging pays off the first time you have a production incident and can search your logs by userId, filter to level: error, correlate to a trace, and understand what happened in five minutes instead of forty-five. That’s not a hypothetical — it’s a consistent experience reported by teams that make the transition.
The overhead is low. The integration with OpenTelemetry is straightforward once the SDK is in place. And the configuration described here covers the scenarios that matter in production: appropriate log levels, serialised errors, trace context injection, and request-scoped correlation.
If your Node.js services are still running on console.log, this is one of the higher-leverage observability improvements you can make without significant architectural change.