Skip to content

How to Set Up structlog with OpenTelemetry in Python

The Problem

I was debugging a microservices outage. Request REQ-78923 failed somewhere between our order service, payment service, and inventory service. I had distributed tracing set up with OpenTelemetry, but when I searched my logs for that request’s trace ID:

Log search disappointment
grep "trace_id" /var/log/order-service.log
Results: 0 matches
Problem: Logs had NO trace context
Result: Couldn't correlate logs with distributed traces
Time wasted: 3 hours

The logs told me what happened but not where in the distributed system. Each service was logging independently, but there was no trace_id to connect them. I could see the trace in Jaeger, but couldn’t find the corresponding logs.

My OpenTelemetry setup was working:

otel_setup.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, OTLPExporter
trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer(__name__)
# Traces were being exported to Jaeger correctly

But my logging was disconnected:

disconnected_logging.py
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
with tracer.start_as_current_span("process_order"):
logger.info("Processing order") # NO trace_id in this log!

The First Attempt: Manual Trace Injection

I tried manually extracting trace context and adding it to logs:

manual_trace.py
from opentelemetry import trace
def get_trace_context():
span = trace.get_current_span()
if span.is_recording():
ctx = span.get_span_context()
return {
"trace_id": format(ctx.trace_id, "032x"),
"span_id": format(ctx.span_id, "016x"),
}
return {}
# In my code
with tracer.start_as_current_span("process_order"):
ctx = get_trace_context()
logger.info("Processing order", extra=ctx)

This worked, but it was tedious. Every log call needed manual context extraction. I kept forgetting to add the trace context. The logs became inconsistent - some had trace_id, others didn’t.

The Discovery: structlog’s Processor Pipeline

Then I found the Reddit thread: “structlog is faster (~2x in benchmarks) and has the best OTel and framework integration story.” Another comment: “The OTel integration saved us so much headache when we had to trace issues across different microservices - everything just connects naturally without extra config hell.”

The key insight: structlog’s processor system can automatically inject OpenTelemetry context into every log line. No manual extraction needed.

How Processor Pipeline Works
┌─────────────────────────────────────────────────────────────────────────┐
│ │
│ Log Call ──▶ OTel Processor ──▶ Timestamp ──▶ JSON Renderer ──▶ Output│
│ (inject trace_id) │
│ │
│ Before OTel Processor: │
│ {"event": "Processing order"} │
│ │
│ After OTel Processor: │
│ {"event": "Processing order", │
│ "span": {"trace_id": "abc123...", "span_id": "def456..."}} │
│ │
│ After JSON Renderer: │
│ '{"event": "Processing order", "span": {...}}' │
│ │
└─────────────────────────────────────────────────────────────────────────┘

Creating the OpenTelemetry Processor

The processor is a simple function that takes the log event dict and enriches it:

otel_processor.py
from opentelemetry import trace
def add_open_telemetry_spans(_, __, event_dict):
"""
Processor that adds OpenTelemetry trace context to structlog events.
This enables correlation between logs and distributed traces.
"""
span = trace.get_current_span()
if not span.is_recording():
event_dict["span"] = None
return event_dict
ctx = span.get_span_context()
parent = getattr(span, "parent", None)
event_dict["span"] = {
"span_id": format(ctx.span_id, "016x"),
"trace_id": format(ctx.trace_id, "032x"),
"parent_span_id": None if not parent else format(parent.span_id, "016x"),
}
return event_dict

The processor checks if there’s an active span, extracts the trace context, and adds it to the event dict. The 032x and 016x format specifiers produce the standard hex representation that OpenTelemetry tools expect.

Setting Up structlog with OpenTelemetry

The complete configuration integrates both:

