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 anhttp.routeattribute. 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 confirmemit_receiptshares the sametrace_idas the server span. Filter the backend forparent_span_id = null— only the server span should appear. - No double server spans. If you launch with
opentelemetry-instrument, remove the explicitinstrument_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
lifespanshutdown handler guarantees this underBatchSpanProcessor.
Common Pitfalls
- BackgroundTasks lose context. This is the signature FastAPI failure. Tasks run after the response commits, outside the request’s
contextvarsscope, 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 likef"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 carryhttp.route. - uvicorn workers and provider setup. With
uvicorn --workers N, each worker is a separate process that must initialise its ownTracerProvider. 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 preferBatchSpanProcessoroverSimpleSpanProcessorunder 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.
Related
- Instrumenting Web Frameworks with OpenTelemetry — the framework-agnostic server-span model this guide specialises.
- Instrumenting Express with OpenTelemetry — the Node.js counterpart, with AsyncLocalStorage in place of contextvars.
- Handling Async Boundaries in Node.js and Python — the full attach/detach toolkit for BackgroundTasks, create_task, and thread pools.