Structured Logging for Distributed Systems

5.0 out of 5 from 1 votes
By Oleksandr Andrushchenko — Published on
1 Likes
0 Dislikes
Structured Logging for Distributed Systems
Structured Logging for Distributed Systems

Logging becomes much harder once a request crosses multiple services, queues, workers, and external dependencies. Plain text that is readable inside one process quickly becomes difficult to search, correlate, aggregate, and operate across a distributed production system.

Structured logging solves that problem by treating each log entry as a machine-readable event with consistent fields such as service name, environment, trace ID, event type, user-facing operation, error category, and deployment version. The value is not JSON itself. The value is creating a predictable event model that supports fast investigation across large systems.

A production logging architecture must also handle high ingestion rates, sensitive data, retention, indexing cost, backpressure, sampling, asynchronous export, and schema evolution. Poor logging design can become both an observability problem and an infrastructure problem.

Table of Contents

Why Structured Logging Matters

Unstructured text works reasonably well while debugging one process:


Payment failed for order ORD-91821 because provider timed out

At scale, engineers need to answer questions across thousands of instances:


How many payment timeouts occurred?

Which provider is affected?

Which region?

Which application version?

Which requests were retried?

Did failures begin after a deployment?

Which customer orders are affected?

Free-form strings make those questions expensive because the log platform must parse human-oriented text after ingestion.

A structured event makes the dimensions explicit:

{
  "timestamp": "2026-08-22T21:14:18.392Z",
  "level": "error",
  "service": "payment-service",
  "version": "2026.08.22.7",
  "environment": "production",
  "region": "us-east",
  "event": "payment_provider_timeout",
  "order_id": "ORD-91821",
  "provider": "provider_a",
  "attempt": 3,
  "duration_ms": 3012,
  "trace_id": "72fa781a9cf34cb1"
}

The same event now supports direct filtering and aggregation:


event = "payment_provider_timeout"
AND region = "us-east"
AND version = "2026.08.22.7"

Structured logging is therefore less about log formatting and more about creating a stable production event schema.

Property Unstructured Logs Structured Logs
Human readability Often high Good with proper formatting
Machine querying Requires parsing Direct field queries
Aggregation Difficult Natural
Schema consistency Usually weak Can be standardized
Cross-service correlation Difficult Strong with shared identifiers
Operational scalability Poor at large scale Much better when fields are controlled

Designing a Production Log Schema

The most important structured logging decision is deciding which fields are standardized across the entire platform and which belong only to specific events.

A schema should make common operational questions answerable without forcing every team to invent a different field name for the same concept.

Standard Fields

Useful standard fields include:


timestamp
level
service
version
environment
region
availability_zone
instance_id
event
trace_id
span_id
request_id
duration_ms
error_type

A request log might look like:

{
  "timestamp": "2026-08-22T21:20:02.103Z",
  "level": "info",
  "service": "shipment-service",
  "version": "v41",
  "environment": "production",
  "region": "us-east",
  "availability_zone": "us-east-1a",
  "event": "http_request_completed",
  "method": "POST",
  "route": "/shipments",
  "status_code": 201,
  "duration_ms": 142,
  "trace_id": "af9123de881a"
}

Consistency matters more than field count. If one service uses service, another uses app_name, and another uses component, cross-service searches become unnecessarily difficult.

Advantages:

  • consistent incident queries across teams;
  • easier dashboards and alert enrichment;
  • simpler correlation with traces and deployments;
  • less custom parsing and transformation.

Disadvantages:

  • schema governance is required;
  • too many mandatory fields increase event size;
  • poorly chosen standard fields become difficult to change later;
  • inconsistent producers can still violate the schema.

Event-Specific Fields

Business and operational events need additional context specific to the event.

A carrier booking failure may need:

{
  "event": "carrier_booking_failed",
  "shipment_id": "SHP-9812",
  "carrier": "carrier_a",
  "service_level": "priority",
  "attempt": 2,
  "error_type": "timeout",
  "duration_ms": 5012
}

A database saturation event may need:

{
  "event": "db_connection_wait",
  "pool_name": "primary",
  "pool_active": 100,
  "pool_max": 100,
  "pool_waiters": 1824,
  "wait_ms": 1632
}

The schema should not try to force unrelated events into identical structures. The better pattern is:


Common operational fields
        +
Event-specific context

Event names should also be stable. A machine-readable event field should be preferred over extracting meaning from the human-readable message.


