Instrumenting Python Web Frameworks with OpenTelemetry

Auto-instrumentation is the fastest path from a blank trace view to a populated service map: a single instrumentor call wraps every inbound request in a span carrying the route template, HTTP method, and status code — no view code changes, no decorators, no manual context plumbing. This guide details how the opentelemetry-instrumentation-* contrib packages hook FastAPI, Starlette, Django, Flask, and raw WSGI/ASGI applications, how server spans differ from client spans, and how to constrain span volume with excluded_urls before traces overwhelm your collector. It is part of the Distributed Tracing and OpenTelemetry in Python guide and assumes you have already completed the OpenTelemetry SDK setup for Python. For framework-specific deep dives, see setting up OpenTelemetry in FastAPI and the dedicated walkthrough for instrumenting Django with OpenTelemetry.

One inbound request, one server span tree Across the top, an inbound request moves through four stages: the request arrives, the instrumentor middleware extracts the traceparent header and opens a span, the router resolves the route template and runs the view, and the response is written so the span ends and is queued for export. Below, the resulting span waterfall shows a SERVER span spanning the whole request, an INTERNAL span for the view's manual work nested inside it, and a CLIENT span for an outbound call nested inside that. One inbound request, one server span tree Inbound request GET /orders/4815 Instrumentor extract traceparent Router, then view route template resolved Response written span ends, batch queued The span waterfall the collector receives SERVER · GET /orders/{order_id} http.route · http.request.method · http.response.status_code INTERNAL · compute_total CLIENT · GET pricing-svc Manual spans in the view need no wiring — the server span is already the active context but outbound calls only become CLIENT spans once the matching client instrumentor is active
The instrumentor middleware opens a server span that parents the internal view span and any outbound client span before export.

Prerequisites

Auto-instrumentation packages depend on a configured SDK. Pin the API, SDK, exporter, and the framework instrumentor together so a contrib release never drags in an incompatible core. Contrib packages version on the 0.x pre-release track and must align with the 1.x SDK release they were built against — a 0.51b0 instrumentor expects the 1.30.x API, and mixing tracks is the most common cause of an ImportError on startup.

# Core pipeline (shared across every framework)
pip install "opentelemetry-api>=1.30.0,<2.0.0" \
            "opentelemetry-sdk>=1.30.0,<2.0.0" \
            "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"

# Framework instrumentors — install only what you run
pip install "opentelemetry-instrumentation-fastapi>=0.51b0,<1.0.0"   # FastAPI / Starlette
pip install "opentelemetry-instrumentation-django>=0.51b0,<1.0.0"    # Django
pip install "opentelemetry-instrumentation-flask>=0.51b0,<1.0.0"     # Flask
pip install "opentelemetry-instrumentation-wsgi>=0.51b0,<1.0.0"      # raw WSGI
pip install "opentelemetry-instrumentation-asgi>=0.51b0,<1.0.0"      # raw ASGI

# Client-side instrumentors for outbound CLIENT spans
pip install "opentelemetry-instrumentation-requests>=0.51b0,<1.0.0" \
            "opentelemetry-instrumentation-httpx>=0.51b0,<1.0.0"

Set the exporter target through environment variables so the same image runs in every environment without a code change:

export OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4317"
export OTEL_SERVICE_NAME="checkout-api"
export OTEL_PYTHON_EXCLUDED_URLS="healthz,readyz,metrics"
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS="authorization,cookie"

Concept & Architecture

Every framework instrumentor works the same way: it injects a middleware (WSGI or ASGI) at the outermost layer of the request stack. When a request arrives, that middleware extracts incoming W3C trace headers, starts a SERVER span, sets it as the active context for the duration of the request, and closes the span when the response is written. Because the span is the active context, anything you trace inside the view — a manual start_as_current_span, a database query, an outbound HTTP call — automatically becomes a child without explicit wiring.

The span kind matters for backend topology. A SERVER span tells the backend "this service received a request," while a CLIENT span on the calling side tells it "this service sent a request." A backend stitches the two halves of a network hop together by matching the traceparent header that the client instrumentor injects and the server instrumentor extracts. This is the same mechanism described in context propagation and baggage: the instrumentor does extraction and injection for you, but it relies on the global propagators you registered during SDK setup. If those propagators are missing or mismatched between services, every hop starts a fresh trace and the service map fragments into single-span traces.

