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.

Per-worker SDK initialisation for Django under gunicorn The gunicorn master imports orders.wsgi and creates no provider. It forks worker processes; each worker runs the post_fork hook, which builds a TracerProvider with its own batch span processor thread and its own OTLP exporter channel. The spans that worker exports form a SERVER span named GET orders slash int order_id, parenting a CLIENT span for the SELECT on orders_order. one master · many workers · one SDK per worker gunicorn master imports orders.wsgi no provider here worker · pid 8412 post_fork → configure_tracing() TracerProvider + batch thread its own OTLP channel + buffer worker · pid 8413 post_fork → configure_tracing() TracerProvider + batch thread its own OTLP channel + buffer spans from this worker SERVER span GET orders/<int:order_id>/ CLIENT span SELECT orders_order os.fork() the SQL span's parent_id is the SERVER span_id each worker owns its provider, its flush thread and its exporter socket
The master holds no telemetry state; each forked worker builds its own provider in post_fork and exports a request span parenting its SQL spans.

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.

Which package owns which layer of a Django request A request descends six layers. The gunicorn worker process emits no spans. The OpenTelemetry middleware at index zero, owned by opentelemetry-instrumentation-django, emits the SERVER span, which opens first and closes last. Django's URL resolver and view emit no span but supply the http.route attribute that names it. The ORM builds SQL text and emits nothing. The psycopg cursor, patched by opentelemetry-instrumentation-psycopg, emits a CLIENT span per query as a child of the SERVER span. PostgreSQL itself emits nothing. a request descends the stack — only two layers are instrumented layer · package that owns it telemetry emitted gunicorn worker process gunicorn · post_fork bootstrap no spans OpenTelemetry middleware · index 0 opentelemetry-instrumentation-django SERVER span opens first, closes last URL resolver → view django · orders.urls names the span http.route ORM queryset → SQL text django.db.models no spans psycopg cursor.execute() opentelemetry-instrumentation-psycopg CLIENT span child of the SERVER span PostgreSQL network + server time no spans the request span comes from the middleware, the query spans from the driver — two packages, two layers
Two of the six layers are instrumented: the Django package wraps the request boundary, the driver package wraps each query. Install both or half the trace is missing.

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.

Where the OpenTelemetry middleware sits in MIDDLEWARE order The MIDDLEWARE list runs OpenTelemetryMiddleware at index 0, then SecurityMiddleware, SessionMiddleware, AuthenticationMiddleware and GZipMiddleware, and finally the view. Django runs the request phase downwards through the list and the response phase upwards, so the entry at index 0 is the first to see the request and the last to see the response. A dashed enclosure shows the SERVER span covering that whole descent and ascent, which is why response-rewriting middleware must stay below index 0 and why request.user is only available from AuthenticationMiddleware downwards. request phase top-down response phase bottom-up SERVER span — opened at index 0, closed last 0 OpenTelemetryMiddleware injected by instrument() — never add it by hand 1 SecurityMiddleware 2 SessionMiddleware 3 AuthenticationMiddleware request.user is populated from here downwards 4 GZipMiddleware response rewriters must stay below index 0 view · orders.views.order_detail index 0 sees the request first and the response last, so the span carries the full duration and the final status code
The instrumentor inserts itself at index 0, so the SERVER span opens before every other middleware and closes after all of them have written the response.

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")
The N+1 signature inside one Django request span A single request span, GET orders slash int order_id, runs 430 milliseconds. Beneath it a short middleware segment is followed by six repeated CLIENT spans, each a SELECT on orders_item taking about 24 milliseconds, together 144 milliseconds, and then a 150 millisecond template render. The six repeated queries are the N plus one signature; without the psycopg instrumentation the request span would be a single flat bar with no visible cause. one request · 430 ms · six queries a request-only trace cannot show GET orders/<int:order_id>/ · 430 ms SERVER span django instrumentation middleware 6 × SELECT orders_item ≈24 ms each · 144 ms total template render 0 100 200 300 400 ms DjangoInstrumentor alone draws only the top bar — the repeated SELECTs appear once the driver is instrumented
The same request with driver instrumentation on: six near-identical query spans under one request span is the N+1 signature a request-only trace hides.

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.
Choosing between exclusion and sampling for a Django route Every incoming request reaches a first decision: does the route have diagnostic value? If not — health probes, static assets, metrics scrapes — exclude it with OTEL_PYTHON_DJANGO_EXCLUDED_URLS, which means no span is ever created. If it does, a second decision asks whether the route is high traffic. If not, keep every trace with the parentbased_always_on sampler. If it is, sample a fixed share with parentbased_traceidratio, which keeps whole traces intact. Exclusion is irreversible; sampling is proportional. every request Django handles does the route have diagnostic value? high traffic? more than a few rps exclude the route OTEL_PYTHON_DJANGO_EXCLUDED_URLS no span is ever created keep every trace parentbased_always_on full fidelity, full cost sample a fixed share parentbased_traceidratio whole traces kept intact no · probes, static, metrics yes · user-facing routes no yes exclusion is irreversible: a suppressed span cannot be recovered downstream sampling is proportional: a smaller but representative share of whole traces
Exclude what can never help you debug; sample what is merely too voluminous. The two controls are not interchangeable — only one of them is reversible.

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())
A correctly wired Django trace next to a broken one On the left, correct wiring: a SERVER span named GET orders slash int order_id with two CLIENT children, a SELECT on orders_order and a SELECT on orders_item, all sharing one trace id and each child carrying the server span id as its parent id. On the right, broken wiring: the same request produces a SERVER span named with the raw path slash orders slash 4821 and no children at all, leaving 900 milliseconds unexplained. The two causes are a database driver that was never instrumented and a URL resolver that matched no named route. correct wiring broken wiring SERVER span GET orders/<int:order_id>/ CLIENT span SELECT orders_order CLIENT span SELECT orders_item one trace_id · parent_id = SERVER span_id span name is the route template SERVER span GET /orders/4821/ no child spans 900 ms unexplained cause 1 · the DB driver was never instrumented cause 2 · the URL resolver matched no route same request, same code — the difference is which instrumentors ran and whether the route resolved
Read the collector payload for both signals at once: children under the request span, and a route template rather than a concrete path in the span name.

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.UNAVAILABLE errors 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 the post_fork hook so each worker owns an isolated provider, exporter, and buffer, and never call instrument() 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 DjangoInstrumentor is active; the database driver is untouched, so ORM queries emit nothing. Remediation: activate PsycopgInstrumentor — or trace_integration() from the DB-API instrumentor for other drivers — in the same bootstrap, so each query opens a CLIENT span beneath the request span.

  • Error signature: two SERVER spans per request, or a status code on the span that does not match the response the client received. Root cause: OpenTelemetryMiddleware was added to MIDDLEWARE by 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 let DjangoInstrumentor().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, a re_path with an unnamed catch-all, or a request handled before resolution — so no http.route was 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.

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/ stays bounded regardless of the concrete id in the request.

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.