GOOD:

event = "shipment_booking_failed"
message = "Carrier booking failed"


FRAGILE:

message = "Booking request could not be completed by carrier"

The message can change for readability without breaking dashboards and queries when the event identifier remains stable.

Correlation Across Services

A distributed request frequently produces logs from several services. Without correlation identifiers, engineers must manually infer which events belong together.


Client
  |
  v
API Gateway
  |
  v
Order Service
  |
  v
Payment Service
  |
  v
Provider

The same trace identifier should appear in each participating service:


API Gateway:
trace_id=abc123

Order Service:
trace_id=abc123

Payment Service:
trace_id=abc123

An engineer can then query:


trace_id = "abc123"

and reconstruct the event sequence from logs even before opening the distributed trace.

For asynchronous processing, correlation context needs to travel through messages:

{
  "event_id": "evt_9182",
  "trace_id": "abc123",
  "shipment_id": "SHP-4812",
  "type": "shipment.booking.requested"
}

The worker can restore the context and include it in its logs:


HTTP Request
   |
   | trace=abc123
   v
Booking Service
   |
   | message trace=abc123
   v
Queue
   |
   v
Worker
   |
   | logs trace=abc123
   v
Carrier API

Correlation should not depend on one identifier alone. Different identifiers answer different questions:

Identifier Purpose
trace_id Connect one distributed operation
request_id Identify one inbound request
event_id Identify one asynchronous event
order_id / shipment_id Investigate business workflow state
deployment version Compare behavior across releases

Trace propagation is discussed in more detail in Distributed Tracing Across Microservices.

Production Logging Architecture

Applications should emit logs locally and avoid synchronously calling a centralized logging service for every event.

A production pipeline typically separates application logging from centralized ingestion:


                    Applications
                  /      |       \
                 v       v        v
              Service  Service  Worker
                 |       |        |
                 v       v        v
             stdout / local logging
                  \      |       /
                   \     |      /
                    v    v     v
                  Log Agents
                      |
                      v
               Aggregation Layer
                      |
               +------+------+
               |             |
               v             v
          Searchable       Archive
          Log Store        Storage
               |
               v
        Query / Dashboards

The local logging path should be fast and predictable. Central ingestion can then handle batching, buffering, transformation, redaction, routing, and retention.

Collection and Buffering

Logging directly to a network backend creates dangerous runtime coupling:


Request
  |
  v
Business Logic
  |
  v
Send log over network
  |
  X logging backend slow
  |
  v
User latency increases

A safer pattern is:


Application
    |
    v
stdout / local buffer
    |
    v
Agent
    |
    v
Batch / Queue
    |
    v
Central backend

The logging path should have explicit overflow behavior.

Suppose applications generate 100 MB/sec of logs but the central backend temporarily accepts only 60 MB/sec:


Incoming:
100 MB/sec

Export:
60 MB/sec

Backlog:
40 MB/sec

After one hour:


40 MB × 3600
≈ 144 GB

Infinite buffering is impossible. The system eventually needs to choose among:

  • bounded memory buffers;
  • disk buffering;
  • dropping low-priority logs;
  • sampling repetitive events;
  • backpressure to noncritical producers.

Audit and security logs may require stronger guarantees than debug or informational logs, so different pipelines can use different loss policies.

Indexing, Retention, and Cost

Log cost is often dominated not by raw storage but by searchable indexed storage.

A practical architecture separates hot searchable data from cheaper long-term archive:


Incoming Logs
     |
     v
Processing
     |
     +--------> Hot Searchable Store
     |          7-30 days
     |
     +--------> Object Archive
                months / years

Not every field should be indexed automatically. High-cardinality fields can make indexing expensive even when they are useful occasionally.

A useful distinction is:


Frequently queried:
service
event
region
level
version
error_type

Occasionally queried:
order_id
shipment_id
request_id
trace_id

Rare / payload:
stack trace
provider response body
large context blobs

Retention should follow operational value rather than one global duration.

Log Type Typical Value Retention Strategy
Debug Short-term troubleshooting Hours or days
Application operational logs Incident investigation Days or weeks searchable
Security logs Detection and investigation Longer according to security requirements
Audit logs Compliance and accountability Often long-term immutable retention
Archived application logs Historical investigation Cheap object storage

Security and Sensitive Data

Logs are replicated, indexed, backed up, exported, and accessed by operational teams. That makes accidental sensitive-data logging particularly dangerous.

