feat(monitoring): supervise l'API, la base et l'hôte avec Prometheus et Grafana

L'API exposait /metrics, mais aucun collecteur ne le lisait : monitoring/ ne contenait que des
.gitkeep.

Sous le profil Compose `monitoring` : prometheus, alertmanager, grafana, postgres-exporter,
node-exporter et cadvisor. Tous ont un mem_limit, pour environ 700 Mo au total sur la VM de 8 Go,
et leurs interfaces n'écoutent que sur 127.0.0.1. Le profil est actif en prod via
COMPOSE_PROFILES, donc à chaque déploiement, et se lance à la demande ailleurs
(make monitoring-up).

- Neuf règles d'alerte (API, base, hôte, cibles). Chacune a un cas dans les tests joués par
  `promtool test rules`, en CI comme par make monitoring-check.
- Alertmanager route les alertes par courriel vers Mailpit ; un critical masque le warning de la
  même cible.
- Grafana est provisionné : sources Prometheus et TimescaleDB, et trois tableaux de bord (API,
  données et dérive du modèle, infrastructure).
- Le rôle PostgreSQL `supervision` est en lecture seule sur les seules tables métier
  (db/roles/supervision.sql), posé par make db-ensure-supervision et par stack-up quand le
  profil est actif.
- Le jeton de /metrics passe à Prometheus en secret Compose (APP_METRICS_TOKEN) ;
  provision-host.sh génère ce secret et les deux autres.

Backend :
- un APP_METRICS_TOKEN vide vaut absent ;
- les sondes de santé ne comptent plus dans les métriques ;
- seaux de latence fins autour de 500 ms ;
- un registre Prometheus par application, sans quoi toute application créée après la première
  (dans les tests) ne mesurait rien.

Réf : #26
This commit is contained in:
Johan LEROY
2026-09-23 09:41:12 +02:00
parent 2ed9e1cee4
commit dc952d13aa
28 changed files with 2364 additions and 19 deletions
+8 -1
View File
@@ -1,7 +1,7 @@
from functools import lru_cache
from typing import Literal, Self
from pydantic import Field, SecretStr, model_validator
from pydantic import Field, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Environment = Literal["local", "dev", "staging", "prod"]
@@ -76,6 +76,13 @@ class Settings(BaseSettings):
expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None
# Compose passe `APP_METRICS_TOKEN` vide quand aucun jeton n'est posé : vide vaut absent, sinon
# `/metrics` exigerait un `Bearer` sans valeur et plus rien ne pourrait le scruter.
@field_validator("metrics_token", mode="before")
@classmethod
def _jeton_vide_vaut_absent(cls, valeur: object) -> object:
return None if valeur == "" else valeur
@property
def allowed_origins(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
+20 -2
View File
@@ -6,7 +6,8 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator
from prometheus_client import CollectorRegistry, GCCollector, PlatformCollector, ProcessCollector
from prometheus_fastapi_instrumentator import Instrumentator, metrics
from starlette.requests import Request
from starlette.responses import HTMLResponse
@@ -37,6 +38,16 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await get_engine().dispose()
# Pourquoi : le registre global n'accepte chaque métrique qu'une fois. Toute application créée
# après la première, dans les tests notamment, n'aurait rien mesuré.
def _registre_de_metriques() -> CollectorRegistry:
registre = CollectorRegistry()
ProcessCollector(registry=registre)
PlatformCollector(registry=registre)
GCCollector(registry=registre)
return registre
def create_app(settings: Settings | None = None) -> FastAPI:
resolved = settings or get_settings()
configure_logging(resolved)
@@ -102,7 +113,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
register_error_handlers(application)
Instrumentator().instrument(application).expose(
# Les sondes de santé tombent toutes les 30 s : comptées, elles fausseraient latences et débit.
# Seaux fins autour du seuil de charge (p95 < 500 ms, ADR 0015), route par route.
registre = _registre_de_metriques()
Instrumentator(
excluded_handlers=["/metrics", f"{resolved.api_prefix}/health/.*"], registry=registre
).add(
metrics.default(latency_lowr_buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5), registry=registre)
).instrument(application).expose(
application,
endpoint="/metrics",
include_in_schema=False,