Journaux structurés et diagnostic
Texte libre contre JSON
Un journal (log) est une ligne écrite par l'application pour raconter ce qu'elle fait. En texte libre ("analyse OK en 2673 ms"), il faut des expressions régulières pour en tirer quoi que ce soit. En JSON structuré, chaque information est un champ que l'on peut filtrer, compter et grapher.
Sur Cloud Run, tout ce que le conteneur écrit sur la sortie standard part dans Cloud Logging. Si la ligne est un objet JSON, il devient un jsonPayload dont certains champs ont un sens spécial :
| Champ | Effet dans Cloud Logging |
|---|---|
severity | la gravité (INFO, WARNING, ERROR) : couleur, filtres, alertes |
message | le texte affiché dans la liste des journaux |
logging.googleapis.com/trace | relie la ligne à la requête (et à sa trace dans Cloud Trace) |
| tout autre champ | consultable et filtrable : jsonPayload.tokens_in, jsonPayload.status... |
flowchart LR A["log.info(..., extra=fields)"] --> F["JsonFormatter : une ligne JSON"] F --> O["stdout du conteneur"] O --> CL["Cloud Logging : jsonPayload"] CL --> Q["Requêtes : jsonPayload.status=503"] CL --> M["Métriques basées sur les journaux, alertes"]
Le formateur
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
entry = {"severity": record.levelname, "message": record.getMessage()}
entry.update(getattr(record, "fields", {}))
return json.dumps(entry, ensure_ascii=False)Pas de bibliothèque supplémentaire : le module logging de Python suffit. Les champs métier passent par extra={"fields": {...}}.
Une ligne par requête, une ligne par appel au modèle
@app.middleware("http")
async def access_log(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
fields = {"path": request.url.path, "status": response.status_code,
"latency_ms": round((time.perf_counter() - start) * 1000)}
trace = request.headers.get("x-cloud-trace-context", "").split("/")[0]
if trace and PROJECT:
fields["logging.googleapis.com/trace"] = f"projects/{PROJECT}/traces/{trace}"
log.info("requete", extra={"fields": fields})
return responseUn middleware est une fonction qui entoure toutes les requêtes : idéal pour mesurer la latence et le code de retour partout. La route /analyze ajoute sa propre ligne avec model, category, priority, llm_ms, tokens_in, tokens_out.
Pour la route SSE, le middleware journalise une latence de quelques millisecondes alors que le flux dure plusieurs secondes. Pourquoi ?
Pour une réponse en flux, call_next renvoie la réponse avant que le générateur soit consommé. Pour mesurer la durée réelle, il faut journaliser à la fin du générateur (après l'événement end).
Interroger les journaux
# Les 20 dernières erreurs du service
gcloud logging read 'resource.type="cloud_run_revision"
AND resource.labels.service_name="lab-api-ia" AND severity>=ERROR' \
--project "$PROJECT_ID" --limit 20 --format json
# Les requêtes lentes (plus de 5 s)
gcloud logging read 'resource.labels.service_name="lab-api-ia"
AND jsonPayload.message="requete" AND jsonPayload.latency_ms>5000' --project "$PROJECT_ID" --limit 20La même syntaxe de filtre fonctionne dans l'Explorateur de journaux de la console. Pour suivre une tendance (taux de 503, jetons par heure), créez une métrique basée sur les journaux puis une alerte dessus.
Quel filtre retrouve les appels au modèle qui ont consommé plus de 1 000 jetons en entrée ?
Les champs JSON sont filtrables avec des comparaisons numériques. C'est tout l'intérêt de journaux structurés par rapport à du texte libre.
Exercice : mesurer la vraie durée du flux
Modifiez la route SSE pour qu'elle journalise, à la fin du flux, sa durée réelle et le nombre de morceaux envoyés.
Voir la solution
@app.post("/v1/tickets/summarize-stream", response_class=EventSourceResponse)
async def summarize_stream(ticket: Ticket, llm: GeminiLLM = Depends(get_llm)):
start, chunks = time.perf_counter(), 0
try:
async for piece in llm.stream_summary(ticket):
chunks += 1
yield ServerSentEvent(data={"text": piece}, event="delta")
yield ServerSentEvent(data={"done": True}, event="end")
except errors.APIError as e:
log.error("llm_stream_error", extra={"fields": {"code": e.code}})
yield ServerSentEvent(data={"error": "erreur du modèle"}, event="error")
finally:
log.info("stream_fin", extra={"fields": {
"chunks": chunks, "stream_ms": round((time.perf_counter() - start) * 1000)}})Le finally s'exécute aussi si le client ferme la connexion en cours de route : vous saurez combien de flux sont abandonnés.
Résultat obtenu
Extrait réel de gcloud logging read sur le service déployé, le 2026-10-15 (la colonne de droite indique si l'entrée est reliée à une trace) :
INFO {"latency_ms": 3, "message": "requete", "path": "/v1/tickets/analyze", "status": 422} trace
INFO {"latency_ms": 4779, "message": "requete", "path": "/v1/tickets/analyze", "status": 503} trace
ERROR {"code": 429, "detail": "429 RESOURCE_EXHAUSTED. {'error': {'code': 429, 'message': 'Resource exhausted. Please try again later. ...", "message": "llm_error"}
INFO {"latency_ms": 2675, "message": "requete", "path": "/v1/tickets/analyze", "status": 200} trace
INFO {"category": "compte", "llm_ms": 2673, "message": "analyse", "model": "gemini-2.5-flash", "priority": "haute", "tokens_in": 161, "tokens_out": 57}
INFO {"latency_ms": 7, "message": "requete", "path": "/v1/tickets/summarize-stream", "status": 200} traceDeux constats réels : la ligne SSE affiche 7 ms (le piège du quiz ci-dessus), et seules les lignes du middleware portent la trace. Pour relier aussi analyse et llm_error, passez l'identifiant de trace à ces lignes (par exemple via une variable de contexte contextvars).
Je sais faire
- Écrire des journaux JSON avec
severityetmessagesans bibliothèque externe - Relier une ligne à sa requête avec
logging.googleapis.com/trace - Filtrer les journaux sur des champs numériques (
latency_ms,tokens_in) - Éviter de journaliser des données personnelles