Never log raw values such as:

  • passwords;
  • authorization headers;
  • access tokens;
  • refresh tokens;
  • private keys;
  • payment credentials;
  • session cookies.

A dangerous request log:

{
  "headers": {
    "authorization": "Bearer eyJ..."
  }
}

A safer representation:

{
  "auth_present": true,
  "auth_type": "bearer"
}

Personally identifiable information should also be logged only when it is operationally necessary.

For example, an email address may be replaced by an internal user identifier:


Instead of:

email = "person@example.com"

Prefer:

user_id = "usr_48192"

Logging middleware should redact common sensitive fields before serialization rather than depending entirely on every developer remembering to do it manually.

A redaction utility can recursively remove known sensitive keys:

from typing import Any

SENSITIVE_KEYS = {
    "authorization",
    "password",
    "access_token",
    "refresh_token",
    "cookie",
    "set-cookie",
}


def redact(value: Any) -> Any:
    if isinstance(value, dict):
        return {
            key: (
                "[REDACTED]"
                if key.lower() in SENSITIVE_KEYS
                else redact(item)
            )
            for key, item in value.items()
        }

    if isinstance(value, list):
        return [redact(item) for item in value]

    return value

Redaction should happen before the event leaves the application or trusted collector boundary whenever possible.

Failure Scenarios and Degraded Operation

The logging system must fail safely. A logging failure should not normally cause the application itself to fail.

Log backend unavailable. Agents buffer temporarily and retry. Once bounded buffers are full, low-priority logs may need to be dropped according to policy.

Agent crashes. Another agent instance or process should take over where architecture permits. Local logs may remain available for collection after restart.

Disk fills. Local buffering must have quotas. Logging should never be allowed to consume all disk space needed by the application or operating system.

Network partition. Agents can buffer for a bounded period. Long partitions eventually require dropping or spilling according to durability requirements.

Log storm. One failing dependency can produce millions of repetitive error events:


Provider unavailable
      |
      v
Every request logs error
      |
      v
Retries also log error
      |
      v
Log volume explodes

Rate limiting or sampling can reduce repeated diagnostic noise while counters and metrics preserve the total failure rate.

Deployment changes field names. Queries and dashboards can break even though applications continue running. Logging schema changes should therefore be treated as compatibility changes.

Serialization failure. A non-serializable object should not cause a business request to fail. Logging libraries should handle serialization errors defensively.

Clock skew. Events from different hosts can appear out of order. Distributed investigations should rely on trace relationships and stable timestamps rather than assuming all local clocks are perfectly aligned.

Production Design Example

Consider a logistics system processing shipment creation and carrier bookings.


                           Client
                             |
                             v
                       API Gateway
                             |
                             v
                    Shipment Service
                             |
                       +-----+------+
                       |            |
                       v            v
                  PostgreSQL   Booking Queue
                                    |
                                    v
                              Booking Worker
                                    |
                                    v
                                Carrier API

A shipment request produces a trace ID at the edge:


trace_id=912fab781

The Shipment Service logs:

{
  "level": "info",
  "service": "shipment-service",
  "event": "shipment_created",
  "shipment_id": "SHP-8912",
  "trace_id": "912fab781",
  "version": "v62",
  "region": "us-east"
}

The queue message preserves the context:

{
  "event_id": "evt_7812",
  "type": "shipment.booking.requested",
  "shipment_id": "SHP-8912",
  "trace_id": "912fab781"
}

The worker receives the message and produces:

{
  "level": "info",
  "service": "booking-worker",
  "event": "carrier_booking_started",
  "shipment_id": "SHP-8912",
  "carrier": "carrier_a",
  "attempt": 1,
  "trace_id": "912fab781"
}

Suppose the provider times out:

{
  "level": "warning",
  "service": "booking-worker",
  "event": "carrier_booking_retry",
  "shipment_id": "SHP-8912",
  "carrier": "carrier_a",
  "attempt": 2,
  "error_type": "timeout",
  "duration_ms": 5004,
  "trace_id": "912fab781"
}

An engineer can investigate at several levels:


All carrier timeouts:
event="carrier_booking_retry"
AND error_type="timeout"

One carrier:
carrier="carrier_a"

One deployment:
version="v62"

One shipment:
shipment_id="SHP-8912"

One distributed operation:
trace_id="912fab781"

That is the operational benefit of structured logs: one event model supports both broad production analysis and narrow request-level investigation.

