Instrumenting Django with Prometheus Metrics
Django's middleware stack makes request metrics straightforward, and two details decide whether the result is useful: which label carries the route, and whether the scrape counts itself. This page covers both, plus database timing through the execute wrapper and the multiprocess behaviour every gunicorn deployment meets. It builds on Prometheus client instrumentation, part of the Python metrics and instrumentation section.
Prerequisites
pip install "django>=5.0,<6.0" \
"prometheus-client>=0.20.0,<1.0.0" \
"gunicorn>=21.2.0,<23.0.0"
export PROMETHEUS_MULTIPROC_DIR=/tmp/django_prom
mkdir -p "$PROMETHEUS_MULTIPROC_DIR" && rm -f "$PROMETHEUS_MULTIPROC_DIR"/*
Implementation
Step 1 — Define the instruments once. Constructing a metric twice raises Duplicated timeseries, and Django settings modules can be imported more than once.
# observability/metrics.py — the single owner of every instrument
from prometheus_client import Counter, Gauge, Histogram
REQUESTS = Counter(
"django_http_requests_total", "Total requests",
["method", "route", "status"],
)
LATENCY = Histogram(
"django_http_request_duration_seconds", "Request latency",
["method", "route"],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
)
IN_FLIGHT = Gauge(
"django_http_requests_in_progress", "Requests being handled",
["method"], multiprocess_mode="livesum",
)
DB_QUERY = Histogram(
"django_db_query_duration_seconds", "Database query duration",
["alias", "operation"],
buckets=(0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0),
)
Step 2 — Write the middleware. Read the route on the way out, and fold unmatched requests into one value.
# observability/middleware.py
import time
from django.db import connections
from .metrics import REQUESTS, LATENCY, IN_FLIGHT, DB_QUERY
class MetricsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
started = time.perf_counter()
IN_FLIGHT.labels(request.method).inc()
try:
with connections["default"].execute_wrapper(self._time_query):
response = self.get_response(request)
finally:
IN_FLIGHT.labels(request.method).dec()
route = self._route(request)
elapsed = time.perf_counter() - started
LATENCY.labels(request.method, route).observe(elapsed)
REQUESTS.labels(request.method, route, response.status_code).inc()
return response
@staticmethod
def _route(request) -> str:
match = getattr(request, "resolver_match", None)
return match.route if match is not None else "<unmatched>" # 404s, static, blocks
@staticmethod
def _time_query(execute, sql, params, many, context):
alias = context["connection"].alias
operation = sql.split(None, 1)[0].upper() if sql else "UNKNOWN"
started = time.perf_counter()
try:
return execute(sql, params, many, context)
finally:
DB_QUERY.labels(alias, operation).observe(time.perf_counter() - started)
Register it outermost — first in the list, since Django processes MIDDLEWARE top-down on the way in:
# settings.py
MIDDLEWARE = [
"observability.middleware.MetricsMiddleware", # first = outermost
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
...
]
The _time_query wrapper labels by operation — SELECT, INSERT, UPDATE — and never by statement. A statement label is unbounded by construction and would also put query text, including bound values, into a metric name.
Step 3 — Serve /metrics outside the middleware stack. A Django view would pass through MetricsMiddleware and count every scrape.
# wsgi.py
import os
from django.core.wsgi import get_wsgi_application
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from prometheus_client import CollectorRegistry, make_wsgi_app, multiprocess
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
django_app = get_wsgi_application()
def metrics_app(environ, start_response):
registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry) # merge per-worker files
return make_wsgi_app(registry)(environ, start_response)
target = metrics_app if os.environ.get("PROMETHEUS_MULTIPROC_DIR") else make_wsgi_app()
application = DispatcherMiddleware(django_app, {"/metrics": target})
Step 4 — Handle gunicorn's workers. Each prefork worker has its own registry unless the multiprocess directory is set.
# gunicorn.conf.py
from prometheus_client import multiprocess
def child_exit(server, worker):
multiprocess.mark_process_dead(worker.pid) # retire the dead worker's files
gunicorn -c gunicorn.conf.py --workers 4 --bind 0.0.0.0:8000 wsgi:application
Counters and histograms sum across workers automatically; gauges need an explicit multiprocess_mode, which is why IN_FLIGHT declares livesum. The full mechanism is in Prometheus client instrumentation.
Configuration options
| Option | Where | Purpose | Notes |
|---|---|---|---|
| Middleware position | settings.MIDDLEWARE |
see every request | first in the list |
resolver_match.route |
middleware | bounded route label | read on the way out |
<unmatched> fallback |
middleware | 404s and blocks | one series, not none |
execute_wrapper |
middleware | query timing | label by operation, never statement |
/metrics mount |
wsgi.py |
avoid self-counting | DispatcherMiddleware |
PROMETHEUS_MULTIPROC_DIR |
env | worker aggregation | cleared on boot |
multiprocess_mode |
gauges | cross-worker reduction | livesum for in-flight |
child_exit |
gunicorn | clean dead workers | mark_process_dead |
Verification
curl -s localhost:8000/orders/1 > /dev/null
curl -s localhost:8000/orders/2 > /dev/null
curl -s localhost:8000/metrics | grep -E 'django_http_requests_total|django_db_query'
Expected Output:
django_http_requests_total{method="GET",route="orders/<int:pk>/",status="200"} 2.0
django_db_query_duration_seconds_bucket{alias="default",le="0.01",operation="SELECT"} 4.0
django_db_query_duration_seconds_count{alias="default",operation="SELECT"} 4.0
Three properties confirm the setup: two different IDs produced one series with the URL pattern as the route, the database queries were measured without any model code being touched, and there is no series at all for the /metrics path — because the scrape never entered Django.
Common mistakes
Every route label is None
Error signature: one series with route="None" covering all traffic.
Root cause: resolver_match was read before URL resolution — on the way in rather than the way out.
Remediation: read it after get_response returns, and fold the genuinely unmatched requests into one fallback value.
One series per primary key
Error signature: series count grows with traffic and the store starts rejecting writes.
Root cause: the label was taken from request.path.
Remediation: use resolver_match.route, which is the pattern. The budgeting method is in controlling label cardinality in Prometheus.
Totals fluctuate between scrapes
Error signature: counters go down as often as up, and every value looks about a quarter too small.
Root cause: four gunicorn workers with four private registries.
Remediation: set PROMETHEUS_MULTIPROC_DIR, render through MultiProcessCollector, and add the child_exit hook.
Beyond request metrics
Request counts and latency are the baseline; the metrics that make a Django service genuinely observable are usually the ones specific to what it does. Four categories are worth adding deliberately.
Business outcomes. Orders placed, payments declined, signups completed — counters incremented at the point the outcome is decided, labelled by outcome and nothing else. These are the metrics anyone outside engineering asks about, and they are also the fastest way to detect an incident that does not manifest as an error: requests succeeding while the thing they were supposed to do stopped happening.
Queue and job state. Where the project uses Celery or a similar worker, the depth of each queue and the age of the oldest waiting item. Depth alone is ambiguous — a deep queue draining quickly is fine — and age is the number that says whether work is actually stuck.
Cache effectiveness. Hits and misses by cache name, which is two counters and answers "is the cache doing anything" without a profiling session. A hit ratio that quietly drops after a deploy is a common and otherwise invisible cause of a latency regression.
External dependency health. Latency and error counters for each outbound service, labelled by the dependency rather than by the endpoint. The tracing signal covers individual calls better; the metric is what an alert can be built on.
ORDERS = Counter("orders_total", "Orders by outcome", ["outcome"])
CACHE = Counter("cache_operations_total", "Cache operations", ["cache", "result"])
QUEUE_AGE = Gauge("queue_oldest_item_age_seconds", "Age of the oldest queued item", ["queue"])
| Metric family | Labels | Answers |
|---|---|---|
| Business outcomes | outcome | did the service do its job |
| Queue depth and age | queue | is work getting stuck |
| Cache hits and misses | cache, result | is the cache working |
| Dependency latency and errors | dependency | which one is degraded |
Cardinality discipline in a Django project
Two Django-specific patterns account for most cardinality accidents.
The first is labelling by anything derived from a model instance: a user, an organisation, a product. Each is unbounded in principle and large in practice, and the metric that results grows with the business rather than with the code. The rule is that a label's value set should be enumerable from the source, not from the database.
The second is a per-view metric created inside the view, which produces one instrument per view rather than one instrument with a view label — and, if the view is a class-based view instantiated per request, one instrument per request, which raises Duplicated timeseries on the second one. Instruments belong at module scope in one place, and the view identity belongs in a label.
The one legitimate exception is a small, fixed set that happens to be a model field: a subscription tier with four values, a region with six. Those are enumerable, stable, and worth having — the test is whether adding a row to the table can add a series.
A note on where these live: business counters belong in the same instruments module as the request metrics, not scattered across the apps that increment them. One module means one place to review the label sets, and it avoids the duplicate-registration error that appears the first time two apps declare a metric with the same name.
Serving the endpoint safely
Two details about the exposition endpoint in a Django deployment. It should not be routed through the URL configuration if the WSGI mount is available, for the self-counting reason above. And it should be protected: the exposition names every route, every dependency, every queue and the build version, which is a useful map for anyone probing the service. A network policy, an ingress rule, or a separate port bound to an internal interface all work; what does not work is relying on obscurity, because /metrics is the first path anything scanning will try.
Related
- Prometheus client instrumentation in Python — the parent guide: registries, exposition and multiprocess mode.
- Instrumenting Flask with Prometheus metrics — the same pattern with request hooks instead of middleware.
- Choosing histogram buckets for latency SLOs — the ladder this middleware should use.
- Logging configuration in Django settings — the logs signal for the same project.
- Instrumenting Django with OpenTelemetry — the traces signal, and the route label again.
Frequently Asked Questions
Do I need django-prometheus?
No. The official prometheus_client plus about forty lines of middleware gives you request counts, latency, in-flight requests and database timing, with label choices you control. django-prometheus is a reasonable convenience wrapper, and writing the middleware yourself keeps the route label — the one that decides your cardinality — under your own review rather than a library's defaults.
Where do I get a bounded route label in Django?
request.resolver_match.route, which is the URL pattern string such as orders/
Why should the metrics endpoint not be a Django view?
Because a Django view passes through your middleware, so every scrape counts itself: a fifteen-second scrape interval adds four requests a minute to the totals for a route nobody called. Mounting the client's WSGI app beside Django through a dispatcher keeps the exposition outside the middleware stack entirely, and inherits the same port and network policy.
How do I measure database time without instrumenting every query?
Use Django's database instrumentation hook, connection.execute_wrapper, which wraps every query executed inside its context and gives you the duration without touching any model code. Register it in middleware so the wrapper is active for the request's lifetime, and label by alias and operation rather than by statement, since a statement label is unbounded.