Leçon 7 — Déployer et exploiter sur Cloud Run

Journaux structurés et diagnostic

5 min de lecture

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 :

ChampEffet dans Cloud Logging
severityla gravité (INFO, WARNING, ERROR) : couleur, filtres, alertes
messagele texte affiché dans la liste des journaux
logging.googleapis.com/tracerelie la ligne à la requête (et à sa trace dans Cloud Trace)
tout autre champconsultable 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

files/main.py
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

files/main.py
@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 response

Un 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 ?

Interroger les journaux

terminal
# 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 20

La 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 ?

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
main.py
@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}       trace

Deux 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 severity et message sans 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
Suivant