The logging pipeline is isolated from application processing:


Services / Workers
       |
       v
stdout
       |
       v
Local Agent
       |
       v
Central Collector
       |
       +------> Hot Searchable Logs
       |
       +------> Archive Storage

Monitoring. The platform should monitor log ingestion rate, rejected events, parsing failures, collector queue utilization, agent disk usage, searchable storage growth, query latency, and log volume by service and event.

Scaling. Collectors scale from bytes per second and processing backlog rather than business RPS alone. A logging bug can increase telemetry volume without increasing application traffic.

Deployments. Every event includes service version. A release that increases error logs or changes event schema becomes visible immediately.

Ready-to-Use Example

A practical Python logging layer should centralize schema creation instead of allowing every endpoint to build arbitrary dictionaries.

import json
import logging
from contextvars import ContextVar
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from typing import Any

trace_id_context: ContextVar[str] = ContextVar(
    "trace_id",
    default="",
)


@dataclass(frozen=True)
class ServiceMetadata:
    service: str
    version: str
    environment: str
    region: str


SERVICE = ServiceMetadata(
    service="booking-service",
    version="2026.08.22.7",
    environment="production",
    region="us-east",
)


class StructuredLogger:
    def __init__(self, logger: logging.Logger) -> None:
        self.logger = logger

    def emit(
        self,
        level: int,
        event: str,
        message: str,
        **fields: Any,
    ) -> None:
        payload = {
            "timestamp": datetime.now(UTC).isoformat(),
            **asdict(SERVICE),
            "level": logging.getLevelName(level).lower(),
            "event": event,
            "message": message,
            "trace_id": trace_id_context.get(),
            **fields,
        }

        self.logger.log(
            level,
            json.dumps(payload, separators=(",", ":")),
        )

    def info(
        self,
        event: str,
        message: str,
        **fields: Any,
    ) -> None:
        self.emit(
            logging.INFO,
            event,
            message,
            **fields,
        )

    def warning(
        self,
        event: str,
        message: str,
        **fields: Any,
    ) -> None:
        self.emit(
            logging.WARNING,
            event,
            message,
            **fields,
        )

    def error(
        self,
        event: str,
        message: str,
        **fields: Any,
    ) -> None:
        self.emit(
            logging.ERROR,
            event,
            message,
            **fields,
        )


logging.basicConfig(level=logging.INFO)

log = StructuredLogger(
    logging.getLogger("booking-service")
)

Application code can now emit stable events:

from dataclasses import dataclass


@dataclass(frozen=True)
class BookingRequest:
    shipment_id: str
    carrier: str


def book_shipment(request: BookingRequest) -> None:
    log.info(
        "carrier_booking_started",
        "Carrier booking started",
        shipment_id=request.shipment_id,
        carrier=request.carrier,
    )

    try:
        call_carrier(request)

    except TimeoutError:
        log.warning(
            "carrier_booking_timeout",
            "Carrier booking timed out",
            shipment_id=request.shipment_id,
            carrier=request.carrier,
            error_type="timeout",
        )
        raise

HTTP middleware can establish correlation context:

from uuid import uuid4

from fastapi import FastAPI, Request

app = FastAPI()


@app.middleware("http")
async def request_context(
    request: Request,
    call_next,
):
    trace_id = request.headers.get(
        "x-trace-id",
        str(uuid4()),
    )

    token = trace_id_context.set(trace_id)

    try:
        response = await call_next(request)
        response.headers["x-trace-id"] = trace_id
        return response

    finally:
        trace_id_context.reset(token)

The important production property is not the particular logging library. It is that service identity, correlation, event naming, and field structure are centralized rather than reimplemented differently in every request handler.

A collector can then normalize and redact fields:

