Instrumenting Django with OpenTelemetry

Django needs process-aware OpenTelemetry wiring: the SDK must initialize inside each forked worker, the instrumentor must inject its middleware at the top of the stack, and database queries only appear when you also instrument the driver. This guide is the concrete walkthrough for that setup. It belongs to the instrumenting Python web frameworks guide and the broader Distributed Tracing and OpenTelemetry in Python reference, and it assumes a completed OpenTelemetry SDK setup.

Per-worker SDK initialization for Django A gunicorn master process forks worker processes. Each worker runs the post_fork hook to initialize the TracerProvider and the Django and Psycopg instrumentors. A request then produces a server span parenting a database query span. Gunicorn master Worker + post_fork init SDK + instrument Worker + post_fork init SDK + instrument SERVER span http.route SQL span Psycopg query
Each gunicorn worker initializes the SDK and instrumentors in post_fork, then emits a server 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 other drivers.

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"

Implementation

1. Build a reusable bootstrap. Put SDK initialization and instrumentor activation in one function so it runs identically from a worker hook or manage.py runserver. The function configures the TracerProvider, a BatchSpanProcessor, then calls DjangoInstrumentor and the database instrumentor.

# 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. Initialize per worker with the gunicorn post_fork hook. The gunicorn master imports your app and then forks workers. Initializing 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-initialized 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 to Celery workers and 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.

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.

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)

Configuration Options

Option / Environment variable Effect
is_sql_commentor_enabled=True Appends trace context as a SQL comment, linking slow-query logs to traces.
request_hook / response_hook Callbacks to enrich the request span from the HttpRequest / HttpResponse.
OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS Comma-separated HttpRequest attributes copied to the span.
OTEL_PYTHON_DJANGO_EXCLUDED_URLS / OTEL_PYTHON_EXCLUDED_URLS Path patterns never traced (probes, metrics).
PsycopgInstrumentor(enable_commenter=True) Adds trace context comments to PostgreSQL queries for DB-side correlation.

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.

{
  "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"}}
          ]
        }
      ]
    }]
  }]
}

The SQL span lists the server span's span_id as its parent_span_id, and both share the same trace_id. Trace continuity across service boundaries relies on the propagators registered during SDK setup and detailed in context propagation and baggage.

Common Mistakes

Initializing the SDK in the gunicorn master instead of post_fork. Symptom: spans are dropped, duplicated, or never exported, and you see broken-pipe errors from the exporter. Fix: move all initialization into the post_fork hook so every worker owns an isolated exporter and buffer; never call instrument() before the fork.

Forgetting database instrumentation. Symptom: request spans exist but no SQL spans, so slow queries are invisible in the trace. Fix: activate PsycopgInstrumentor (or the DB-API instrumentor for other drivers) in the same bootstrap so each query nests under the request span.

Adding the OpenTelemetry middleware to MIDDLEWARE by hand. Symptom: duplicate server spans per request, or status codes that do not match the response. Fix: let DjangoInstrumentor().instrument() inject the middleware automatically and remove any manual entry; keep response-modifying middleware below it.

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.