Instrumenting FastAPI with OpenTelemetry
Key takeaways:
- A tracer provider with a service-name resource and a batch processor is the foundation.
- An OTLP exporter sends spans to a collector, which forwards to your backend.
FastAPIInstrumentorauto-creates a span per request.- Database and HTTP client instrumentations add child spans automatically.
- Manual spans time the business operations you care about.
This guide implements the tracing half of Observability and Tracing. Pair it with the correlation ID from request-tracing middleware.
The Problem This Solves
Across services, a single slow request is invisible in logs alone — you cannot see which downstream call cost the time. Distributed tracing stitches the request's path into one timeline, so a latency spike points directly at the responsible span.
Prerequisites
opentelemetry-sdk, the FastAPI instrumentation, and an OTLP exporter installed.- An OpenTelemetry collector (or a backend that accepts OTLP) reachable from the app.
Step-by-Step Implementation
1. Configure the tracer provider and exporter
# app/telemetry.py
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
def configure_tracing() -> None:
provider = TracerProvider(resource=Resource.create({"service.name": "orders-api"}))
# Batch processor exports in the background — no per-request latency.
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
2. Auto-instrument the app and clients
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
def instrument(app) -> None:
FastAPIInstrumentor.instrument_app(app) # One span per request.
HTTPXClientInstrumentor().instrument() # Child spans for outbound calls.
3. Add manual spans for business operations
from opentelemetry import trace
tracer = trace.get_tracer("orders")
async def place_order(order) -> None:
with tracer.start_as_current_span("place_order", attributes={"order.id": order.id}):
await persist(order) # Timed as a child of the request span.
4. Wire it into the factory
def create_app():
configure_tracing()
app = FastAPI()
instrument(app)
return app
Edge Cases and Gotchas
- Context across tasks. Background jobs start a new context; propagate the trace context explicitly into queued work.
- Cardinality. Avoid high-cardinality span attributes such as raw user input; they explode storage.
- Sampling. Under heavy load, configure a sampler so you record a representative fraction, not every request.
Verification
Use an in-memory span exporter in tests to assert spans are produced:
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
def test_request_creates_span(client, span_exporter: InMemorySpanExporter):
client.get("/orders/1")
names = [s.name for s in span_exporter.get_finished_spans()]
assert any("place_order" in n for n in names)
Related Reading
- Up to the topic: Observability and Tracing.
- Related guides: Structured JSON Logging with Request IDs and Implementing Custom Middleware for Request Tracing.