receivers:
  filelog:
    include:
      - /var/log/app/*.log

processors:
  attributes:
    actions:
      - key: authorization
        action: delete

      - key: password
        action: delete

  batch:
    send_batch_size: 2048
    timeout: 2s

exporters:
  otlp:
    endpoint: log-backend:4317

service:
  pipelines:
    logs:
      receivers:
        - filelog
      processors:
        - attributes
        - batch
      exporters:
        - otlp

This collector layer gives operations teams another place to enforce platform-wide policy without requiring application redeployments for every routing or export change.

Common Mistakes

Mistake Production Impact Better Approach
Using arbitrary text as the primary log format Queries depend on fragile parsing and string matching. Emit structured fields with stable event names.
Using different field names across services Cross-service investigation requires custom queries. Standardize common operational fields platform-wide.
Logging every successful request at high volume Storage and indexing cost can grow dramatically. Log high-value events and use metrics for aggregate success traffic.
Missing trace IDs Related service events cannot be correlated efficiently. Propagate and include trace context everywhere.
Dropping correlation at queue boundaries Background work becomes disconnected from the originating request. Carry correlation metadata inside messages.
Logging secrets or authentication headers Sensitive values spread into indexes, backups, and archives. Redact before telemetry leaves trusted boundaries.
Indexing every field Search storage becomes expensive and slow. Index frequently queried operational dimensions selectively.
Keeping all logs searchable forever Hot storage cost grows continuously. Use retention tiers and cheap archives.
Synchronous network logging Logging backend latency leaks into application latency. Write locally and export asynchronously.
Unlimited buffering during backend outages Agents eventually exhaust memory or disk. Use bounded queues and explicit overflow policies.
Logging the same failure on every retry layer One incident creates massive duplicate noise. Use event semantics, rate limiting, and metrics for aggregate retry counts.
Changing event names casually Dashboards and saved queries silently stop matching. Treat log schemas as operational contracts.
Using log level as the only classification Operational queries become too broad. Use explicit event and error-type fields.
Ignoring log pipeline health Logs can silently disappear during incidents. Monitor ingestion lag, drops, parsing failures, and agent saturation.

Production Checklist

  • Define standard fields: use consistent names for service, version, environment, region, event, and correlation identifiers.
  • Define stable event names: avoid deriving event type from human-readable messages.
  • Use machine-readable timestamps: include timezone-aware timestamps consistently.
  • Include service version: make deployment regressions queryable directly.
  • Include trace IDs: connect logs with distributed traces.
  • Include business identifiers selectively: support investigation by order, shipment, or job when operationally useful.
  • Propagate context through queues: preserve trace and business identifiers in asynchronous workflows.
  • Centralize log formatting: prevent handlers and services from inventing incompatible schemas.
  • Redact secrets before export: remove tokens, passwords, cookies, and authorization headers.
  • Review personal data: log only identifiers needed for operational troubleshooting.
  • Use asynchronous export: prevent backend logging failures from blocking business requests.
  • Bound agent memory: enforce maximum in-memory queue sizes.
  • Bound agent disk usage: prevent log buffering from exhausting host storage.
  • Define overflow behavior: decide which log classes can be sampled or dropped under pressure.
  • Separate audit logs: use stronger durability when legal or security requirements demand it.
  • Monitor ingestion rate: detect unexpected volume increases after deployments.
  • Monitor dropped events: missing logs should generate operational signals.
  • Monitor parsing failures: schema drift should be visible immediately.
  • Monitor collector backlog: queue growth indicates export or backend saturation.
  • Monitor searchable storage: alert before indexes run out of capacity.
  • Use retention tiers: keep high-value recent logs searchable and archive older data cheaply.
  • Review indexed fields: avoid expensive indexing for rarely queried high-cardinality values.
  • Rate-limit repetitive failures: prevent one incident from creating a log storm.
  • Validate schemas in CI: catch incompatible field or event changes before deployment.
  • Test backend outages: verify application behavior when the log platform is unavailable.
  • Test disk-pressure behavior: ensure buffering cannot destabilize application hosts.
  • Measure query latency: logs must remain searchable under incident traffic.
  • Track log cost by service: make high-volume producers visible to owning teams.

Conclusion

Structured logging turns production events into an operational data model. Consistent fields, stable event names, correlation identifiers, and deployment metadata make logs useful across service boundaries instead of limiting them to local debugging.

The production trade-offs are substantial. More fields increase event size, more indexing increases storage cost, longer retention increases infrastructure requirements, and synchronous export can damage application reliability. Logging therefore needs deliberate decisions around schema governance, correlation, redaction, buffering, retention, indexing, and failure behavior.

Key Takeaway: Treat logs as structured production events rather than formatted strings. Standardize common fields, preserve correlation across synchronous and asynchronous boundaries, keep sensitive data out of telemetry, export asynchronously, and operate the logging pipeline with explicit capacity, retention, indexing, and backpressure policies.

Author

Enjoyed this article?

Support Oleksandr Andrushchenko

Buy me a coffee

This helps Oleksandr Andrushchenko continue creating useful content

Related articles

Comments (0)