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.
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk