Logging

intermediate nodejs logging production observability

console.log is great in dev. In production it’s a disaster:

  • Blocks the event loop on a slow terminal/file.
  • No log levels — can’t filter “warn and above.”
  • Unstructured strings — grep works but querying (“show me all 500s in the last hour”) doesn’t.
  • No timestamps unless we add them manually.
  • No request correlation — can’t follow one request across many log lines.

In simple language: console.log is a printf, not a logger. We need a logger.

Structured (JSON) logs > free-text

Pre-cloud: tail -f app.log | grep ERROR. Post-cloud: logs go to Datadog/Loki/CloudWatch/ELK and get queried. Those systems work way better with JSON.

// Free-text — hard to query
2026-05-26 12:34:56 ERROR: user 42 failed login from 1.2.3.4

// Structured — every field is queryable
{"level":"error","time":1716720896000,"msg":"failed login","userId":42,"ip":"1.2.3.4"}

Now level:error AND userId:42 is a one-liner in any log system.

Pino — fast and JSON-first

Pino is the de facto standard for new Node services. Async, structured by default, very fast (claims ~5x faster than Winston in their benchmarks).

import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
});

logger.info('server started');
logger.info({ port: 3000, env: 'prod' }, 'listening');
logger.error({ err, userId }, 'failed to load user');

Note the argument order: object first, message second. The object’s keys become top-level fields in the log line.

Output:

{"level":30,"time":1716720896,"pid":42,"hostname":"app-1","port":3000,"env":"prod","msg":"listening"}

level: 30 is info (10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal).

Child loggers for request context

app.use((req, res, next) => {
  req.log = logger.child({ reqId: crypto.randomUUID() });
  next();
});

app.get('/users/:id', async (req, res) => {
  req.log.info({ userId: req.params.id }, 'fetching user');
  // every log line in this request includes reqId automatically
});

Now we can trace one request through five log lines by filtering on reqId.

Pretty-print in dev

Raw JSON in dev is ugly. Pipe through pino-pretty:

node server.js | pino-pretty

Or configure it conditionally:

const logger = pino({
  transport: process.env.NODE_ENV !== 'production'
    ? { target: 'pino-pretty' }
    : undefined,
});

Winston — flexible, more batteries included

Winston has been around longer. More transports out of the box (files, HTTP, Slack, Loggly). More configurable formatters. Slower than Pino but rarely the bottleneck.

import winston from 'winston';

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
  ],
});

Pino vs Winston

Pino Winston
SpeedVery fastSlower
Default formatJSONConfigurable
TransportsWorker-thread basedBuilt-in zoo
Best forNew services, microservices, high throughputLegacy projects, complex routing needs

For new projects in 2026, default to Pino unless we have a specific reason for Winston.

Log levels — actually use them

  • trace — extremely verbose, “entered this function”
  • debug — dev-only details
  • info — normal lifecycle events (“server started”, “job processed”)
  • warn — something unexpected but not broken
  • error — request failed, operation failed
  • fatal — process is dying

Default to info in prod, debug in dev. Letting debug lines into prod logs makes them noisy and expensive.

What to log (and what NOT to)

Log:

  • Every incoming request (URL, status, latency, requestId, userId)
  • Every error with stack trace
  • Job/cron start and end
  • External API calls (target, duration, status)

Never log:

  • Passwords, tokens, API keys, session IDs
  • Full credit card numbers, PII (unless legally OK and you have redaction)
  • The full request body of every request (huge volume, often contains PII)

Use a redaction config:

const logger = pino({
  redact: ['req.headers.authorization', 'password', '*.token'],
});

Where logs go

In containerized environments (Docker, Kubernetes), log to stdout/stderr only. Don’t write log files inside the container. The orchestrator captures stdout and routes it to your log backend. Files inside containers vanish when the container restarts.

For VMs/bare metal, write to stdout and let systemd-journald / a sidecar agent ship them off.