Setting Up OpenTelemetry in FastAPI

A FastAPI service instrumented the wrong way fails in one of two ways: it loses async context so handler spans detach from their request, or it exports synchronously and stalls the event loop under load. This page solves that precise problem — wiring OpenTelemetry into FastAPI so every request produces a correctly parented span that exports off the request path. It is written for backend engineers and SREs already running an ASGI service, it is part of the OpenTelemetry SDK setup guide within Distributed Tracing and OpenTelemetry in Python, and it applies that provider lifecycle to FastAPI's event-loop architecture. The broader treatment of instrumenting Python web frameworks applies the same ASGI/WSGI pattern to Django, Flask, and Starlette directly.

FastAPI is built on Starlette and ASGI, so instrumentation hooks the ASGI application rather than individual routes. That is why a single instrument_app call covers every endpoint, including ones added later, and why the wrapping must respect the async call chain — a synchronous middleware injected in the wrong place breaks the await that drives the whole stack.

How the ASGI middleware wraps a FastAPI request An inbound request carrying a traceparent header enters the OpenTelemetry ASGI middleware, which encloses the Starlette router and the async handler. The middleware opens a SERVER span covering the whole call; the handler opens an INTERNAL child span nested inside it through the active contextvar. When the response is returned both spans are handed to the BatchSpanProcessor, which exports them on a background thread rather than on the request path. Inbound request traceparent header OpenTelemetry ASGI middleware Starlette / FastAPI routing async def process_item() start_as_current_span() Response spans end Spans produced SERVER · GET /process/{item_id} INTERNAL · process_item_logic nested via contextvar BatchSpanProcessor → OTLP background thread, loop never blocks
The middleware wraps the entire ASGI call, so the SERVER span brackets everything the handler does and the manual child span nests inside it through the request's contextvar — while export happens off the request path.

Prerequisites

Async context loss usually starts as a version mismatch between the instrumentation packages and Starlette's routing layer, so pin the versions that ship together. Mismatched opentelemetry-api and opentelemetry-sdk versions raise an ImportError during provider initialization instead of failing quietly, which is the good case; the bad case is an instrumentation package built against a different Starlette middleware contract that installs cleanly and then drops context at runtime.

pip install \
  "opentelemetry-sdk>=1.30.0,<2.0.0" \
  "opentelemetry-instrumentation-fastapi>=0.51b0,<1.0.0" \
  "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"
export OTEL_SERVICE_NAME="fastapi-backend"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,team=platform"
export OTEL_EXPORTER_OTLP_ENDPOINT="otel-collector:4317"

The dependency order is deliberate. opentelemetry-instrumentation-fastapi pulls in a compatible opentelemetry-instrumentation-asgi, and that ASGI package — not the FastAPI one — is the layer that actually wraps Starlette's application callable. Upgrading the SDK without moving the instrumentation package to a matching beta is exactly how the wrapper falls out of step with Starlette and loses context, which is what the bounded ranges prevent. Set programmatic resource defaults as well as the environment variables, so the service never falls back to a generic unknown_service label when a variable is missing from one deployment target.

Which FastAPI tracing packages sit on top of which The instrumentation stack on the left runs top to bottom: opentelemetry-instrumentation-fastapi provides instrument_app and excluded_urls, opentelemetry-instrumentation-asgi actually wraps the ASGI callable, and the Starlette application sits underneath. A dashed outline marks the top two packages as a matched pair that must be upgraded together. On the right the export pipeline holds opentelemetry-sdk, the OTLP gRPC exporter, and the shared instrumentation base. Every layer resolves its tracer through opentelemetry-api at the bottom. Instrumentation stack (what wraps what) Export pipeline Upgrade these two together — a mismatch loses async context opentelemetry-instrumentation-fastapi instrument_app(), excluded_urls, request hooks opentelemetry-instrumentation-asgi wraps the ASGI callable — the real integration point Starlette ASGI application app(scope, receive, send) — FastAPI builds on this opentelemetry-sdk TracerProvider, processors opentelemetry-exporter- otlp-proto-grpc OTLPSpanExporter, :4317 opentelemetry-instrumentation shared base, pulled in for you opentelemetry-api shared trace API — every layer above resolves its tracer through this
The FastAPI package only supplies the entry points; the ASGI package underneath it does the wrapping, which is why those two have to move as a pair while the SDK and exporter version independently.

