Django applications can be instrumented with OpenTelemetry in two ways: automatically using the opentelemetry-instrument command with zero code changes, or programmatically by calling DjangoInstrumentor().instrument() inside your application. Both approaches produce the same spans.
The programmatic approach is the right choice for most production deployments because it gives you explicit control over SDK initialization order, exporter configuration, and signal setup. This guide covers both paths end-to-end, including database query tracing, ASGI setup, log correlation, and manual spans.
Key Takeaways
- Latest versions as of May 2026: opentelemetry-api==1.42.1, opentelemetry-sdk==1.42.1, opentelemetry-instrumentation-django==0.62b1. The Django instrumentation package requires Python 3.9 or above.
- Django 6.0.5 is the latest release. Django 5.2.x is the current LTS (security support until April 2028). Django 4.2 LTS reached the end of support in April 2026.
- DjangoInstrumentor().instrument() must be called after DJANGO_SETTINGS_MODULE is set and before Django processes any requests. The correct place in most deployments is manage.py or the WSGI/ASGI entry point.
- Database query spans are not captured by the Django instrumentation package alone. Install the appropriate database driver instrumentation separately: opentelemetry-instrumentation-psycopg2 for PostgreSQL via psycopg2, opentelemetry-instrumentation-psycopg for psycopg3, or opentelemetry-instrumentation-sqlite3 for SQLite.
- The default OTLP export protocol is HTTP/protobuf (port 4318). Use opentelemetry-exporter-otlp-proto-http unless your environment specifically requires gRPC.
- Use OTEL_PYTHON_DJANGO_EXCLUDED_URLS to exclude health check and readiness endpoints from tracing.
What Gets Instrumented Automatically
When you instrument a Django application with OpenTelemetry, the following are captured without additional code:
| Signal | What is captured |
| Traces | One span per HTTP request: method, URL template (not filled path), status code, duration |
| Context propagation | Incoming W3C TraceContext headers are read and used to continue parent traces from upstream services |
| Request attributes | HTTP method, URL, status code, user agent attached as span attributes by default |
Database query spans, outgoing HTTP call spans, and Celery task spans require separate instrumentation packages installed alongside the Django package.
Step 1: Install the Required Packages
pip install opentelemetry-api==1.42.1 \
opentelemetry-sdk==1.42.1 \
opentelemetry-instrumentation-django==0.62b1 \
opentelemetry-exporter-otlp-proto-http==1.42.1Install database driver instrumentation matching your backend:
# PostgreSQL via psycopg2 (Python 3.10+ required for 0.63b0)
pip install opentelemetry-instrumentation-psycopg2==0.63b0
# PostgreSQL via psycopg3
pip install opentelemetry-instrumentation-psycopg==0.62b1
# MySQL via mysqlclient
pip install opentelemetry-instrumentation-dbapi==0.62b1
# SQLite
pip install opentelemetry-instrumentation-sqlite3==0.62b1Note: opentelemetry-instrumentation-psycopg2==0.63b0 requires Python 3.10 or above. If you are running Python 3.9, pin to 0.62b1 instead, which supports Python 3.9.
For outgoing HTTP calls via the requests library:
pip install opentelemetry-instrumentation-requests==0.63b0
Pin all packages in requirements.txt to keep builds reproducible:
opentelemetry-api==1.42.1
opentelemetry-sdk==1.42.1
opentelemetry-instrumentation-django==0.62b1
opentelemetry-instrumentation-psycopg2==0.63b0
opentelemetry-exporter-otlp-proto-http==1.42.1Step 2A: Programmatic Instrumentation (Recommended)
Initialize the OTel SDK and call DjangoInstrumentor().instrument() before Django starts serving requests. The correct place is manage.py for development and the WSGI or ASGI entry point for production.
manage.py
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
# Initialize OTel before Django imports
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.instrumentation.django import DjangoInstrumentor
resource = Resource.create({
"service.name": "order-service",
"service.version": "1.0.0",
"deployment.environment": "production",
})
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces")
)
)
trace.set_tracer_provider(tracer_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint="http://otel-collector:4318/v1/metrics"),
export_interval_millis=60000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
# Instrument Django after DJANGO_SETTINGS_MODULE is set
DjangoInstrumentor().instrument()
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()wsgi.py (production WSGI deployments)
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.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.django import DjangoInstrumentor
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
resource = Resource.create({"service.name": "order-service"})
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces")
)
)
trace.set_tracer_provider(tracer_provider)
DjangoInstrumentor().instrument()
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()asgi.py (ASGI deployments with Daphne or Uvicorn)
For ASGI deployments, Django instrumentation wraps the ASGI application via the opentelemetry-instrumentation-asgi package, which is installed as a dependency of the Django instrumentation package. The setup is identical to WSGI:
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.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.django import DjangoInstrumentor
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
resource = Resource.create({"service.name": "order-service"})
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces")
)
)
trace.set_tracer_provider(tracer_provider)
DjangoInstrumentor().instrument()
from django.core.asgi import get_asgi_application
application = get_asgi_application()BatchSpanProcessor buffers spans and exports them in batches. Do not use SimpleSpanProcessor in production. It exports each span synchronously on the request thread, which adds latency to every response.
Step 2B: Zero-Code Auto-Instrumentation
For deployments where you cannot modify application code, use the opentelemetry-instrument command. Install opentelemetry-distro to get the command:
pip install opentelemetry-distro==0.63b1Run the bootstrap command to auto-detect and install instrumentation for packages in your environment:
opentelemetry-bootstrap -a installThen start Django with environment variables:
OTEL_SERVICE_NAME=order-service \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
OTEL_PYTHON_DJANGO_EXCLUDED_URLS="health,readyz,metrics" \
opentelemetry-instrument python manage.py runserverIn Docker:
services:
order-service:
image: order-service:latest
environment:
OTEL_SERVICE_NAME: order-service
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_PYTHON_DJANGO_EXCLUDED_URLS: "health,readyz,metrics"
command: >
opentelemetry-instrument
gunicorn myproject.wsgi:application --bind 0.0.0.0:8000Step 3: Instrument Database Queries
Django ORM queries are not captured by the Django instrumentation package alone. After installing the appropriate database driver instrumentation, call its instrumentor explicitly alongside DjangoInstrumentor:
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
DjangoInstrumentor().instrument()
Psycopg2Instrumentor().instrument()Each database query then produces a child span under the request span, with the SQL statement, database name, and duration as attributes.
Enabling sqlcommenter for database log correlation:
sqlcommenter appends trace context as SQL comments to every query, enabling correlation between database logs and application traces. Enable it on DjangoInstrumentor:
DjangoInstrumentor().instrument(is_sql_commentor_enabled=True)A query like User.objects.all() then executes as:
SELECT * FROM auth_user /*traceparent='00-abc123-def456-01'*/Step 4: Add Manual Spans for Business Logic
Auto-instrumentation captures the HTTP request and database lifecycle. Add manual spans for business logic inside your views:
from opentelemetry import trace
tracer = trace.get_tracer("order-service", "1.0.0")
def process_order(request):
with tracer.start_as_current_span("order.validate") as span:
span.set_attribute("order.customer_id", str(request.user.id))
span.set_attribute("order.item_count", len(request.POST.getlist("items")))
validate_order(request)
with tracer.start_as_current_span("order.charge") as span:
span.set_attribute("payment.provider", "stripe")
try:
result = charge_customer(request)
span.set_attribute("payment.transaction_id", result.transaction_id)
except PaymentError as e:
span.record_exception(e)
span.set_status(trace.StatusCode.ERROR, str(e))
raise
return resultAdding attributes to the current active span without creating a child span:
from opentelemetry import trace
def order_detail(request, order_id):
trace.get_current_span().set_attribute("order.id", str(order_id))
return render(request, "order_detail.html", {"order": get_order(order_id)})Step 5: Correlate Logs with Traces
The OTel Python SDK can inject the active trace ID and span ID into Django’s log records automatically, enabling log-to-trace correlation in your backend.
import logging
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
# Add to your SDK initialization block alongside TracerProvider setup
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(endpoint="http://otel-collector:4318/v1/logs")
)
)
set_logger_provider(logger_provider)
# Attach OTel handler to the root logger
handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
logging.getLogger().addHandler(handler)After this setup, every log record emitted during a traced request automatically carries the trace_id and span_id of the active span.
Excluding URLs from Tracing
Health check, readiness, and liveness endpoints should be excluded to prevent noise in your traces.
Via environment variable:
OTEL_PYTHON_DJANGO_EXCLUDED_URLS="health,readyz,livez,metrics"Via code:
DjangoInstrumentor().instrument(excluded_urls="health,readyz,livez,metrics")The value is a comma-separated list of regex patterns matched against the full request URL path. health matches /health, /healthz, and /api/health.
Configure the OTel Collector
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 512
exporters:
otlp:
endpoint: your-backend:4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlp]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlp]Common Setup Problems
| Problem | Likely cause | Fix |
| No spans appearing in the backend | DjangoInstrumentor().instrument() called after Django starts handling requests | Move the call to manage.py or wsgi.py before get_wsgi_application() |
| Database queries have no spans | Database driver instrumentation not installed or its instrumentor not called | Install the matching package and call its instrumentor explicitly after DjangoInstrumentor().instrument() |
| opentelemetry-instrument command not found | opentelemetry-distro not installed | Run pip install opentelemetry-distro==0.63b1 |
| Health check endpoints flooding traces | Not excluded from instrumentation | Set OTEL_PYTHON_DJANGO_EXCLUDED_URLS=health,readyz,livez |
| Spans appear but have no custom request attributes | OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS not configured | Set it to a comma-separated list of Django request attribute names, e.g. OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS=path_info,content_type |
| psycopg2 instrumentation install fails on Python 3.9 | opentelemetry-instrumentation-psycopg2==0.63b0 requires Python 3.10+ | Pin to opentelemetry-instrumentation-psycopg2==0.62b1 if running Python 3.9 |
| Logs not correlated with traces | LoggingHandler not attached or attached after the first log call | Attach the handler before Django’s logging system emits any records |
| Metrics not appearing | PeriodicExportingMetricReader export interval not elapsed | Default export interval is 60 seconds. Data may not appear immediately after startup |
From Traces to Root Cause: Where CubeAPM Fits
Once OpenTelemetry tracing is flowing from your Django application, the value it delivers depends on what your backend does with the data. A span showing a 900ms request is visible in any trace viewer. Understanding whether that 900ms came from a slow Django ORM query, a downstream API call, or a Redis cache miss requires a backend that correlates spans with infrastructure metrics and logs from the same request.
CubeAPM accepts OTLP directly from your existing Django instrumentation with no additional configuration. It correlates distributed request traces with database query spans, outgoing HTTP call durations, and infrastructure metrics using the shared context that OpenTelemetry provides. When a slow request trace appears, CubeAPM links it to the specific query or downstream call responsible. It runs self-hosted inside your own infrastructure at $0.15/GB ingestion with no per-user fees.
Summary
Instrumenting a Django application with OpenTelemetry takes three steps: installing the packages, initializing the SDK, and calling DjangoInstrumentor().instrument() before requests are served. Database query spans require separate driver instrumentation packages called alongside the Django instrumentor. Use BatchSpanProcessor in production, never SimpleSpanProcessor. Add manual spans inside view functions for business logic that auto-instrumentation cannot see.
| Step | What to do | Key detail |
| Install packages | opentelemetry-api, opentelemetry-sdk, opentelemetry-instrumentation-django, database driver instrumentation | SDK 1.42.1, Django instrumentation 0.62b1. Python 3.9 or above |
| Initialize SDK | Create TracerProvider, set globally before Django starts | Use BatchSpanProcessor. Initialize in manage.py or wsgi.py |
| Instrument Django | DjangoInstrumentor().instrument() after DJANGO_SETTINGS_MODULE is set | Call database instrumentors immediately after |
| Export to Collector | HTTP exporter pointing at port 4318. gRPC exporter at port 4317 | HTTP/protobuf is the recommended default |
| Instrument database | Call Psycopg2Instrumentor().instrument() or equivalent | Database spans appear as children of the request span |
| Add manual spans | tracer.start_as_current_span(“name”) inside view functions | Call span.record_exception() and set status on failure |
| Correlate logs | Add LoggingHandler to root logger | Injects trace ID and span ID into every log record |
| Exclude noisy URLs | OTEL_PYTHON_DJANGO_EXCLUDED_URLS=health,readyz,livez | Comma-separated regex patterns matched against the URL path |
Disclaimer : Package versions are verified directly from PyPI: opentelemetry-api 1.42.1 and opentelemetry-sdk 1.42.1 (May 21, 2026), opentelemetry-instrumentation-django 0.62b1 (April 24, 2026), opentelemetry-instrumentation-psycopg2 0.63b0 (May 19, 2026), opentelemetry-instrumentation-requests 0.63b0 (May 19, 2026), opentelemetry-distro 0.63b1 (May 21, 2026). Django version information verified from djangoproject.com/download (Django 6.0.5 latest, Django 5.2.x current LTS) as of May 2026.
Also read:
How to Add OpenTelemetry Tracing to a Spring Boot Application
What is the Difference Between Synthetic monitoring and Real User Monitoring (RUM)?





