Un tableau de bord Grafana provisionné en code, relié aux traces et aux journaux
Le premier tableau de bord de l'assistant d'Ondine a été construit à la souris par un développeur parti depuis. Personne ne sait quelle requête calcule le « taux d'erreur », deux panneaux ont été modifiés directement en production, et la réinstallation de Grafana l'a fait disparaître. Ce qu'on attend d'un tableau de bord d'astreinte tient en une phrase : voir un pic de latence, ouvrir la trace d'un appel lent, lire ses journaux, en trois clics. Et pouvoir relire tout changement dans une revue de code.
Le mécanisme : tout est fichier, tout est relié
Au démarrage, Grafana lit /etc/grafana/provisioning : sources de données, fournisseurs de tableaux de bord, règles d'alerte. Rien n'est créé à la main.
flowchart LR
subgraph CODE["Dépôt (files/grafana/)"]
GEN["generer_tableau.py"] -->|"génère"| JSON["dashboards/assistant-ia.json"]
DS["provisioning/datasources/sources.yaml<br/>uid fixes"]
AL["provisioning/alerting/regles.yaml"]
end
JSON --> G["Grafana"]
DS --> G
AL --> G
P[("Prometheus")] -->|"exemplar trace_id"| T[("Tempo")]
T -->|"tracesToLogsV2"| L[("Loki")]
L -->|"derivedFields trace_id"| T
Trois liens transforment trois outils en un seul parcours :
- Exemplars : un point d'un histogramme Prometheus porte l'identifiant de la trace d'une mesure réelle. Le SDK OpenTelemetry les attache quand la mesure a lieu dans un span actif, le collecteur les expose en format OpenMetrics (
enable_open_metrics: true), Prometheus les stocke (--enable-feature=exemplar-storage), et Grafana les affiche comme des losanges cliquables. - Traces vers journaux : depuis un span dans Tempo,
tracesToLogsV2ouvre les journaux Loki filtrés par le mêmetrace_id. - Journaux vers traces : le gestionnaire de journaux OpenTelemetry ajoute
trace_idà chaque ligne émise pendant une requête ; Loki le stocke comme métadonnée structurée, et un champ dérivé en fait un lien vers Tempo.
La mise en œuvre
1. Des sources aux uid fixes
- name: Prometheus
uid: prometheus
type: prometheus
url: http://prometheus:9090
jsonData:
exemplarTraceIdDestinations:
- { name: trace_id, datasourceUid: tempo }
- name: Tempo
uid: tempo
type: tempo
url: http://tempo:3200
jsonData:
tracesToLogsV2: { datasourceUid: loki, filterByTraceID: true, spanStartTimeShift: "-5m", spanEndTimeShift: "5m" }Le tableau de bord référence les sources par uid. Laissé au hasard, l'uid change à chaque installation et tous les panneaux affichent « datasource not found ».
2. Le tableau de bord est généré
Un JSON de tableau de bord Grafana fait 600 lignes pour 13 panneaux : illisible en revue. Il est donc généré par un script de 70 lignes, et c'est le script qu'on relit.
add("Erreurs (modèle)", "stat",
[t(f'sum(rate(gen_ai_client_operation_duration_seconds_count{{{SVC},error_type!=""}}[5m])) '
f'/ sum(rate(gen_ai_client_operation_duration_seconds_count{{{SVC}}}[5m])) or vector(0)')],
8, 0, 4, 4, "percentunit", stat(1, th([{"color": "green", "value": None},
{"color": "orange", "value": 0.02}, {"color": "red", "value": 0.05}])))
add("Latence des appels au modèle (p50, p95)", "timeseries", [
t(f'histogram_quantile(0.5, sum by (le) (rate(gen_ai_client_operation_duration_seconds_bucket{{{SVC}}}[2m])))', "p50", "A", True),
t(f'histogram_quantile(0.95, sum by (le) (rate(gen_ai_client_operation_duration_seconds_bucket{{{SVC}}}[2m])))', "p95", "B", True)],
0, 4, 12, 8, "s", ts, desc="Les points (exemplars) ouvrent la trace d'un appel réel dans Tempo.")python3 grafana/generer_tableau.py grafana/dashboards/assistant-ia.json # 13 panneauxLe fournisseur déclare allowUiUpdates: false : une modification dans l'interface ne peut pas être enregistrée. Pour essayer une idée, on duplique le tableau de bord dans l'interface, puis on reporte le résultat dans le script.
Deux tests protègent ce code :
test_tableau_de_bord_ne_reference_que_des_metriques_emisesextrait les noms de métriques de chaque requête et les compare à la liste de ce que le service et le collecteur émettent vraiment. Un renommage d'un côté sans l'autre casse le test au lieu de laisser un panneau vide.- Le même test vérifie que chaque panneau a une unité. Il a été ajouté après la première capture du tableau de bord, où le taux d'erreur s'affichait
0.3au lieu de30 %: le style des panneaux « stat » remplaçait le blocfieldConfigentier, unité comprise. Le générateur fusionne désormais les deux.
3. Le test de bout en bout lit les trois stockages
e2e.py démarre le service, envoie 100 requêtes (dont 4 à l'agent), attend l'export et le scrape, puis interroge Prometheus, Tempo (en TraceQL), Loki et l'API de Grafana. Il échoue si un seul signal manque.
Sa première version, écrite le 2026-10-15, a échoué alors que tout fonctionnait :
Charge (gemini-3.5-flash-lite) : {'requetes': 100, 'ok': 100, 'erreurs': 0, 'cache': 75, 'p50_s': 0.002, 'p95_s': 1.149}
Prometheus : {'appels_modele': 0.0, 'tokens_entree': 0.0, ..., 'taux_cache': nan}
AssertionError: métriques gen_ai absentesLa requête était sum(increase(...[fenêtre])). Or increase() et rate() calculent une différence entre échantillons d'une même série. Une série née pendant le test, et dont la charge tient en quelques secondes, n'a qu'une ou deux valeurs, toutes égales à la valeur finale : la différence est nulle. Prometheus ne voit jamais le passage de « absent » à « 25 ». La version actuelle interroge la dernière valeur des compteurs du processus testé, identifié par service.instance.id :
instance = f"{socket.gethostname()}-{service.pid}" # = service.instance.id posé par telemetrie.py
sel = f'{SVC},service_instance_id="{instance}"'
mesures = {"appels_modele": promql(f"sum(gen_ai_client_operation_duration_seconds_count{{{sel}}})"), ...}Le même phénomène touche le tableau de bord : un panneau en rate() sur une métrique rare (quatre invocations d'agent par heure) reste vide ou sous-estime. Sur un service en continu, l'erreur ne porte que sur le premier incrément de chaque série ; sur une métrique rare, c'est l'essentiel du signal.
Résultat obtenu
make e2e le 2026-10-16, service sur Vertex AI (gemini-3.5-flash-lite, location global) :
Charge (gemini-3.5-flash-lite) : {'requetes': 100, 'ok': 100, 'erreurs': 0, 'cache': 75, 'p50_s': 0.002, 'p95_s': 1.536}
Attente de l'export (10 s) et du scrape Prometheus (15 s)...
Prometheus : {'appels_modele': 29.0, 'tokens_entree': 2349.0, 'tokens_sortie': 1285.0, 'cout_usd': 0.003917,
'taux_cache': 0.78125, 'p95_modele_s': 4.544, 'invocations_adk': 4.0}
Tempo : 21 traces avec un appel au modèle ; spans de la plus récente : ['POST /question', 'POST /question http receive',
'POST /question http send', 'chat gemini-3.5-flash-lite']
Tempo : 4 traces d'agent ; spans de la plus récente : ['POST /agent', 'POST /agent http receive', 'POST /agent http send',
'call_llm', 'execute_tool etat_ligne', 'generate_content gemini-3.5-flash-lite', 'invocation', 'invoke_agent diagnostic']
Loki : 903 lignes de journal portant un trace_id
Grafana : tableau « Assistant IA : latence, tokens, coût, erreurs » (13 panneaux), 3 règles d'alerte
OK en 50 sLecture : 75 des 96 questions servies par le cache, d'où un p50 de 2 ms ; 29 appels au modèle pour 0,0039 USD, soit 21 appels directs (les questions absentes du cache) et 8 faits par l'agent, deux par tour : décider d'appeler l'outil, puis rédiger la réponse.
Le tableau de bord, pendant une charge réelle étalée sur cinq minutes (9 vagues de 40 requêtes dont 2 à l'agent, cache de 90 s, Vertex AI) :
Capture réelle du 2026-10-16. Les losanges du graphe de latence sont des exemplars : chacun ouvre la trace de l'appel mesuré.
Une trace d'agent ouverte dans Tempo : l'invocation, l'agent, les deux appels au modèle et l'outil etat_ligne.
Capture réelle du 2026-10-16 : 2,47 s au total, dont 1,5 s et 0,9 s pour les deux appels au modèle (décider d'appeler l'outil, puis répondre) ; l'outil etat_ligne prend 0,4 ms. Pour accélérer l'agent, c'est le nombre d'appels au modèle qu'il faut réduire, pas l'outil.
Et les journaux de la même période dans Loki, chacun porteur de son trace_id :
Capture réelle : le détail d'une ligne porte le lien « Voir la trace », construit à partir de la métadonnée trace_id (chemin local du poste masqué dans les attributs).
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Panneau vide pour une métrique rare, ou test qui lit 0 | rate()/increase() ignorent le premier incrément d'une série nouvelle | fenêtres plus longues pour les métriques rares ; dans les tests, dernière valeur des compteurs du processus testé |
| Tous les panneaux en « datasource not found » après réinstallation | uid de source générés aléatoirement | uid fixés dans le provisionnement |
| Une correction faite dans l'interface disparaît au redémarrage | tableau provisionné, allowUiUpdates: false | c'est voulu : reporter la modification dans le script, relire en revue |
| Un panneau affiche 0.3 au lieu de 30 % | bloc de style qui écrase fieldConfig et son unité | fusion des réglages dans le générateur, test « chaque panneau a une unité » |
| Aucun losange sur le graphe de latence | exemplars non exposés (collecteur sans OpenMetrics) ou non stockés (option Prometheus absente) | enable_open_metrics: true et --enable-feature=exemplar-storage, vérifiés ensemble |
| Lien « Voir la trace » absent des journaux | ligne de journal émise hors d'une requête (démarrage, tâche de fond) : pas de span actif, pas de trace_id | normal pour ces lignes ; pour une tâche de fond, ouvrir un span autour du traitement |
La règle de décision
Provisionnez en code tout tableau de bord dont dépend une astreinte ; gardez l'interface pour l'exploration ponctuelle. Générez le JSON dès qu'il dépasse quelques panneaux, et testez les noms de métriques qu'il cite. Construisez le parcours métrique → trace → journal avant d'en avoir besoin : c'est lui qui transforme « le p95 monte » en « cet appel-là, avec ce prompt de 12 000 tokens ». Surveillez les panneaux vides : en observabilité, un graphe vide est plus souvent une requête cassée qu'une absence de problème.
Pratique
Le panneau « Agent ADK : durée p95 » est vide sur la dernière heure alors que les journaux montrent 3 réponses de l'agent. Que vérifiez-vous en premier ?
Les journaux prouvent que l'agent tourne. Avec quelques invocations, rate() ne voit pas le premier incrément de chaque série et le quantile n'a rien à calculer. Tempo n'intervient pas dans un panneau Prometheus.
Un développeur veut ajouter un panneau « coût par client » au tableau de bord provisionné. Quelle réponse donnez-vous ?
Un label client multiplie les séries par le nombre de clients. Les spans portent déjà le client et le coût de chaque appel : la question se traite à la demande. Créer le panneau dans l'interface est impossible avec allowUiUpdates: false, et contournerait la revue.
Exercice. Ajoutez au tableau de bord un panneau « Tokens d'entrée par appel (moyenne) », qui permet de repérer un prompt qui grossit, et adaptez le test si nécessaire.
Voir la solution
Rapport de deux taux : tokens d'entrée par seconde sur appels par seconde.
add("Tokens d'entrée par appel (moyenne)", "timeseries", [
t(f'sum(rate(gen_ai_client_inference_usage_input_tokens_total{{{SVC}}}[5m])) '
f'/ sum(rate(gen_ai_client_operation_duration_seconds_count{{{SVC},error_type=""}}[5m]))', "moyenne", "A")],
0, 28, 12, 8, "short", ts, desc="Une hausse lente signale un prompt ou un historique qui grossit.")Les deux métriques figurent déjà dans CONNUES : le test passe sans modification. Régénérez le JSON, vérifiez le diff (un panneau ajouté, rien d'autre), puis make test. Pour une distribution plutôt qu'une moyenne, la convention prévoit l'histogramme gen_ai.client.inference.operation.input_tokens, que ce service n'émet pas encore.
- Je sais provisionner sources, tableaux de bord et alertes en fichiers.
- Je sais générer un tableau de bord par script et le tester.
- Je sais relier métriques, traces et journaux (exemplars,
tracesToLogsV2, champ dérivé). - Je sais pourquoi
rate()sous-estime une métrique rare ou nouvelle.