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
- Designing a Production Log Schema
- Correlation Across Services
- Production Logging Architecture
- Security and Sensitive Data
- Failure Scenarios and Degraded Operation
- Production Design Example
- Ready-to-Use Example
- Common Mistakes
- Production Checklist
- Conclusion
- More Articles to Read
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.
Comments (0)