Skip to content
Codeloom
Backend

Structured Logging: Beyond console.log

Replace unstructured log lines with structured JSON logging. Covers log levels, correlation IDs, context propagation, and integration with observability tools.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Why plain text logs fail at scale and structured logs succeed
  • Choosing and using log levels correctly
  • Adding correlation IDs to trace requests across services
  • Setting up structured logging in Node.js and Python
  • Integrating with log aggregation and alerting tools

Prerequisites

  • Experience building backend services
  • Basic familiarity with JSON

Most applications start with console.log("User created: " + userId). This works during development. In production, with 50 instances writing thousands of lines per second, that string is nearly impossible to search, filter, or alert on. You cannot ask your log aggregator “show me all logs where the user ID is 42 and the response time exceeded 500ms” if the user ID is buried in a free-text string.

Structured logging solves this by emitting each log entry as a JSON object with consistent, queryable fields. The message is still there for humans, but every piece of context is a named field that machines can parse.

Unstructured vs structured

Unstructured:

2026-07-02T10:15:32Z INFO User created: 42, email: alice@example.com, plan: pro

Structured:

{
  "timestamp": "2026-07-02T10:15:32.123Z",
  "level": "info",
  "message": "User created",
  "userId": 42,
  "email": "alice@example.com",
  "plan": "pro",
  "service": "user-api",
  "traceId": "abc123def456"
}

The structured version can be filtered (userId = 42), aggregated (count by plan), and alerted on (level = "error" AND service = "user-api") without regex parsing.

Log levels and when to use them

Most logging libraries support these levels, from most to least severe:

LevelWhen to use
fatalThe process is about to crash. Unrecoverable errors.
errorAn operation failed and needs attention. A payment failed, a database query timed out, an external API returned 500.
warnSomething unexpected happened but the operation continued. A retry succeeded, a deprecated endpoint was called, a fallback was used.
infoNormal operations worth recording. A user signed up, a job completed, a deployment started.
debugDetailed diagnostic information. Query parameters, cache hit/miss, internal state. Disabled in production by default.

The most common mistake is logging errors at the warn level. If something failed and a human should know about it, it is an error. If it succeeded despite an issue, it is a warning.

Setting up structured logging

Node.js with Pino

Pino is the fastest structured logger for Node.js:

import pino from 'pino';

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

// Usage
logger.info({ userId: 42, plan: 'pro' }, 'User created');
logger.error({ err, orderId: 'ORD-123' }, 'Payment processing failed');

Output:

{"level":"info","time":"2026-07-02T10:15:32.123Z","userId":42,"plan":"pro","msg":"User created"}

For local development, use pino-pretty to get readable output:

node app.js | pino-pretty

Python with structlog

import structlog

structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.add_log_level,
        structlog.processors.JSONRenderer(),
    ],
)

logger = structlog.get_logger()

logger.info("user_created", user_id=42, plan="pro")
logger.error("payment_failed", order_id="ORD-123", error=str(e))

Correlation IDs

When a single user action triggers work across multiple services, a correlation ID (also called a trace ID or request ID) ties all the log entries together.

Generate the ID at the entry point (API gateway or first service) and propagate it through every subsequent call:

import { randomUUID } from 'crypto';

app.use((req, res, next) => {
  req.correlationId = req.headers['x-correlation-id'] || randomUUID();
  res.set('x-correlation-id', req.correlationId);

  // Bind the ID to every log call in this request
  req.log = logger.child({ correlationId: req.correlationId });
  next();
});

// In route handlers
app.post('/orders', async (req, res) => {
  req.log.info({ customerId: req.body.customerId }, 'Creating order');

  const order = await createOrder(req.body);

  req.log.info({ orderId: order.id }, 'Order created');
  res.json(order);
});

Every log entry from this request includes the same correlationId. When the order service calls the inventory service, it passes the ID in a header:

await fetch('http://inventory-service/reserve', {
  method: 'POST',
  headers: {
    'x-correlation-id': correlationId,
    'content-type': 'application/json',
  },
  body: JSON.stringify({ sku, quantity }),
});

Now a search for correlationId = "abc123" returns every log entry across every service for that user action.

Context propagation

Beyond correlation IDs, add consistent context to every log entry from a request:

app.use((req, res, next) => {
  const requestContext = {
    correlationId: req.headers['x-correlation-id'] || randomUUID(),
    method: req.method,
    path: req.path,
    userAgent: req.headers['user-agent'],
    ip: req.ip,
  };

  if (req.user) {
    requestContext.userId = req.user.id;
    requestContext.accountId = req.user.accountId;
  }

  req.log = logger.child(requestContext);
  next();
});

Every log call from this request automatically includes the HTTP method, path, user ID, and correlation ID without the developer needing to pass them explicitly.

What to log and what not to log

Do log:

  • Business events: user signup, order placed, payment processed
  • Errors with enough context to debug: error message, stack trace, input that caused it
  • Performance markers: request duration, database query time, external API latency
  • Security events: login attempts, permission denials, rate limit hits

Do not log:

  • Passwords, tokens, API keys, or session secrets
  • Full credit card numbers or social security numbers
  • Personal data that violates GDPR or similar regulations
  • High-volume noise: every cache hit, every heartbeat, every health check

Sanitize sensitive fields before logging:

function sanitize(obj) {
  const sanitized = { ...obj };
  if (sanitized.password) sanitized.password = '[REDACTED]';
  if (sanitized.token) sanitized.token = '[REDACTED]';
  if (sanitized.creditCard) sanitized.creditCard = '[REDACTED]';
  return sanitized;
}

logger.info({ body: sanitize(req.body) }, 'Request received');

Request duration logging

Log the duration of every HTTP request as a structured field:

app.use((req, res, next) => {
  const start = process.hrtime.bigint();

  res.on('finish', () => {
    const durationMs = Number(process.hrtime.bigint() - start) / 1e6;

    req.log.info({
      statusCode: res.statusCode,
      durationMs: Math.round(durationMs * 100) / 100,
    }, 'Request completed');
  });

  next();
});

This gives you queryable duration data: “show me all requests where durationMs > 1000” becomes a simple filter in your log aggregator.

Log aggregation

Structured logs are designed to flow into aggregation tools. The standard pipeline:

  1. Your application writes JSON to stdout.
  2. The container runtime or log shipper (Fluentd, Fluent Bit, Vector, Filebeat) collects the output.
  3. Logs are sent to a central store (Elasticsearch, Loki, Datadog, CloudWatch).
  4. Dashboards and alerts query the structured fields.

Writing to stdout (not files) is the standard practice for containerized services. The orchestrator handles collection and routing. Your application should not manage log files, rotation, or shipping.

Alerting on structured fields

With structured logs, alerts become precise:

# Datadog log monitor
level:error service:payment-api @durationMs:>5000

This alert fires only for errors in the payment service that took more than 5 seconds. With unstructured logs, you would need a fragile regex pattern that breaks whenever someone changes the log message text.

Structured logging is a foundational investment. It takes 30 minutes to set up and pays dividends every time you debug a production issue, set up an alert, or trace a request across services. Start with a structured logger, add correlation IDs, and let your log aggregator do the rest.