metalfrom.eu/libs/bm_health.py
Nicolas FRYDER e35e3d6de3 refactor: module de santé partagé, supervision complète, endpoints morts traités
health.py existait en deux copies rigoureusement identiques — crawler et
geocoder — dont une seule était couverte par des tests. Rien ne signalait une
divergence : une correction appliquée d'un seul côté serait passée inaperçue.
Le module part dans libs/bm_health.py, embarqué dans les images via un contexte
de build ramené à la racine du dépôt, et rendu importable en local par les
conftest.py. Un .dockerignore racine évite que node_modules parte dans les
images au passage. Les deux images ont été reconstruites et le module vérifié
importable à l'exécution dans chacune.

La migration 015 annonçait un battement de coeur pour groq-worker, mais aucun
n'était jamais écrit : sa ligne n'existait pas, et le bandeau de santé de
l'admin ne pouvait donc rien signaler — y compris quand le service était mort.
Même trou pour geocoder-enqueue. Les deux écrivent désormais leur état, avec
des sondes propres à leur rôle (clé API, progression de leur file).

Deux endpoints étaient définis et testés sans qu'aucun bouton ne les appelle.
/admin/api/locations/reset-llm était pourtant le seul moyen de relancer les
lieux passés en 'manual' après épuisement des tentatives LLM : la
fonctionnalité existait sans que personne puisse l'atteindre. Elle rejoint la
zone de danger de l'admin. /admin/api/crawl-checkpoints faisait doublon avec
/admin/api/live et disparaît, avec ses tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:41:59 +02:00

141 lines
5.2 KiB
Python

"""
Contrôle de santé périodique des services de fond — module PARTAGÉ.
Vit dans libs/ et non dans une app : le crawler et le geocoder en avaient
deux copies rigoureusement identiques, dont une seule était couverte par des
tests. Rien ne signalait une divergence, et une correction appliquée d'un
seul côté serait passée inaperçue.
Embarqué dans les images par les Dockerfile (contexte de build = racine du
dépôt) et rendu importable en local par les conftest.py de chaque app.
Ces services ne sont pas exposés par Traefik : aucune sonde HTTP externe ne peut
les atteindre. Sans ce module, un crawler dont FlareSolverr est injoignable ou
un worker à court de quota reste muet, et le seul symptôme est l'absence de
données nouvelles — qu'il faut remarquer soi-même.
Chaque sonde renvoie (nom, ok, détail). Le résultat agrégé est écrit dans
`service_health` (une ligne par service, écrasée), lu par le dashboard admin.
Aucune sonde ne peut interrompre le service : toute exception est convertie en
échec de sonde. Un contrôle de santé qui fait tomber ce qu'il surveille serait
pire que pas de contrôle du tout.
"""
import logging
import time
log = logging.getLogger(__name__)
# Intervalle minimal entre deux contrôles complets.
DEFAULT_INTERVAL_S = 3600
_last_run = 0.0
def probe(name, fn):
"""Exécute une sonde et convertit toute exception en échec."""
try:
ok, detail = fn()
return name, bool(ok), (detail or "")
except Exception as e: # noqa: BLE001 - une sonde ne doit jamais propager
return name, False, f"{type(e).__name__}: {e}"
def database_probe(get_conn):
"""La base répond-elle ?"""
def _run():
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return True, "connexion établie"
return _run
def http_probe(session_get, url, expect_below=500, timeout=10):
"""Une dépendance HTTP répond-elle sans erreur serveur ?"""
def _run():
r = session_get(url, timeout=timeout)
code = getattr(r, "status_code", 0)
return code < expect_below, f"HTTP {code}"
return _run
def freshness_probe(get_conn, sql, max_age_hours, label):
"""Les données progressent-elles ?
`sql` doit renvoyer un unique timestamp (le plus récent). Une base vide
(NULL) n'est pas un échec : c'est un état légitime au premier démarrage.
"""
def _run():
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql)
row = cur.fetchone()
latest = row[0] if row else None
if latest is None:
return True, f"{label}: aucune donnée pour l'instant"
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT EXTRACT(EPOCH FROM (now() - %s)) / 3600.0", (latest,)
)
age_h = float(cur.fetchone()[0])
return age_h <= max_age_hours, f"{label}: {age_h:.1f} h (seuil {max_age_hours} h)"
return _run
def write_health(get_conn, service, results):
"""Enregistre le résultat agrégé. N'échoue jamais bruyamment."""
import json
checks = {name: ok for name, ok, _ in results}
failed = [f"{name}{detail}" for name, ok, detail in results if not ok]
ok = not failed
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO service_health (service, ok, checks, error, checked_at)
VALUES (%s, %s, %s::jsonb, %s, now())
ON CONFLICT (service) DO UPDATE SET
ok = EXCLUDED.ok,
checks = EXCLUDED.checks,
error = EXCLUDED.error,
checked_at = EXCLUDED.checked_at
""",
(service, ok, json.dumps(checks), failed[0] if failed else None),
)
except Exception as e: # noqa: BLE001
log.warning(f"[health] écriture impossible: {e}")
return ok, failed
def run_checks(get_conn, service, probes, log_event=None):
"""Exécute toutes les sondes, enregistre le résultat, journalise les échecs."""
results = [probe(name, fn) for name, fn in probes]
ok, failed = write_health(get_conn, service, results)
if failed:
msg = f"[health:{service}] DÉGRADÉ — " + " | ".join(failed)
log.warning(msg)
if log_event:
try:
log_event("warning", msg)
except Exception: # noqa: BLE001, S110 - journaliser un échec de
# journalisation n'apporte rien et risquerait une récursion.
pass
else:
log.info(f"[health:{service}] toutes les sondes sont au vert")
return ok
def maybe_run(get_conn, service, probes, interval_s=DEFAULT_INTERVAL_S, log_event=None, force=False):
"""Point d'entrée depuis une boucle de service : ne contrôle qu'une fois par intervalle."""
global _last_run
now = time.monotonic()
if not force and _last_run and (now - _last_run) < interval_s:
return None
_last_run = now
return run_checks(get_conn, service, probes, log_event=log_event)