Instrumenting Starlette and ASGI Middleware
ASGI applications — Starlette, and frameworks built on it such as FastAPI — are instrumented by middleware that wraps the whole application and creates a server span per request. Getting a useful trace depends on where that middleware sits relative to everything else, on giving the span a route template rather than a raw path, and on enriching the one server span rather than creating a second. Streaming responses and WebSockets add their own behaviour. This page covers all of it. It is a task article under instrumenting Python web frameworks, part of the distributed tracing and OpenTelemetry in Python section, and it complements setting up OpenTelemetry in FastAPI.
Prerequisites
pip install "opentelemetry-instrumentation-starlette>=0.48b0,<1.0.0" \
"opentelemetry-instrumentation-asgi>=0.48b0,<1.0.0" \
"starlette>=0.37.0,<1.0.0"
Implementation
Step 1 — Instrument the application so the middleware is outermost. StarletteInstrumentor.instrument_app adds the OpenTelemetry ASGI middleware to the application. Called after the application is created and before other middleware is added, it ends up outermost, so its span covers every other layer. For a plain ASGI application without Starlette, wrapping the application object directly with the ASGI middleware achieves the same.
from starlette.applications import Starlette
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
app = Starlette(routes=routes)
StarletteInstrumentor.instrument_app(app) # 1. outermost: covers everything below
app.add_middleware(AuthMiddleware) # runs inside the server span
app.add_middleware(CORSMiddleware, allow_origins=["https://app.example"])
Step 2 — Let Starlette supply the route template. The generic ASGI middleware sees only the raw path when the request arrives. Starlette's instrumentation renames the span once the router has matched, so the span is named GET /orders/{order_id} rather than with the identifier. Unmatched requests are named by method alone, so random paths from scanners form one group rather than thousands, as described in naming spans and using semantic conventions.
Step 3 — Enrich the server span from your own middleware. Application middleware often knows things the instrumentation does not: the authenticated tenant, the API version requested, a feature flag. Adding them as attributes on the current span puts them on the server span. Starting a new span for "the request" inside middleware produces a redundant nested server span that doubles the span count and confuses every aggregate.
from opentelemetry import trace
from starlette.middleware.base import BaseHTTPMiddleware
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
span = trace.get_current_span() # 2. the server span
if span.is_recording():
span.set_attribute("app.api_version", request.headers.get("x-api-version", "1"))
if (tenant := getattr(request.state, "tenant", None)):
span.set_attribute("app.tenant.id", tenant.id)
response = await call_next(request)
return response
Step 4 — Create child spans only for distinct steps. A child span is worth its cost only when it measures something an engineer would want to see on its own. Where middleware does something costly and separable — a remote token verification, a rate limit check against a shared store — a child span around that step shows its cost. Everything else about the request belongs on the server span.
tracer = trace.get_tracer("auth")
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
with tracer.start_as_current_span("verify token"): # 3. a distinct, costly step
request.state.user = await verify(request.headers.get("authorization"))
return await call_next(request)
Step 5 — Exclude health and metrics endpoints. Probes and scrapes arrive constantly — every few seconds from every load balancer and every scraper — carry no diagnostic value and consume sampling budget. Excluding them by URL keeps the traces about real traffic.
export OTEL_PYTHON_STARLETTE_EXCLUDED_URLS="healthz,readyz,metrics"
Expected Output: one server span per request, named by template, with application attributes and a child span for the token check.
GET /orders/{order_id} SERVER 214 ms app.tenant.id=acme app.api_version=2
verify token INTERNAL 31 ms
SELECT orders CLIENT 41 ms
Streaming, WebSockets and lifespan
Three ASGI features behave differently from ordinary requests, and each needs a decision.
Streaming responses. The server span ends when the last chunk of the body is sent, not when the handler returns. A handler that returns a streaming response immediately and then generates data for five minutes produces a five-minute span, which is accurate and distorts any latency aggregate it is included in. The options are to exclude streaming endpoints from latency views, to mark them with an attribute so views can filter them, or — where time-to-first-byte is the meaningful measure — to record it as an attribute or an event on the span when the first chunk goes out.
WebSockets. A WebSocket connection is one long-lived span from handshake to close, which may be hours. Individual messages are better represented as span events on the connection span, or as their own short spans when message handling is substantial. A span per message that is a child of the connection span produces traces with thousands of children, which few viewers handle well; linking message spans to the connection, as in adding span links for batch work, is often more readable.
Lifespan. ASGI lifespan events — startup and shutdown — are not requests and are not traced by the middleware. Startup work that is worth measuring, such as warming a cache or loading a model, can be wrapped in a manual span in the startup handler. That span is the root of its own trace, which is correct: startup belongs to no request. The shutdown handler is where providers should be flushed, as covered in graceful shutdown and telemetry flush.
BaseHTTPMiddleware and context
Starlette's BaseHTTPMiddleware is convenient and has a property that matters for tracing: it runs the downstream application in a separate task. Context variables are copied into that task when it is created, so the server span is visible inside the handler, and spans started in the handler are correctly parented. What does not flow is the reverse direction — context variables set inside the handler are not visible to the middleware after call_next returns, because they were set in the child task's copy.
For tracing this is usually harmless, because the middleware reads the server span, which it did not create and which exists in both contexts. It becomes relevant when middleware expects to read something the handler set in a context variable — a tenant resolved deep in the handler, for example — to add it to the span after the response. That value will not be there. The workaround is to put such values on request.state, which is a shared object rather than a context variable, and read them from there.
Pure ASGI middleware — a class implementing the ASGI interface directly — does not create a separate task and does not have this property. It is more verbose to write and slightly faster, and for middleware whose only job is enriching the current span, the difference rarely matters. Where middleware does heavy work per request, or where the separate-task behaviour causes confusion, writing it as pure ASGI middleware removes both the overhead and the surprise.
Configuration options
| Setting | Value | Why |
|---|---|---|
| Middleware position | outermost | the span covers every layer |
| Route naming | Starlette instrumentation | template names, not raw paths |
| Application context | attributes on the current span | no redundant server spans |
| Child spans in middleware | only for costly, distinct steps | shows their cost separately |
| Excluded URLs | health, readiness, metrics | sampling spent on real traffic |
| Streaming endpoints | marked or excluded from latency views | accurate but long spans |
| WebSocket messages | events or linked spans | readable connection traces |
| Lifespan | manual spans at startup; flush at shutdown | not covered by the middleware |
Verification
Assert that each request produces exactly one server span, named by template.
from starlette.testclient import TestClient
def test_one_server_span_per_request(exporter):
TestClient(app).get("/orders/8812")
servers = [s for s in exporter.get_finished_spans()
if s.kind == trace.SpanKind.SERVER]
assert len(servers) == 1
assert servers[0].name == "GET /orders/{order_id}"
Expected Output:
.
1 passed
Two server spans per request means both the ASGI and Starlette instrumentations were applied independently, or application middleware is starting its own request span.
Common mistakes
Instrumentation added after other middleware. Error signature: traces faster than client-side timings, and no spans for requests rejected by authentication. Root cause: middleware outside the server span. Remediation: instrument first so it is outermost.
Raw paths as span names. Error signature: thousands of operations, one per identifier. Root cause: generic ASGI middleware without Starlette's route renaming. Remediation: use the Starlette instrumentation.
A second request span in middleware. Error signature: nested server spans and doubled span counts. Root cause: middleware starting its own span for the request. Remediation: add attributes to the current span.
Streaming endpoints in latency aggregates. Error signature: a p99 of several minutes. Root cause: long-lived spans mixed with ordinary requests. Remediation: exclude or mark them.
Reading handler-set context variables in middleware after the response. Error signature: an attribute the middleware adds after call_next is always empty. Root cause: the handler ran in a copied context. Remediation: pass such values through request.state.
Instrumenting twice. Error signature: duplicate spans after enabling automatic instrumentation on an already instrumented app. Root cause: both the launcher and instrument_app applied. Remediation: choose one mechanism.
Frequently Asked Questions
What is the difference between the ASGI and Starlette instrumentations?
The ASGI instrumentation is framework-agnostic middleware that creates a server span for every ASGI request. The Starlette instrumentation builds on it and names the span with the matched route template, which the generic middleware cannot know.
Where should the OpenTelemetry middleware go in the stack?
Outermost. Middleware added after it runs inside its span, so authentication, CORS and error handling are timed as part of the request. Middleware outside it is invisible to the trace, including any latency it adds.
Should my own middleware create spans?
Usually not for the request itself — that would create a redundant nested server span. Add attributes to the current span instead. Create a child span only for a distinct, costly step the middleware performs, such as a token verification call.
Why do streaming responses show very long spans?
Because the server span ends when the response body finishes sending, not when the handler returns. For a stream that lasts minutes, the span lasts minutes. That is accurate, and it means such endpoints should be excluded from latency aggregates or named distinctly.