Implementation

  1. Bootstrap the provider before the app exists. Build the resource and provider, attach a BatchSpanProcessor (never SimpleSpanProcessor, which exports synchronously and blocks the loop), and set the global provider. This is the same deterministic lifecycle the SDK setup guide describes, applied one module earlier than you might expect: the provider must exist before any instrumentation resolves its tracer, or the middleware binds to a no-op tracer that silently records nothing.

  2. Instrument after constructing the app. Call FastAPIInstrumentor.instrument_app(app) once the FastAPI() instance exists so the ASGI middleware wraps every route natively. The middleware extracts traceparent and tracestate from inbound requests — the server-side half of context propagation and baggage — and opens a SERVER span, so a call arriving from an instrumented upstream joins the existing trace with no manual header parsing.

  3. Nest manual spans for business logic. Auto-instrumentation captures only the request boundary. Open a child span with tracer.start_as_current_span() inside the handler or a dependency to record the work that defines your latency. Because FastAPI runs on a single event loop, the active span lives in a contextvar and stays correct across every await within one request, so the manual span simply nests under the server span the middleware already created. The only places you must intervene are thread-pool offloads and fire-and-forget tasks, where the context does not follow automatically — the boundary cases covered in the async tracing patterns guide.

  4. Flush on shutdown. Force-flush and shut down the provider in the lifespan handler so spans buffered at process exit are not dropped during a graceful restart. The yield in the lifespan context separates startup from shutdown; everything after it runs once when the server begins draining, which is the right moment to call force_flush before shutdown closes the exporter connection.

Two details make this robust under real traffic. First, instrument_app must receive the same tracer_provider you registered globally; passing it explicitly removes any ambiguity about which provider the middleware uses and avoids a subtle bug where the middleware binds to a stale default installed by another import. Second, excluded_urls keeps health checks, metrics scrapes, and the docs UI out of your traces — these fire constantly, carry no diagnostic value, and would otherwise dominate span volume and cost. Exclude them by path fragment so a load balancer's liveness probe never creates a span.

import os
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.trace import SpanKind