Auto-instrumentation captures three high-value attributes by default: the route template (for example /orders/{order_id} rather than /orders/4815), the HTTP method, and the response status code. Using the route template instead of the resolved URL is the single most important behavior for cardinality control — it keeps span names bounded regardless of how many distinct IDs hit the endpoint, which is the same discipline described in span lifecycle and attributes. The trade-off is that the template only becomes available once the framework's router has matched the request, which is why instrumentor ordering matters.

WSGI and ASGI are the two substrates beneath these frameworks. Flask and classic Django run on WSGI, a synchronous request/response protocol. FastAPI, Starlette, and ASGI-mode Django run on ASGI, the async protocol. The opentelemetry-instrumentation-wsgi and opentelemetry-instrumentation-asgi packages provide the low-level middleware; the framework-specific packages are thin wrappers that know how to extract the route template and integrate with the framework's lifecycle. That layering explains a common surprise: the generic middleware still produces valid server spans for an unsupported framework, but with the raw path as the span name, because it has no router to consult.

Where the instrumentor sits in the request stack An inbound request descends through five layers. The framework instrumentor wrapper is outermost, then the generic WSGI or ASGI OpenTelemetryMiddleware, then application middleware such as auth and CORS, then the router, then the view function. A callout beside the middleware layer notes that the span opens before the route is known, so it is first named by the raw path. A second callout beside the router notes that the route template resolves there and the span is renamed. Where the instrumentor sits in the request stack Inbound request · GET /orders/4815 Framework instrumentor wrapper FastAPIInstrumentor · DjangoInstrumentor · FlaskInstrumentor OpenTelemetryMiddleware (WSGI / ASGI) extracts traceparent, opens the SERVER span, sets it active Application middleware auth · CORS · gzip — all of it already inside the server span Router matches the URL against /orders/{order_id} View / endpoint function your code — manual child spans nest with no extra wiring The span opens here route unknown — named by the raw path Template resolves here span renamed, http.route set Drop the framework wrapper and the generic middleware still emits valid SERVER spans but it has no router to consult, so the raw path becomes the span name and cardinality explodes
The instrumentor wraps the outermost layer, so the span is open before the router runs — the route template only lands on the span once the match happens further down.

There are two ways to attach these packages. Calling the instrumentor in code (FastAPIInstrumentor.instrument_app(app)) is explicit and lets you pass options. The alternative is the opentelemetry-instrument CLI wrapper, which discovers every installed instrumentation package through Python entry points and activates it before your application module is imported: opentelemetry-instrument uvicorn main:app. The CLI is excellent for a first rollout or for services you cannot edit, but it gives you no place to pass excluded_urls or hooks except environment variables, and it activates everything installed — trim the surface with OTEL_PYTHON_DISABLED_INSTRUMENTATIONS when a library's instrumentation is noisy or misbehaving.

Two hooks let you shape every captured span without touching view code. A server_request_hook fires once per inbound request with the freshly created server span and the framework's request object, which is the right place to copy a tenant identifier, a feature-flag cohort, or an authenticated user id onto the span. A client_request_hook and client_response_hook fire for the nested send/receive events that ASGI exposes, letting you annotate streaming responses or websocket frames. Hooks run synchronously inside the request path, so keep them allocation-light: read an attribute, set it on the span, return. Heavy work in a hook becomes per-request latency on every traced route, and an exception raised inside a hook can surface as a 500 on an otherwise healthy endpoint — wrap anything that can fail.

Header capture deserves a deliberate policy rather than a blanket allow-list. The OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST and ..._SERVER_RESPONSE variables record named headers as span attributes, which is invaluable for debugging routing and content negotiation. But headers frequently carry secrets, so always pair capture with OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS set to at least authorization and cookie. Sanitized values are replaced with a redaction marker before export, so the attribute key still appears in traces while the secret never leaves the process. Treat any header that can hold a session token, an API key, or a signed URL as sanitize-list material by default.

Step-by-Step Implementation

Step 1 — Bootstrap the SDK first

Provider initialization must happen before any instrumentor attaches, exactly as covered in the OpenTelemetry SDK setup. An instrumentor that runs first will bind to the no-op default provider and silently drop every span. The snippet below is the shared bootstrap every framework example reuses.

# otel_bootstrap.py — import this once at process start
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter


def init_tracing() -> None:
    resource = Resource.create({
        ResourceAttributes.SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "checkout-api"),
        ResourceAttributes.SERVICE_VERSION: os.getenv("SERVICE_VERSION", "1.0.0"),
        ResourceAttributes.DEPLOYMENT_ENVIRONMENT: os.getenv("DEPLOYMENT_ENV", "production"),
    })
    provider = TracerProvider(resource=resource)
    provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))  # endpoint via env var
    trace.set_tracer_provider(provider)

Step 2 — Instrument FastAPI and Starlette

The FastAPI instrumentor accepts the application instance. Pass excluded_urls to skip probes and server_request_hook to enrich the span with request-specific attributes. Starlette uses the identical pattern via StarletteInstrumentor, which is what you want when you run Starlette directly or mount a FastAPI sub-application inside a larger ASGI app.

from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from otel_bootstrap import init_tracing

init_tracing()                       # SDK before instrumentation
app = FastAPI()


def server_request_hook(span, scope):
    # Attach a tenant attribute pulled from the ASGI scope headers
    if span and span.is_recording():
        headers = dict(scope.get("headers") or [])
        tenant = headers.get(b"x-tenant-id", b"unknown").decode()
        span.set_attribute("tenant.id", tenant)


FastAPIInstrumentor.instrument_app(
    app,
    excluded_urls="healthz,readyz",
    server_request_hook=server_request_hook,
)


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

Step 3 — Instrument Flask

The Flask instrumentor wraps the Flask app object. It records the route rule (/orders/<order_id>) as the span name, preserving Flask's own converter syntax rather than normalising it — expect <order_id> in span names, not {order_id}. Capture extra request headers with OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST.

from flask import Flask
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from otel_bootstrap import init_tracing

init_tracing()
app = Flask(__name__)
FlaskInstrumentor().instrument_app(app, excluded_urls="healthz,readyz")


@app.get("/orders/<order_id>")
def get_order(order_id):
    return {"order_id": order_id}

Step 4 — Instrument Django

Django is configured globally rather than per-app because the framework owns its own settings and middleware registry. Call DjangoInstrumentor().instrument() from a startup hook; it reads DJANGO_SETTINGS_MODULE and inserts OpenTelemetryMiddleware at the top of MIDDLEWARE so it wraps every other middleware. Enabling is_sql_commentor_enabled appends the active trace context as a SQL comment, which lets database-side tooling attribute a slow query back to the exact trace. The full middleware-ordering and database-instrumentation details live in the dedicated Django and OpenTelemetry guide.

from opentelemetry.instrumentation.django import DjangoInstrumentor
from otel_bootstrap import init_tracing

init_tracing()
# Reads DJANGO_SETTINGS_MODULE and injects OpenTelemetryMiddleware
DjangoInstrumentor().instrument(is_sql_commentor_enabled=True)

Step 5 — Instrument a raw WSGI or ASGI app

When you run a framework with no dedicated instrumentor, wrap the application object directly with the WSGI or ASGI middleware. This produces server spans but cannot resolve route templates, since the generic middleware has no router to consult. Compensate by setting the route yourself in a hook, or by naming the span from a path pattern you control.

# Raw ASGI middleware wrapping any ASGI callable
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from otel_bootstrap import init_tracing

init_tracing()
from my_app import application                     # any ASGI app
application = OpenTelemetryMiddleware(application)  # adds SERVER spans

Step 6 — Add client instrumentation for outbound spans

Framework instrumentors only cover inbound traffic. To turn outbound calls into CLIENT spans that join the same trace, install and activate the matching client instrumentor once at startup. Each one patches its library at import time, so activate them before the first request rather than lazily.

from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor

RequestsInstrumentor().instrument()       # requests -> CLIENT spans
HTTPXClientInstrumentor().instrument()    # httpx -> CLIENT spans

For aiohttp, the equivalent package and its event-loop caveats are covered in instrumenting aiohttp client requests, and database spans are handled by the driver instrumentors described in tracing SQLAlchemy async queries.

Step 7 — Instrument inside each worker, after the fork

Gunicorn and Uvicorn's multi-worker mode fork the master process. A TracerProvider built before the fork carries a background export thread and a gRPC channel that do not survive fork() cleanly, producing workers that appear healthy but export nothing. Build the provider and attach instrumentors inside each child.

# gunicorn.conf.py
workers = 4


def post_fork(server, worker):
    from otel_bootstrap import init_tracing
    from opentelemetry.instrumentation.flask import FlaskInstrumentor
    from opentelemetry.instrumentation.requests import RequestsInstrumentor

    init_tracing()                                  # provider is now process-local
    FlaskInstrumentor().instrument()                # patches the Flask class itself
    RequestsInstrumentor().instrument()

For Uvicorn with --workers, the equivalent hook is the ASGI lifespan startup event or an application factory that each worker imports fresh, since Uvicorn re-imports the app module in every child.

Provider before the fork versus provider in post_fork The upper timeline shows the master process building a TracerProvider with a batch export thread and a gRPC channel, then calling fork. The four workers inherit a provider whose export thread did not survive, so the batch queue never drains and no spans are exported, with no error logged. The lower timeline shows the master forking before any provider exists; each worker then runs init_tracing inside the post_fork hook, owning its own provider, batch queue and OTLP channel, so every worker exports spans. Where you build the provider decides whether workers export Before the fork — every span is lost, silently master process provider + export thread fork() worker 1 … worker 4 inherited provider, dead thread Nothing reaches the collector queue never drains, no error logged Inside post_fork — every worker exports its own batches master process no provider built yet fork() post_fork: init_tracing() own queue + own OTLP channel Spans exported per worker instrumentors attach in the child too The failure is silent: workers look healthy, requests succeed, and the trace view stays empty Uvicorn with --workers needs the same move: a lifespan startup handler or an application factory
A provider built in the master process leaves each worker holding an export thread and gRPC channel that did not survive fork(); building it in post_fork gives every child its own pipeline.

Step 8 — Verify the spans reaching the collector

Issue one real request and one probe request, then read the collector's debug output. You are checking three things: that the span name is the route template, that http.route and http.response.status_code are present, and that the excluded path produced nothing at all.

curl -s localhost:8000/orders/4815 > /dev/null   # expect one SERVER span
curl -s localhost:8000/healthz     > /dev/null   # expect no span at all

Configuration Reference

Option / Environment variable Type Default Production recommendation
excluded_urls (kwarg) / OTEL_PYTHON_EXCLUDED_URLS comma-separated string unset (everything traced) healthz,readyz,metrics — probe traffic is high-volume and low-value
server_request_hook callable (span, scope/environ) None Set one to stamp tenant.id, deploy version, or user cohort; keep it allocation-light
client_request_hook / client_response_hook callable (span, scope, message) None Use only when you need per-message ASGI detail such as streaming or websocket frames
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST comma-separated header names unset Allow-list a handful (x-tenant-id, x-request-id); never *
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE comma-separated header names unset content-type,cache-control when debugging negotiation; otherwise leave off
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS comma-separated header names unset Always set to at least authorization,cookie,set-cookie
is_sql_commentor_enabled (kwarg) bool False True on Django services whose database team correlates slow queries to traces
tracer_provider / meter_provider (kwarg) provider instance global provider Leave unset in production; override in tests to isolate span state per test case
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS comma-separated package names unset Set when using the opentelemetry-instrument CLI to silence noisy libraries
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG string / float parentbased_always_on parentbased_traceidratio with a tuned ratio once traffic exceeds your budget
Attach call, route template, hooks and substrate per framework A five-row matrix. FastAPI and Starlette attach with instrument_app on the application object, expose curly-brace route templates, support server and client hooks, and run on ASGI. Django attaches with DjangoInstrumentor().instrument() globally, exposes angle-bracket route templates, supports request and response hooks, and runs on WSGI or ASGI. Flask attaches with instrument_app, exposes angle-bracket templates, supports request and response hooks, and runs on WSGI. The raw OpenTelemetryMiddleware wrapper has no router, so it records the raw path only and offers client hooks alone. What each instrumentor gives you Framework Attach call Route template Hooks Substrate FastAPI instrumentation-fastapi FastAPIInstrumentor.instrument_app(app) {order_id} server + client ASGI Starlette instrumentation-starlette StarletteInstrumentor.instrument_app(app) {order_id} server + client ASGI Django instrumentation-django DjangoInstrumentor().instrument() <order_id> request/response WSGI or ASGI Flask instrumentation-flask FlaskInstrumentor().instrument_app(app) <order_id> request/response WSGI Raw WSGI / ASGI instrumentation-wsgi · -asgi OpenTelemetryMiddleware(app) raw path only client only WSGI or ASGI Every framework instrumentor also accepts excluded_urls and tracer_provider — only the raw middleware has no router to ask, which is why it alone cannot put a bounded template on the span.
The attach call and the shape of the route template differ per framework; only the raw WSGI/ASGI wrapper is left without a template, and with it without bounded span names.

Async & Concurrency Considerations

ASGI frameworks run on an event loop, so the request span lives in a contextvars context that the SDK attaches and detaches automatically across await boundaries. The FastAPI and Starlette instrumentors are built on this guarantee: an async def view that awaits a database driver or an httpx call keeps the server span active for the whole coroutine, so child spans nest correctly without manual context copying.

What the active span survives, and what it does not A band across the top represents the SERVER span and its active contextvars context, which closes when the final response body is sent. Three branches hang off it. Awaiting inside the view keeps the context, so child spans nest correctly. Scheduling work with create_task or an executor loses the context, producing a fresh parentless trace unless the context is copied first. A streaming or websocket response keeps the server span open for the whole connection, delaying export. The active span follows await — not a new task, thread, or stream SERVER span · active contextvars context closes when the final http.response.body message is sent await — context follows The SDK re-attaches the span context across every await, so child spans nest with no wiring. Safe: async views, httpx, async drivers, FastAPI's pool. new task — context lost create_task() and executor. submit() start with no active span: a fresh, parentless trace. Fix: copy_context() before you schedule the work. stream — span stays open An SSE or websocket response holds the SERVER span open for the whole connection lifetime. Fix: exclude the route, or span only the setup phase. WSGI is the same story with threads: the span lives in thread-local context, so a pool you create yourself loses it gevent is the exception — monkey-patching moves what "current thread" means, so initialise the SDK after the patch
Awaiting keeps the server span active; handing work to a new task, a thread pool, or a long-lived stream does not, and each needs its own fix.

The hazard appears when you spawn background work. Calling asyncio.create_task or handing work to a thread pool does not propagate the active span unless the new task inherits the context. Copy the context explicitly with contextvars.copy_context() before scheduling, or create the task inside the request scope so it captures the current context at creation time. Fire-and-forget tasks created after the response is sent will start a fresh, parentless trace. These patterns are covered in depth in async tracing patterns, and the same rule governs work handed to a queue — a Celery task only stays in the trace if you carry the context across the broker, as shown in propagating trace context across Celery tasks.

For WSGI frameworks the model is simpler: each request owns a worker thread, and the instrumentor stores the span in thread-local context. The risk there is thread pools inside a view — an executor.submit call runs on a worker that has no active span, so wrap submitted callables to re-attach context if you need their spans to nest. Gunicorn's gthread worker class behaves the same way; gevent workers do not, because monkey-patching relocates what "current thread" means, and the SDK's context storage must be initialised after the patch is applied.

A subtle but common failure is mixing sync and async carelessly. Calling a blocking driver inside an async def view stalls the event loop, and while the span still records correctly, the latency it captures will be dominated by event-loop starvation rather than the operation itself, producing misleading traces: a span that reads as a slow database query is actually a busy loop. Run blocking work through asyncio.to_thread or a properly instrumented async driver so the recorded duration reflects real I/O. FastAPI's own threadpool offload for def (non-async) views is safe here — the instrumentor's context is copied into the worker thread — but any pool you create yourself is not.