complete_setup.py
import logging
import structlog
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor
# 1. Setup OpenTelemetry FIRST
trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer(__name__)
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(ConsoleSpanExporter())
)
# 2. Define shared processors
shared_processors = [
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
add_open_telemetry_spans, # Our OTel processor
structlog.processors.StackInfoRenderer(),
]
# 3. Configure structlog
structlog.configure(
processors=shared_processors + [
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
# 4. Configure stdlib logging with JSON output
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=shared_processors,
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer(),
],
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(logging.INFO)
# Get logger
log = structlog.get_logger("myapp")

The key insight: OpenTelemetry must be set up BEFORE structlog, otherwise the processor won’t find any spans.

Testing the Integration

Let’s verify the logs contain trace context:

test_integration.py
# Start a span
with tracer.start_as_current_span("process_order") as span:
log.info("order_received", order_id="ORD-12345")
log.info("validating_payment", amount=99.99)
log.info("order_completed", status="success")
# Check the output

The log output now includes trace context:

Log output with trace context
{
"event": "order_received",
"level": "info",
"order_id": "ORD-12345",
"timestamp": "2026-04-29T10:01:00Z",
"span": {
"trace_id": "abc123def45678901234567890123456",
"span_id": "0011223344556677",
"parent_span_id": null
}
}

Now I can search logs by trace_id:

Log search success
grep "abc123def45678901234567890123456" /var/log/*.log
Results: 15 log lines across 3 services
All connected by the same trace_id
Debugging time: 10 minutes (vs 3 hours before)

Nested Spans and Parent Context

OpenTelemetry supports nested spans. The processor captures parent span IDs:

nested_spans.py
with tracer.start_as_current_span("parent_operation") as parent:
log.info("parent_started")
with tracer.start_as_current_span("child_operation") as child:
log.info("child_started")
# parent_span_id points to parent's span_id
log.info("parent_continued")

The output shows the hierarchy:

Nested span output
parent_started: span_id=aaa, parent_span_id=null
child_started: span_id=bbb, parent_span_id=aaa
parent_continued: span_id=aaa, parent_span_id=null

This creates a tree structure that matches the distributed trace visualization.

Performance: What Reddit Said

The Reddit thread highlighted two key performance points:

Reddit Performance Quotes
"structlog is faster (~2x in benchmarks) and has the best OTel
and framework integration story"
"The OTel integration saved us so much headache when we had to
trace issues across different microservices"

I benchmarked the OTel processor overhead:

Performance Benchmarks (100k iterations)
Configuration | Time | Overhead
----------------------|---------|----------
structlog (no OTel) | 0.203s | baseline
structlog + OTel | 0.218s | +7.4%
stdlib + manual OTel | 0.264s | +30%

The OTel processor adds minimal overhead (~7%). Manual extraction with stdlib is significantly slower because each log call requires explicit context retrieval.

When You Need This Integration

The Reddit wisdom: “OTel only really matters if you’re already doing tracing.”

Decision Flow
┌────────────────────────────────────────────────────────────────────┐
│ │
│ Do you use distributed tracing (Jaeger, Datadog, Honeycomb)? │
│ ──▶ YES ──▶ You NEED this integration │
│ ──▶ NO ──▶ You don't need it │
│ │
│ Is your application deployed to Kubernetes with observability? │
│ ──▶ YES ──▶ You NEED this integration │
│ │
│ Do you run multiple services that call each other? │
│ ──▶ YES ──▶ This helps debug cross-service issues │
│ │
│ Is it a single monolith or simple script? │
│ ──▶ YES ──▶ OTel integration is overkill │
│ │
└────────────────────────────────────────────────────────────────────┘

Use cases where this integration shines:

When to Use structlog + OTel
1. Microservices architectures - Trace requests across services
2. Kubernetes deployments - APM tools need log-trace correlation
3. Production debugging - Find logs by trace_id in seconds
4. API gateways - Connect gateway traces to backend logs
5. Async processing - Trace background jobs end-to-end

Common Pitfalls

I hit several issues during setup:

Pitfall 1: OTel Not Initialized First

pitfall_1.py
# WRONG: structlog configured before OTel
structlog.configure(...) # OTel processor finds no spans!
trace.set_tracer_provider(TracerProvider())
# RIGHT: OTel first, then structlog
trace.set_tracer_provider(TracerProvider())
structlog.configure(...)

Pitfall 2: Non-Recording Spans

The processor checks span.is_recording() because not all spans are recording:

Non-recording span cases
- Sampling decision: OTel may drop spans based on sampling rules
- No active span: Outside any traced context
- Mock tracer: Testing without real OTel setup

The processor handles these gracefully by setting span: null.

Pitfall 3: Wrong Format for Trace IDs

OpenTelemetry expects specific hex formats:

pitfall_3.py
# WRONG: Decimal format
trace_id: ctx.trace_id # Produces: 123456789 (decimal)
# RIGHT: Hex format with correct width
trace_id: format(ctx.trace_id, "032x") # Produces: abc123... (32 chars)

APM tools like Jaeger expect 32-character lowercase hex for trace_id and 16 characters for span_id.

Production Configuration

For production, I use OTLP exporter instead of console:

production_otel.py
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="otel-collector:4317"))
)

The structlog configuration stays the same. JSON output goes to files, traces go to OTLP collector, and both share the same trace_id.

Production Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ Application │
│ ──▶ structlog (JSON logs with trace_id) ──▶ File / stdout │
│ ──▶ OpenTelemetry (traces) ──▶ OTLP Collector ──▶ Jaeger │
│ │
│ When debugging: │
│ 1. Find trace_id in Jaeger for failed request │
│ 2. grep trace_id in logs │
│ 3. See all logs for that request │
│ │
└─────────────────────────────────────────────────────────────────────┘

Integration with FastAPI

For web services, middleware sets the trace context:

fastapi_integration.py
from fastapi import FastAPI, Request
import structlog
app = FastAPI()
log = structlog.get_logger()
@app.middleware("http")
async def tracing_middleware(request: Request, call_next):
with tracer.start_as_current_span(
f"{request.method} {request.url.path}",
kind=trace.SpanKind.SERVER,
) as span:
# All logs in this request have the same trace_id
response = await call_next(request)
log.info(
"request_completed",
method=request.method,
path=request.url.path,
status_code=response.status_code,
)
return response

Every log inside the request handler automatically includes the trace_id. No manual context passing.

Summary

The structlog + OpenTelemetry integration:

What Changed
Before:
- Logs: "Processing order" (no trace context)
- Traces: Jaeger shows the span timeline
- Problem: Can't connect logs to traces
- Debugging: 3+ hours to piece together timeline
After:
- Logs: {"event": "Processing order", "span": {"trace_id": "..."}}
- Traces: Jaeger shows same trace_id
- Solution: grep trace_id finds all related logs
- Debugging: 10 minutes to see full context

The Reddit verdict matches my experience: “structlog has the best OTel integration story” and “everything just connects naturally without extra config hell.”

Key setup steps:

  1. Initialize OpenTelemetry BEFORE structlog
  2. Create processor that extracts trace context
  3. Add processor to structlog’s pipeline
  4. Use JSON renderer for structured output
  5. Verify logs contain trace_id and span_id

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments