Leçon 5 — Visualiser et alerter

Alertes provisionnées et charge de test réelle

9 min de lecture

Alertes provisionnées et charge de test réelle

Un tableau de bord ne réveille personne. La nuit où le quota Vertex AI d'Ondine a été atteint, l'assistant a renvoyé « assistant indisponible » à 30 % des clients pendant quatre heures ; le tableau de bord le montrait parfaitement, et personne ne le regardait. Il faut des alertes, peu nombreuses, chacune liée à une action. Et il faut les avoir vues se déclencher au moins une fois avant de leur faire confiance.

Le mécanisme : requête, seuil, durée

Une règle d'alerte gérée par Grafana évalue une requête à intervalle fixe (ici chaque minute), compare le résultat à un seuil, et ne passe en alerte que si la condition tient pendant une durée minimale (for) :

stateDiagram-v2
  [*] --> Normal
  Normal --> Pending: "condition vraie"
  Pending --> Normal: "condition fausse avant la fin de for"
  Pending --> Firing: "condition vraie pendant for"
  Firing --> Normal: "condition fausse"
  Normal --> Normal: "aucune donnée (noDataState: OK)"

Trois règles, chacune liée à une décision :

RègleSeuilforCe que fait l'astreinte
Taux d'erreur du modèle> 5 % sur 5 min2 minlit error_type : 429 = quota, demander une hausse ou réduire le trafic ; 503 = fournisseur, basculer de région
Latence p95 du modèle> 8 s sur 5 min5 mincompare avec la taille des prompts (tokens d'entrée) et l'état publié du fournisseur
Dépense projetée> 2 USD/h sur 5 min10 mincherche une boucle d'agent ou un cache désactivé ; budget prévu : 600 USD/mois, soit 0,83 USD/h

Le for filtre les pics isolés : deux minutes d'erreurs pour la première, dix minutes de dépense pour la troisième, car un pic de coût de cinq minutes ne justifie pas de réveiller quelqu'un.

La mise en œuvre

1. Les règles, en YAML versionné

files/grafana/provisioning/alerting/regles.yaml (extrait)
      - uid: llm-taux-erreur
        title: "Taux d'erreur du modèle supérieur à 5 %"
        condition: C
        for: 2m
        noDataState: OK          # pas de trafic = pas d'erreur
        execErrState: Error
        labels: { severity: critique, equipe: support-ia }
        annotations:
          summary: "{{ humanizePercentage $values.A.Value }} des appels au modèle échouent (5 dernières minutes)"
        data:
          - refId: A
            datasourceUid: prometheus
            relativeTimeRange: { from: 600, to: 0 }
            model:
              refId: A
              instant: true
              expr: >-
                sum(rate(gen_ai_client_operation_duration_seconds_count{service_name="assistant-support",error_type!=""}[5m]))
                / sum(rate(gen_ai_client_operation_duration_seconds_count{service_name="assistant-support"}[5m]))
          - refId: C
            datasourceUid: __expr__
            model: { refId: C, type: threshold, expression: A,
                     conditions: [{ evaluator: { type: gt, params: [0.05] } }] }

A est la requête, C le seuil appliqué à A. noDataState: OK est un choix explicite : la nuit, sans trafic, la division vaut « pas de donnée », et le défaut de Grafana (NoData) déclencherait une notification chaque nuit. Ce choix a un revers, traité plus bas. Les règles provisionnées portent la mention Provisioned dans l'interface et ne s'y modifient pas : la source de vérité est le fichier.

Le test unitaire test_regles_d_alerte vérifie que chaque règle a la structure A puis C et ne cite que des métriques réellement émises.

2. Une charge de test qui ressemble au trafic

files/src/assistant_obs/charge.py (extrait)
POIDS = [1 / (rang + 1) for rang in range(len(QUESTIONS))]  # loi de Zipf : quelques questions dominent

async def lancer(base, requetes, concurrence, agent, graine=7) -> dict:
    sem = asyncio.Semaphore(concurrence)
    ...
    async with httpx.AsyncClient() as client:
        await asyncio.gather(*(une(client, i) for i in range(requetes)))
    return {"requetes": requetes, "ok": ok, "erreurs": requetes - ok, "cache": caches,
            "p50_s": ..., "p95_s": ...}

Seize questions réelles du support, tirées selon une loi de Zipf : la première est posée deux fois plus souvent que la deuxième, trois fois plus que la troisième. C'est le profil habituel d'un support client, et c'est ce qui rend un cache rentable. La concurrence est bornée par un sémaphore : on simule 4 conseillers simultanés, pas une attaque.

3. Voir l'alerte se déclencher, sans clé

make demo-alerte   # FAUX_MODELE=1 TAUX_ERREUR_FAUX=0.3 E2E_REQUETES=1500 CACHE_TTL_S=0

Le faux modèle échoue en 503 sur 30 % des appels pendant un peu plus de cinq minutes, cache désactivé pour que chaque requête atteigne le modèle. Aucun appel payant.

Résultat obtenu

make demo-alerte le 2026-10-16 :

Charge (faux modèle) : {'requetes': 1500, 'ok': 1052, 'erreurs': 448, 'cache': 0, 'p50_s': 0.669, 'p95_s': 1.462}
Prometheus : {'appels_modele': 1500.0, 'tokens_entree': 73382.0, 'tokens_sortie': 54786.0, 'cout_usd': 0.15898,
              'taux_cache': 0.0, 'p95_modele_s': 1.854488, 'invocations_adk': 0.0}
Grafana : tableau « Assistant IA : latence, tokens, coût, erreurs » (13 panneaux), 3 règles d'alerte
OK en 316 s

Le coût de 0,159 USD est fictif (tokens simulés, prix réels) : il montre que la chaîne de calcul fonctionne aussi avec le faux modèle. État des règles lu par l'API de Grafana juste après :

Taux d'erreur du modèle supérieur à 5 %       firing    ['Alerting']
Latence p95 du modèle supérieure à 8 s        inactive  ['Normal']
Dépense projetée supérieure à 2 USD par heure inactive  ['Normal']
Règle « Taux d'erreur du modèle supérieur à 5 % » à l'état Firing dans Grafana : courbe autour de 30 %, seuil à 0,05

Capture réelle du 2026-10-16 : la règle provisionnée est passée à « Firing » ; la courbe du taux d'erreur tient autour de 0,3, six fois le seuil.

Les deux autres règles sont restées normales, ce qui est la bonne réponse : la latence p95 du faux modèle (1,9 s) est sous 8 s, et la dépense projetée (0,9 USD/h d'après le tableau de bord) sous 2 USD/h.

Ce qui casse en production

SymptômeCauseParade
Une notification chaque nuit à 2 hnoDataState par défaut (NoData) sur un ratio sans traficnoDataState: OK pour les ratios ; une règle séparée sur l'absence de trafic aux heures ouvrées
Aucune alerte alors que le service est tombénoDataState: OK : un service arrêté ne produit plus d'erreur, donc plus de donnéealerte d'absence (absent(up{job="otel-collector-app"}), ou trafic nul entre 8 h et 20 h)
L'alerte de latence clignote toutes les heures la nuitp95 calculé sur une poignée d'appels : une seule réponse lente suffitcondition de volume minimal (and sum(rate(...count[5m])) > 0.1), for plus long
Alerte déclenchée, personne prévenuaucun point de contact configuré : Grafana évalue, mais n'envoie rienpoint de contact (e-mail, Chat, PagerDuty) provisionné lui aussi en YAML, testé par une alerte de test
Test de charge rassurant, production lente75 % de la charge servie par le cache : la latence mesurée est celle du cachetest du modèle avec CACHE_TTL_S=0, test de bout en bout avec le cache actif, les deux chiffres publiés

Dans ce lab, aucun point de contact n'est configuré : l'alerte se déclenche et reste visible dans Grafana, sans notification. C'est l'étape à ajouter avant tout usage réel.

La règle de décision

Une alerte n'existe que si quelqu'un sait quoi faire quand elle sonne : écrivez l'action dans l'annotation description. Préférez des alertes sur des ratios et des percentiles, filtrées par une durée for, à des seuils sur des valeurs brutes. Gardez-en peu : trois règles qui sonnent à raison valent mieux que vingt que l'astreinte a appris à ignorer. Déclenchez chaque nouvelle règle volontairement au moins une fois, comme make demo-alerte, avant de la considérer comme active.

Pratique

L'alerte de taux d'erreur se déclenche à 3 h du matin : 2 appels sur 3 ont échoué en 5 minutes. Que changez-vous dans la règle ?

Pendant un test de charge, l'alerte de dépense ne se déclenche pas alors que le tableau de bord affiche 2,4 USD/h depuis 6 minutes. Pourquoi ?

Exercice. Ajoutez une quatrième règle : « Aucune requête reçue depuis 10 minutes entre 8 h et 20 h », pour couvrir le revers de noDataState: OK.

Voir la solution

Une requête qui vaut 1 quand le trafic est nul pendant les heures ouvrées (heure UTC de Prometheus : ajustez au fuseau du support), 0 sinon :

      - uid: assistant-sans-trafic
        title: "Aucune requête reçue depuis 10 minutes en heures ouvrées"
        condition: C
        for: 10m
        noDataState: Alerting    # ici, l'absence de donnée EST le problème
        execErrState: Error
        labels: { severity: critique, equipe: support-ia }
        data:
          - refId: A
            datasourceUid: prometheus
            relativeTimeRange: { from: 600, to: 0 }
            model:
              refId: A
              instant: true
              expr: >-
                ((sum(rate(http_server_request_duration_seconds_count{service_name="assistant-support"}[10m])) or vector(0)) == bool 0
                and on() (hour() >= 6 and hour() < 18)) or vector(0)
          - refId: C
            datasourceUid: __expr__
            model: { refId: C, type: threshold, expression: A,
                     conditions: [{ evaluator: { type: gt, params: [0] } }] }

Le or vector(0) final rend 0 hors des heures ouvrées : sans lui, la requête serait vide la nuit et noDataState: Alerting sonnerait. Requête vérifiée sur le Prometheus du lab le 2026-10-16 (résultat 0, trafic présent). Pensez à mettre à jour test_regles_d_alerte (ensemble des uid), sinon le test échoue, et c'est voulu : une règle ajoutée doit être relue.

  • Je sais écrire une règle d'alerte Grafana provisionnée (requête, seuil, for, noDataState).
  • Je sais justifier chaque alerte par une action de l'astreinte.
  • Je sais générer une charge réaliste et déclencher une alerte volontairement.
  • Je sais pourquoi une alerte sans point de contact ne prévient personne.
Suivant