Streaming responses stretch the span's lifetime in a way that surprises people. Under ASGI, the server span closes when the final http.response.body message is sent, so a long-lived server-sent-events endpoint holds an open span for as long as the client stays connected. That is technically correct but useless for latency percentiles, and it delays export until the stream ends. Exclude streaming routes, or record a short child span for the setup phase and treat the streaming duration as its own metric. Websocket connections behave similarly: prefer per-message spans created in a client_request_hook over a single connection-length span. Finally, the batch processor exports on a background thread, so a container that is killed the instant a response is written can lose the last batch — call provider.shutdown() from your framework's shutdown hook so in-flight spans flush.

Production Code Examples

End-to-end: FastAPI service with manual spans and an outbound call

This service combines auto-instrumentation, a client instrumentor, and a hand-written child span. The manual span nests under the auto-generated server span with zero extra context plumbing.

import httpx
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from otel_bootstrap import init_tracing

init_tracing()
HTTPXClientInstrumentor().instrument()           # outbound CLIENT spans

app = FastAPI()
FastAPIInstrumentor.instrument_app(app, excluded_urls="healthz")
tracer = trace.get_tracer(__name__)
client = httpx.AsyncClient(base_url="http://pricing-svc:8080")


@app.get("/orders/{order_id}/total")
async def order_total(order_id: str):
    # Manual INTERNAL span nests under the FastAPI SERVER span automatically
    with tracer.start_as_current_span("compute_total") as span:
        span.set_attribute("order.id", order_id)
        resp = await client.get(f"/price/{order_id}")   # becomes a CLIENT span
        return {"order_id": order_id, "total": resp.json()["amount"]}


@app.on_event("shutdown")
def flush_spans() -> None:
    trace.get_tracer_provider().shutdown()       # flush the last batch on exit

Expected Output:

{
  "resourceSpans": [{
    "resource": {"attributes": [
      {"key": "service.name", "value": {"stringValue": "checkout-api"}}
    ]},
    "scopeSpans": [{
      "spans": [
        {
          "name": "GET /orders/{order_id}/total",
          "kind": "SPAN_KIND_SERVER",
          "attributes": [
            {"key": "http.request.method", "value": {"stringValue": "GET"}},
            {"key": "http.route", "value": {"stringValue": "/orders/{order_id}/total"}},
            {"key": "http.response.status_code", "value": {"intValue": "200"}}
          ]
        },
        {"name": "compute_total", "kind": "SPAN_KIND_INTERNAL"},
        {
          "name": "GET",
          "kind": "SPAN_KIND_CLIENT",
          "attributes": [
            {"key": "http.request.method", "value": {"stringValue": "GET"}},
            {"key": "server.address", "value": {"stringValue": "pricing-svc"}}
          ]
        }
      ]
    }]
  }]
}

All three spans share one trace_id; compute_total and the client span list the server span's span_id as their parent_span_id. The downstream pricing-svc continues the same trace because the httpx instrumentor injected traceparent on the outbound request.

End-to-end: Flask service with header capture and exclusions

import os
os.environ["OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST"] = "x-tenant-id"
os.environ["OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS"] = "authorization,cookie"

from flask import Flask
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from otel_bootstrap import init_tracing

init_tracing()
RequestsInstrumentor().instrument()
app = Flask(__name__)
FlaskInstrumentor().instrument_app(app, excluded_urls="healthz,metrics")


@app.get("/orders/<order_id>")
def get_order(order_id):
    return {"order_id": order_id}

Expected Output:

# A request carrying `x-tenant-id: acme` produces a server span with:
name: GET /orders/<order_id>
kind: SPAN_KIND_SERVER
attributes:
  http.request.method            = GET
  http.route                     = /orders/<order_id>
  http.response.status_code      = 200
  http.request.header.x_tenant_id = ["acme"]
# Requests to /healthz and /metrics produce no spans.

