Instrumenting FastAPI with OpenTelemetry

Add FastAPIInstrumentor.instrument_app(app) to create an automatic ASGI server span for every route, then create manual child spans inside async def handlers for business logic — but re-attach the OpenTelemetry context by hand before any BackgroundTask or thread-pool call, or those spans will detach into separate traces.

Context & When It Matters

FastAPI runs on Starlette’s ASGI stack, so OpenTelemetry instruments it through ASGI middleware rather than a synchronous WSGI hook. That middleware creates the SERVER span at the request boundary, extracts the inbound W3C traceparent header, and names the span from Starlette’s matched route template so operation cardinality stays bounded. Because handlers are coroutines, the active span is stored in a contextvars slot that survives await transparently — but does not survive the boundaries FastAPI is specifically designed to cross: BackgroundTasks scheduled after the response, and CPU work offloaded to a thread pool. Getting FastAPI right is therefore two-thirds automatic and one-third knowing where the async model drops your context.

Minimal Working Setup

Start with the smallest configuration that produces correct server spans. This is the auto-instrumentation path — no per-route code.

# tracing.py — import this before the app module
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

provider = TracerProvider(resource=Resource.create({"service.name": "orders-api"}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317")))
trace.set_tracer_provider(provider)
# main.py
import tracing  # noqa: F401  — provider set up before FastAPI is touched
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)  # ASGI middleware creates the SERVER span

@app.get("/orders/{order_id}")
async def get_order(order_id: str):
    return {"order_id": order_id, "status": "shipped"}

A request to /orders/42 now produces one server span named GET /orders/{order_id} with http.route set to the template — not the literal path. If the request arrived with a traceparent header, the span inherits that trace ID instead of starting a new one, provided OTEL_PROPAGATORS=tracecontext,baggage is set in the environment.

To add value inside a handler, create a manual child span. Inside an async def, start_as_current_span nests correctly under the server span because both live in the same contextvars scope:

tracer = trace.get_tracer(__name__)

@app.get("/orders/{order_id}/total")
async def order_total(order_id: str):
    with tracer.start_as_current_span("compute_order_total") as span:
        span.set_attribute("order.id", order_id)
        items = await fetch_items(order_id)      # await preserves the context slot
        total = sum(i["price"] for i in items)
        span.set_attribute("order.item_count", len(items))
        return {"order_id": order_id, "total": total}

Implementation

The production configuration adds three things the minimal version omits: a hook to enrich the server span, a lifespan handler to flush buffered spans on shutdown, and the context-safe pattern for BackgroundTasks.

# app.py — production FastAPI instrumentation
from contextlib import asynccontextmanager
from fastapi import FastAPI, BackgroundTasks
from opentelemetry import trace, context
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

tracer = trace.get_tracer(__name__)

# Lifespan flushes the BatchSpanProcessor so in-flight spans are not lost on exit.
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    trace.get_tracer_provider().shutdown()   # ForceFlush + drain export queue

# server_request_hook runs on the SERVER span; enrich it, do not rename it.
def server_request_hook(span, scope):
    if span and span.is_recording():
        span.set_attribute("app.tenant", scope.get("headers_tenant", "unknown"))

app = FastAPI(lifespan=lifespan)
FastAPIInstrumentor.instrument_app(app, server_request_hook=server_request_hook)

async def emit_receipt(order_id: str):
    # Runs after the response — needs context re-attached (see handler below).
    with tracer.start_as_current_span("emit_receipt") as span:
        span.set_attribute("order.id", order_id)
        await send_email(order_id)

@app.post("/orders/{order_id}/confirm")
async def confirm_order(order_id: str, background_tasks: BackgroundTasks):
    with tracer.start_as_current_span("confirm_order") as span:
        span.set_attribute("order.id", order_id)

        # Capture the live context NOW, while it is still attached to this coroutine.
        captured = context.get_current()

        async def traced_background():
            token = context.attach(captured)   # restore the parent context in the task
            try:
                await emit_receipt(order_id)
            finally:
                context.detach(token)          # prevents context leak in long-lived loops

        background_tasks.add_task(traced_background)
        return {"order_id": order_id, "confirmed": True}

Each annotated line maps to a tracing concept: instrument_app installs the ASGI middleware that owns the server span and injects traceparent on outbound calls; server_request_hook lets you add attributes to that auto-created span without taking ownership of its name or lifecycle; context.get_current() snapshots the active span plus any baggage before the response detaches it; context.attach/detach restore and pop that snapshot inside the background coroutine so emit_receipt nests under confirm_order rather than starting a fresh trace. The deeper treatment of these async-boundary fixes — including asyncio.to_thread and create_task — lives in the async boundaries guide.

Verification

Confirm the instrumentation before shipping:

  • One server span, route-named. Query the backend and check the operation is GET /orders/{order_id}, span.kind = server, with an http.route attribute. A literal path in the name means routing ran outside the instrumented layer.
  • Trace continuity across BackgroundTasks. Send a request that triggers confirm_order, wait for the background task, and confirm emit_receipt shares the same trace_id as the server span. Filter the backend for parent_span_id = null — only the server span should appear.
  • No double server spans. If you launch with opentelemetry-instrument, remove the explicit instrument_app() call, or you will get two nested server spans per request.
  • Spans flush on shutdown. Stop the process and confirm the last request’s spans still arrive — the lifespan shutdown handler guarantees this under BatchSpanProcessor.

Common Pitfalls

  • BackgroundTasks lose context. This is the signature FastAPI failure. Tasks run after the response commits, outside the request’s contextvars scope, so spans created in them orphan into new traces. Always capture context in the handler and re-attach it in the task, as shown above.
  • Naming manual spans from request.url.path. Building a span name like f"handle {request.url.path}" reintroduces the exact high-cardinality problem the instrumentation solves. Name manual spans by operation (compute_order_total) and let the server span carry http.route.
  • uvicorn workers and provider setup. With uvicorn --workers N, each worker is a separate process that must initialise its own TracerProvider. Configure tracing inside the app module (imported per worker) rather than in a __main__ block that only the parent runs, or the workers export nothing. Also prefer BatchSpanProcessor over SimpleSpanProcessor under multiple workers to avoid one export call blocking the event loop.

FAQ

Do I need FastAPIInstrumentor if I already use the opentelemetry-instrument launcher?

No. The opentelemetry-instrument launcher auto-detects FastAPI and applies the same instrumentation at startup, so you do not also call FastAPIInstrumentor.instrument_app(). Use one or the other. Call instrument_app() explicitly only when you start uvicorn programmatically or need to pass options like a custom server_request_hook.

Why are my FastAPI spans named by full path instead of the route template?

The instrumentation reads the route template from Starlette’s matched route, which is only resolved after routing. If spans show /orders/42 rather than /orders/{id}, a middleware registered outside the instrumentation is intercepting the request before routing, or you named a manual span with the raw request.url.path. Let the instrumentation set http.route and never build span names from path parameters.

Why do spans created in a FastAPI BackgroundTask become separate traces?

FastAPI runs BackgroundTasks after the response is sent, outside the request coroutine’s contextvars scope, so the OpenTelemetry context has already been detached. Capture context.get_current() inside the handler and re-attach it with context.attach() at the top of the background function, detaching in a finally block.


↑ Back to Instrumenting Web Frameworks with OpenTelemetry