Leçon 4 — Visualiser et alerter

Un tableau de bord Grafana provisionné en code, relié aux traces et aux journaux

10 min de lecture

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 :

  1. 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.
  2. Traces vers journaux : depuis un span dans Tempo, tracesToLogsV2 ouvre les journaux Loki filtrés par le même trace_id.
  3. 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

files/grafana/provisioning/datasources/sources.yaml (extrait)
  - 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.

files/grafana/generer_tableau.py (extrait)
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 panneaux

Le 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_emises extrait 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.3 au lieu de 30 % : le style des panneaux « stat » remplaçait le bloc fieldConfig entier, 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 absentes

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

files/src/assistant_obs/e2e.py (extrait)
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 s

Lecture : 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) :

Tableau de bord Grafana de l'assistant : requêtes par seconde, latence p95, taux d'erreur, taux de cache, coût projeté, latence p50/p95 avec exemplars, tokens par minute, appels par résultat, cache, agent ADK, journaux, collecteur

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.

Trace d'une requête POST /agent dans Grafana Tempo : spans invocation, invoke_agent diagnostic, call_llm, execute_tool etat_ligne, generate_content

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 :

Journaux de l'assistant dans Grafana Loki, filtrés sur l'agent, avec le volume par minute

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ômeCauseParade
Panneau vide pour une métrique rare, ou test qui lit 0rate()/increase() ignorent le premier incrément d'une série nouvellefenê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éinstallationuid de source générés aléatoirementuid fixés dans le provisionnement
Une correction faite dans l'interface disparaît au redémarragetableau provisionné, allowUiUpdates: falsec'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 latenceexemplars 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 journauxligne de journal émise hors d'une requête (démarrage, tâche de fond) : pas de span actif, pas de trace_idnormal 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 ?

Un développeur veut ajouter un panneau « coût par client » au tableau de bord provisionné. Quelle réponse donnez-vous ?

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.
Suivant