# 1. Resource + provider, bootstrapped before the app is built.
resource = Resource.create({
    "service.name": os.getenv("OTEL_SERVICE_NAME", "fastapi-backend"),
    "deployment.environment": os.getenv("DEPLOYMENT_ENV", "production"),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(            # async export, never SimpleSpanProcessor
    OTLPSpanExporter(
        endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317"),
        insecure=os.getenv("OTEL_EXPORTER_OTLP_INSECURE", "false").lower() == "true",
    ),
    max_export_batch_size=512,
    max_queue_size=2048,
    schedule_delay_millis=5000,
))
trace.set_tracer_provider(provider)


# 4. Flush buffered spans on graceful shutdown.
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    provider.force_flush(timeout_millis=5000)
    provider.shutdown()


app = FastAPI(lifespan=lifespan)
FastAPIInstrumentor.instrument_app(                        # 2. instrument after app construction
    app,
    tracer_provider=provider,
    excluded_urls="healthz,metrics,docs",
)

tracer = trace.get_tracer(__name__)


@app.get("/process/{item_id}")
async def process_item(item_id: str, request: Request):
    # 3. Manual child span for business logic, nested under the server span.
    with tracer.start_as_current_span(
        "process_item_logic",
        kind=SpanKind.INTERNAL,
        attributes={"item.id": item_id},
    ) as span:
        await asyncio.sleep(0.05)
        span.set_attribute("processing.status", "completed")
        return {"item_id": item_id, "status": "processed"}
Tracing lifecycle of a single FastAPI worker process Reading top to bottom: at import the resource and TracerProvider are built and the global provider is set, then the FastAPI app is constructed and instrument_app is called. At startup the lifespan code before the yield runs and the worker accepts connections. The highlighted band is the only step that repeats — every request opens a SERVER span and a manual child span whose finished spans land in the batch queue. On SIGTERM the lifespan resumes after the yield, force_flush drains the queue, and provider.shutdown closes the exporter before the process exits. Process lifetime — one uvicorn worker import import startup runtime SIGTERM exit 1 · Resource + TracerProvider BatchSpanProcessor attached, then set_tracer_provider() 2 · app = FastAPI(lifespan=lifespan) FastAPIInstrumentor.instrument_app(app, tracer_provider=provider) 3 · lifespan startup, before the yield worker starts accepting connections 4 · request in → SERVER span → child span finished spans land in the batch queue repeats for every request 5 · SIGTERM → lifespan resumes provider.force_flush(timeout_millis=5000) 6 · provider.shutdown() exporter closed, process exits
Only the highlighted step repeats: everything above it runs once as the worker boots, and everything below it runs once as it drains. Steps 1 and 2 must happen inside the worker, not in a preloading parent.

Spans inside yield-based dependencies

When a route depends on a yield-based dependency — a database session, a unit of work, an authenticated principal — the dependency's setup runs before the handler body and its teardown runs after the response is produced. Both halves sit inside the server span but outside any span the handler opens, so connection-acquisition time and commit time vanish into the server span's unattributed remainder. If that time matters, open a span inside the dependency itself and it nests correctly under the request.

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

async def get_session() -> AsyncSession:
    # Setup and teardown each get their own recorded span.
    with tracer.start_as_current_span("db.session.acquire"):
        session = SessionLocal()
    try:
        yield session
    finally:
        with tracer.start_as_current_span("db.session.commit"):
            await session.commit()
            await session.close()


@app.get("/orders/{order_id}")
async def read_order(order_id: str, session: AsyncSession = Depends(get_session)):
    with tracer.start_as_current_span("load_order", attributes={"order.id": order_id}):
        return await load_order(session, order_id)

Expected Output: four spans in one trace, with the dependency's work attributed rather than absorbed.

GET /orders/{order_id}   SPAN_KIND_SERVER    duration=41ms
  db.session.acquire     SPAN_KIND_INTERNAL  duration=3ms
  load_order             SPAN_KIND_INTERNAL  duration=29ms
  db.session.commit      SPAN_KIND_INTERNAL  duration=8ms

If your dependency hands back an async SQLAlchemy session, the query spans themselves come from the database instrumentation rather than from hand-written spans; see tracing SQLAlchemy async queries for wiring that layer underneath these.

Background tasks close after the server span

BackgroundTasks callables run after the response is sent, which means after the ASGI middleware has already ended the server span. A span opened inside a background task therefore has no active parent and starts a fresh trace unless you capture the request context first and re-enter it in the task. Capture the context with contextvars.copy_context() or, more explicitly, hold a reference to the request span and attach it as a link so the two traces remain queryable together.

from opentelemetry import context as otel_context
from fastapi import BackgroundTasks

def emit_receipt(order_id: str, parent_ctx) -> None:
    token = otel_context.attach(parent_ctx)        # re-enter the request's context
    try:
        with tracer.start_as_current_span("emit_receipt"):
            send_receipt(order_id)
    finally:
        otel_context.detach(token)


@app.post("/orders/{order_id}/confirm")
async def confirm(order_id: str, tasks: BackgroundTasks):
    tasks.add_task(emit_receipt, order_id, otel_context.get_current())
    return {"status": "queued"}

The same capture-and-attach pattern is what carries context over a broker when the work leaves the process entirely, as in propagating trace context across Celery tasks.

Sampling a high-traffic endpoint

On a busy service you should not record every request. Set a sampler when you construct the TracerProvider, in the same place the resource is fixed: ParentBased(TraceIdRatioBased(0.1)) keeps 10% of the traces this service roots while honouring any decision already carried in the inbound traceparent, so a request an upstream gateway chose to sample is recorded here too and the trace does not come back with holes in it. Leave error-and-slow-request retention to tail sampling in the collector rather than encoding that logic in the application, where the decision has to be made before you know how the request ends. The trade-offs between head and tail approaches are worked through in sampling strategies for distributed tracing.

Configuration Options

Option Where Default Recommended
excluded_urls instrument_app none healthz,metrics,docs to drop noise
tracer_provider instrument_app global pass explicitly to avoid ambiguity
server_request_hook instrument_app None add tenant or route-owner attributes to the server span
OTEL_EXPORTER_OTLP_INSECURE env false false in production (use TLS)
max_queue_size BatchSpanProcessor 2048 2× peak concurrent requests
schedule_delay_millis BatchSpanProcessor 5000 2000–5000 to amortize I/O
OTEL_EXPORTER_OTLP_TIMEOUT env 10000 5000 so retries cannot block the loop

Size max_queue_size against concurrency rather than throughput: the queue only has to hold what accumulates between flushes, so twice your peak in-flight request count leaves headroom for a slow collector without letting a backlog grow unbounded. When the queue is full the processor drops spans instead of applying backpressure, which is the correct trade for a web service — losing a sample of traces is always better than adding latency to live requests. server_request_hook runs with the server span already open, so anything it sets lands on the request's own span; keep it synchronous and cheap, because it executes on the event loop.

How the BatchSpanProcessor decouples export from the event loop Finished spans from the event loop are enqueued into a bounded queue sized by max_queue_size, drawn as a row of slots that are partly full. A separate exporter thread drains up to max_export_batch_size spans every schedule_delay_millis and ships them over gRPC to the collector, off the request path. When the queue is already full the incoming span is dropped instead of applying backpressure, so the event loop never waits. Event loop finished spans on_end() enqueue Bounded queue · max_queue_size size it ≈ 2× peak concurrent requests drain Exporter thread ≤ max_export_batch_size spans every schedule_delay_millis off the request path OTLP collector gRPC :4317 queue already full Dropped on overflow never blocks the event loop
The queue is the only coupling between the request path and the exporter, and it is deliberately lossy: when it fills, spans are discarded rather than made to wait behind a slow collector.

Verification

Send a request and confirm the collector receives a server span and the nested process_item_logic child sharing one trace_id.

curl -s localhost:8000/process/12345

Expected Output (collector side):

{
  "resourceSpans": [{
    "resource": {"attributes": [
      {"key": "service.name", "value": {"stringValue": "fastapi-backend"}},
      {"key": "deployment.environment", "value": {"stringValue": "production"}}
    ]},
    "scopeSpans": [{"spans": [{
      "name": "process_item_logic",
      "kind": "SPAN_KIND_INTERNAL",
      "attributes": [
        {"key": "item.id", "value": {"stringValue": "12345"}},
        {"key": "processing.status", "value": {"stringValue": "completed"}}
      ]
    }]}]
  }]
}

A correctly wired service shows two spans per request: an auto-generated GET /process/{item_id} server span and the manual child nested beneath it. The server span also carries the standard HTTP attributes — method, route, and status code — applied by the ASGI instrumentation, so you can filter and aggregate by route in the backend without adding them yourself. Note that the route appears as the templated path, not the concrete 12345: that is what keeps span names low-cardinality, and the actual value lives in the item.id attribute where it can be queried without exploding the number of distinct names. The same low-cardinality discipline is covered in depth under span lifecycle and attributes.

To verify without a collector, attach a ConsoleSpanExporter through a SimpleSpanProcessor in development and watch the two spans print to stdout in parent-child order on each request. Confirm the child's parent_span_id matches the server span's span_id; if it is empty or points elsewhere, async context was lost — usually because the CLI launcher was used instead of instrument_app, the mistake covered below. For a test-suite assertion rather than an eyeball check, swap in an InMemorySpanExporter, drive the app with httpx.AsyncClient, and assert both that two spans were exported and that they share a trace_id; that single assertion catches a detached child and a missing server span at once.

Correctly parented trace versus a detached child span On the left, one trace id covers both spans: the SERVER span for GET slash process slash item id has span id 9a1c, and process_item_logic carries parent_span_id 9a1c, so the backend renders a single nested waterfall. On the right the SERVER span is unchanged but process_item_logic has parent_span_id null and a different trace id, so the same request produces two disconnected roots — the signature of lost async context. Correct — one trace Detached — two roots trace_id 4bf92f…a7ad36 GET /process/{item_id} SPAN_KIND_SERVER · span_id 9a1c… process_item_logic parent_span_id 9a1c… (matches) one root, child nested beneath it trace_id 4bf92f…a7ad36 GET /process/{item_id} SPAN_KIND_SERVER · span_id 9a1c… link never formed process_item_logic parent_span_id null · trace_id 7c2e… two roots, one request, no waterfall
Assert on the shape, not the eyeball: two spans exported, one shared trace_id, and the child's parent_span_id equal to the server span's span_id. The right-hand pattern is what a lost context looks like in the backend.

Correlating those traces with your application logs is a separate step: the trace id has to reach the log record before a backend can join them. If you are choosing a logging stack for this service at the same time, choosing a logging library for FastAPI covers the options, and adding trace ids to log records covers the wiring.

Common Mistakes

  • Error signature: RuntimeWarning: coroutine 'Starlette.__call__' was never awaited, or child spans with no parent. Root cause: the opentelemetry-instrument CLI wrapper injected middleware around an app it discovered late, bypassing FastAPI's async ASGI stack. Remediation: drop the CLI wrapper and call FastAPIInstrumentor.instrument_app() programmatically after the app object is constructed, so the wrapping happens at a point where the ASGI chain is fully assembled.

  • Error signature: asyncio.exceptions.TimeoutError at peak load, followed by SpanExportError: Export timed out. Root cause: SimpleSpanProcessor runs a synchronous gRPC export at the end of every request, blocking the event loop for the duration of the round trip. Remediation: use BatchSpanProcessor, set max_queue_size to twice expected concurrency, keep schedule_delay_millis between 2000 and 5000, and bound OTEL_EXPORTER_OTLP_TIMEOUT so a stalled collector cannot hold the exporter thread indefinitely.

  • Error signature: the final requests before a deploy are missing from the backend. Root cause: the provider is never flushed, so spans buffered in the batch queue die with the process. Remediation: call provider.force_flush() and provider.shutdown() in the lifespan handler as shown above, and make sure the process actually receives a graceful signal — a hard SIGKILL skips lifespan teardown entirely, so configure a real termination grace period in your orchestrator.

  • Error signature: traces appear when running a single uvicorn process but stop entirely under gunicorn --preload -k UvicornWorker. Root cause: the TracerProvider and its exporter thread were created in the parent before fork(), and threads do not survive forking, so each worker holds a provider whose background exporter never runs. Remediation: build the provider inside the worker — in a gunicorn post_fork hook, or by dropping --preload so each worker imports the app module itself.

Frequently Asked Questions

Does FastAPI auto-instrumentation capture async generator dependencies?

No. The HTTP instrumentation only covers the outer request and response cycle. Wrap yield-based dependencies and async generators in tracer.start_as_current_span manually to record their sub-spans.

How do I prevent OTLP exporter retries from blocking the event loop?

Set a bounded exporter timeout and pair it with a BatchSpanProcessor sized to your concurrency. The processor flushes on a background thread and drops spans under backpressure rather than queuing indefinitely on the request path.

Can I inject custom baggage into the FastAPI request context?

Yes. Call opentelemetry.baggage.set_baggage inside a dependency or middleware before the route runs, and the W3C baggage header will propagate automatically to downstream HTTP and gRPC calls.

Should I initialise the provider before or after uvicorn forks workers?

After. The BatchSpanProcessor runs a background thread and that thread does not survive fork, so a provider built in a preloading parent process exports nothing from the children. Build it inside each worker — at module import when uvicorn imports the app per worker, or in a gunicorn post_fork hook.