Node.js Logging Best Practices: From Console.log to Structured Observability
Production logging is more than console.log. This guide covers structured logging, log levels, performance benchmarks, and integrating with OpenTelemetry for complete observability.

Logging is the first line of defense when debugging production issues. But console.log in a high-throughput API will kill performance. This guide covers choosing a logger, structured logging, log levels, correlation IDs, and avoiding common mistakes that lead to unusable logs.
Why Structured Logging Matters
Instead of console.log(`User ${userId} logged in at ${new Date()}`), structured logging outputs JSON:
{"level":"info","userId":123,"event":"login","timestamp":"2026-06-10T10:00:00Z"}
JSON logs are machine-parseable, searchable in log aggregators (Datadog, Loki, CloudWatch), and can be automatically indexed.
Choosing a Logger: Pino vs Winston vs Bunyan
Benchmark (log lines/second, higher is better):
- Pino: 45,000 req/s — fastest, minimal overhead, async logging by default.
- Winston: 25,000 req/s — feature-rich, many transports, sync by default.
- Bunyan: 22,000 req/s — good but less maintained.
Recommendation: Pino for performance-critical apps, Winston if you need complex transports (file, Slack, HTTP).
Pino Setup with Express and Request IDs
import pino from 'pino';
import pinoHttp from 'pino-http';
import { randomUUID } from 'crypto';
// Production logger — JSON, no pretty prints
const logger = pino({
level: process.env.LOG_LEVEL || ‘info’,
formatters: {
level: (label) => ({ level: label }),
},
timestamp: pino.stdTimeFunctions.isoTime,
redact: [‘req.headers.authorization’, ‘user.password’], // Hide secrets
});
// HTTP middleware with request ID
const httpLogger = pinoHttp({
logger,
genReqId: (req) => req.headers[‘x-request-id’] || randomUUID(),
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
id: req.id,
}),
res: pino.stdSerializers.res,
},
customSuccessMessage: (req, res) => ${req.method} ${req.url} completed,
});
app.use(httpLogger);
// In your route handlers, use req.log
app.get(‘/api/users/:id’, (req, res) => {
req.log.info({ userId: req.params.id }, ‘Fetching user’);
// …
});
Log Levels and When to Use Them
- fatal: Process will exit. Use for unrecoverable errors (DB connection lost).
- error: Request failed but process continues. Use for caught exceptions.
- warn: Unexpected but handled condition (rate limit approaching).
- info: Normal lifecycle events (user login, order placed).
- debug: Development details (function arguments, intermediate values).
- trace: Very verbose (loop iterations, detailed state).
Correlation IDs Across Microservices
Propagate the same request ID across service boundaries:
// In gateway or entry service
const requestId = req.log.id;
// Call downstream service
const response = await fetch(‘http://order-service/api/orders’, {
headers: {
‘X-Request-Id’: requestId,
…otherHeaders
}
});
// In downstream service middleware
app.use((req, res, next) => {
const requestId = req.headers[‘x-request-id’] || randomUUID();
req.log = logger.child({ requestId });
next();
});
Avoiding Performance Pitfalls
Don't log large objects: Never log full database rows or request bodies (>1KB).
// BAD
req.log.info({ user: userObject }); // Could be 10KB
// GOOD
req.log.info({ userId: userObject.id, userEmail: userObject.email });
Don't log in hot loops: Logging inside a loop processing 10k items will destroy throughput. Log aggregated results after the loop.
Use async logging: Pino does this by default. Winston requires new winston.transports.File({ filename: 'log.log', async: true }).
Log Rotation and Retention
Use pino-roll or let your orchestration handle it:
import { createWriteStream } from 'fs';
import { pino } from 'pino';
const transport = pino.transport({
target: ‘pino-roll’,
options: { file: ‘app.log’, frequency: ‘daily’, size: ‘10m’ }
});
const logger = pino(transport);
Alternatively, write to stdout/stderr and let container runtime (Docker, Kubernetes) handle log collection and rotation.
Sampling Noisy Logs
For high-volume endpoints (health checks), sample logs to reduce cost:
function shouldLog() {
return Math.random() < 0.01; // 1% sampling
}
app.get(‘/health’, (req, res) => {
if (shouldLog()) {
req.log.info(‘Health check’);
}
res.send(‘OK’);
});
Integrating with OpenTelemetry for Traces
Modern observability combines logs, metrics, and traces. Use the same correlation ID across all:
import { trace } from '@opentelemetry/api';
app.use((req, res, next) => {
const span = trace.getActiveSpan();
const traceId = span?.spanContext().traceId;
req.log = logger.child({ traceId });
next();
});
Common Mistakes
- Logging sensitive data: Credit cards, passwords, auth tokens. Use
redactoption in Pino. - Synchronous logging in production: Blocks event loop. Always use async transports.
- No log levels in development: Set
LOG_LEVEL=debuglocally,infoin production. - Not including timestamps: Many loggers default to no timestamp. Always enable ISO timestamps.
Conclusion
Stop using console.log. Switch to Pino for its performance and structured JSON output. Implement request IDs for tracing requests through your system. Keep logs concise, use appropriate levels, and never log secrets. In production, collect logs via stdout and use a log aggregator (Datadog, Loki, CloudWatch) to query and visualize. Good logging transforms debugging from guesswork to science.
Comments
Join the conversation — sign in to leave a comment.