Aller au contenu principal

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.

Un identifiant qui traverse trois systèmes

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-Id gé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.