Module 8 — Journalisation et sondes de santé
Un service qu'on ne voit pas est un service qu'on ne peut ni diagnostiquer, ni faire évoluer. La journalisation et les sondes sont les deux canaux par lesquels le service raconte ce qu'il fait à l'équipe qui le tient. Ce module écrit les deux et les branche sur Prometheus pour que le tableau de bord d'exploitation ait des chiffres utiles.
Journaux structurés : JSON plutôt que phrases
Un journal en clair, INFO scoring OK 42ms, est lisible par un humain
mais imbuvable pour un moteur d'agrégation. Un journal structuré —
chaque événement est un objet JSON avec des clés fixes — se filtre, se
compte, se corrèle sans écrire une seule expression régulière.
import logging
import sys
from pythonjsonlogger.json import JsonFormatter
def configurer_journaux() -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter(
"%(asctime)s %(levelname)s %(name)s %(message)s %(request_id)s %(appelant)s",
rename_fields={"asctime": "temps", "levelname": "niveau", "name": "logger"},
))
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(logging.INFO)
Chaque ligne du journal est un JSON avec temps, niveau, logger,
message, request_id, appelant. On peut alors interroger
Elasticsearch ou Datadog avec niveau:ERROR AND appelant:batch-nocturne AND temps:[maintenant-1h à maintenant] et obtenir une réponse en une
seconde.
Un identifiant de requête, du bord au cœur
Un identifiant unique par requête, ajouté dès l'arrivée et propagé dans tous les journaux de cette requête, permet de retracer une prédiction particulière parmi des millions. On l'appelle request_id ou trace_id.
import uuid
from fastapi import Request
@app.middleware("http")
async def middleware_request_id(request: Request, appel_suivant):
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request.state.request_id = rid
reponse = await appel_suivant(request)
reponse.headers["X-Request-Id"] = rid
return reponse
Deux effets. Si l'appelant a mis un X-Request-Id, on le respecte, ce qui
lui permet de corréler avec ses propres journaux. Sinon, on en génère un
et on le retourne dans la réponse, ce qui lui donne un identifiant à
mettre dans un ticket en cas de problème.
On l'injecte ensuite dans chaque appel au logger via un LoggerAdapter ou
contextvars.
from contextvars import ContextVar
_request_id: ContextVar[str] = ContextVar("request_id", default="-")
class ContexteFilter(logging.Filter):
def filter(self, record):
record.request_id = _request_id.get()
record.appelant = getattr(record, "appelant", "-")
return True
# dans le middleware :
_request_id.set(rid)
Sur le fil rouge, ce même request_id remonte au tableau de bord
Streamlit : un utilisateur qui voit un score étrange peut « signaler » et
envoyer le request_id à l'équipe qui retrouve la ligne dans les journaux
en trois secondes.
Journaliser la prédiction elle-même
Chaque appel POST /predict produit une ligne de journal qui contient de
quoi diagnostiquer sans exposer les données personnelles.
@app.post("/predict")
def predict(dossier: DossierAbonne, appelant=Depends(verifier_cle)):
debut = time.perf_counter()
proba = float(modele.predict_proba([dossier_vers_vecteur(dossier)])[0, 1])
duree_ms = (time.perf_counter() - debut) * 1000
logger.info(
"prediction",
extra={
"appelant": appelant,
"abonne_id": dossier.abonne_id,
"proba": round(proba, 4),
"version_modele": app.state.version_modele,
"duree_ms": round(duree_ms, 1),
},
)
return {"probabilite": proba}
On journalise la probabilité mais pas les 24 variables du dossier :
elles contiennent des informations personnelles et gonflent inutilement
le volume. Un debug peut les inclure pour du diagnostic local, jamais en
production.
Sondes de santé : liveness contre readiness
Les orchestrateurs (Kubernetes, ECS, Cloud Run) attendent deux sondes.
La sonde de vie (liveness) répond « le service est-il vivant, ou
faut-il le tuer et le relancer ? ». Elle doit être triviale et ne rien
tester d'externe : un return {"status": "ok"} suffit. Une sonde de vie
qui teste la base de données déclenche un cascade de redémarrages quand
la base tousse — le service n'était pourtant pas en cause.
La sonde de disponibilité (readiness) répond « le service est-il
prêt à recevoir du trafic ? ». Elle vérifie les dépendances : modèle
chargé, base accessible, magasin de configuration lisible. Un service qui
ne passe pas la sonde est mis hors du répartiteur mais n'est pas
redémarré.
@app.get("/health/live")
def sonde_vie():
return {"status": "ok"}
@app.get("/health/ready")
def sonde_prete(request: Request):
if getattr(request.app.state, "modele", None) is None:
raise HTTPException(503, "modèle non chargé")
return {"status": "ok", "version_modele": request.app.state.version_modele}
La séparation des deux sondes est fondamentale. La confondre — c'est le piège classique — soit fait redémarrer trop, soit ne fait jamais sortir de trafic malgré une dépendance en carafe.
La sonde qui ment
Une sonde /health qui répond ok alors que le modèle est cassé est
plus dangereuse que pas de sonde du tout : l'orchestrateur croit que
tout va bien, le trafic continue d'arriver, les prédictions sortent
absurdes ou 500. On tombe dans ce piège quand la sonde ne teste
littéralement rien, ou pire, quand elle est écrite avant que le
lifespan ne soit branché.
Le contrat implicite d'une sonde de disponibilité : si elle passe, n'importe quelle requête normale doit passer. On teste la sonde à l'occasion — en simulant un modèle non chargé — pour vérifier qu'elle échoue quand elle doit échouer.
Métriques Prometheus : les quatre indicateurs de base
Prometheus expose une route /metrics que le serveur Prometheus va lire
toutes les 15 secondes. On expose quatre indicateurs qui répondent aux
quatre questions classiques (le sigle est RED : Rate, Errors,
Duration + saturation).
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app)
Cette ligne unique branche les métriques par défaut : nombre de requêtes, répartition par code de retour, histogramme de latence par route. On y ajoute une métrique métier : nombre de prédictions par version de modèle.
from prometheus_client import Counter
PREDICTIONS = Counter(
"predictions_total",
"Nombre de prédictions",
["appelant", "version_modele"],
)
@app.post("/predict")
def predict(...):
PREDICTIONS.labels(appelant=appelant, version_modele=app.state.version_modele).inc()
# ...
Le tableau de bord d'exploitation affiche alors : trafic par appelant, taux d'erreur, latence à p50, p95, p99. Les seuils d'alerte s'écrivent sur ces quatre chiffres : plus d'alerte sur du bruit ou sur du bruit de fond.
Ne pas surjournaliser
Un service qui écrit trois lignes de journal par requête finit par
saturer un disque et coûter dix fois plus cher qu'il ne rapporte à
diagnostiquer. La règle : une ligne INFO par requête (l'événement
m étier), zéro DEBUG en production, ERROR uniquement pour ce qui exige
une intervention humaine. Les traces de pile (module 5) restent en
ERROR, mais elles sont rares.
Le X-Request-Id du service FastAPI voyage jusqu'au tableau de bord
Streamlit (cours 38) et jusqu'au registre MLflow (cours 20) via une
convention partagée. Ce n'est pas une fantaisie : un incident nécessite
généralement de comparer trois vues du même moment.
En résumé
- Journaux structurés (JSON) plutôt que phrases : filtrage, comptage, corrélation deviennent triviaux dans les outils d'agrégation.
- Un
X-Request-Idgénéré à l'entrée, propagé dans tous les journaux d'une requête, retourné dans la réponse — un identifiant, une trace. - Séparer sonde de vie (triviale) et sonde de disponibilité (dépendances). Une sonde qui ment est le pire des cas.
- Métriques Prometheus RED (rate, errors, duration) et une métrique métier par version de modèle ; les alertes se posent sur ces chiffres.
Le module 9 emballe tout ce travail dans une image Docker fine et propose une stratégie de déploiement.