En bref: Sans observabilité, chaque incident devient “le modèle était bizarre” — impossible à tester et à corriger. Il te faut : traces de tools, stop reasons, suivi des coûts, replay. Ce n’est pas optionnel.
Tu vas apprendre : monitoring minimum • un schéma d’événements unique • taxonomie de stop reasons • replay basique • un incident concret
Sans monitoring : les utilisateurs signalent en premier • debug “au feeling” • pas de replay
Avec monitoring minimal : drift détecté tôt • debug via traces + stop reasons • replay des derniers runs
Impact : réponse incident plus rapide + moins de récidive (tu fixes vraiment la cause)
Problème (d’abord)
Un run d’agent part en vrille.
Un utilisateur dit : “il a envoyé le mauvais email.”
Tu ouvres les logs :
- le texte final (peut-être)
- une stack trace (peut-être)
- des vibes (oui)
Ce que tu n’as pas : Si tu ne peux pas répondre à ces 5 questions, le système n’est pas opérable :
- Quels tools ont été appelés (et dans quel ordre) ?
- Avec quels args (ou au moins des hashes) ?
- Qu’est-ce qui est revenu (ou au moins des snapshot hashes) ?
- Quelle version (model/prompt/tools) tournait ?
- Pourquoi ça s’est arrêté ?
Ce n’est pas “il manque des dashboards”. C’est “ce système n’est pas opérable”.
Le moment 03:00
03:12 — Support: "Agent emailed the wrong customer. Please stop it."
Tu greppes et tu trouves… rien de joinable.
2026-02-07T03:11:58Z INFO sent email to customer@example.com
2026-02-07T03:11:59Z INFO sent email to customer@example.com
2026-02-07T03:12:01Z WARN http.get 429
2026-02-07T03:12:03Z INFO Agent completed task
Pas de run_id. Pas de trace par step. Pas de stop reason. Pas de hash d’args. Pas de version.
Pourquoi ça casse en production
1) Les agents sont des systèmes distribués avec plus d’étapes
Tools = dépendances + modes d’échec + retries. Sans logs par step, tu expliques des histoires.
2) Le “success rate” cache les failures intéressantes
Le drift se voit par :
- plus de tool calls / run
- plus de tokens / request
- plus de latence
- stop reasons qui changent
3) Tu ne peux pas corriger ce que tu ne peux pas rejouer
Si tu ne peux pas rejouer (ou au moins reconstruire) un run à partir des logs, tu ne peux pas faire confiance à un “fix”. Tu devines.
4) Le monitoring fait partie de la gouvernance
Budgets, allowlists et kill switch ne servent à rien si tu ne vois pas quand ça trigger.
Hard invariants (non négociables)
- Chaque run a un
run_id. - Chaque step a un
step_id. - Chaque tool call log : tool, hash d’args, durée, status, classe d’erreur.
- Chaque run se termine par un stop event :
stop_reason. - Sans replay (même partiel), tu ne peux pas faire confiance à un fix.
Implémentation (vrai code)
Le piège classique : deux formats de logs :
- tool events structurés
- stop events “spéciaux”
Résultat : impossible à joindre.
Ce sample utilise un seul schéma d’événements pour tool results et stop events.
from __future__ import annotations
from dataclasses import dataclass, asdict
import hashlib
import json
import time
from typing import Any, Literal
EventKind = Literal["tool_result", "stop"]
def sha(obj: Any) -> str:
raw = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:24]
@dataclass(frozen=True)
class Event:
run_id: str
kind: EventKind
ts_ms: int
# optional fields
step_id: int | None = None
tool: str | None = None
args_sha: str | None = None
duration_ms: int | None = None
status: Literal["ok", "error"] | None = None
error: str | None = None
stop_reason: str | None = None
usage: dict[str, Any] | None = None
def log_event(ev: Event) -> None:
print(json.dumps(asdict(ev), ensure_ascii=False))
def call_tool(run_id: str, step_id: int, tool: str, args: dict[str, Any]) -> Any:
started = time.time()
try:
out = tool_impl(tool, args=args) # (pseudo)
dur = int((time.time() - started) * 1000)
log_event(
Event(
run_id=run_id,
kind="tool_result",
ts_ms=int(time.time() * 1000),
step_id=step_id,
tool=tool,
args_sha=sha(args),
duration_ms=dur,
status="ok",
error=None,
)
)
return out
except Exception as e:
dur = int((time.time() - started) * 1000)
log_event(
Event(
run_id=run_id,
kind="tool_result",
ts_ms=int(time.time() * 1000),
step_id=step_id,
tool=tool,
args_sha=sha(args),
duration_ms=dur,
status="error",
error=type(e).__name__,
)
)
raise
def stop(run_id: str, *, reason: str, usage: dict[str, Any]) -> dict[str, Any]:
log_event(
Event(
run_id=run_id,
kind="stop",
ts_ms=int(time.time() * 1000),
stop_reason=reason,
usage=usage,
)
)
return {"status": "stopped", "stop_reason": reason, "usage": usage}import crypto from "node:crypto";
export function sha(obj) {
const raw = JSON.stringify(obj, Object.keys(obj || {}).sort());
return crypto.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 24);
}
export function logEvent(ev) {
console.log(JSON.stringify(ev));
}
export async function callTool(runId, stepId, tool, args) {
const started = Date.now();
try {
const out = await toolImpl(tool, { args }); // (pseudo)
logEvent({
run_id: runId,
kind: "tool_result",
ts_ms: Date.now(),
step_id: stepId,
tool,
args_sha: sha(args),
duration_ms: Date.now() - started,
status: "ok",
error: null,
});
return out;
} catch (e) {
logEvent({
run_id: runId,
kind: "tool_result",
ts_ms: Date.now(),
step_id: stepId,
tool,
args_sha: sha(args),
duration_ms: Date.now() - started,
status: "error",
error: e?.name || "Error",
});
throw e;
}
}
export function stop(runId, { reason, usage }) {
logEvent({
run_id: runId,
kind: "stop",
ts_ms: Date.now(),
stop_reason: reason,
usage,
});
return { status: "stopped", stop_reason: reason, usage };
}Example failure case (concret)
🚨 Incident: “Tout est lent” (et on ne savait pas pourquoi)
Date: 2024-10-08
Duration: 3 jours sans alerte, ~2 heures de debug quand on a eu de la visibilité
System: agent de support client
What actually happened
Le tool http.get renvoyait des 429/503 intermittents.
Notre layer tool a retry jusqu’à 8× par call (au lieu de 2×) sans jitter. L’agent interprétait ça comme “essaie une autre requête”, donc plus de tool calls par run.
Sur 3 jours (chiffres illustratifs, mais le pattern est classique) :
- avg tool calls/run : 4.3 → 11.7
- latence p95 : 2.1s → 8.4s
- spend/run : ~2×
Rien n’a “crashé”. Le success rate restait ~91%, donc le drift ressemblait à “les users sont impatients” jusqu’à l’escalade support.
Root cause (version boring)
- retries sans jitter → thundering herd
- pas de stop reasons dans les logs → “success” masque le drift
- pas de tool-call trace → impossible de prouver où partaient temps et spend
Fix
- logs structurés (run_id, step_id, tool, args hash, durée, status)
- stop reasons retournés à l’UI
- dashboards + alerting sur signaux de drift (tool calls/run, latence P95, stop reasons)
Dashboards + alerts (exemples)
Tu n’as pas besoin d’une plateforme parfaite. Tu as besoin d’une observabilité utile.
PromQL (Grafana)
# Tool calls per run (p95)
histogram_quantile(0.95, sum(rate(agent_tool_calls_bucket[5m])) by (le))
# Stop reasons over time
sum(rate(agent_stop_total[10m])) by (stop_reason)
# Latency p95
histogram_quantile(0.95, sum(rate(agent_run_latency_ms_bucket[5m])) by (le))
SQL (style Postgres/BigQuery)
-- Alert: tool_calls/run spike vs baseline
SELECT
date_trunc('hour', created_at) AS hour,
avg(tool_calls) AS avg_tool_calls
FROM agent_runs
WHERE created_at > now() - interval '7 days'
GROUP BY 1
HAVING avg(tool_calls) > 2 * (
SELECT avg(tool_calls)
FROM agent_runs
WHERE created_at BETWEEN now() - interval '14 days' AND now() - interval '7 days'
);
Alert rules (plain English)
- Si
tool_calls_per_run_p95fait 2× la baseline sur 10 minutes → investigate (et pense à couper les writes). - Si
stop_reason=loop_detectedaugmente → investigate (tool spam / outage / prompt). - Si
stop_reason=tool_timeoutspike → upstream, pas “model weirdness”.
Compromis
- Logger coûte (stockage, indexation). Toujours moins que des incidents aveugles.
- Évite de logger PII/secrets en clair. Hash + redaction.
- Le replay exige une politique de rétention + contrôles d’accès.
Quand NE PAS l’utiliser
- Ne construis pas une grosse plateforme de tracing avant d’avoir des logs structurés. Start small.
- Ne logge jamais des args bruts si ça contient PII/secrets.
- Ne shippe pas sans stop reasons. Tu fabriques des retry loops.
Checklist (copier-coller)
- [ ]
run_id/step_idpour chaque run - [ ] schéma d’événements unique (tool results + stop events)
- [ ] logs tool-call : tool, args_hash, durée, status, classe d’erreur
- [ ] stop reason retournée + loggée
- [ ] metrics tokens/tool calls/spend par run
- [ ] dashboards : latence P95, tool_calls/run, stop_reason distribution
- [ ] replay : snapshot hashes (rétention + accès)
Safe default config
logging:
events:
enabled: true
schema: "unified"
store_args: false
store_args_hash: true
include: ["run_id", "step_id", "tool", "duration_ms", "status", "error", "stop_reason"]
metrics:
track: ["tokens_per_request", "tool_calls_per_run", "latency_p95", "spend_per_run", "stop_reason"]
retention:
tool_snapshot_days: 14
logs_days: 30
FAQ
Related pages
Production takeaway
What breaks without this
- ❌ incidents inexplicables
- ❌ drift = “model weirdness”
- ❌ dérive de coûts détectée trop tard
What works with this
- ✅ runs joignables, rejouables, debuggables
- ✅ drift = un graph, pas un débat
- ✅ kill switch déclenché sur des signaux réels
Minimum to ship
- Logs structurés
- Stop reasons
- Metrics + dashboards
- Alerting sur drift