How to Add OpenTelemetry to a FastAPI Service
OpenTelemetry turns a FastAPI service's invisible request lifecycle into a chain of named spans you can inspect in any APM. Auto-instrumentation covers the inbound side; a single context manager adds custom spans for the calls in between.
How OpenTelemetry tracing works in FastAPI
A trace is a tree of spans. For a FastAPI service that makes a downstream HTTP call, the tree looks like:
GET /api/process <- root span (auto-created by FastAPIInstrumentor)
downstream_call <- manual span (tracer.start_as_current_span)
GET /api/downstream <- auto-created span in the inner request
All three share the same trace_id. The W3C traceparent header carries that ID across the HTTP boundary, so the inner service picks up the parent context automatically when HTTPXClientInstrumentor is active.
Set up the TracerProvider
For local inspection and testing, use the InMemorySpanExporter - no external collector needed:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
memory_exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(memory_exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("myservice")
Auto-instrument FastAPI and httpx
Two one-liners wire up automatic spans for every request and every outbound HTTP call:
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
app = FastAPI()
FastAPIInstrumentor.instrument_app(app) # root span per request
HTTPXClientInstrumentor().instrument() # injects traceparent into httpx calls
Call both before the app starts handling requests - typically at module level.
Add a custom span around a downstream call
Wrap the downstream HTTP call in start_as_current_span to create a child span, then attach metadata with set_attribute:
import httpx
@app.get("/api/process")
def process():
with tracer.start_as_current_span("downstream_call") as span:
url = "http://localhost:8000/api/downstream"
span.set_attribute("downstream.url", url)
with httpx.Client() as client:
r = client.get(url)
return {"result": "ok", "downstream": r.json()}
Because HTTPXClientInstrumentor is active, the httpx.Client.get() call automatically injects the W3C traceparent header. The downstream handler - also auto-instrumented - reads that header and creates its own span under the same trace ID.
Inspect captured spans
Expose a /_spans endpoint to see what was recorded:
@app.get("/_spans")
def list_spans():
out = []
for s in memory_exporter.get_finished_spans():
ctx = s.get_span_context()
out.append({
"name": s.name,
"trace_id": format(ctx.trace_id, "032x"),
"span_id": format(ctx.span_id, "016x"),
"parent_span_id": format(s.parent.span_id, "016x") if s.parent else None,
"attributes": dict(s.attributes) if s.attributes else {},
})
return out
Hit /api/process, then /_spans - you should see the downstream_call span and the two auto-generated request spans, all sharing a trace_id.
Production: swap in an OTLP exporter
For production, replace InMemorySpanExporter with an OTLP exporter pointing at a collector:
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317")))
The auto-instrumentation packages cover Postgres, Redis, Kafka, and most popular libraries - so you rarely need manual spans except around your own business logic.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Wiring a TracerProvider with InMemorySpanExporter for local span inspection
- Adding automatic request spans with FastAPIInstrumentor and auto-propagating trace context with HTTPXClientInstrumentor
- Creating a custom child span with start_as_current_span and set_attribute
FAQ
How do I add OpenTelemetry tracing to a FastAPI app?
Wire a TracerProvider with a span processor, call trace.set_tracer_provider(provider), then FastAPIInstrumentor.instrument_app(app). That gives you automatic root spans for every request. Add HTTPXClientInstrumentor().instrument() to also auto-instrument outbound httpx calls.
How do I propagate trace context across HTTP calls in Python?
Install opentelemetry-instrumentation-httpx and call HTTPXClientInstrumentor().instrument() once at startup. It automatically injects the W3C traceparent header into every httpx request, so the downstream service creates its span as a child of the caller's span.
What is the difference between auto-instrumentation and a manual span in OpenTelemetry?
Auto-instrumentation (FastAPIInstrumentor, HTTPXClientInstrumentor) adds spans for every request/response without changing your handler code. A manual span - tracer.start_as_current_span('name') - wraps a specific block you care about and lets you attach custom attributes like URLs, user IDs, or query results.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →