(backend) add opt-in prometheus metrics on /metrics

We want to export metrics to prometheus. We install for this
django-prometheus. By default the feature is disabled and must be
explicitly enabled. A complete documentation is available in
documentation/metrics.md
This commit is contained in:
Manuel Raynaud
2026-09-21 14:48:42 +02:00
parent 5acd4c2902
commit 6c9a319a83
14 changed files with 809 additions and 4 deletions
+3
View File
@@ -14,6 +14,9 @@ and this project adheres to
- 🐛(frontend) reduce PostHog volume from web vitals and opt_in spam #2701
- ✨(backend) expose the attachment max size in the config endpoint #2577
- ✨(frontend) warn before uploading an attachment over the size limit #2577
- ✨(backend) add opt-in prometheus metrics on `/metrics`, protected by a
bearer token
- ✨(helm) add a dedicated ingress for the prometheus metrics
- ✨(backend) add a service generating cached RS256 JWT tokens
- ✨(backend) publish the JWT public key on a JWKS endpoint
- 🔧(dev) generate the JWT signing key when bootstrapping the dev stack
+4
View File
@@ -133,6 +133,10 @@ These are the environment variables you can set for the `impress-backend` contai
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| POSTHOG_KEY | Posthog key for analytics | |
| PROMETHEUS_API_KEY | Bearer token required on `/metrics`. Required when the metrics are enabled (the application refuses to start without it). Can be given as a file with PROMETHEUS_API_KEY_FILE | |
| PROMETHEUS_DB_METRICS_ENABLED | When the metrics are enabled, also count and time the SQL queries (swaps the postgresql engine for the instrumented one of django-prometheus) | True |
| PROMETHEUS_METRICS_ENABLED | Instrument the application with django-prometheus and serve `/metrics`. OFF by default. See documentation/metrics.md | False |
| PROMETHEUS_MULTIPROC_DIR | Directory the uvicorn workers share their metrics through. Local to the host or pod, owned by the user running the application | `<tmp>/impress-prometheus-<uid>` |
| POSTHOG_HOST | Posthog host for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
+122
View File
@@ -0,0 +1,122 @@
# Prometheus metrics of the backend
The backend ships an **opt-in** instrumentation,
[django-prometheus](https://github.com/django-commons/django-prometheus). It is
**disabled by default**: when `PROMETHEUS_METRICS_ENABLED` is unset,
`django_prometheus` is not in `INSTALLED_APPS`, its middlewares are not
installed, the database engine is left alone and `/metrics` is not routed.
## Enabling it
```bash
PROMETHEUS_METRICS_ENABLED=True
PROMETHEUS_API_KEY=<a long random secret> # or PROMETHEUS_API_KEY_FILE
```
The application **refuses to start** with the metrics enabled and no key: the
endpoint would otherwise answer to anybody.
Then scrape `GET /metrics` with the key as a bearer token:
```yaml
scrape_configs:
- job_name: docs-backend
scheme: https
metrics_path: /metrics
authorization:
type: Bearer
credentials_file: /etc/prometheus/docs-api-key
static_configs:
- targets: ["metrics.docs.example.com"]
```
## What is measured
- HTTP requests: counts and latency histograms, labelled by **view name**,
method and status
(`django_http_requests_latency_seconds_by_view_method`,
`django_http_responses_total_by_status_view_method_total`, ...).
- SQL (unless `PROMETHEUS_DB_METRICS_ENABLED=False`): queries, errors and query
duration (`django_db_execute_total`, `django_db_query_duration_seconds`, ...).
With `DB_PSYCOPG_POOL_ENABLED`, `django_db_new_connections_total` counts the
connections taken from the pool, not the connections opened to Postgres.
No label ever carries a path, a user or a document identifier. A request that
matches no route is counted under `<unnamed view>`.
Every sample carries a `hostname` label (the pod name in Kubernetes), see
[Several replicas](#several-replicas-behind-one-address).
## How the endpoint is protected
| Layer | What it guarantees |
|---|---|
| Off by default | Nothing is installed, and `/metrics` is a 404, unless the deployment asks for it |
| Served on `/metrics`, not under `/api/` | The ingress of the application publishes `/api` and `/external_api` only: the metrics are not reachable from outside until a route is added on purpose |
| Bearer token | `core.middleware.PrometheusAuthMiddleware` refuses anything but `Authorization: Bearer <PROMETHEUS_API_KEY>`, compared in constant time. It fails closed: no key, no answer |
| First middleware | A refused call stops there: no session is created, no database or cache access is made |
| Dedicated ingress (chart) | One exact path on a host of its own, where the callers are filtered by address |
| Labels | No path, user or document identifier leaves the application |
The key is a long-lived shared secret, which is what a Prometheus scraper can
present. Treat it as such: give it through `PROMETHEUS_API_KEY_FILE` or a
Kubernetes secret, keep TLS on wherever it travels, and rotate it by changing
the value on both sides.
## uvicorn workers
uvicorn runs several worker processes (`WEB_CONCURRENCY`) and a scrape is
answered by one of them. The workers therefore all write their numbers to one
directory (prometheus_client's multiprocess mode), and whichever answers adds
them up. Nothing has to be configured: the directory defaults to
`<tmp>/impress-prometheus-<uid>`, is created with mode `0700`, and the
application refuses a directory owned by another user or a symbolic link. Set
`PROMETHEUS_MULTIPROC_DIR` to put it elsewhere — it must be writable, and local
to the host or the pod: it is **not** shared between replicas.
Known limit: uvicorn recycles its workers (`--limit-max-requests`) and has no
hook to tell when one is gone. Every new worker writes two new 64 KiB files,
and the files of the workers that are gone must stay — their counters are part
of the totals. The directory grows, and the scrape gets slower, until the
container is replaced. Watch `scrape_duration_seconds` on long runs. Outside of
a container, empty the directory when the application is stopped.
## Several replicas behind one address
A scrape that goes through a load balancer — the dedicated ingress in front of
several backend pods — is answered by a different replica each time. The
`hostname` label keeps the replicas apart: each has its own series, and its
counters never go backwards. Each series is only sampled when its replica
happens to answer, so with `N` replicas expect one sample every `N` scrapes on
average: use a short scrape interval and rate windows of several minutes, and
aggregate with `sum without (hostname) (rate(...[5m]))`.
This degrades as the number of replicas grows. A Prometheus running inside the
cluster should scrape each pod directly instead (a `PodMonitor` or pod
discovery on the `http` port, same path, same bearer token).
## Kubernetes (Helm chart)
```yaml
backend:
envVars:
PROMETHEUS_METRICS_ENABLED: "True"
PROMETHEUS_API_KEY:
secretKeyRef:
name: backend
key: PROMETHEUS_API_KEY
DJANGO_ALLOWED_HOSTS: docs.example.com,metrics.docs.example.com
ingressMetrics:
enabled: true
host: metrics.docs.example.com
annotations:
nginx.ingress.kubernetes.io/whitelist-source-range: "203.0.113.10/32"
```
`ingressMetrics` routes the exact path `/metrics` of that host to the backend
and nothing else. Its host has to be in `DJANGO_ALLOWED_HOSTS`.
The celery worker receives `backend.envVars` too. It serves no request, so its
metrics are never read: turn them off there with
`backend.celery.envVars.PROMETHEUS_METRICS_ENABLED: "False"`.
+58
View File
@@ -0,0 +1,58 @@
"""Prometheus metrics of the application, as served on /metrics.
Enabled by PROMETHEUS_METRICS_ENABLED and protected by
`core.middleware.PrometheusAuthMiddleware` — see documentation/metrics.md.
"""
import socket
from django.http import HttpResponse
from django.views.decorators.http import require_safe
from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, generate_latest
from prometheus_client.core import Metric
from prometheus_client.multiprocess import MultiProcessCollector
HOSTNAME_LABEL = "hostname"
class HostnameLabelCollector:
"""
The metrics of every worker process of this host, labelled with its name.
uvicorn runs several workers and the scrape is answered by one of them: the
multiprocess collector reads what they all wrote to PROMETHEUS_MULTIPROC_DIR
and adds it up, instead of reporting the numbers of that worker alone.
The label is for a scraper that goes through a load balancer (an ingress in
front of several replicas): each scrape is then answered by a different
replica, and without it their counters would land in the same series, which
would jump from the numbers of one replica to those of another. With it,
each replica has its own series, sampled whenever it happens to answer.
"""
def __init__(self):
self.hostname = socket.gethostname()
def collect(self):
"""Yield the aggregated metrics with the hostname added to every sample."""
# a registry of its own: the collector registers itself in the one it is given
for metric in MultiProcessCollector(CollectorRegistry()).collect():
labelled = Metric(metric.name, metric.documentation, metric.type)
for sample in metric.samples:
labelled.add_sample(
sample.name,
{**sample.labels, HOSTNAME_LABEL: self.hostname},
sample.value,
sample.timestamp,
sample.exemplar,
)
yield labelled
@require_safe
def metrics_view(request):
"""Serve the metrics in the Prometheus text format."""
registry = CollectorRegistry()
registry.register(HostnameLabelCollector())
return HttpResponse(generate_latest(registry), content_type=CONTENT_TYPE_LATEST)
+40 -2
View File
@@ -1,13 +1,24 @@
"""Force session creation for all requests."""
"""Custom middlewares of the impress core application."""
from secrets import compare_digest
from django.conf import settings
from django.http import HttpResponse
from django.utils.deprecation import MiddlewareMixin
# Where the Prometheus metrics are served when PROMETHEUS_METRICS_ENABLED is set.
# Defined here rather than next to the view so that this module, which is always
# loaded, does not import prometheus_client when the metrics are disabled.
METRICS_PATH = "/metrics"
# Paths that must never touch the session store. Liveness and readiness
# probes should never create a new session in redis. Also, the liveness probe
# should never reach redis before returning its answer.
# should never reach redis before returning its answer. A scraper carries no
# cookie either: each of its calls would leave one more session behind.
SESSION_EXEMPT_PATHS = (
"/__lbheartbeat__",
"/__heartbeat__",
METRICS_PATH,
)
@@ -37,3 +48,30 @@ class SaveRawBodyMiddleware(MiddlewareMixin):
"""Save the raw request body in the request to use it later."""
if request.path.endswith(("/ai-proxy/", "/ai-proxy")):
request.raw_body = request.body
class PrometheusAuthMiddleware(MiddlewareMixin):
"""
Require PROMETHEUS_API_KEY as a bearer token on the metrics endpoint.
Installed first, and only when PROMETHEUS_METRICS_ENABLED is set. It fails
closed: with no key configured nothing is served, whatever is presented.
"""
def process_request(self, request):
"""Refuse a call to the metrics endpoint that does not present the key."""
if request.path.rstrip("/") != METRICS_PATH:
return None
api_key = settings.PROMETHEUS_API_KEY
authorization = request.headers.get("Authorization") or ""
# compared as bytes: `compare_digest` refuses non-ASCII strings, and
# what a caller sends in a header is not ours to trust
if not api_key or not compare_digest(
authorization.encode(), f"Bearer {api_key}".encode()
):
response = HttpResponse("Unauthorized", status=401)
response["WWW-Authenticate"] = 'Bearer realm="metrics"'
return response
return None
@@ -0,0 +1,170 @@
"""
Test the Prometheus metrics endpoint and the instrumentation of the application.
"""
import socket
from importlib import reload
from django.test import override_settings
from django.urls import clear_url_caches, resolve
import pytest
from prometheus_client import REGISTRY, Counter, values
from rest_framework.test import APIClient
from impress import urls
pytestmark = pytest.mark.django_db
API_KEY = "test-prometheus-api-key"
AUTH_MIDDLEWARE = "core.middleware.PrometheusAuthMiddleware"
BEFORE_MIDDLEWARE = "django_prometheus.middleware.PrometheusBeforeMiddleware"
AFTER_MIDDLEWARE = "django_prometheus.middleware.PrometheusAfterMiddleware"
def _reload_urls():
"""The route only exists when the metrics are enabled at import time."""
reload(urls)
clear_url_caches()
@pytest.fixture(name="metrics_enabled")
def metrics_enabled_fixture(settings, monkeypatch, tmp_path):
"""Configure the application the way PROMETHEUS_METRICS_ENABLED does at startup."""
settings.PROMETHEUS_METRICS_ENABLED = True
settings.PROMETHEUS_API_KEY = API_KEY
settings.MIDDLEWARE = [
AUTH_MIDDLEWARE,
BEFORE_MIDDLEWARE,
*settings.MIDDLEWARE,
AFTER_MIDDLEWARE,
]
# what a worker started with PROMETHEUS_MULTIPROC_DIR does on its own:
# write the values to that directory instead of keeping them in memory
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", str(tmp_path))
monkeypatch.setattr(values, "ValueClass", values.MultiProcessValue())
_reload_urls()
yield
settings.PROMETHEUS_METRICS_ENABLED = False
_reload_urls()
@pytest.mark.parametrize("path", ["/metrics", "/metrics/", "/prometheus/metrics"])
def test_prometheus_metrics_disabled_by_default(path):
"""Nothing should be served unless the metrics are enabled, key or not."""
response = APIClient().get(path, HTTP_AUTHORIZATION=f"Bearer {API_KEY}")
assert response.status_code == 404
@pytest.mark.usefixtures("metrics_enabled")
@pytest.mark.parametrize(
"authorization",
[
None,
"",
"Bearer",
"Bearer ",
"Bearer wrong-key",
f"Bearer {API_KEY} ",
f"bearer {API_KEY}",
f"Token {API_KEY}",
f"Basic {API_KEY}",
API_KEY,
"Bearer clé-non-ascii",
],
)
def test_prometheus_metrics_requires_the_api_key(authorization):
"""Anything but the exact bearer token should be refused, before any other work."""
headers = {} if authorization is None else {"HTTP_AUTHORIZATION": authorization}
response = APIClient().get("/metrics", **headers)
assert response.status_code == 401
assert response["WWW-Authenticate"] == 'Bearer realm="metrics"'
assert response.content == b"Unauthorized"
# refused before the session middleware: a scraper must not fill the session store
assert not response.cookies
@pytest.mark.usefixtures("metrics_enabled")
@pytest.mark.parametrize("api_key", [None, ""])
@pytest.mark.parametrize("authorization", ["Bearer ", "Bearer None", "Bearer"])
def test_prometheus_metrics_fails_closed_without_api_key(
settings, api_key, authorization
):
"""With no key configured nothing should be served, whatever is presented."""
settings.PROMETHEUS_API_KEY = api_key
response = APIClient().get("/metrics", HTTP_AUTHORIZATION=authorization)
assert response.status_code == 401
@pytest.mark.usefixtures("metrics_enabled")
def test_prometheus_metrics_served_with_the_api_key():
"""
The right key should get what every worker wrote to the shared directory, labelled
with the name of the host, and the call should not create a session.
"""
counter = Counter(
"docs_metrics_test", "A counter written by a worker.", registry=None
)
counter.inc(3)
response = APIClient().get("/metrics", HTTP_AUTHORIZATION=f"Bearer {API_KEY}")
assert response.status_code == 200
assert response["Content-Type"].startswith("text/plain")
assert (
f'docs_metrics_test_total{{hostname="{socket.gethostname()}"}} 3.0'
in response.content.decode()
)
assert not response.cookies
@pytest.mark.usefixtures("metrics_enabled")
def test_prometheus_metrics_is_read_only():
"""Only GET and HEAD should be answered."""
response = APIClient().post("/metrics", HTTP_AUTHORIZATION=f"Bearer {API_KEY}")
assert response.status_code == 405
@pytest.mark.usefixtures("metrics_enabled")
@pytest.mark.parametrize(
"path",
["/api/v1.0/metrics", "/api/v1.0/metrics/", "/api/v1.0/prometheus/metrics"],
)
def test_prometheus_metrics_not_served_under_the_api(path):
"""The ingress publishes /api/: the metrics should not be found under it."""
response = APIClient().get(path, HTTP_AUTHORIZATION=f"Bearer {API_KEY}")
assert response.status_code == 404
@pytest.mark.usefixtures("metrics_enabled")
def test_prometheus_metrics_other_routes_need_no_api_key():
"""The key should only be asked for on the metrics endpoint."""
assert APIClient().get("/api/v1.0/config/").status_code == 200
@override_settings(MIDDLEWARE=[BEFORE_MIDDLEWARE, AFTER_MIDDLEWARE])
def test_prometheus_metrics_requests_are_labelled_by_view():
"""A request should be counted under the name of its view, never under its path."""
path = "/api/v1.0/config/"
labels = {"status": "200", "view": resolve(path).view_name, "method": "GET"}
metric = "django_http_responses_total_by_status_view_method_total"
before = REGISTRY.get_sample_value(metric, labels) or 0
response = APIClient().get(path)
assert response.status_code == 200
assert REGISTRY.get_sample_value(metric, labels) == before + 1
# no label of any sample carries the path that was requested
assert not any(
path in value
for family in REGISTRY.collect()
for sample in family.samples
for value in sample.labels.values()
)
+149
View File
@@ -2,6 +2,9 @@
Unit tests for the User model
"""
import os
import tempfile
import pytest
from impress.settings import Base
@@ -64,3 +67,149 @@ def test_settings_psycopg_pool_enabled(monkeypatch):
"timeout": 3,
}
}
PROMETHEUS_AUTH_MIDDLEWARE = "core.middleware.PrometheusAuthMiddleware"
PROMETHEUS_BEFORE_MIDDLEWARE = "django_prometheus.middleware.PrometheusBeforeMiddleware"
PROMETHEUS_AFTER_MIDDLEWARE = "django_prometheus.middleware.PrometheusAfterMiddleware"
def _prometheus_settings(**attributes):
"""
Build fake settings enabling the metrics.
post_setup edits the lists and the database in place, which is the only way for Django
to see the change: copies keep it away from the settings the other tests run with.
"""
return type(
"TestSettings",
(Base,),
{
"PROMETHEUS_METRICS_ENABLED": True,
"PROMETHEUS_API_KEY": "a-key",
"INSTALLED_APPS": list(Base.INSTALLED_APPS),
"MIDDLEWARE": list(Base.MIDDLEWARE),
"DATABASES": {"default": {"ENGINE": "django.db.backends.postgresql"}},
**attributes,
},
)
def test_settings_prometheus_metrics_not_enabled(monkeypatch):
"""django-prometheus should be absent from the settings unless it is asked for."""
monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False)
class TestSettings(Base):
"""Fake test settings without enabling the metrics."""
TestSettings.post_setup()
assert "django_prometheus" not in TestSettings.INSTALLED_APPS
assert PROMETHEUS_AUTH_MIDDLEWARE not in TestSettings.MIDDLEWARE
assert PROMETHEUS_BEFORE_MIDDLEWARE not in TestSettings.MIDDLEWARE
assert PROMETHEUS_AFTER_MIDDLEWARE not in TestSettings.MIDDLEWARE
assert "PROMETHEUS_MULTIPROC_DIR" not in os.environ
@pytest.mark.parametrize("api_key", [None, ""])
def test_settings_prometheus_metrics_enabled_requires_api_key(
monkeypatch, tmp_path, api_key
):
"""Enabling the metrics without a key should be refused: they would be public."""
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", str(tmp_path))
test_settings = _prometheus_settings(PROMETHEUS_API_KEY=api_key)
with pytest.raises(ValueError) as excinfo:
test_settings.post_setup()
assert str(excinfo.value) == (
"PROMETHEUS_METRICS_ENABLED requires PROMETHEUS_API_KEY to be set."
)
assert PROMETHEUS_BEFORE_MIDDLEWARE not in test_settings.MIDDLEWARE
def test_settings_prometheus_metrics_enabled(monkeypatch, tmp_path):
"""
Enabling the metrics should create the directory and hand it to prometheus_client,
install the application, wrap the middlewares and instrument the database.
"""
multiproc_dir = tmp_path / "prometheus"
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", "overwritten-below")
test_settings = _prometheus_settings(PROMETHEUS_MULTIPROC_DIR=str(multiproc_dir))
installed_apps = test_settings.INSTALLED_APPS
middleware = test_settings.MIDDLEWARE
default_database = test_settings.DATABASES["default"]
test_settings.post_setup()
assert multiproc_dir.is_dir()
assert multiproc_dir.stat().st_mode & 0o777 == 0o700
assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(multiproc_dir)
# the very objects Django was handed before post_setup ran
assert test_settings.INSTALLED_APPS is installed_apps
assert test_settings.MIDDLEWARE is middleware
assert test_settings.DATABASES["default"] is default_database
assert installed_apps[-1] == "django_prometheus"
# the authentication first: a refused scrape must not reach anything else
assert middleware[0] == PROMETHEUS_AUTH_MIDDLEWARE
assert middleware[1] == PROMETHEUS_BEFORE_MIDDLEWARE
assert middleware[-1] == PROMETHEUS_AFTER_MIDDLEWARE
assert middleware[2:-1] == Base.MIDDLEWARE
assert default_database["ENGINE"] == "django_prometheus.db.backends.postgresql"
# running it again must not wrap the middlewares a second time
test_settings.post_setup()
assert installed_apps.count("django_prometheus") == 1
assert middleware.count(PROMETHEUS_AUTH_MIDDLEWARE) == 1
assert middleware.count(PROMETHEUS_BEFORE_MIDDLEWARE) == 1
assert middleware.count(PROMETHEUS_AFTER_MIDDLEWARE) == 1
def test_settings_prometheus_multiproc_dir_default(monkeypatch, tmp_path):
"""
Without a directory, a fixed one should be used: every uvicorn worker computes it on
its own and they have to agree.
"""
# set then unset, so that what post_setup writes to the environment is undone
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", "unset-below")
monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR")
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
expected = tmp_path / f"impress-prometheus-{os.getuid()}"
_prometheus_settings().post_setup()
assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(expected)
assert expected.is_dir()
_prometheus_settings().post_setup()
assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(expected)
def test_settings_prometheus_multiproc_dir_must_not_be_a_link(monkeypatch, tmp_path):
"""A link planted in a shared temporary directory should not be followed."""
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", "unset-below")
monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR")
target = tmp_path / "elsewhere"
target.mkdir()
link = tmp_path / "link"
link.symlink_to(target, target_is_directory=True)
with pytest.raises(ValueError, match="must be a directory owned by the user"):
_prometheus_settings(PROMETHEUS_MULTIPROC_DIR=str(link)).post_setup()
assert "PROMETHEUS_MULTIPROC_DIR" not in os.environ
def test_settings_prometheus_db_metrics_can_be_disabled(monkeypatch, tmp_path):
"""The database engine should be left alone when its metrics are not wanted."""
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", "overwritten-below")
test_settings = _prometheus_settings(
PROMETHEUS_MULTIPROC_DIR=str(tmp_path), PROMETHEUS_DB_METRICS_ENABLED=False
)
test_settings.post_setup()
assert (
test_settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
)
+94
View File
@@ -11,6 +11,8 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import os
import stat
import tempfile
import tomllib
from socket import gethostbyname, gethostname
@@ -1233,6 +1235,39 @@ class Base(Configuration):
SILKY_MAX_REQUEST_BODY_SIZE = 0
SILKY_MAX_RESPONSE_BODY_SIZE = 0
# -- Metrics (django-prometheus) -----------------------------------------
# Opt-in Prometheus instrumentation, OFF by default. When enabled,
# `django_prometheus` is added to INSTALLED_APPS and its two middlewares
# wrap MIDDLEWARE (see setup_prometheus_metrics below) to count and time
# every request, labelled by view name, method and status — never by path,
# user or document, so no identifier leaves the application through a label.
#
# The metrics are served on /metrics, outside of /api/ so that the ingress
# of the application does not publish them: a deployment that wants them
# reachable from outside routes that path on purpose, and filters who may
# call it. Whoever reaches it still has to present PROMETHEUS_API_KEY as a
# bearer token (core.middleware.PrometheusAuthMiddleware), and the
# application refuses to start with the metrics enabled and no key.
PROMETHEUS_METRICS_ENABLED = values.BooleanValue(
False, environ_name="PROMETHEUS_METRICS_ENABLED", environ_prefix=None
)
PROMETHEUS_API_KEY = SecretFileValue(
None, environ_name="PROMETHEUS_API_KEY", environ_prefix=None
)
# Also count and time the SQL queries, by swapping the database engine for
# django-prometheus' instrumented subclass of it.
PROMETHEUS_DB_METRICS_ENABLED = values.BooleanValue(
True, environ_name="PROMETHEUS_DB_METRICS_ENABLED", environ_prefix=None
)
# uvicorn runs several worker processes, and a scrape is answered by one of
# them: they all write their numbers to this directory so that whichever
# answers can add them up. It has to be the same for every worker, hence a
# fixed default rather than a random one. Defaults to a directory of the
# system's temporary directory, named after the user running the application.
PROMETHEUS_MULTIPROC_DIR = values.Value(
None, environ_name="PROMETHEUS_MULTIPROC_DIR", environ_prefix=None
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -1263,6 +1298,62 @@ class Base(Configuration):
},
}
@classmethod
def setup_prometheus_metrics(cls):
"""Wire django-prometheus into the settings (PROMETHEUS_METRICS_ENABLED)."""
if not cls.PROMETHEUS_API_KEY:
# fail closed: without a key the endpoint would answer to anybody
raise ValueError(
"PROMETHEUS_METRICS_ENABLED requires PROMETHEUS_API_KEY to be set."
)
# prometheus_client decides whether the workers share their numbers
# when it is first imported, by looking for this variable in the
# environment of the process: a setting alone would come too late.
multiproc_dir = cls.PROMETHEUS_MULTIPROC_DIR or os.path.join(
tempfile.gettempdir(), f"impress-prometheus-{os.getuid()}"
)
try:
os.makedirs(multiproc_dir, mode=0o700, exist_ok=True)
stat_result = os.lstat(multiproc_dir)
except OSError as error:
raise ValueError(
f"PROMETHEUS_MULTIPROC_DIR ({multiproc_dir}) cannot be created: {error}"
) from error
# A shared temporary directory is writable by every local user: refuse a
# directory somebody else prepared, or a link to somewhere else.
if not stat.S_ISDIR(stat_result.st_mode) or stat_result.st_uid != os.getuid():
raise ValueError(
f"PROMETHEUS_MULTIPROC_DIR ({multiproc_dir}) must be a directory "
"owned by the user running the application."
)
cls.PROMETHEUS_MULTIPROC_DIR = multiproc_dir
os.environ["PROMETHEUS_MULTIPROC_DIR"] = multiproc_dir
# Edited in place, like silk above: post_setup runs once the settings
# have been handed to Django, which only sees an assignment made
# here through the objects it already holds.
if "django_prometheus" not in cls.INSTALLED_APPS:
cls.INSTALLED_APPS.append("django_prometheus")
# The measuring middlewares go first and last, so that the time spent in
# every other middleware is part of what is measured. The authentication
# goes before them all: a refused scrape costs nothing and touches
# neither the session store nor the database.
auth = "core.middleware.PrometheusAuthMiddleware"
before = "django_prometheus.middleware.PrometheusBeforeMiddleware"
after = "django_prometheus.middleware.PrometheusAfterMiddleware"
if before not in cls.MIDDLEWARE:
cls.MIDDLEWARE.insert(0, before)
cls.MIDDLEWARE.insert(0, auth)
cls.MIDDLEWARE.append(after)
default_database = cls.DATABASES["default"]
if (
cls.PROMETHEUS_DB_METRICS_ENABLED
and default_database.get("ENGINE") == "django.db.backends.postgresql"
):
default_database["ENGINE"] = "django_prometheus.db.backends.postgresql"
@classmethod
def post_setup(cls):
"""Post setup configuration.
@@ -1354,6 +1445,9 @@ class Base(Configuration):
if "silk.middleware.SilkyMiddleware" not in cls.MIDDLEWARE:
cls.MIDDLEWARE.insert(1, "silk.middleware.SilkyMiddleware")
if cls.PROMETHEUS_METRICS_ENABLED:
cls.setup_prometheus_metrics()
class Build(Base):
"""Settings used when the application is built.
+12
View File
@@ -17,6 +17,18 @@ urlpatterns = [
path("", include("core.urls")),
]
# Serve the Prometheus metrics only when they are enabled for this environment
# (PROMETHEUS_METRICS_ENABLED=1). Outside of /api/ on purpose, so that the ingress
# of the application does not publish them, and behind a bearer token
# (core.middleware.PrometheusAuthMiddleware) — see documentation/metrics.md.
if settings.PROMETHEUS_METRICS_ENABLED:
from core.metrics import metrics_view
from core.middleware import METRICS_PATH
urlpatterns += [
path(METRICS_PATH.lstrip("/"), metrics_view, name="prometheus-metrics")
]
# Serve the django-silk profiling UI at /silk/ only when profiling is enabled
# for this environment (SILK_ENABLED=1). The view itself is further gated behind
# a staff session by SILKY_AUTHENTICATION / SILKY_AUTHORISATION.
+2
View File
@@ -36,6 +36,7 @@ dependencies = [
"django-filter==26.1",
"django-lasuite[all]==0.0.27",
"django-parler==2.4",
"django-prometheus==2.5.0",
"django-redis==7.0.0",
"django-silk==5.5.2",
"django-storages[s3]==1.14.6",
@@ -59,6 +60,7 @@ dependencies = [
"nested-multipart-parser==1.6.0",
"openai==2.48.0",
"posthog==7.29.0",
"prometheus-client==0.26.0",
"psycopg[binary,pool]==3.3.4",
"pycrdt==0.14.1",
"pydantic==2.13.4",
+28 -2
View File
@@ -570,6 +570,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/87/6d6129e14a7815bb383b61e7f86ffa85a4f43132eafa15de4a3a30ff9424/django_parler-2.4-py3-none-any.whl", hash = "sha256:0fff7185d581790d7063cbe0f7c57a399517d5ec976536b6c6bc06cf8ad4b956", size = 112605, upload-time = "2026-05-14T08:58:06.545Z" },
]
[[package]]
name = "django-prometheus"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "prometheus-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/c7/dc39c4c19f7b35e827a486d08376de1fad31c50decb26c56e32668314f13/django_prometheus-2.5.0.tar.gz", hash = "sha256:4837b3c3734d8350880839ab8235aafd250b668c348e159d4aecc3cbefeee53e", size = 26465, upload-time = "2026-05-26T19:04:00.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/5d/6ec3083ba69545696c962ae505a0e52e280e7592d4c278c2f3803cabb688/django_prometheus-2.5.0-py2.py3-none-any.whl", hash = "sha256:f15efb526cd53f9cf12da72dc55506322f5566b017a819ff27be1da302303134", size = 31801, upload-time = "2026-05-26T19:03:59.505Z" },
]
[[package]]
name = "django-redis"
version = "7.0.0"
@@ -965,6 +978,7 @@ dependencies = [
{ name = "django-filter" },
{ name = "django-lasuite", extra = ["all"] },
{ name = "django-parler" },
{ name = "django-prometheus" },
{ name = "django-redis" },
{ name = "django-silk" },
{ name = "django-storages", extra = ["s3"] },
@@ -987,11 +1001,12 @@ dependencies = [
{ name = "nested-multipart-parser" },
{ name = "openai" },
{ name = "posthog" },
{ name = "prometheus-client" },
{ name = "psycopg", extra = ["binary", "pool"] },
{ name = "pycrdt" },
{ name = "pydantic" },
{ name = "pydantic-ai-slim", extra = ["logfire", "mistral", "openai", "web"] },
{ name = "pyjwt" , extra = ["crypto"] },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-magic" },
{ name = "redis" },
{ name = "requests" },
@@ -1038,6 +1053,7 @@ requires-dist = [
{ name = "django-filter", specifier = "==26.1" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.27" },
{ name = "django-parler", specifier = "==2.4" },
{ name = "django-prometheus", specifier = "==2.5.0" },
{ name = "django-redis", specifier = "==7.0.0" },
{ name = "django-silk", specifier = "==5.5.2" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
@@ -1056,7 +1072,7 @@ requires-dist = [
{ name = "gunicorn", specifier = "==26.0.0" },
{ name = "ipdb", marker = "extra == 'dev'", specifier = "==0.13.13" },
{ name = "ipython", marker = "extra == 'dev'", specifier = "==9.15.0" },
{ name = "joserfc", specifier = "==1.6.5" },
{ name = "joserfc", specifier = "==1.6.5" },
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "langfuse", specifier = "==3.11.2" },
{ name = "lxml", specifier = "==6.1.1" },
@@ -1065,6 +1081,7 @@ requires-dist = [
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "openai", specifier = "==2.48.0" },
{ name = "posthog", specifier = "==7.29.0" },
{ name = "prometheus-client", specifier = "==0.26.0" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = "==3.3.4" },
{ name = "pycrdt", specifier = "==0.14.1" },
{ name = "pydantic", specifier = "==2.13.4" },
@@ -1723,6 +1740,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952, upload-time = "2018-07-01T01:42:36.496Z" },
]
[[package]]
name = "prometheus-client"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
+10
View File
@@ -66,6 +66,16 @@
| `ingressAdmin.tls.secretName` | Secret name for TLS config | `nil` |
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMetrics.enabled` | whether to enable the Ingress publishing the Prometheus metrics of the backend (requires PROMETHEUS_METRICS_ENABLED and PROMETHEUS_API_KEY in backend.envVars) | `false` |
| `ingressMetrics.className` | IngressClass to use for the Ingress | `nil` |
| `ingressMetrics.host` | Host for the Ingress. Use a host of its own, and add it to DJANGO_ALLOWED_HOSTS | `metrics.impress.example.com` |
| `ingressMetrics.path` | Path to use for the Ingress, matched exactly | `/metrics` |
| `ingressMetrics.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressMetrics.tls.enabled` | Whether to enable TLS for the Ingress | `true` |
| `ingressMetrics.tls.secretName` | Secret name for TLS config | `nil` |
| `ingressMetrics.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressMetrics.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMetrics.annotations` | Annotations of the Ingress. Restrict the callers by address here | `{}` |
| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` |
| `ingressMedia.className` | IngressClass to use for the Ingress | `nil` |
| `ingressMedia.host` | Host for the Ingress | `impress.example.com` |
@@ -0,0 +1,90 @@
{{- /*
Publishes the Prometheus metrics of the backend (PROMETHEUS_METRICS_ENABLED) and
nothing else: one exact path, on a host of its own. The main ingress sends every
path but /api and /external_api to the frontend, so without this one the metrics
are not reachable from outside the cluster. Restrict who may call it with the
annotations of your ingress controller; the bearer token is the second lock.
*/ -}}
{{- if .Values.ingressMetrics.enabled -}}
{{- $fullName := include "impress.fullname" . -}}
{{- if and .Values.ingressMetrics.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressMetrics.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressMetrics.annotations "kubernetes.io/ingress.class" .Values.ingressMetrics.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-metrics
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "impress.labels" . | nindent 4 }}
{{- with .Values.ingressMetrics.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressMetrics.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressMetrics.className }}
{{- end }}
{{- if .Values.ingressMetrics.tls.enabled }}
tls:
{{- if .Values.ingressMetrics.host }}
- secretName: {{ .Values.ingressMetrics.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressMetrics.host | quote }}
{{- end }}
{{- range .Values.ingressMetrics.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressMetrics.host }}
- host: {{ .Values.ingressMetrics.host | quote }}
http:
paths:
- path: {{ .Values.ingressMetrics.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Exact
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "impress.backend.fullname" . }}
port:
number: {{ .Values.backend.service.port }}
{{- else }}
serviceName: {{ include "impress.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
{{- end }}
{{- range .Values.ingressMetrics.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $.Values.ingressMetrics.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Exact
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "impress.backend.fullname" $ }}
port:
number: {{ $.Values.backend.service.port }}
{{- else }}
serviceName: {{ include "impress.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
{{- end }}
{{- end }}
+27
View File
@@ -186,6 +186,33 @@ ingressAdmin:
secretName: null
additional: []
## @param ingressMetrics.enabled whether to enable the Ingress publishing the Prometheus metrics of the backend (requires PROMETHEUS_METRICS_ENABLED and PROMETHEUS_API_KEY in backend.envVars)
## @param ingressMetrics.className IngressClass to use for the Ingress
## @param ingressMetrics.host Host for the Ingress. Use a host of its own, and add it to DJANGO_ALLOWED_HOSTS
## @param ingressMetrics.path Path to use for the Ingress, matched exactly
ingressMetrics:
enabled: false
className: null
host: metrics.impress.example.com
path: /metrics
## @param ingressMetrics.hosts Additional host to configure for the Ingress
hosts: []
## @param ingressMetrics.tls.enabled Whether to enable TLS for the Ingress
## @param ingressMetrics.tls.secretName Secret name for TLS config
## @skip ingressMetrics.tls.additional
## @extra ingressMetrics.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMetrics.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
additional: []
## @param ingressMetrics.annotations Annotations of the Ingress. Restrict the callers by address here
## The bearer token travels in a header: keep TLS on, and filter the source
## addresses, e.g. with ingress-nginx:
## nginx.ingress.kubernetes.io/whitelist-source-range: "203.0.113.10/32"
annotations: {}
## @param ingressMedia.enabled whether to enable the Ingress or not
## @param ingressMedia.className IngressClass to use for the Ingress
## @param ingressMedia.host Host for the Ingress