Usage guide¶
Quick start¶
Installation¶
For everything:
Or pick what you need:
| Extra | Adds |
|---|---|
django-o11y[celery] |
Celery tracing and structured task logs |
django-o11y[profiling] |
Pyroscope continuous profiling |
django-o11y[all] |
Everything |
Basic setup¶
Add to your Django settings:
from django_o11y.logging.config import build_logging_dict
LOGGING_CONFIG = None
LOGGING = build_logging_dict()
INSTALLED_APPS = [
"django_prometheus",
"django_o11y",
# ...
]
MIDDLEWARE = [
"django_prometheus.middleware.PrometheusBeforeMiddleware",
# Django middleware
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
# Observability (after auth so request.user is available)
"django_o11y.middleware.TracingMiddleware",
"django_o11y.middleware.LoggingMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_prometheus.middleware.PrometheusAfterMiddleware",
]
Metrics¶
Metrics use django-prometheus for infrastructure metrics and a thin wrapper around prometheus_client for custom business metrics.
Infrastructure metrics¶
Wrap your database and cache backends to get query counts, latency, and cache hit rates:
DATABASES = {
"default": {
"ENGINE": "django_prometheus.db.backends.postgresql",
# ...
}
}
CACHES = {
"default": {
"BACKEND": "django_prometheus.cache.backends.redis.RedisCache",
# ...
}
}
Expose the metrics endpoint in your URL config:
# urls.py
from django.urls import include, path
urlpatterns = [
# ...
path("", include("django_prometheus.urls")),
]
This exposes /metrics for Prometheus to scrape.
Custom metrics¶
Track business events with counters and histograms. Label names must be declared upfront (Prometheus convention):
from django_o11y.metrics import counter, histogram
# Counter
payment_counter = counter(
"payments_processed_total",
description="Total payments processed",
labelnames=["status", "method"],
)
payment_counter.add(1, {"status": "success", "method": "card"})
# Histogram — manual observation
payment_latency = histogram(
"payment_processing_seconds",
description="Payment processing time",
labelnames=["method"],
)
payment_latency.record(0.532, {"method": "card"})
# Histogram — automatic timing via context manager
with payment_latency.time({"method": "card"}):
result = process_payment()
Custom metrics appear on the same /metrics endpoint alongside infrastructure metrics.
Logs¶
Structured logging via Structlog with automatic trace correlation. Every log line includes trace_id and span_id, so you can jump from a log entry directly to its trace in Grafana.
Setup¶
Call build_logging_dict() in each settings file. The defaults are keyed off DEBUG, so most of the difference between environments is handled automatically.
LOGGING_CONFIG = None is required in every settings file. Without it Django applies its own default handlers at startup, which causes duplicate access logs and Werkzeug's color handler showing up alongside structlog output.
settings/local.py
from django_o11y.logging.config import build_logging_dict
LOGGING_CONFIG = None
LOGGING = build_logging_dict()
# DEBUG=True: console format, colorized, file output to /tmp/django-o11y/django.log
settings/production.py
from django_o11y.logging.config import build_logging_dict
LOGGING_CONFIG = None
LOGGING = build_logging_dict()
# DEBUG=False: JSON format, no file output
settings/test.py
from django_o11y.logging.config import build_logging_dict
LOGGING_CONFIG = None
LOGGING = build_logging_dict({"LEVEL": "WARNING", "FILE_ENABLED": False})
# Quiet in tests regardless of DEBUG
Usage¶
Use structlog.get_logger() everywhere — not logging.getLogger():
import structlog
logger = structlog.get_logger(__name__)
logger.info("order_placed", order_id=order_id, amount=total)
logger.error("payment_failed", error=str(e), order_id=order_id)
Use keyword arguments, not f-strings. This keeps logs machine-readable and queryable in Loki.
Output formats¶
Development (DEBUG=True) — colorized console:
Production (DEBUG=False) — JSON with trace correlation:
{
"event": "order_placed",
"order_id": 123,
"amount": 49.99,
"trace_id": "a1b2c3d4e5f6g7h8",
"span_id": "i9j0k1l2",
"timestamp": "2026-02-12T10:30:45.123Z",
"level": "info",
"logger": "myapp.views",
"filename": "views.py",
"lineno": 42
}
The format switches automatically based on DEBUG. Override it:
Or via env var: DJANGO_LOG_FORMAT=json.
Log file (dev only)¶
When DEBUG=True, logs are also written as JSON to /tmp/django-o11y/django.log. The local dev stack (o11y stack start) tails this file with Alloy and ships it to Loki, so logs show up in Grafana without needing OTLP push enabled. Useful when running runserver or runserver_plus directly on the host.
Override the path:
Disable it:
Extending the config¶
Pass extra to deep-merge additional loggers or handlers into the base dict:
from django_o11y.logging.config import build_logging_dict
LOGGING_CONFIG = None
LOGGING = build_logging_dict(extra={
"loggers": {
"myapp": {"level": "DEBUG"},
"myapp.payments": {"level": "WARNING"},
},
})
Nested dicts are merged rather than replaced, so you only need to specify what you want to change.
Adding context¶
Attach extra fields to all logs within the current request or task:
from django_o11y.context import add_log_context, set_custom_tags
# Logs only
add_log_context(tenant_id="acme", checkout_variant="B")
# Logs + traces
set_custom_tags({"tenant_id": "acme", "feature": "checkout_v2"})
Profiling¶
Continuous profiling via Pyroscope. Disabled by default. The Python SDK only supports push mode.
Setup¶
Install the extra:
Enable in settings:
Profiles are pushed to Pyroscope on startup. View them in Grafana under Explore → Pyroscope.
Custom tags¶
Tag profiles with extra context for filtering:
DJANGO_O11Y = {
"PROFILING": {
"ENABLED": True,
"TAGS": {
"region": "us-east-1",
"tier": "premium",
},
}
}
Traces¶
Distributed tracing via OpenTelemetry. Django requests, database queries, cache operations, and outbound HTTP calls are instrumented automatically.
What gets instrumented¶
- Every HTTP request — span per view with status code, route, and user ID
- Database queries — span per query (requires django-prometheus DB backend)
- Outbound HTTP — spans for
requestsandurllib3calls (requiresdjango-o11y[http]) - Redis — spans for cache operations (requires
django-o11y[redis]) - Celery tasks — span per task, linked to the request that triggered it (requires
django-o11y[celery])
Configuration¶
DJANGO_O11Y = {
"TRACING": {
"ENABLED": True, # default: False
"OTLP_ENDPOINT": "http://localhost:4317",
"SAMPLE_RATE": 1.0, # 1.0 = 100%, use 0.01 in high-traffic prod
}
}
To disable tracing entirely, set ENABLED: False or DJANGO_O11Y_TRACING_ENABLED=false.
Adding custom context¶
from django_o11y.context import set_custom_tags, add_span_attribute, set_user_context
def checkout_view(request):
# Attached to both the trace span and all logs in this request
set_custom_tags({"tenant_id": "acme", "experiment": "new_checkout"})
# Span only
add_span_attribute("cart_size", len(cart.items))
# User identity on the span (called automatically if using AuthenticationMiddleware)
set_user_context(user_id=str(request.user.id), username=request.user.username)
| Function | Trace span | Logs | Use case |
|---|---|---|---|
set_custom_tags() |
Yes | Yes | Business context |
add_span_attribute() |
Yes | No | Technical span data |
add_log_context() |
No | Yes | Debug info |
set_user_context() |
Yes | Yes | User identity |
Celery¶
Install the extra:
Enable in settings:
Observability is set up via Celery signals when the worker starts. Each task gets a trace span linked to the request that triggered it, structured logs with trace_id/span_id, and task lifecycle metrics.
import structlog
from celery import shared_task
from django_o11y.context import set_custom_tags
logger = structlog.get_logger(__name__)
@shared_task
def process_order(order_id: int):
set_custom_tags({"order_id": order_id})
logger.info("order_processing_started", order_id=order_id)
result = do_processing(order_id)
logger.info("order_processing_completed", order_id=order_id)
return result
Verification¶
Run the built-in health check:
This checks the OTLP endpoint, installed packages, and sends a test trace you can find in Tempo.