You’ve seen this log line before:

2026-02-2305:30:00INFOUserjohn@example.comloggedinfrom192.168.1.100after2failedattempts

Human readable. Grep-able. And completely useless for answering questions like “how many users had failed login attempts yesterday?” or “what’s the P95 response time for requests from the EU region?”

Plain text logs are write-only storage. Structured logs are queryable data.

What Structured Logging Looks Like

Same event, structured:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "timestamp": "2026-02-23T05:30:00.123Z",
  "level": "info",
  "event": "user_login",
  "user_email": "john@example.com",
  "client_ip": "192.168.1.100",
  "failed_attempts": 2,
  "session_id": "abc123",
  "request_id": "req-789",
  "duration_ms": 145
}

Every field is addressable. You can filter, aggregate, correlate, and alert on any combination.

Implementation: Python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import structlog
import logging

# Configure once at startup
structlog.configure(
    processors=[
        structlog.stdlib.filter_by_level,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.JSONRenderer()
    ],
    wrapper_class=structlog.stdlib.BoundLogger,
    context_class=dict,
    logger_factory=structlog.stdlib.LoggerFactory(),
)

logger = structlog.get_logger()

# Usage
logger.info("user_login", 
    user_email="john@example.com",
    client_ip=request.remote_addr,
    failed_attempts=2)

Implementation: Node.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const pino = require('pino');

const logger = pino({
  level: 'info',
  timestamp: pino.stdTimeFunctions.isoTime,
});

// Usage
logger.info({
  event: 'user_login',
  userEmail: 'john@example.com',
  clientIp: req.ip,
  failedAttempts: 2
}, 'User logged in');

The Context Hierarchy

Good structured logs have context at multiple levels:

Application context (set once at startup):

  • Service name, version, environment
  • Host, container ID, region

Request context (set per request):

  • Request ID, trace ID
  • User ID, session ID
  • Endpoint, method

Event context (set per log line):

  • What happened
  • Relevant metrics
  • Error details
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Bind context that carries through all logs in this request
log = logger.bind(
    request_id=request.headers.get('X-Request-ID'),
    user_id=current_user.id,
    endpoint=request.path
)

# All subsequent logs include this context automatically
log.info("processing_started")
log.info("cache_miss", key="user_preferences")
log.info("processing_complete", duration_ms=145)

Log Levels That Mean Something

Use levels consistently across your organization:

  • DEBUG: Detailed diagnostic info. Volume too high for production.
  • INFO: Normal operations. User actions, business events, state changes.
  • WARN: Something unexpected but handled. Degraded service, retries.
  • ERROR: Something failed and wasn’t recovered. Requires attention.
  • FATAL: Process cannot continue. Immediate intervention needed.

The test: Can your on-call engineer filter to ERROR and know exactly what needs attention?

What NOT to Log

Sensitive data: Passwords, tokens, SSNs, full credit card numbers. Even in structured logs, PII creates compliance nightmares.

1
2
3
4
5
# Bad
logger.info("payment_processed", card_number="4111111111111111")

# Better
logger.info("payment_processed", card_last_four="1111", amount=99.99)

Massive payloads: Request/response bodies belong in traces, not logs. A 5MB JSON blob in your logs destroys your log aggregator’s performance and your budget.

High-cardinality IDs as field names: Don’t do {user_123: "clicked"}. Do {user_id: 123, action: "clicked"}.

Correlation: The Killer Feature

The real power of structured logs appears when you correlate across services:

1
2
3
4
{"request_id": "req-789", "service": "api-gateway", "event": "request_received"}
{"request_id": "req-789", "service": "user-service", "event": "user_lookup", "duration_ms": 12}
{"request_id": "req-789", "service": "payment-service", "event": "charge_created", "amount": 99.99}
{"request_id": "req-789", "service": "api-gateway", "event": "response_sent", "status": 200}

One query — request_id = "req-789" — shows the entire request flow across all services. Without correlation IDs, you’re back to timestamp guessing and grep.

Log Aggregation

Structured logs need somewhere to go:

  • ELK Stack (Elasticsearch, Logstash, Kibana): Self-hosted, powerful, complex
  • Loki + Grafana: Prometheus-style labels, cost-effective
  • Datadog/Splunk/New Relic: Managed, expensive, batteries included
  • CloudWatch Logs Insights: If you’re already in AWS

The choice depends on volume, budget, and existing infrastructure. But any of these is infinitely better than SSH + grep.

Migration Path

You don’t have to rewrite everything at once:

  1. Add a structured logger alongside existing logs. New code uses structured, old code continues working.

  2. Add request context middleware. Even plain-text logs become more useful with consistent request IDs.

  3. Identify high-value log points. Errors, authentication events, payments — structure these first.

  4. Gradually convert hot paths. Each conversion improves queryability.

1
2
3
4
5
# Middleware that adds context to all logs in the request
@app.before_request
def add_request_context():
    request_id = request.headers.get('X-Request-ID', str(uuid4()))
    structlog.contextvars.bind_contextvars(request_id=request_id)

Structured logging is one of those investments that pays dividends forever. Every new feature automatically becomes queryable. Every incident investigation becomes faster. Every metric you wish you had is probably already in your logs — you just need to be able to ask the question.

Stop writing logs for humans to read sequentially. Start writing logs for machines to query instantly.