When your API goes down at 3 AM, you open the logs and see: "Error processing request". No request ID, no standardized timestamp, no context of what was happening. That log doesn't help resolve anything.

Structured logging solves this: each log is a JSON object with standardized fields that observability tools can index, filter, and correlate.

The format that works

Each log should be a JSON line with consistent fields:

{
  "level": "error",
  "timestamp": "2026-12-18T14:30:00.123Z",
  "message": "Failed to process payment",
  "service": "payment-api",
  "request_id": "req_abc123",
  "user_id": "usr_xyz789",
  "error": {
    "name": "PaymentGatewayError",
    "message": "Card declined",
    "stack": "PaymentGateway.ts:45:12"
  },
  "context": {
    "amount": 15000,
    "currency": "BRL",
    "payment_method": "credit_card"
  }
}

Compare with the unstructured log: "Error processing payment". The structured format allows searching "all payment errors for user xyz789", or "card declined errors in the last 5 minutes", or "what's the failure rate of gateway X".

The fields that matter

  • level: error, warn, info, debug. Standardize. Never use console.log in production.
  • timestamp: ISO 8601, always UTC. Avoids timezone issues.
  • service: which microservice generated the log. Mandatory in distributed architecture.
  • request_id: unique request ID. Propagates between services. Allows correlating logs from the same flow.
  • user_id: when applicable, the affected user's ID.
  • error: object with name, message, and stack trace.
  • context: operation-specific data that helps debugging.

Implementation with pino (Node.js)

Pino is the standard logger for Node.js in production. Fast, structured, and with native JSON support:

import pino from 'pino'

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label }),
  },
  timestamp: pino.stdTimeFunctions.isoTime,
})

// Request middleware
app.use((req, res, next) => {
  req.id = crypto.randomUUID()
  req.log = logger.child({
    request_id: req.id,
    method: req.method,
    url: req.url,
  })

  const start = Date.now()
  res.on('finish', () => {
    req.log.info({
      status_code: res.statusCode,
      duration_ms: Date.now() - start,
    }, 'Request completed')
  })

  next()
})

Request ID: the connecting thread

The request_id is the most important field for debugging in distributed systems. It needs to be born in the first layer and propagated through all calls:

// Generate at the gateway/API
const requestId = req.headers['x-request-id'] || crypto.randomUUID()

// Propagate in internal calls
await fetch('http://user-service/api/users', {
  headers: { 'X-Request-Id': requestId }
})

With tools like Datadog, Elasticsearch, or Loki, you can search for all logs of a specific request_id and see the complete flow of a request, end to end.

What to log and what not to log

Log: errors, warnings, important business events (payment approved, account created), performance metrics (query duration, response time).

Don't log: sensitive data (passwords, tokens, card data), PII without necessity, normal operation flow (excessive), environment variables.

// ❌ Never
logger.info({ password: user.password, token: jwt })

// ✅ Correct
logger.info({ user_id: user.id, action: 'login', ip: req.ip })

Log levels in practice

Error: something failed and needs attention. Immediate alert.

Warn: something unexpected but not critical. Warrants investigation.

Info: normal system events. Payment processed, email sent.

Debug: details for development. Disabled in production.

In production, run with level: 'info'. Temporarily enable debug via environment variable when you need to investigate something specific.

Correlation with metrics

Structured logs combined with metrics (Prometheus, Datadog) create dashboards showing: error rates by endpoint, percentile latency, throughput by service. The log gives the what happened, the metric gives the how much.