Node.js
June 10, 20265 min read...
Node.jsJune 10, 20265 min read

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.

Node.js Logging Best Practices: From Console.log to Structured 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 redact option in Pino.
  • Synchronous logging in production: Blocks event loop. Always use async transports.
  • No log levels in development: Set LOG_LEVEL=debug locally, info in 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.