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.
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.
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.
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 |
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.
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.
Related Reading
- Distributed Tracing and OpenTelemetry in Python — the parent guide covering architecture, sampling, and collector topology.
- Instrumenting Django with OpenTelemetry — middleware ordering, ORM spans, and SQL commenter detail.
- OpenTelemetry SDK Setup — provider lifecycle, processors, and exporter configuration this guide depends on.
- Context Propagation and Baggage — how the
traceparentheader stitches client and server spans together. - Async Tracing Patterns — keeping context intact across coroutines, task groups, and thread pools.
- Adding Trace IDs to Log Records — correlate the request span with the logs it produced.
- Instrumenting Flask with OpenTelemetry — WSGI middleware, excluded probes, and the prefork detail every gunicorn deployment meets.
- Tracing gRPC services in Python — interceptors, metadata propagation, status mapping, and long-lived streams.
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.