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.
  • FastAPIInstrumentor auto-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)