Note the environment variables are set before the instrumentation modules are imported. Header capture configuration is read at import time, so assigning these after the import does nothing — in a real deployment set them in the container environment rather than in Python.

Common Mistakes

Instrumenting after the app is already serving. Error signature: server spans appear for some routes but not others, or stop entirely after a reload. Root cause: the instrumentor must inject its middleware before the first request is handled; attaching it lazily in a request handler or after app.run() misses the middleware stack. Remediation: call the instrumentor at module import time, immediately after init_tracing() and before the server binds.

Expecting outbound calls to appear without a client instrumentor. Error signature: server spans are present but downstream services start brand-new traces, breaking the service map. Root cause: framework instrumentors only cover inbound requests; outbound requests/httpx/aiohttp calls are untraced until their own instrumentor is active. Remediation: call RequestsInstrumentor().instrument() (and the matching client instrumentors) at startup, and confirm the propagators are registered as in the SDK setup.

Letting health checks dominate trace volume. Error signature: the backend is flooded with thousands of identical /healthz spans, inflating cost and burying real traffic. Root cause: probes hit the service every few seconds and are traced by default. Remediation: set excluded_urls or OTEL_PYTHON_EXCLUDED_URLS to skip probe paths; pair with sampling strategies for distributed tracing for ratio-based control.

High-cardinality span names from raw URLs. Error signature: the backend shows one unique operation per request ID, making aggregation impossible. Root cause: a generic WSGI/ASGI wrapper was used, or custom middleware ran before the router resolved the template, so the resolved URL became the span name. Remediation: use the framework-specific instrumentor and ensure it sits at the outermost layer so http.route carries the template, not the path.

Building the provider before the worker fork. Error signature: traces work under uvicorn main:app locally but vanish under gunicorn with multiple workers, with no error in the logs. Root cause: the TracerProvider's batch-export thread and gRPC channel were created in the master process and did not survive fork(). Remediation: move init_tracing() and every .instrument() call into a post_fork hook or lifespan startup handler so each worker owns its pipeline.

Double-wrapping with the generic middleware. Error signature: every request produces two nested SERVER spans and durations look inflated at the parent level. Root cause: OpenTelemetryMiddleware was applied by hand to an app that already had a framework instrumentor attached, or the app was instrumented in both application code and via the opentelemetry-instrument CLI. Remediation: pick one attachment point — the framework instrumentor for supported frameworks, the generic middleware only for unsupported ones — and never combine the CLI with in-code instrumentation of the same library.

Frequently Asked Questions

Does auto-instrumentation create both server and client spans?

The framework instrumentor creates SERVER spans for inbound requests. Outbound HTTP calls become CLIENT spans only when you also install the matching client instrumentation, such as the requests or httpx instrumentor.

How do I stop health checks from flooding my traces?

Set the excluded_urls option on the instrumentor or the OTEL_PYTHON_EXCLUDED_URLS environment variable to a comma-separated list of path patterns. Matching requests are never sampled.

Can I mix auto-instrumentation with manual spans?

Yes. The instrumentor sets the request span as the active context, so any tracer.start_as_current_span call inside a view automatically becomes a child of the server span without extra wiring.

Why does my route attribute show the raw URL instead of the template?

Some frameworks resolve the route template late in the request cycle. Ensure the instrumentor runs before custom middleware that short-circuits requests, and confirm the framework version exposes the route pattern to the instrumentation hook.

Should I use the opentelemetry-instrument CLI or call instrumentors in code?

The CLI is the fastest way to get coverage without editing the application and is ideal for a first rollout or for third-party services you do not control. Calling instrumentors in code is preferable in production because you can pass excluded_urls and hooks, control ordering relative to the provider bootstrap, and guarantee instrumentation happens after a worker fork.

Do I get duplicate spans if both the framework and the underlying ASGI instrumentor are active?

Yes, if you wrap an app that already has a framework instrumentor attached with OpenTelemetryMiddleware by hand, you get two nested SERVER spans for every request. Use the framework-specific instrumentor alone; the generic WSGI/ASGI middleware is only for frameworks that have no dedicated package.