Instrumenting Django with OpenTelemetry
A Django deployment produces useful traces only when three separate things are true: the SDK is initialised inside each worker process rather than in the master, the instrumentor's middleware sits at the top of MIDDLEWARE, and the database driver is instrumented so ORM queries appear as child spans. Miss any one and you get silence, duplicates, or request spans with no visible cause of latency. This page is the concrete walkthrough for engineers running Django behind gunicorn, uvicorn, or daphne; it belongs to the instrumenting Python web frameworks guide inside Distributed Tracing and OpenTelemetry in Python, and it assumes the provider lifecycle from the OpenTelemetry SDK setup guide.
Prerequisites
Pin the Django instrumentor with the SDK and the database instrumentation that matches your driver. Psycopg is the common choice for PostgreSQL; the generic DB-API instrumentor covers MySQL, SQLite, and anything else Django talks to through a DB-API 2.0 driver. The instrumentation packages track a separate pre-1.0 version line from the API and SDK, so bound both ranges — an SDK upgrade without a matching instrumentation bump is the usual source of import-time failures.
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" \
"opentelemetry-instrumentation-django>=0.51b0,<1.0.0" \
"opentelemetry-instrumentation-psycopg>=0.51b0,<1.0.0" \
"opentelemetry-instrumentation-dbapi>=0.51b0,<1.0.0"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4317"
export OTEL_SERVICE_NAME="orders-web"
export OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS="path_info,content_type"
export OTEL_PYTHON_EXCLUDED_URLS="healthz,readyz"
DJANGO_SETTINGS_MODULE must be importable before the instrumentor runs, because DjangoInstrumentor reads the settings object to insert its middleware. In practice that means instrumentation happens after django.setup() or after the WSGI/ASGI callable has been built — not at the top of a module that Django itself imports during settings loading.
Implementation
1. Build a reusable bootstrap. Put SDK initialisation and instrumentor activation in one function so it runs identically from a worker hook, from manage.py runserver, and from a test fixture. The function configures the TracerProvider, attaches a BatchSpanProcessor so export never happens on the request path, then calls DjangoInstrumentor and the database instrumentor. Keeping it in one place matters more than it looks: divergence between the dev path and the gunicorn path is how teams end up with traces that work locally and vanish in production.
# tracing.py
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
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
def configure_tracing() -> None:
resource = Resource.create({
ResourceAttributes.SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "orders-web"),
ResourceAttributes.DEPLOYMENT_ENVIRONMENT: os.getenv("DEPLOYMENT_ENV", "production"),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
# Inject OpenTelemetry middleware at the top of the stack.
# is_sql_commentor_enabled tags queries with trace context for DB-side correlation.
DjangoInstrumentor().instrument(is_sql_commentor_enabled=True)
# Each executed query becomes a CLIENT span under the active request span.
PsycopgInstrumentor().instrument(enable_commenter=True)
2. Initialise per worker with the gunicorn post_fork hook. The gunicorn master imports your app and then forks workers. Initialising the SDK in the master would share a single exporter connection and batch buffer across forked children, corrupting export: the BatchSpanProcessor runs a background flush thread, and os.fork() copies the parent's memory but not its threads, so a child inherits a half-initialised buffer with no thread draining it. The gRPC channel beneath the OTLP exporter is similarly fork-unsafe — a file descriptor copied into multiple processes leads to interleaved writes and broken-pipe errors. The post_fork hook runs inside each child after the fork, giving every worker its own provider, its own flush thread, its own exporter socket, and its own buffer. This is the same post-fork discipline that applies when propagating trace context across Celery tasks and to any other pre-forking server.
# gunicorn.conf.py
bind = "0.0.0.0:8000"
workers = 4
def post_fork(server, worker):
# Runs in each worker process after fork — the only safe place to init the SDK.
from tracing import configure_tracing
configure_tracing()
Run it with gunicorn orders.wsgi:application -c gunicorn.conf.py. For local development without gunicorn, call configure_tracing() from the bottom of manage.py or an AppConfig.ready() hook instead. If you use AppConfig.ready(), guard against the autoreloader running it twice by checking os.environ.get("RUN_MAIN"), or you will see duplicate providers in development and blame the instrumentor.
3. Confirm middleware order. Do not add anything to MIDDLEWARE manually — the instrumentor inserts opentelemetry.instrumentation.django.middleware.otel_middleware.OpenTelemetryMiddleware at index 0 when it runs. Django processes the request phase of middleware top-down and the response phase bottom-up, so a middleware at index 0 is the first to see the request and the last to see the response. That position is exactly what tracing needs: the server span opens before any other middleware runs and closes after every other middleware has finished writing the response, so it captures the full request duration and the final status code. Keep GZipMiddleware and any response-rewriting middleware below it. Authentication middleware can stay anywhere below position 0; if you want the authenticated user on the span, read it inside a request_hook, because the user is only populated after AuthenticationMiddleware runs, which is necessarily below the OpenTelemetry middleware.
4. Capture request attributes. Set OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS to a comma-separated list of HttpRequest attributes to copy onto the span (for example path_info, content_type). For values that are not plain request attributes, attach a request_hook to read headers or the resolved user. Everything you add here is subject to the same span attribute discipline as manual spans: keep identifiers as attributes rather than folding them into span names, and never copy raw session cookies or authorisation headers onto a span.
def request_hook(span, request):
if span and span.is_recording() and request.user.is_authenticated:
span.set_attribute("enduser.id", str(request.user.pk))
DjangoInstrumentor().instrument(request_hook=request_hook)
The is_recording() guard is not decorative. Under a head sampler that dropped this trace, the span is a non-recording stub, and the guard skips the work of touching request.user — which, on a lazily evaluated SimpleLazyObject, can trigger a session lookup and an extra database query on every unsampled request.
Database spans and the SQL commenter
DjangoInstrumentor traces the request boundary only; the ORM is invisible to it. PsycopgInstrumentor (or DBAPIInstrumentor for other drivers) patches the driver's cursor so each execute opens a CLIENT span parented by whatever span is current — the request span, in a normal view. That parenting is what turns "this endpoint is slow" into "this endpoint issues 43 queries", the N+1 signature that a request-only trace can never show you.
enable_commenter=True appends the active trace context as a SQL comment on the outgoing statement, so pg_stat_statements rows and slow-query logs carry the traceparent that produced them. The comment is appended after the statement text, which means it does not alter the query plan, but it does defeat prepared-statement reuse in some poolers because the literal SQL text now differs per request. Turn it on deliberately: it is invaluable for DBA-side correlation and mildly costly in front of PgBouncer running in statement-pooling mode.
from opentelemetry.instrumentation.dbapi import trace_integration
import MySQLdb # any DB-API 2.0 driver
# Generic fallback when no driver-specific instrumentor exists.
trace_integration(MySQLdb, "connect", "mysql")
Django under ASGI
DjangoInstrumentor().instrument() is the same call for ASGI deployments; it detects the ASGI handler and wraps it with the ASGI middleware rather than the WSGI one. What changes is everything around it. Uvicorn and daphne workers under gunicorn's UvicornWorker still fork, so post_fork remains the initialisation point; a bare uvicorn process without workers initialises once at startup instead. Async views keep the active span in a contextvar, so it survives await boundaries automatically, but sync_to_async hops the call into a thread executor: context is copied into the thread by Django's own asgiref machinery, so ORM calls made through sync_to_async still nest correctly, whereas work you push onto a bare ThreadPoolExecutor does not. The general rules for that boundary are covered in the async tracing patterns guide, and the async-ORM equivalent in tracing SQLAlchemy async queries.
Excluded URLs and span volume
Health probes fire every few seconds per pod and carry no diagnostic value, so exclude them before they dominate span volume and cost. OTEL_PYTHON_DJANGO_EXCLUDED_URLS takes comma-separated regular expressions matched against the request path; OTEL_PYTHON_EXCLUDED_URLS applies the same list to every instrumentation in the process. Excluding a path suppresses the span entirely — it is not a sampling decision, so nothing downstream can recover it. Use it for probes, static-asset routes, and metrics scrapes; use a sampler, as described in sampling strategies for distributed tracing, for everything you might one day want a fraction of.
Configuration options
| Option / Environment variable | Default | Production use |
|---|---|---|
is_sql_commentor_enabled (kwarg) |
False |
True to append trace context as a SQL comment, linking slow-query rows to traces. |
request_hook / response_hook (kwargs) |
None |
Enrich the request span from the HttpRequest / HttpResponse; always guard with is_recording(). |
OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS |
unset | path_info,content_type — low-cardinality request fields copied to the span. |
OTEL_PYTHON_DJANGO_EXCLUDED_URLS |
unset | healthz,readyz,static so probes and assets never create spans. |
PsycopgInstrumentor(enable_commenter=...) |
False |
True for database-side correlation; leave off behind statement-pooling PgBouncer. |
OTEL_TRACES_SAMPLER |
parentbased_always_on |
parentbased_traceidratio with OTEL_TRACES_SAMPLER_ARG on high-traffic services. |
Verification
Issue a request that reads from the database and inspect the collector payload. You should see a SERVER span carrying the URL pattern as http.route, parenting a database CLIENT span for the query.
curl -s localhost:8000/orders/4821/
Expected Output (collector side):
{
"resourceSpans": [{
"resource": {"attributes": [
{"key": "service.name", "value": {"stringValue": "orders-web"}}
]},
"scopeSpans": [{
"spans": [
{
"name": "GET orders/<int:order_id>/",
"kind": "SPAN_KIND_SERVER",
"attributes": [
{"key": "http.request.method", "value": {"stringValue": "GET"}},
{"key": "http.route", "value": {"stringValue": "orders/<int:order_id>/"}},
{"key": "http.response.status_code", "value": {"intValue": "200"}}
]
},
{
"name": "SELECT orders",
"kind": "SPAN_KIND_CLIENT",
"attributes": [
{"key": "db.system", "value": {"stringValue": "postgresql"}},
{"key": "db.statement", "value": {"stringValue": "SELECT * FROM orders_order WHERE id = %s"}}
]
}
]
}]
}]
}
Three things confirm the wiring is correct. The SQL span lists the server span's span_id as its parent_span_id, and both share the same trace_id. The span name is the route template, not /orders/4821/, which is what keeps span names bounded no matter how many orders exist. And db.statement shows the parameterised SQL with %s placeholders rather than interpolated values, so customer data never leaves the process inside a span attribute.
For a check that needs no collector, assert on spans inside Django's test client using an in-memory exporter — a fast regression test that catches a broken bootstrap before deploy.
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
def test_request_emits_server_and_sql_spans(client, tracer_provider):
exporter = InMemorySpanExporter()
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
client.get("/orders/4821/")
spans = {s.name: s for s in exporter.get_finished_spans()}
server = spans["GET orders/<int:order_id>/"]
assert server.attributes["http.route"] == "orders/<int:order_id>/"
assert any(s.parent and s.parent.span_id == server.context.span_id
for s in exporter.get_finished_spans())
Trace continuity beyond this service depends on the propagators registered during SDK setup, detailed in context propagation and baggage. If the trace ids line up but your Django logs still cannot be joined to them, the gap is log enrichment rather than propagation — see adding trace ids to log records and structlog JSON logging in Django.
Common mistakes
-
Error signature: spans are dropped, duplicated, or never exported, and worker logs fill with broken-pipe or
StatusCode.UNAVAILABLEerrors from the exporter. Root cause: the SDK was initialised in the gunicorn master, so every forked worker inherited the same gRPC socket and a batch queue whose flush thread did not survive the fork. Remediation: move all initialisation into thepost_forkhook so each worker owns an isolated provider, exporter, and buffer, and never callinstrument()before the fork. -
Error signature: request spans exist but contain no children, so a 900 ms endpoint shows 900 ms of unexplained time. Root cause: only
DjangoInstrumentoris active; the database driver is untouched, so ORM queries emit nothing. Remediation: activatePsycopgInstrumentor— ortrace_integration()from the DB-API instrumentor for other drivers — in the same bootstrap, so each query opens aCLIENTspan beneath the request span. -
Error signature: two
SERVERspans per request, or a status code on the span that does not match the response the client received. Root cause:OpenTelemetryMiddlewarewas added toMIDDLEWAREby hand in addition to the automatic injection, or a response-rewriting middleware sits above it and changes the status after the span has closed. Remediation: remove the manual entry and letDjangoInstrumentor().instrument()insert the middleware at index 0; keep every response-modifying middleware below it. -
Error signature: span names appear as raw paths such as
GET /orders/4821/, and the backend shows millions of distinct operation names. Root cause: the URL resolver did not match a route — typically a 404, are_pathwith an unnamed catch-all, or a request handled before resolution — so nohttp.routewas available. Remediation: name your URL patterns and confirm the failing request resolves; for genuinely dynamic segments, keep the identifier in an attribute and let the template stay in the name, the same low-cardinality rule applied when setting up OpenTelemetry in FastAPI.
Related
- Instrumenting Python web frameworks — the parent guide covering WSGI and ASGI instrumentation across frameworks.
- Setting up OpenTelemetry in FastAPI — the same walkthrough for an ASGI-native framework.
- Propagating trace context across Celery tasks — carrying the Django request's trace into background workers.
- OpenTelemetry SDK setup — provider lifecycle, processors, and exporter tuning these spans depend on.
- Structlog JSON logging in Django — pairing these traces with structured logs from the same request.
Frequently Asked Questions
Where should I call DjangoInstrumentor().instrument()?
Call it once per worker process after forking. The gunicorn post_fork hook is the correct place because it runs in each worker, avoiding shared exporter connections inherited from the master process.
Does the Django instrumentor capture the route or the raw path?
It records the resolved URL pattern as http.route when the URL resolver matches a named route, so /orders/
Why are my SQL queries missing from traces?
DjangoInstrumentor traces requests but not the database. Install and activate the Psycopg or DB-API instrumentor as well so each query opens a CLIENT span under the request span.
Where does the OpenTelemetry middleware sit in MIDDLEWARE order?
The instrumentor injects its own middleware at the top of the stack automatically. Do not add it to MIDDLEWARE by hand, and keep response-rewriting middleware below it so status codes are captured accurately.
Does the same setup work for Django running under ASGI?
Yes. DjangoInstrumentor detects an ASGI application and wraps it with the ASGI middleware instead of the WSGI one, so the instrument() call is identical. What changes is the server: uvicorn or daphne workers still need per-process initialisation, and async views must not lose context across manual thread hops.