Instrumenting Django with OpenTelemetry
Initialize the SDK in the gunicorn post_fork hook rather than in settings.py, put the OpenTelemetry middleware first in MIDDLEWARE, and take http.route from resolver_match.route so every endpoint is a bounded dimension rather than one value per URL.
Context and when it matters
Django’s request path passes through several layers before a view runs — WSGI server, middleware chain, URL resolver, view, template rendering, ORM — and the default auto-instrumentation covers the outer boundary well and the inner layers not at all. That produces a trace with a well-named server span and a large unexplained gap in the middle, which is precisely where the time usually is.
Two Django-specific characteristics also break naive setups. Production almost always runs behind a forking server, so an SDK initialized at import time exists in the master process rather than the workers. And Django’s URL resolver only knows the matched route after the resolver runs, so middleware placed too early cannot set http.route.
The request path and where spans come from
Implementation
Initialize after the fork
# gunicorn.conf.py — the only reliable place for a forking server.
def post_fork(server, worker):
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.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
resource = Resource.create({
"service.name": "storefront",
"service.instance.id": f"worker-{worker.pid}", # distinguishes workers
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
DjangoInstrumentor().instrument()
PsycopgInstrumentor().instrument(enable_commenter=True)
Initializing in settings.py instead produces one of two failures depending on the server: with gunicorn’s default sync workers, the exporter’s background thread does not survive the fork and spans queue forever; with uWSGI, the same, unless --enable-threads and --lazy-apps are both set. The post_fork hook sidesteps all of it.
Middleware order
# settings.py — OpenTelemetry first, so its span encloses everything else.
MIDDLEWARE = [
"opentelemetry.instrumentation.django.middleware.otel_middleware._DjangoMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"myapp.middleware.RouteAttributeMiddleware", # sets http.route after resolution
]
First place matters for a specific reason: middleware below it that raises or short-circuits — a rate limiter returning 429, an authentication middleware returning 401 — is then still inside the server span, so those responses appear in traces. Placed lower, rejected requests produce no span at all and your traced traffic silently excludes everything that was blocked.
The route template
# myapp/middleware.py — resolver_match is populated only after resolution,
# so this must run as a response-phase step.
from opentelemetry import trace
class RouteAttributeMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
match = getattr(request, "resolver_match", None)
span = trace.get_current_span()
if match and span.is_recording():
# match.route is "orders/<int:order_id>/" — bounded by endpoint count.
# request.path is "/orders/8412/" — one value per order.
span.set_attribute("http.route", match.route)
span.set_attribute("django.view", match.view_name or "unknown")
span.update_name(f"{request.method} /{match.route}")
return response
update_name matters as much as the attribute: span names drive latency grouping in most backends, and a name derived from the raw path produces one group per URL.
The ORM
Django’s ORM issues queries through the database driver, so instrumenting the driver — not the ORM — gives one span per statement. That is what makes the N+1 pattern visible:
# The classic Django N+1, exactly as it appears in a trace.
for order in Order.objects.filter(tenant=tenant): # 1 query
print(order.customer.name) # 1 query per order
# In the trace: 1 span "SELECT orders" followed by 200 spans "SELECT customers".
# The fix collapses it to two:
for order in Order.objects.filter(tenant=tenant).select_related("customer"):
print(order.customer.name)
Adding a span around the view body is worth the few lines, because it separates “the view is slow” from “the database is slow”:
from functools import wraps
from opentelemetry import trace
tracer = trace.get_tracer("app.views")
def traced_view(fn):
@wraps(fn)
def wrapper(request, *args, **kwargs):
with tracer.start_as_current_span(f"view {fn.__name__}") as span:
response = fn(request, *args, **kwargs)
span.set_attribute("django.view.status", getattr(response, "status_code", 0))
return response
return wrapper
ASGI, Channels, and background work
Django under ASGI (uvicorn, daphne) needs the async context manager rather than the WSGI one, and Channels consumers are long-lived: a WebSocket connection open for an hour should not be one span. Create a span per message, linked to the connection span, for the same reason batch work is linked rather than nested.
Management commands and Celery tasks start their own traces. Instrument them explicitly — a management command that runs nightly and takes two hours is often the least observed and most expensive thing in a Django deployment. See tracing Celery tasks end to end for the task side.
Verification
Django REST Framework, admin, and background commands
Three parts of a typical Django deployment need attention beyond the basic setup, and all three are commonly left uninstrumented.
Django REST Framework adds its own layers — serializers, permissions, pagination, filter backends — between the view and the response. On a list endpoint returning several hundred objects, serialization is frequently the largest single contributor to latency, and it is entirely invisible in a trace that stops at the view. A span around serializer.data costs two lines and routinely reveals that the database was never the problem.
The Django admin is usually the least monitored and most expensive part of a deployment. Admin list views generate queries dynamically from model metadata, which makes them prone to N+1 patterns that no one wrote deliberately, and they are used by internal staff whose complaints do not reach a status page. The same instrumentation covers them automatically once the middleware is in place, but nobody looks — a dashboard filtered to admin routes is worth creating once.
Management commands and migrations run outside the request path entirely, so no middleware applies. A nightly command that takes two hours and occasionally locks a table is exactly the kind of work that traces are good at explaining, and it needs an explicit root span:
class Command(BaseCommand):
def handle(self, *args, **options):
with tracer.start_as_current_span("command reconcile_orders") as span:
span.set_attribute("django.command", "reconcile_orders")
processed = self.reconcile()
span.set_attribute("command.records_processed", processed)
The connecting theme is that Django’s request/response cycle is only part of what a Django deployment does, and instrumentation scoped to that cycle leaves the slowest and least observed work in the dark.
Common pitfalls
- SDK initialized in
settings.py. Works in the dev server, silently emits nothing under gunicorn or uWSGI. http.routefromrequest.path. Unbounded cardinality and useless endpoint dashboards.- Tracing middleware placed last. Blocked and rejected requests produce no spans, so your error rate looks better than it is.
- Assuming the ORM is instrumented. It is not; the driver is. Without driver instrumentation the entire database layer is a gap.
- Ignoring template rendering. On template-heavy pages it is frequently 20–40% of the request, and it is invisible by default.
- Forgetting
service.instance.idper worker. Without it, a problem isolated to one worker is impossible to see.
Channels and long-lived connections
Django Channels changes the shape of the problem. A WebSocket consumer lives for the duration of the connection, which may be hours, so a span covering the connection is useless for latency analysis — it measures how long the user kept a tab open.
The workable model is a short span per message, with the connection itself represented by a single span that is linked rather than parented. Message spans then have meaningful durations, and the connection span carries the attributes that describe the session: the consumer class, the group memberships, and the disconnect reason. That last attribute is worth more than it sounds, because disconnects are the failure mode nobody instruments and the one users notice first.
Related
- Instrumenting web frameworks with OpenTelemetry — the shared patterns across frameworks
- Instrumenting FastAPI with OpenTelemetry — the async Python equivalent
- Detecting N+1 queries from trace waterfalls — the pattern Django’s ORM produces most often