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

Django's request path and the spans covering each layer A vertical pipeline. The gunicorn worker and WSGI boundary produce the server span automatically. Middleware produces no span by default. The URL resolver supplies the route template. The view body produces no span unless added manually. ORM queries produce spans only if the database driver is instrumented. Template rendering produces no span by default. Auto-instrumentation covers the ends, not the middle gunicorn worker · WSGI server span — automatic middleware chain no span — add one if it is slow URL resolver supplies http.route view body no span — add for slow business logic ORM → database driver spans if the driver is patched The unexplained gap in most Django traces is the view body plus template rendering — neither is instrumented by default.

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

What a correctly instrumented Django request looks like A waterfall for one request. The server span named GET slash orders slash angle bracket int colon order id lasts 210 milliseconds. Inside it a view span lasts 190 milliseconds, containing a SELECT orders span of 12 milliseconds and three SELECT customers spans, plus a template render span of 40 milliseconds. One request · every layer accounted for GET /orders/<int:order_id>/ · 210 ms view order_detail · 190 ms SELECT orders · 12 ms SELECT customers ×3 — an N+1 in miniature render · 40 ms If the server span is present but empty, the driver was patched before the fork — or not at all.

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.

Where a typical Django request spends its time Where a typical Django request spends its time. middleware chain 9 ms; ORM queries 41 ms; view body logic 27 ms; template rendering 36 ms; serialization (DRF) 50 ms Where a typical Django request spends its time middleware chain 9 ms ORM queries 41 ms view body logic 27 ms template rendering 36 ms serialization (DRF) 50 ms The three emphasised rows are invisible by default — over half the request, unaccounted for.

Common pitfalls

  • SDK initialized in settings.py. Works in the dev server, silently emits nothing under gunicorn or uWSGI.
  • http.route from request.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.id per 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

↑ Back to Instrumenting Web Frameworks with OpenTelemetry