Pratique guidée — Démarrer, vérifier, casser et réparer le labo

Pratique guidée53 min
Durée
60 à 90 min
Module
1/7
Tu vas construire
un labo de dix services démarré et vérifié par trois chemins (le script etat, l'onglet Graph de Prometheus, Explore dans Grafana), puis volontairement cassé avec casser api et réparé avec reparer
Livrable
la sortie complète de etat avec Labo : 10/10 services, 8/8 cibles up, 0 alertes actives., plus trois lignes écrites à la main : l'heure où APIInjoignable est passée firing dans Prometheus, l'heure où le webhook l'a reçue, l'heure où elle est passée resolved

Comment lire cette page. Chaque section est repliée sous son titre : clique sur « Afficher … » pour l'ouvrir, et referme-la quand tu as fini pour garder la page lisible. Ordre de lecture : Objectif, puis En bref (les commandes à taper), puis Le jeu de données (à lire avant toute requête), puis les requêtes Prometheus (P1 à P12) et Grafana Explore (G1 à G8), classées de la plus simple (up, {service="api"}) à la plus parlante, avec une explication après chacune. Le pas à pas détaillé, avec la sortie attendue de chaque commande et la panne à provoquer, est en annexe : annexe A pour Windows (PowerShell), annexe B pour Linux, macOS, WSL 2 et Git Bash. Ouvre une seule annexe, celle de ton système. L'annexe C, commune, regroupe les cas où ça coince. Toutes les sorties de cette page ont été capturées sur le labo du cours ; les valeurs qui dépendent du moment (compteurs, durées, horodatages) seront différentes chez toi, les formes seront identiques.

Objectif

Tu rejoins l'équipe qui exploite le catalogue de cours d'une plateforme en ligne. Ta cheffe te tend le kit du labo : « Demain matin, je veux la pile d'observabilité qui tourne sur ton poste, l'API dedans, et la preuve que tu sais lire une panne sans m'appeler. » Tu vas donc démarrer les dix services, prouver que Prometheus lit bien ses huit cibles et que Loki reçoit les journaux de l'API, taper douze requêtes PromQL et huit requêtes LogQL pour apprendre à lire ce que le labo mesure, puis arrêter l'API exprès. Tu regarderas la panne se propager : etat la voit en deux secondes, Prometheus met la cible en DOWN, l'alerte APIInjoignable passe de pending à firing, Alertmanager l'envoie au webhook. Puis tu répares et tu regardes l'alerte s'éteindre. Reconnaître « ce service est arrêté » en dix secondes, et savoir où le chercher, c'est ce qui évite des heures de recherche au mauvais endroit.

Les neuf étapes de ce schéma sont détaillées, avec la sortie attendue de chaque commande, dans l'annexe A (Windows) ou l'annexe B (Linux, macOS) en bas de page.

En bref : les commandes du lab

Afficher les commandes

Kit du labo : https://github.com/hrhouma2/aiopsatlas-observabilite-labo-fr

Tu clones le kit dans un dossier lab3, tu vérifies que Docker est prêt, tu démarres les dix services, tu ouvres les pages web, tu tapes les requêtes, puis tu casses et tu répares. À la fin, etat doit afficher Labo : 10/10 services, 8/8 cibles up, 0 alertes actives., l'API doit connaître 64 cours, et le webhook doit avoir reçu deux notifications pour APIInjoignable : une firing, une resolved. Commence par exécuter ce bloc.

Windows (PowerShell)

powershell
git clone https://github.com/hrhouma2/aiopsatlas-observabilite-labo-fr.git lab3
cd lab3
ls                       # explorer le contenu : docker-compose.yml, labo.ps1, labo.sh, api/, prometheus/, grafana/, modules/
.\labo.ps1 prerequis
.\labo.ps1 demarrer
.\labo.ps1 etat          # attendu : Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.

Attends deux minutes (le temps que Prometheus ait quelques mesures), puis ouvre les pages dans le navigateur :

text
Prometheus       http://localhost:9090          (Status → Target health : 8 cibles UP ; onglet Graph pour P1 à P12)
Grafana          http://localhost:3000          (utilisateur admin · mot de passe aiopsatlas2026 ; menu → Explore, source Loki pour G1 à G8)
Alertmanager     http://localhost:9093          (vide au départ)
API catalogue    http://localhost:8000/cours    ·   http://localhost:8000/metrics
Webhook          http://localhost:8090          (vide au départ)

Les requêtes P1 à P12 sont aussi dans modules\01-le-labo\requetes.txt, et G1 à G8 dans modules\01-le-labo\requetes-logql.txt : ouvre-les dans un éditeur et copie-colle. Puis la panne :

powershell
.\labo.ps1 casser api    # arrête le conteneur de l'API ; la charge continue de frapper dans le vide
.\labo.ps1 etat          # attendu : 9/10 services, 7/8 cibles up ; regarde aussi Targets, Alerts, 9093 et 8090
.\labo.ps1 reparer       # redémarre l'API
.\labo.ps1 etat          # attendu : Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.

Si PowerShell refuse .\labo.ps1 (« l'exécution de scripts est désactivée ») : Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, réponds O, relance. Si le port 3000 est déjà pris sur ta machine, $env:GRAFANA_PORT = '3001' avant demarrer, et remplace 3000 par 3001 dans les URL de Grafana.

Linux, macOS, WSL 2, Git Bash

bash
git clone https://github.com/hrhouma2/aiopsatlas-observabilite-labo-fr.git lab3
cd lab3
ls                       # explorer le contenu : docker-compose.yml, labo.sh, labo.ps1, api/, prometheus/, grafana/, modules/
./labo.sh prerequis
./labo.sh demarrer
./labo.sh etat           # attendu : Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.

Attends deux minutes, puis ouvre les pages dans le navigateur :

text
Prometheus       http://localhost:9090          (Status → Target health : 8 cibles UP ; onglet Graph pour P1 à P12)
Grafana          http://localhost:3000          (utilisateur admin · mot de passe aiopsatlas2026 ; menu → Explore, source Loki pour G1 à G8)
Alertmanager     http://localhost:9093          (vide au départ)
API catalogue    http://localhost:8000/cours    ·   http://localhost:8000/metrics
Webhook          http://localhost:8090          (vide au départ)

Les requêtes sont dans modules/01-le-labo/requetes.txt (P1 à P12) et modules/01-le-labo/requetes-logql.txt (G1 à G8). Puis la panne :

bash
./labo.sh casser api     # arrête le conteneur de l'API ; la charge continue de frapper dans le vide
./labo.sh etat           # attendu : 9/10 services, 7/8 cibles up ; regarde aussi Targets, Alerts, 9093 et 8090
./labo.sh reparer        # redémarre l'API
./labo.sh etat           # attendu : Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.

Si le port 3000 est pris : GRAFANA_PORT=3001 ./labo.sh demarrer, puis 3001 dans les URL de Grafana.

Le jeu de données : ce que tu vas manipuler

Afficher le jeu de données

Avant de taper une seule requête, regarde ce que le labo observe. Tout tourne autour d'une API de catalogue de cours : un petit service web écrit en Python (FastAPI) qui sert 64 cours et enregistre des inscriptions. Un deuxième service, charge, joue le rôle des utilisateurs : il appelle l'API en continu, avec des requêtes réussies, quelques 404 volontaires et, une fois sur cent, une erreur 500 que l'API fabrique elle-même. Tout ce que tu vas lire dans Prometheus et dans Loki vient de ces deux services. Le reste du kit, en clair :

text
lab3/
├── api/
│   ├── app.py                     l'API catalogue (FastAPI) : 6 routes publiques, 3 routes /admin
│   └── donnees/cours.json         64 cours → servis par GET /cours et GET /cours/{id}
├── charge/charge.py               le générateur de trafic : GET /cours, /cours/{id}, POST /inscriptions, GET /lent, des 404
├── prometheus/
│   ├── prometheus.yml             8 cibles lues toutes les 15 s (scrape_interval: 15s)
│   └── regles/alertes.yml         10 règles d'alerte ; APIInjoignable est la première
├── alertmanager/alertmanager.yml  groupe les alertes et les envoie au webhook (group_wait: 10s)
├── alloy/config.alloy             lit les journaux des conteneurs et les pousse dans Loki
├── grafana/provisioning/          3 tableaux de bord et 3 sources de données, créés au démarrage
└── modules/01-le-labo/
    ├── requetes.txt               P1 à P12, à coller dans Prometheus
    └── requetes-logql.txt         G1 à G8, à coller dans Grafana Explore

Ouvre les données toi-même, ça prend dix secondes :

powershell
# Windows (PowerShell), depuis le dossier lab3
Get-Content api\donnees\cours.json -TotalCount 16
Invoke-RestMethod "http://localhost:8000/cours?limite=2"
Invoke-RestMethod http://localhost:8000/cours/C0001
(Invoke-WebRequest http://localhost:8000/metrics -UseBasicParsing).Content -split "`n" | Select-String "^http_requetes_total"
bash
# Linux, macOS, WSL 2, Git Bash, depuis le dossier lab3
head -n 16 api/donnees/cours.json
curl -s "http://localhost:8000/cours?limite=2"
curl -s http://localhost:8000/cours/C0001
curl -s http://localhost:8000/metrics | grep "^http_requetes_total"

L'API catalogue : 64 cours dans un fichier JSON

api/donnees/cours.json est un tableau JSON de 64 objets, un par cours, identifiants C0001 à C0064. Le premier, tel que l'API le renvoie sur GET /cours/C0001 :

json
{"id":"C0001","titre":"Introduction à Python","categorie":"programmation","niveau":"debutant","prix":89,"duree_heures":6,"professeur":"Karim Haddad","tags":["code","algorithmes"],"note":4.8,"inscrits":2319}
ChampExempleCe que c'est
idC0001Identifiant du cours, C suivi de quatre chiffres, de C0001 à C0064
titreIntroduction à PythonTitre affiché
categorieprogrammationUne des neuf catégories : cloud, donnees, gestion, ia, outils, programmation, securite, systemes, web
niveaudebutantdebutant, intermediaire ou avance
prix89Prix en dollars, entier
duree_heures6Durée totale, en heures
professeurKarim HaddadUn des dix professeurs du catalogue
tags["code","algorithmes"]Liste de mots-clés
note4.8Note moyenne sur 5
inscrits2319Nombre d'inscrits au chargement ; les inscriptions faites pendant le labo sont comptées à part, dans la métrique inscriptions_total

Les routes de l'API et ce qu'elles répondent sur le labo du cours :

RouteRéponse réelleCe qu'elle fait
GET /sante{"etat":"ok","version":"1.0.0","cours":64}Le test de santé que Docker appelle ; etat lit version et cours ici
GET /cours?limite=2{"total":64,"page":1,"limite":2,"cours":[…]}La liste paginée ; filtres categorie et niveau (?categorie=cloud"total":6)
GET /cours/C0001le document ci-dessusUne fiche ; l'API compte chaque consultation dans cours_consultes_total{cours_id="C0001"}
GET /cours/C9999404 {"detail":"cours C9999 introuvable"}Un 404 propre : le service est sain, la ressource n'existe pas
POST /inscriptions201 (ou 404 si le cours n'existe pas, 422 si le corps est invalide)Appelée par charge ; incrémente inscriptions_total{cours_id="…"}
GET /lent{"attente_ms":305}Une route volontairement lente (300 à 900 ms) pour nourrir l'histogramme de latence
GET /admin/etat{"taux_erreurs":0.01,"lenteur_ms":0,"inscriptions_enregistrees":855,…}Les réglages de panne ; casser erreurs et casser lenteur les changent, reparer les remet
GET /metricsenviron 270 lignes de texteCe que Prometheus lit toutes les 15 secondes

Chaque réponse porte un en-tête x-id-requete (par exemple x-id-requete: c5c49525ea53) : c'est le même identifiant que le champ id_requete de la ligne de log écrite pour cette requête. Il te servira à la requête G7.

Les métriques : ce que /metrics expose, une vraie ligne par type

La page http://localhost:8000/metrics est du texte, une série par ligne, précédée de deux lignes de commentaire # HELP (à quoi sert la métrique) et # TYPE (son type). Sur le labo du cours, elle fait environ 270 lignes. Les quatre métriques que tu vas interroger, copiées de la page :

text
# HELP http_requetes_total Nombre de requêtes HTTP reçues, par méthode, route normalisée et code de réponse.
# TYPE http_requetes_total counter
http_requetes_total{code="200",methode="GET",route="/cours"} 3247.0
http_requetes_total{code="500",methode="GET",route="/cours"} 32.0

# HELP requetes_en_cours Nombre de requêtes HTTP en cours de traitement à cet instant.
# TYPE requetes_en_cours gauge
requetes_en_cours 1.0

# HELP http_duree_requete_seconds Durée de traitement des requêtes HTTP, en secondes, par route normalisée.
# TYPE http_duree_requete_seconds histogram
http_duree_requete_seconds_bucket{le="0.005",route="/cours"} 32.0
http_duree_requete_seconds_bucket{le="0.01",route="/cours"} 74.0
http_duree_requete_seconds_bucket{le="0.025",route="/cours"} 1566.0
http_duree_requete_seconds_bucket{le="0.05",route="/cours"} 3248.0
http_duree_requete_seconds_bucket{le="0.1",route="/cours"} 3276.0
http_duree_requete_seconds_bucket{le="0.25",route="/cours"} 3278.0
http_duree_requete_seconds_bucket{le="0.5",route="/cours"} 3279.0
http_duree_requete_seconds_bucket{le="1.0",route="/cours"} 3279.0
http_duree_requete_seconds_bucket{le="2.0",route="/cours"} 3279.0
http_duree_requete_seconds_bucket{le="+Inf",route="/cours"} 3279.0
http_duree_requete_seconds_count{route="/cours"} 3279.0
http_duree_requete_seconds_sum{route="/cours"} 85.42293146001248

# HELP api_info Informations sur l'API (toujours 1) ; la version est dans le label.
# TYPE api_info gauge
api_info{version="1.0.0"} 1.0
TypeMétrique du laboComment la lire
counter (compteur)http_requetes_totalNe fait que monter : 3247 réponses 200 sur /cours depuis le démarrage de l'API. Seule sa vitesse a un sens (P5)
gauge (jauge)requetes_en_coursMonte et descend : 1 requête en train d'être traitée à l'instant de la lecture. Se lit telle quelle (P11)
histogram (histogramme)http_duree_requete_secondsDes paniers cumulatifs : 3248 requêtes sur /cours ont pris moins de 50 ms (le="0.05"), 3279 en tout (+Inf = _count). _sum / _count = durée moyenne (ici 26 ms). P10 en tire un p95
info (une jauge à 1)api_infoLa valeur est toujours 1 ; l'information est dans le label version="1.0.0"

Il n'y a pas de summary dans cette API : Prometheus le déconseille au profit de l'histogramme, qui s'agrège entre instances.

Les labels : les colonnes de tes futurs tableaux

Une série est un nom de métrique plus un jeu de paires label="valeur". Deux origines :

LabelPosé parValeurs sur le labo
methodel'APIGET, POST
routel'API/sante, /cours, /cours/{id}, /inscriptions, /lent, /admin/etat, inconnue (toute URL qui n'existe pas)
codel'API200, 201, 404, 422, 500
lel'API, sur l'histogramme seulement0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, +Inf
cours_idl'API, sur inscriptions_total et cours_consultes_totalC0001 à C0064
jobPrometheus, d'après prometheus.ymlprometheus, api, node-exporter, cadvisor, alertmanager, grafana, loki, alloy
instancePrometheusl'adresse lue : api:8000, localhost:9090, grafana:3000
servicePrometheus, ajouté à la main dans prometheus.yml pour le job apiapi

Remarque la route /cours/{id} : l'API normalise l'URL avant de compter. /cours/C0001 et /cours/C0043 tombent dans la même série. Sans ça, il y aurait 64 séries par code au lieu d'une, et 64 fois plus de lignes dans /metrics.

Les journaux : une ligne JSON par requête

L'API écrit une ligne de journal par requête traitée, en JSON. Alloy lit la sortie de chaque conteneur labo-* et la pousse dans Loki. Une vraie ligne, lue avec .\labo.ps1 journal api (ou ./labo.sh journal api) :

json
{"horodatage": "2026-09-15T19:33:14.781+00:00", "niveau": "ERROR", "id_requete": "dc1c2ff189a6", "methode": "GET", "route": "/cours/{id}", "code": 500, "duree_ms": 0.1, "message": "GET /cours/C0019 -> 500"}
ChampExempleCe que c'est
horodatage2026-09-15T19:33:14.781+00:00Date et heure UTC, à la milliseconde
niveauERRORINFO (2xx), WARNING (4xx), ERROR (5xx)
id_requetedc1c2ff189a6L'identifiant renvoyé dans l'en-tête x-id-requete de la réponse
methode, route, codeGET, /cours/{id}, 500Les mêmes valeurs que les labels de http_requetes_total : c'est le pont entre métriques et journaux
duree_ms0.1Durée de traitement, en millisecondes
messageGET /cours/C0019 -> 500La phrase lisible, avec l'URL réelle cette fois (C0019, pas {id})

Loki ne lit pas le JSON ligne par ligne au moment de la requête, sauf si tu le lui demandes (| json, requête G5). Ce qu'il indexe, ce sont des labels posés par Alloy à l'arrivée :

Label LokiValeursPosé par
serviceapi, charge, webhook, prometheus, alertmanager, grafana, loki, alloy, node-exporter, cadvisorAlloy, d'après le nom du service Compose
conteneurlabo-api, labo-chargeAlloy, d'après le nom du conteneur
niveauINFO, WARNING, ERRORAlloy, extrait du champ niveau du JSON
code200, 201, 404, 422, 500Alloy, extrait du champ code du JSON
detected_levelinfo, warn, errorLoki lui-même, qui devine le niveau ; tu peux l'ignorer

Le fil rouge

Retiens deux choses que tu vas retrouver partout : la série http_requetes_total{code="500",route="/cours"}, à 32 sur /metrics au moment de la capture, que tu reverras à 32 dans P4 ; et l'identifiant dc1c2ff189a6, celui de la ligne d'erreur ci-dessus, que tu retrouveras dans G2 puis que tu iras chercher seul dans G7. Les métriques comptent, les journaux racontent ; les deux parlent de la même requête.

Premières requêtes : une notion à la fois

Les deux sections qui suivent contiennent vingt requêtes : douze pour Prometheus (P1 à P12), huit pour Loki à travers Grafana Explore (G1 à G8). Elles sont classées de la plus simple à la plus parlante, et chacune n'ajoute qu'une seule nouveauté par rapport à la précédente. Si une requête te paraît obscure, c'est presque toujours que la précédente n'est pas encore claire : reviens en arrière plutôt que de continuer.

Tape chaque requête toi-même, compare le résultat à celui de la page, lis l'explication, puis passe à la suivante. Les chiffres seront différents chez toi : les compteurs montent depuis le démarrage de ton API, pas de celle du cours. Les formes (le nombre de séries, les labels, l'ordre de grandeur) doivent être les mêmes. Prometheus et Loki utilisent la même idée de départ, un jeu de labels entre accolades, et c'est exprès : ce que tu apprends dans P2 sert dans G1.

Prometheus, dans l'onglet Graph

Afficher les 12 requêtes PromQL (P1 à P12)

Ouvre http://localhost:9090. Tu arrives sur la page Query (le menu du haut propose Query, Alerts, Status). Colle une requête dans le champ, appuie sur Entrée ou clique Execute. Le résultat s'affiche en dessous dans l'onglet Table (une ligne par série, la valeur à droite) ; l'onglet Graph dessine les mêmes séries dans le temps. Reste sur Table pour cette section, sauf indication contraire. Sous les onglets, une ligne du type Load time: 40ms Result series: 8 te dit combien de séries ont répondu.

L'image à garder en tête : Prometheus est un carnet de relevés. Toutes les 15 secondes, il passe devant chacune de ses huit cibles, lit leur page /metrics et note chaque valeur avec l'heure. Une requête PromQL, c'est une question posée à ce carnet.

P1. Qui répond ?

promql
up
text
up{instance="localhost:9090", job="prometheus"}               1
up{instance="alloy:12345", job="alloy"}                       1
up{instance="api:8000", job="api", service="api"}             1
up{instance="cadvisor:8080", job="cadvisor"}                  1
up{instance="alertmanager:9093", job="alertmanager"}          1
up{instance="loki:3100", job="loki"}                          1
up{instance="node-exporter:9100", job="node-exporter"}        1
up{instance="grafana:3000", job="grafana"}                    1

Result series: 8, toutes à 1.

Ce que la requête demande : « Donne-moi la dernière valeur de la métrique up pour toutes les cibles. »

Zéro paramètre : juste un nom de métrique. up n'est exposée par aucune cible ; c'est Prometheus qui la fabrique à chaque scrape : 1 si la page /metrics a répondu, 0 sinon. Huit séries parce que prometheus.yml déclare huit jobs. Chaque ligne se lit : le nom de la métrique, puis entre accolades les labels que Prometheus a posés (job et instance sur toutes, service en plus sur l'API), puis la valeur. Équivalent SQL : SELECT * FROM up. Ce que Prometheus fait que SQL ne fait pas : il a produit lui-même cette table en allant frapper à huit portes.

Pour bien comprendre : pourquoi 8 et pas 10 ?

Le labo a dix conteneurs mais Prometheus n'en lit que huit : charge et webhook n'exposent pas de page /metrics dans cette version du kit, donc ils ne sont pas des cibles. etat compte les deux à part : 10/10 services (les conteneurs) et 8/8 cibles up (les scrapes). Si un jour up rend 7 séries au lieu de 8, ce n'est pas qu'une cible est tombée (elle serait à 0) : c'est qu'un job a disparu de la configuration. Le kit a une alerte pour ça, CibleAbsente.

P2. Une seule cible

promql
up{job="api"}
text
up{instance="api:8000", job="api", service="api"}             1

Ce que la requête demande : « La valeur de up, seulement pour les séries dont le label job vaut api. »

Une seule nouveauté : le sélecteur {job="api"}. Les accolades filtrent sur les labels, comme un WHERE job = 'api'. Les guillemets sont obligatoires autour de la valeur : up{job=api} est refusé avec parse error: unexpected identifier "api" in label matching, expected string. C'est exactement cette expression, up{job="api"} == 0, que la règle APIInjoignable surveille ; tu la verras passer à 0 dans l'annexe.

P3. Le compteur brut

promql
http_requetes_total
text
http_requetes_total{code="200", instance="api:8000", job="api", methode="GET", route="/sante", service="api"}             91
http_requetes_total{code="200", instance="api:8000", job="api", methode="GET", route="/cours/{id}", service="api"}       2714
http_requetes_total{code="404", instance="api:8000", job="api", methode="GET", route="/cours/{id}", service="api"}        209
http_requetes_total{code="200", instance="api:8000", job="api", methode="GET", route="/lent", service="api"}              314
http_requetes_total{code="404", instance="api:8000", job="api", methode="GET", route="inconnue", service="api"}           312
http_requetes_total{code="200", instance="api:8000", job="api", methode="GET", route="/cours", service="api"}            3241
http_requetes_total{code="201", instance="api:8000", job="api", methode="POST", route="/inscriptions", service="api"}     855
http_requetes_total{code="404", instance="api:8000", job="api", methode="POST", route="/inscriptions", service="api"}      46
http_requetes_total{code="500", instance="api:8000", job="api", methode="GET", route="/cours", service="api"}              32
http_requetes_total{code="422", instance="api:8000", job="api", methode="POST", route="/inscriptions", service="api"}      51
http_requetes_total{code="500", instance="api:8000", job="api", methode="GET", route="/cours/{id}", service="api"}         27
http_requetes_total{code="200", instance="api:8000", job="api", methode="GET", route="/admin/etat", service="api"}         14
http_requetes_total{code="500", instance="api:8000", job="api", methode="GET", route="/lent", service="api"}                1
http_requetes_total{code="500", instance="api:8000", job="api", methode="POST", route="/inscriptions", service="api"}       7

Result series: 14 sur le labo du cours (le nombre exact dépend des combinaisons que charge a déjà produites ; il monte jusqu'à 16 avec le temps).

Ce que la requête demande : « Toutes les séries du compteur http_requetes_total, avec leur valeur actuelle. »

Rien de neuf dans la syntaxe : un nom, comme P1. Ce qui est neuf, c'est ce que tu lis. Compare avec la page /metrics : la ligne http_requetes_total{code="200",methode="GET",route="/cours"} 3247.0 est devenue http_requetes_total{code="200", instance="api:8000", job="api", methode="GET", route="/cours", service="api"} 3241. Prometheus a ajouté trois labels (instance, job, service) et la valeur diffère de quelques unités : la page a été lue à un autre instant. Une série par combinaison (methode, route, code) : c'est ce qu'on appelle la cardinalité de la métrique, ici 14.

La différence essentielle entre /metrics et Prometheus. La page /metrics est l'état de l'API à l'instant où tu l'ouvres, sans historique. Prometheus garde toutes les lectures, une toutes les 15 secondes, et c'est ça qui permet P5 : calculer une vitesse suppose d'avoir au moins deux points.

P4. Seulement les erreurs 500

promql
http_requetes_total{code="500"}
text
http_requetes_total{code="500", instance="api:8000", job="api", methode="GET", route="/cours", service="api"}              32
http_requetes_total{code="500", instance="api:8000", job="api", methode="GET", route="/cours/{id}", service="api"}         27
http_requetes_total{code="500", instance="api:8000", job="api", methode="GET", route="/lent", service="api"}                1
http_requetes_total{code="500", instance="api:8000", job="api", methode="POST", route="/inscriptions", service="api"}       7

Ce que la requête demande : « Les séries de http_requetes_total dont le label code vaut 500. »

Rien de neuf : c'est P2 appliqué à P3. Quatre séries, une par route touchée. Le fil rouge est là : route="/cours" à 32, la valeur lue sur /metrics. Le 500 est une chaîne, pas un nombre : http_requetes_total{code=500} est refusé (parse error: unexpected character inside braces: '5'). Ces 500 ne sont pas une panne : charge provoque volontairement une erreur sur cent (taux_erreurs: 0.01 dans /admin/etat) pour que les courbes d'erreur ne soient jamais vides.

Bilan de P1 à P4 : tu n'as encore rien calculé. Tu as lu des valeurs instantanées et appris à les filtrer par label.

P5. La vitesse d'un compteur

promql
rate(http_requetes_total[1m])
text
{code="200", instance="api:8000", job="api", methode="GET", route="/sante", service="api"}             0.11112345816201799
{code="200", instance="api:8000", job="api", methode="GET", route="/cours/{id}", service="api"}        3.000333370374486
{code="404", instance="api:8000", job="api", methode="GET", route="/cours/{id}", service="api"}        0.2666962995888432
{code="200", instance="api:8000", job="api", methode="GET", route="/lent", service="api"}              0.42226914101566837
{code="200", instance="api:8000", job="api", methode="GET", route="/cours", service="api"}             3.733748194243805
{code="201", instance="api:8000", job="api", methode="POST", route="/inscriptions", service="api"}     0.8667629736637403
{code="500", instance="api:8000", job="api", methode="GET", route="/cours", service="api"}             0.0222246916324036

Result series: 14, en requêtes par seconde.

Ce que la requête demande : « Pour chaque série du compteur, de combien a-t-elle augmenté par seconde, en moyenne, sur la dernière minute ? »

Une seule nouveauté, en deux morceaux inséparables : [1m] transforme la série en plage (toutes les valeurs de la dernière minute, au lieu de la dernière seule), et rate() calcule la pente de cette plage. Regarde le résultat : le nom de la métrique a disparu des accolades, parce que ce n'est plus http_requetes_total, c'est une vitesse dérivée. /cours reçoit 3,7 requêtes par seconde, /sante 0,11 (une toutes les 9 secondes : c'est le healthcheck de Docker). Équivalent SQL : il n'y en a pas de simple ; il faudrait deux lectures, une soustraction et une division par le temps écoulé. rate() fait ça pour chaque série, et corrige en plus les remises à zéro quand l'API redémarre.

Sans la plage, Prometheus refuse : rate(http_requetes_total) donne parse error: expected type range vector in call to function "rate", got instant vector. Tu liras ce message souvent ; il veut dire « il manque […] ».

Pour bien comprendre : pourquoi on ne lit jamais un compteur tel quel

3241 requêtes 200 sur /cours ne dit rien : depuis quand ? Si l'API tourne depuis une heure, c'est calme ; depuis une minute, c'est une attaque. Un compteur ne vaut que par sa pente. C'est pour ça que sur un tableau de bord, tu ne verras jamais http_requetes_total brut, mais toujours rate(http_requetes_total[…]). La fenêtre [1m] lisse sur une minute ; [5m] lisse plus (courbe plus calme, réaction plus lente). Le kit utilise [5m] dans ses alertes et [1m] ici pour que tu voies bouger quelque chose. Règle pratique : la fenêtre doit contenir au moins deux scrapes, donc ici au moins [30s] ; rate(http_requetes_total[10s]) rend un résultat vide.

P6. Additionner par route

promql
sum by (route) (rate(http_requetes_total[1m]))
text
{route="/sante"}          0.11112345816201799
{route="/cours/{id}"}     3.3114790532281364
{route="/lent"}           0.42226914101566837
{route="inconnue"}        0.3778197577508612
{route="/cours"}          3.7559728858762087
{route="/inscriptions"}   0.9334370485609511
{route="/admin/etat"}     0.0444493832648072

Result series: 7.

Ce que la requête demande : « Prends les vitesses de P5 et additionne-les en gardant seulement le label route. »

Une seule nouveauté : l'agrégation sum by (route) (…). Elle fait fondre tous les autres labels (code, methode, instance…) et additionne ce qui reste. /cours/{id} passe de trois séries (200, 404, 500) à une : 3,00 + 0,27 + 0,04 = 3,31. Équivalent SQL : SELECT route, SUM(vitesse) FROM … GROUP BY route. Les parenthèses autour de route sont obligatoires : sum by route (…) est refusé (parse error: unexpected identifier "route" in grouping opts, expected "(").

P7. Additionner par code

promql
sum by (code) (rate(http_requetes_total[1m]))
text
{code="200"}    7.311923547060784
{code="404"}    0.6445160573397044
{code="201"}    0.8667629736637403
{code="500"}    0.0888987665296144
{code="422"}    0.0444493832648072

Ce que la requête demande : « La même somme que P6, mais regroupée par code HTTP. »

Rien de neuf : P6 avec un autre label. C'est ce que fait le panneau « Réponses par code » du tableau de bord « API catalogue — signaux dorés » dans Grafana. Cinq codes, cinq lignes ; les 500 à 0,09 par seconde, soit un peu plus d'une erreur toutes les douze secondes.

P8. Le trafic total

promql
sum(rate(http_requetes_total[1m]))
text
{}    8.956550727858652

Ce que la requête demande : « Additionne toutes les vitesses, sans garder aucun label. »

Une seule nouveauté : sum(…) sans by. Résultat : une série unique, avec un jeu de labels vide ({}), la valeur 8,96 requêtes par seconde. C'est le premier des quatre signaux dorés, le trafic. Vérifie : la somme des sept lignes de P6 fait bien 8,96.

Bilan de P5 à P8 : tu sais transformer un compteur en vitesse, puis regrouper cette vitesse comme tu veux. Les trois quarts des tableaux de bord Prometheus ne font que ça.

P9. Le taux d'erreurs

promql
sum(rate(http_requetes_total{code=~"5.."}[1m])) / sum(rate(http_requetes_total[1m]))
text
{}    0.009925558312655085

Ce que la requête demande : « La vitesse des réponses dont le code commence par 5, divisée par la vitesse de toutes les réponses. »

Deux nouveautés, mais petites. D'abord =~ : un sélecteur par expression régulière, "5.." = un 5 suivi de deux caractères quelconques, donc tous les 5xx. Ensuite la division de deux résultats : Prometheus divise les séries qui ont les mêmes labels, et ici les deux côtés ont un jeu vide {}, donc ça s'apparie. Résultat : 0,0099, soit 1 % ; c'est la valeur réglée dans /admin/etat (taux_erreurs: 0.01). Deuxième signal doré, les erreurs. La règle d'alerte TauxErreursEleve du kit se déclenche quand cette même expression, calculée sur 5 minutes, dépasse 0,05.

P10. La latence p95

promql
histogram_quantile(0.95, sum by (le) (rate(http_duree_requete_seconds_bucket[5m])))
text
{}    0.09797705555555555

Ce que la requête demande : « À partir des paniers de l'histogramme de durée, toutes routes confondues, sous quelle valeur tombent 95 % des requêtes des 5 dernières minutes ? »

Une seule nouveauté : histogram_quantile(0.95, …). Elle veut en entrée les paniers _bucket sommés par le (c'est pour ça que le sum by (le) est obligatoire : sans lui, elle calcule un quantile par route et le résultat n'a plus le sens que tu attendais). Résultat : 0,098 seconde, donc 95 % des requêtes sont servies en moins de 98 ms. C'est le troisième signal doré, la latence, et la métrique que l'alerte LatenceP95Elevee surveille (seuil : 0,5 s). Regarde la répartition des paniers de /cours dans le jeu de données : 3248 requêtes sur 3279 sous 50 ms, mais /lent (300 à 900 ms) tire le p95 global vers le haut.

Pour bien comprendre : que contient un panier d'histogramme ?

Chaque ligne _bucket{le="0.05"} compte les requêtes qui ont pris au plus 0,05 seconde (le = less or equal). Les paniers sont cumulatifs : le="0.1" contient aussi tout ce qui était dans le="0.05". Le dernier, le="+Inf", contient tout, et vaut toujours _count. histogram_quantile cherche le panier où la courbe cumulative franchit 95 % et interpole à l'intérieur. La précision dépend donc du choix des paniers : entre 0.05 et 0.1, Prometheus suppose une répartition uniforme. C'est pour ça que le résultat, 0.0979…, n'est pas une valeur mesurée mais une estimation.

P11. Une jauge, telle quelle

promql
requetes_en_cours
text
requetes_en_cours{instance="api:8000", job="api", service="api"}    0

Ce que la requête demande : « La dernière valeur de la jauge requetes_en_cours. »

Rien de neuf dans la syntaxe, c'est P1. Ce qui est neuf, c'est le type : une jauge se lit telle quelle, sans rate(). Zéro ou un, selon l'instant : l'API traite chaque requête en quelques millisecondes, il est rare d'en attraper une en cours. C'est le quatrième signal doré, la saturation : si cette valeur montait à 50, l'API serait débordée. rate(requetes_en_cours[1m]) ne provoque pas d'erreur, mais rend un nombre qui ne veut rien dire ; Prometheus ne te protège pas de cette confusion.

P12. Les alertes, vues par Prometheus

promql
ALERTS
text
Empty query result

Ce que la requête demande : « Les alertes actuellement pending ou firing. »

Rien de neuf : un nom de métrique, comme P1. ALERTS est, comme up, fabriquée par Prometheus : une série par alerte active, avec les labels alertname et alertstate. Sur un labo sain, le résultat est vide : Empty query result. Ce n'est pas une erreur, c'est la meilleure réponse possible. Tu la retaperas pendant la panne de l'annexe et tu verras apparaître ALERTS{alertname="APIInjoignable", alertstate="pending", …} puis alertstate="firing".

Bilan de P9 à P12 : les quatre signaux dorés (trafic P8, erreurs P9, latence P10, saturation P11) tiennent en quatre requêtes, et les alertes sont une métrique comme les autres.

Le message à faire passer. Ce que SQL fait aussi : filtrer par colonne ({job="api"} = WHERE), regrouper et sommer (sum by (route) = GROUP BY), diviser deux agrégats. Ce que seul Prometheus fait : il est allé chercher lui-même les données toutes les 15 secondes chez huit services, il transforme n'importe quel compteur en vitesse d'une fonction (rate), il estime un quantile depuis des paniers (histogram_quantile), et il expose ses propres alertes comme une métrique (ALERTS).

Grafana, dans Explore

Afficher les 8 requêtes LogQL (G1 à G8)

Ouvre http://localhost:3000 (utilisateur admin, mot de passe aiopsatlas2026). Dans le menu principal (icône en haut à gauche), clique Explore. En haut de la page, le sélecteur de source de données propose Prometheus, Loki et Alertmanager : choisis Loki. À droite du champ de requête, deux modes : Builder (des menus) et Code (tu tapes). Passe en Code, colle la requête, puis Run query (ou Maj+Entrée). Les journaux s'affichent en bas, la ligne la plus récente en premier. En haut à droite, le sélecteur de période est à Last 1 hour par défaut : garde-le. Ces étapes sont identiques sous Windows et sous Linux, c'est le navigateur qui travaille.

L'image à garder en tête : Loki est une armoire de journaux de bord, un classeur par combinaison de labels. Il ne lit pas le contenu des lignes pour les ranger, seulement l'étiquette du classeur. Une requête LogQL commence donc toujours par choisir un classeur, entre accolades, puis éventuellement par filtrer les lignes dedans.

G1. Tout le journal de l'API

logql
{service="api"}
text
2026-09-15 19:33:30.416  {"horodatage": "2026-09-15T19:33:30.416+00:00", "niveau": "INFO", "id_requete": "875939d9cdac", "methode": "GET", "route": "/cours", "code": 200, "duree_ms": 11.5, "message": "GET /cours -> 200"}
2026-09-15 19:33:30.186  {"horodatage": "2026-09-15T19:33:30.186+00:00", "niveau": "INFO", "id_requete": "6d6e21e6d17b", "methode": "GET", "route": "/cours/{id}", "code": 200, "duree_ms": 14.3, "message": "GET /cours/C0001 -> 200"}
2026-09-15 19:33:30.142  {"horodatage": "2026-09-15T19:33:30.142+00:00", "niveau": "INFO", "id_requete": "42a64fb435bf", "methode": "GET", "route": "/cours", "code": 200, "duree_ms": 32.3, "message": "GET /cours -> 200"}

Ce que la requête demande : « Toutes les lignes de journal dont le label service vaut api. »

Zéro nouveauté par rapport à P2 : un sélecteur entre accolades. La différence, c'est le résultat : des lignes de texte, pas des nombres. Grafana affiche l'heure (convertie dans ton fuseau) puis la ligne brute ; clique sur une ligne pour dérouler ses labels : service="api", conteneur="labo-api", niveau="INFO", code="200", detected_level="info". Neuf lignes par seconde environ, autant que P8 l'annonçait : une requête, une ligne. Les accolades sont obligatoires : service="api" seul est refusé (parse error at line 1, col 1: syntax error: unexpected IDENTIFIER), et {service="api" sans fermeture aussi (syntax error: unexpected $end, expecting } or ,).

Pour bien comprendre : pourquoi `{service="API"}` ne rend rien

Un label est une chaîne exacte. {service="API"} en majuscules ne rend aucune ligne, sans erreur : le classeur n'existe pas. Pareil pour {app="api"} : le label s'appelle service dans ce labo, pas app. Quand une requête LogQL rend zéro ligne, vérifie d'abord le nom et la casse du label ; dans Explore, le mode Builder te liste les labels et leurs valeurs existants, c'est la façon la plus sûre de les découvrir.

G2. Seulement les erreurs

logql
{service="api", niveau="ERROR"}
text
2026-09-15 19:33:28.931  {"horodatage": "2026-09-15T19:33:28.931+00:00", "niveau": "ERROR", "id_requete": "f5e09e6ed543", "methode": "POST", "route": "/inscriptions", "code": 500, "duree_ms": 0.0, "message": "POST /inscriptions -> 500"}
2026-09-15 19:33:16.381  {"horodatage": "2026-09-15T19:33:16.381+00:00", "niveau": "ERROR", "id_requete": "18f95992a8fa", "methode": "POST", "route": "/inscriptions", "code": 500, "duree_ms": 0.0, "message": "POST /inscriptions -> 500"}
2026-09-15 19:33:14.781  {"horodatage": "2026-09-15T19:33:14.781+00:00", "niveau": "ERROR", "id_requete": "dc1c2ff189a6", "methode": "GET", "route": "/cours/{id}", "code": 500, "duree_ms": 0.1, "message": "GET /cours/C0019 -> 500"}

Ce que la requête demande : « Les lignes de l'API dont le label niveau vaut ERROR. »

Une seule nouveauté : deux labels dans le sélecteur, séparés par une virgule, c'est un ET. Le label niveau n'est pas dans la ligne au départ : c'est Alloy qui l'a extrait du champ JSON niveau avant d'envoyer à Loki, et c'est ce qui rend cette requête rapide. Le fil rouge est là, troisième ligne : id_requete: dc1c2ff189a6, la ligne du jeu de données. Beaucoup moins de lignes qu'en G1 : environ une toutes les douze secondes, comme P7 le disait pour les 500.

G3. Par code HTTP

logql
{service="api", code="500"}
text
2026-09-15 19:33:28.931  {"horodatage": "2026-09-15T19:33:28.931+00:00", "niveau": "ERROR", "id_requete": "f5e09e6ed543", "methode": "POST", "route": "/inscriptions", "code": 500, …}
2026-09-15 19:33:16.381  {"horodatage": "2026-09-15T19:33:16.381+00:00", "niveau": "ERROR", "id_requete": "18f95992a8fa", "methode": "POST", "route": "/inscriptions", "code": 500, …}
2026-09-15 19:33:14.781  {"horodatage": "2026-09-15T19:33:14.781+00:00", "niveau": "ERROR", "id_requete": "dc1c2ff189a6", "methode": "GET", "route": "/cours/{id}", "code": 500, …}

Ce que la requête demande : « Les lignes de l'API dont le label code vaut 500. »

Rien de neuf : G2 avec un autre label. Les mêmes lignes qu'en G2, parce que dans cette API tout 500 est un ERROR et réciproquement. C'est la version journal de P4 : là où Prometheus te dit « 32 erreurs sur /cours », Loki te montre lesquelles, avec l'URL réelle (/cours/C0019) et l'identifiant de la requête. Remarque que code est ici une chaîne ("500") parce que c'est un label ; dans le JSON de la ligne, c'est un nombre (500). G6 te montrera la différence.

G4. Filtrer sur le texte

logql
{service="api"} |= "inscriptions"
text
2026-09-15 19:33:29.738  {"horodatage": "2026-09-15T19:33:29.738+00:00", "niveau": "INFO", "id_requete": "5afb73494ebd", "methode": "POST", "route": "/inscriptions", "code": 201, "duree_ms": 22.9, "message": "POST /inscriptions -> 201"}
2026-09-15 19:33:28.931  {"horodatage": "2026-09-15T19:33:28.931+00:00", "niveau": "ERROR", "id_requete": "f5e09e6ed543", "methode": "POST", "route": "/inscriptions", "code": 500, "duree_ms": 0.0, "message": "POST /inscriptions -> 500"}
2026-09-15 19:33:27.187  {"horodatage": "2026-09-15T19:33:27.187+00:00", "niveau": "INFO", "id_requete": "c4e29db13e12", "methode": "POST", "route": "/inscriptions", "code": 201, "duree_ms": 26.1, "message": "POST /inscriptions -> 201"}

Ce que la requête demande : « Les lignes de l'API qui contiennent le texte inscriptions. »

Une seule nouveauté : le filtre de ligne |= "…", qui garde les lignes contenant exactement ce texte. C'est grep. Contrairement à un label, Loki doit ici ouvrir chaque ligne du classeur {service="api"} pour regarder dedans : plus lent, mais tu peux chercher n'importe quoi. Les variantes : != (ne contient pas), |~ (expression régulière), !~. Résultat mélangé : des 201 et des 500, tout ce qui touche aux inscriptions.

Bilan de G1 à G4 : deux façons de filtrer, par label (rapide, avant d'ouvrir les lignes) et par texte (souple, après). La bonne requête commence toujours par le label le plus étroit possible.

G5. Ouvrir le JSON

logql
{service="api"} | json
text
2026-09-15 19:33:30.416  {"horodatage": "2026-09-15T19:33:30.416+00:00", "niveau": "INFO", "id_requete": "875939d9cdac", "methode": "GET", "route": "/cours", "code": 200, "duree_ms": 11.5, "message": "GET /cours -> 200"}
   labels : code="200" conteneur="labo-api" duree_ms="11.5" horodatage="2026-09-15T19:33:30.416+00:00" id_requete="875939d9cdac"
            message="GET /cours -> 200" methode="GET" niveau="INFO" route="/cours" service="api" …

Ce que la requête demande : « Les lignes de l'API, et pour chacune, transforme les champs du JSON en labels. »

Une seule nouveauté : le parseur | json. Les lignes affichées sont les mêmes qu'en G1, mais déroule-en une : elle a maintenant beaucoup plus de labels (route, methode, duree_ms, id_requete, message…), un par champ du JSON. Ces labels-là sont calculés au moment de la requête, pas stockés : Loki n'indexe toujours que service, conteneur, niveau, code. Tu verras aussi code_extracted et niveau_extracted : quand un champ du JSON porte le même nom qu'un label déjà posé par Alloy, Loki suffixe la copie plutôt que d'écraser.

G6. Filtrer sur un champ

logql
{service="api"} | json | duree_ms > 500
text
2026-09-15 19:33:27.838  {"horodatage": "2026-09-15T19:33:27.838+00:00", "niveau": "INFO", "id_requete": "416f1ab0eb41", "methode": "GET", "route": "/lent", "code": 200, "duree_ms": 587.4, "message": "GET /lent -> 200"}
2026-09-15 19:33:16.335  {"horodatage": "2026-09-15T19:33:16.335+00:00", "niveau": "INFO", "id_requete": "9d1cb1767846", "methode": "GET", "route": "/lent", "code": 200, "duree_ms": 737.8, "message": "GET /lent -> 200"}
2026-09-15 19:33:15.375  {"horodatage": "2026-09-15T19:33:15.375+00:00", "niveau": "INFO", "id_requete": "cab8f698f89b", "methode": "GET", "route": "/lent", "code": 200, "duree_ms": 526.2, "message": "GET /lent -> 200"}

Ce que la requête demande : « Les lignes de l'API dont le champ duree_ms, une fois le JSON ouvert, dépasse 500. »

Une seule nouveauté : le filtre de label | duree_ms > 500, qui compare un label extrait à un nombre. Ça n'est possible qu'après | json, sinon duree_ms n'existe pas. Résultat : uniquement des /lent, la route volontairement lente (300 à 900 ms). C'est la version journal de P10 : Prometheus dit « le p95 est à 98 ms » ; Loki montre les requêtes individuelles qui ont dépassé un seuil, avec leur identifiant. Équivalent SQL : WHERE duree_ms > 500, à ceci près que la colonne n'existait pas avant la requête.

G7. Retrouver une requête précise

logql
{service="api"} |= "dc1c2ff189a6"
text
2026-09-15 19:33:14.781  {"horodatage": "2026-09-15T19:33:14.781+00:00", "niveau": "ERROR", "id_requete": "dc1c2ff189a6", "methode": "GET", "route": "/cours/{id}", "code": 500, "duree_ms": 0.1, "message": "GET /cours/C0019 -> 500"}

Une seule ligne.

Ce que la requête demande : « La ligne de l'API qui contient l'identifiant dc1c2ff189a6. »

Rien de neuf : c'est G4 avec un autre texte. Ce qui change, c'est l'usage : chez toi, dc1c2ff189a6 n'existe pas ; copie un id_requete vu dans ta propre sortie de G2 et colle-le à la place. C'est le geste que tu feras en production : un utilisateur te donne l'identifiant renvoyé par l'en-tête x-id-requete de sa réponse en erreur, et tu retrouves en une requête la ligne exacte, avec la route, le code et la durée. Une seule ligne : l'identifiant est unique par requête.

G8. Compter les lignes : une métrique tirée des journaux

logql
sum by (niveau) (count_over_time({service="api"}[1m]))

Cette fois, Grafana affiche un graphique au lieu de lignes : trois courbes, {niveau="INFO"} autour de 470 à 500 lignes par minute, {niveau="WARNING"} autour de 40 à 50, {niveau="ERROR"} entre 2 et 10, sur le labo du cours. Passe la souris sur le graphique pour lire les valeurs.

Ce que la requête demande : « Compte les lignes de l'API par tranche d'une minute, puis additionne en gardant le label niveau. »

Une seule nouveauté, en un morceau que tu connais déjà : count_over_time(…[1m]) compte les lignes d'un sélecteur sur une plage, exactement comme rate(…[1m]) calcule une pente en P5. Autour, sum by (niveau) est le sum by (route) de P6, mot pour mot. LogQL a emprunté cette grammaire à PromQL exprès : ce que tu as appris d'un côté sert de l'autre. Compare avec P7 : Prometheus compte 0,09 réponse 500 par seconde, soit 5 par minute ; Loki compte 5 lignes ERROR par minute. Deux outils, deux chemins, le même chiffre.

La différence essentielle entre Prometheus et Loki. Prometheus stocke des nombres déjà comptés par l'API (http_requetes_total), Loki stocke les lignes et peut les recompter à la demande (count_over_time). Le premier est léger et rapide, et répond « combien » ; le second est lourd mais garde le détail, et répond « lesquelles ». Le labo a les deux parce qu'aucun ne remplace l'autre.

Le message à faire passer. Ce que grep fait aussi : chercher un texte dans des lignes (|=). Ce que seul Loki fait : ranger les lignes de dix conteneurs par labels et ne lire que le bon classeur, ouvrir le JSON à la demande pour filtrer sur un champ numérique (| json | duree_ms > 500), et transformer des journaux en courbe avec la grammaire de PromQL (count_over_time).

Défi bonus (optionnel)

Trois requêtes qui combinent ce que tu as vu, sans notion nouvelle. Tape-les, puis explique en une phrase ce que chacune montre.

promql
topk(5, increase(inscriptions_total[1h]))

Les cinq cours qui ont reçu le plus d'inscriptions dans la dernière heure. increase est rate multiplié par la durée de la fenêtre ; topk(5, …) garde les cinq plus grandes séries. Sur le labo du cours, C0001 arrive en tête avec environ 177 inscriptions : charge favorise quelques cours « populaires ».

promql
histogram_quantile(0.95, sum by (le, route) (rate(http_duree_requete_seconds_bucket[5m])))

P10 avec un label de plus dans le by : un p95 par route. Tu verras /lent autour de 0,7 s et les autres routes sous 0,03 s. Regarde ce que ça change par rapport au p95 global de P10.

logql
{service="api"} |= "inscriptions" | json | code = 201

G4, G5 et G6 enchaînés : les inscriptions réussies seulement. Vérifie que le nombre de lignes par minute correspond à la ligne {code="201"} de P7, environ 0,87 par seconde, soit une cinquantaine par minute.

Annexe A — Pas à pas détaillé sous Windows (PowerShell)

Afficher le pas à pas Windows

Toutes les commandes se tapent dans PowerShell, depuis le dossier lab3. Docker Desktop doit être lancé (icône verte). Si PowerShell refuse d'exécuter .\labo.ps1, tape une fois Set-ExecutionPolicy -Scope CurrentUser RemoteSigned et réponds O.

A.0 — Cloner le kit

powershell
cd C:\Users\<toi>\Documents
git clone https://github.com/hrhouma2/aiopsatlas-observabilite-labo-fr.git lab3
cd lab3
ls

Tu dois voir docker-compose.yml, labo.ps1, labo.sh, README.md, et les dossiers alertmanager, alloy, api, charge, grafana, loki, modules, outils, prometheus, webhook. Si tu as déjà cloné le kit à la leçon 03, saute cette étape et fais juste cd lab3.

A.1 — Vérifier les prérequis

powershell
.\labo.ps1 prerequis
text

== Prérequis ==
  ✔ docker : Docker version 29.3.1, build c2be9cc
  ✔ le démon Docker répond
  ✔ docker compose : 5.1.1
  ✔ mémoire disponible pour Docker : 31 Go
  ✔ processeurs : 20
  ✔ port 9090 libre
  ✔ port 9093 libre
  ✔ port 3000 libre
  ✔ port 3100 libre
  ✔ port 12345 libre
  ✔ port 9100 libre
  ✔ port 8080 libre
  ✔ port 8000 libre
  ✔ port 8090 libre

Tout est prêt. Lancez : .\labo.ps1 demarrer

Point de contrôle : la dernière ligne est Tout est prêt.. Les versions, la mémoire et le nombre de processeurs sont ceux de la machine du cours. Si un port est marqué ✘ … déjà occupé, la leçon 03 explique quoi faire ; pour le 3000, $env:GRAFANA_PORT = '3001' suffit.

A.2 — Démarrer

powershell
.\labo.ps1 demarrer

La première fois, le téléchargement des six images publiques prend une à cinq minutes selon ta connexion. Fin de la sortie attendue :

text
== Attente que chaque service soit prêt ==
  prometheus       prêt (0 s)
  alertmanager     prêt (0 s)
  loki             prêt (0 s)
  alloy           .. prêt (6 s)
  node-exporter    prêt (0 s)
  cadvisor         prêt (0 s)
  api              prêt (0 s)
  webhook          prêt (0 s)
  charge           prêt (0 s)
  grafana         ... prêt (9 s)

Le labo est prêt.
  Grafana        http://localhost:3000   (utilisateur admin · mot de passe aiopsatlas2026)
  Prometheus     http://localhost:9090   (Status → Target health, puis onglet Graph)
  Alertmanager   http://localhost:9093
  API catalogue  http://localhost:8000/cours   ·   http://localhost:8000/metrics
  Webhook        http://localhost:8090   (les alertes reçues)
  Loki           http://localhost:3100/ready   ·   Alloy   http://localhost:12345
  node-exporter  http://localhost:9100/metrics   ·   cAdvisor   http://localhost:8080

Étape suivante : .\labo.ps1 etat   (laissez tourner 2 minutes pour avoir des courbes)

Point de contrôle : dix prêt, puis Le labo est prêt.. Sur la machine du cours, images déjà en cache, la commande a pris 44 secondes. La leçon 04 commente cette sortie ligne par ligne.

A.3 — Lire etat

powershell
.\labo.ps1 etat
text

== Conteneurs ==
NAME                 SERVICE         STATUS
labo-alertmanager    alertmanager    Up About a minute (healthy)
labo-alloy           alloy           Up 48 seconds (healthy)
labo-api             api             Up About a minute (healthy)
labo-cadvisor        cadvisor        Up About a minute (healthy)
labo-charge          charge          Up About a minute (healthy)
labo-grafana         grafana         Up 48 seconds (healthy)
labo-loki            loki            Up About a minute (healthy)
labo-node-exporter   node-exporter   Up About a minute (healthy)
labo-prometheus      prometheus      Up About a minute (healthy)
labo-webhook         webhook         Up About a minute (healthy)

== Supervision ==
  ✔ Prometheus répond — cibles up : 8/8
     séries en mémoire : 9038
     alertes : 0 active(s), 0 en attente (pending)
  ✔ Alertmanager répond (http://localhost:9093)
  ✔ Grafana répond (http://localhost:3000)
  ✔ Loki répond (http://localhost:3100)
  ✔ API catalogue répond — version 1.0.0, 64 cours
  ✔ Webhook répond — 0 alerte(s) reçue(s) (http://localhost:8090)

Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.

Point de contrôle : dix (healthy), 8/8, 64 cours, 0 alerte(s) reçue(s), et la dernière ligne Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.. Le nombre de séries en mémoire monte pendant les premières minutes (9038 juste après le démarrage, 12 000 à 15 000 après une heure sur la machine du cours). Si alloy ou grafana sont encore (health: starting), attends trente secondes et relance.

A.4 — Les huit cibles dans Prometheus

Ouvre http://localhost:9090, menu Status, puis Target health. Huit blocs, un par job, chacun avec 1 / 1 up et une ligne :

text
api
1 / 1 up
Endpoint                    Labels                                        Last scrape   State
http://api:8000/metrics     instance="api:8000" job="api" service="api"   6.014s ago    UP

Point de contrôle : huit UP, aucun DOWN. La colonne Last scrape ne dépasse jamais 15 secondes : c'est le scrape_interval de prometheus.yml. En ligne de commande, la même information :

powershell
(Invoke-RestMethod http://localhost:9090/api/v1/targets).data.activeTargets | Select-Object @{n='job';e={$_.labels.job}}, health, scrapeUrl | Sort-Object job
text
job           health scrapeUrl
---           ------ ---------
alertmanager  up     http://alertmanager:9093/metrics
alloy         up     http://alloy:12345/metrics
api           up     http://api:8000/metrics
cadvisor      up     http://cadvisor:8080/metrics
grafana       up     http://grafana:3000/metrics
loki          up     http://loki:3100/metrics
node-exporter up     http://node-exporter:9100/metrics
prometheus    up     http://localhost:9090/metrics

A.5 — Les requêtes P1 à P12 et G1 à G8

Attends que demarrer ait au moins deux minutes derrière lui, puis suis les sections Prometheus, dans l'onglet Graph et Grafana, dans Explore plus haut. Les requêtes sont prêtes à copier :

powershell
Get-Content modules\01-le-labo\requetes.txt
Get-Content modules\01-le-labo\requetes-logql.txt

Point de contrôle : P1 rend 8 séries à 1, P12 rend Empty query result, G1 rend des lignes JSON, G8 rend trois courbes.

A.6 — Casser : arrêter l'API

Avant de casser, note l'heure (Get-Date -Format HH:mm:ss). Puis :

powershell
.\labo.ps1 casser api
text

== Panne : arrêt de l'API ==
 Container labo-api Stopping
 Container labo-api Stopped
  ✔ API arrêtée. La charge continue de frapper dans le vide.
  À observer : .\labo.ps1 etat  ·  http://localhost:9090/targets (api → down)
               http://localhost:9090/alerts (APIInjoignable : pending puis firing après 30 s)
               http://localhost:9093 et http://localhost:8090 (l'alerte arrive ~10 s après firing)

Pour tout remettre en ordre : .\labo.ps1 reparer

Le script a fait un docker compose stop api : le conteneur est arrêté proprement, ses données et son image sont intactes. Maintenant, observe la panne par cinq chemins, dans l'ordre. Tu as environ 70 secondes avant que l'alerte arrive au webhook : lance etat tout de suite.

Chemin 1, etat :

powershell
.\labo.ps1 etat
text

== Conteneurs ==
NAME                 SERVICE         STATUS
labo-alertmanager    alertmanager    Up 22 minutes (healthy)
labo-alloy           alloy           Up 22 minutes (healthy)
labo-api             api             Exited (0) About a minute ago
labo-cadvisor        cadvisor        Up 22 minutes (healthy)
labo-charge          charge          Up 22 minutes (healthy)
labo-grafana         grafana         Up 22 minutes (healthy)
labo-loki            loki            Up 22 minutes (healthy)
labo-node-exporter   node-exporter   Up 22 minutes (healthy)
labo-prometheus      prometheus      Up 22 minutes (healthy)
labo-webhook         webhook         Up 22 minutes (healthy)

== Supervision ==
  ✔ Prometheus répond — cibles up : 7/8
  ✘ cible api (http://api:8000/metrics) : down — Get "http://api:8000/metrics": dial tcp: lookup api on 127.0.0.11:53: no such host
     séries en mémoire : 15116
     alertes : 2 active(s), 0 en attente (pending)
  ✘ APIInjoignable [critique] — L'API catalogue ne répond plus
  ✘ TauxErreursEleve [critique] — Plus de 5 % des requêtes de l'API échouent
  ✔ Alertmanager répond (http://localhost:9093)
  ✔ Grafana répond (http://localhost:3000)
  ✔ Loki répond (http://localhost:3100)
  ✘ API catalogue ne répond pas (http://localhost:8000)
  ✔ Webhook répond — 2 alerte(s) reçue(s) (http://localhost:8090)

Labo : 9/10 services, 7/8 cibles up, 2 alertes actives.

Ce qu'il faut lire, de haut en bas : labo-api est Exited (0) (code 0 : arrêt volontaire, pas un plantage) ; Prometheus ne lit plus que 7/8 cibles et te dit pourquoi (lookup api … no such host : le nom api n'existe plus sur le réseau Docker puisque le conteneur est arrêté) ; l'alerte APIInjoignable est active ; l'API ne répond pas sur le port 8000 ; le webhook a reçu quelque chose. Cette sortie a été capturée sur la machine du cours une minute après casser api, alors qu'un casser erreurs venait d'être joué quelques minutes avant : c'est pour ça qu'une deuxième alerte, TauxErreursEleve, apparaît aussi. Sur ton labo, tu n'auras que APIInjoignable, 1 alertes actives et 1 alerte(s) reçue(s). Si tu lances etat dans les 30 premières secondes, l'alerte est encore en attente (pending) et le webhook est encore à 0 : relance une minute plus tard.

Chemin 2, les cibles. Recharge http://localhost:9090StatusTarget health. Le bloc api est passé à 0 / 1 up, état DOWN, et la colonne Error porte le même message que etat : Get "http://api:8000/metrics": dial tcp: lookup api on 127.0.0.11:53: no such host. Les sept autres restent UP. Retape up dans Query : la ligne up{instance="api:8000", job="api", service="api"} est à 0, les sept autres à 1. Puis up == 0 : une seule ligne.

Chemin 3, les alertes dans Prometheus. Menu Alerts. La règle APIInjoignable change d'état en trois temps, chronométrés sur la machine du cours :

text
t+0 s   : APIInjoignable inactive      (Prometheus n'a pas encore rescrappé l'API)
t+40 s  : APIInjoignable pending       (up{job="api"} == 0 est vrai, le compte à rebours « for: 30s » tourne)
t+70 s  : APIInjoignable firing        (vrai depuis 30 s : Prometheus envoie à Alertmanager)
t+70 s  : APIInjoignable reçue par le webhook (firing)

Pourquoi 40 secondes avant pending : Prometheus lit l'API toutes les 15 secondes, donc il faut jusqu'à 15 secondes pour qu'un scrape échoue, puis il évalue les règles toutes les 15 secondes. Pourquoi 30 de plus avant firing : la règle dit for: 30s. Sur une autre capture, pending est arrivé à 31 s et firing à 61 s : l'ordre de grandeur est le même, le détail dépend de l'instant où tu as cassé par rapport au cycle de scrape. Retape ALERTS dans Query :

text
ALERTS{alertname="APIInjoignable", alertstate="pending", instance="api:8000", job="api", service="api", severite="critique"}    1

puis, trente secondes plus tard, alertstate="firing". Note l'heure du passage en firing : c'est la première des trois lignes de ton livrable.

Chemin 4, Alertmanager. Ouvre http://localhost:9093. La page Alerts montre un groupe alertname="APIInjoignable" service="api" (c'est le group_by: [alertname, service] de alertmanager.yml) avec l'alerte, ses labels (instance="api:8000", job="api", labo="observabilite", severite="critique"), son résumé L'API catalogue ne répond plus et sa description. Le label labo="observabilite" n'était pas dans la règle : c'est l'external_labels de prometheus.yml, ajouté à tout ce qui sort de Prometheus. En ligne de commande :

powershell
(Invoke-RestMethod http://localhost:9093/api/v2/alerts) | Select-Object @{n='alerte';e={$_.labels.alertname}}, @{n='etat';e={$_.status.state}}, startsAt
text
alerte          etat   startsAt
------          ----   --------
APIInjoignable  active 2026-09-15T19:41:11.496Z

Chemin 5, le webhook. Ouvre http://localhost:8090. La page « Alertes reçues d'Alertmanager » n'est plus vide : une ligne APIInjoignable · critique · firing · api · L'API catalogue ne répond plus, et l'en-tête compte 1 alerte(s) en mémoire · 1 notification(s) reçue(s). Le format brut, http://localhost:8090/alertes.json, montre ce qu'Alertmanager a envoyé :

json
{"recu_a":"2026-09-15T19:41:26+00:00","etat":"firing","nom":"APIInjoignable","severite":"critique","service":"api","resume":"L'API catalogue ne répond plus","description":"Prometheus n'arrive plus à lire http://api:8000/metrics depuis 30 secondes (cible api:8000).","debut":"2026-09-15T19:41:11.496Z","fin":"0001-01-01T00:00:00Z","labels":{"alertname":"APIInjoignable","instance":"api:8000","job":"api","labo":"observabilite","service":"api","severite":"critique"}}

Lis debut (19:41:11, l'heure du firing dans Prometheus) et recu_a (19:41:26) : quinze secondes d'écart, dont les 10 secondes de group_wait d'Alertmanager. fin à l'an 0001 veut dire « pas encore terminée ». Note recu_a : deuxième ligne de ton livrable.

Ce que voit la charge, pendant ce temps :

powershell
.\labo.ps1 journal charge
text
labo-charge  | {"horodatage": "2026-09-15T19:40:36.600+00:00", "niveau": "WARNING", "message": "API injoignable : ConnectionError"}
labo-charge  | {"horodatage": "2026-09-15T19:40:40.925+00:00", "niveau": "WARNING", "message": "API injoignable : ConnectionError"}
labo-charge  | {"horodatage": "2026-09-15T19:40:45.393+00:00", "niveau": "WARNING", "message": "API injoignable : ConnectionError"}

Et ce que tu vois si tu appelles l'API toi-même :

powershell
Invoke-RestMethod http://localhost:8000/sante -TimeoutSec 5
text
Invoke-RestMethod : Le délai de l'opération a expiré.

(Sans -TimeoutSec, PowerShell attend plus longtemps avant de renoncer ; curl.exe -sS http://localhost:8000/sante répond plus vite : curl: (7) Failed to connect to localhost:8000 after 2237 ms: Could not connect to server.)

La différence essentielle entre un 404 et pas de réponse. À l'étape A.8, /cours/C9999 répondra 404 : l'API tourne et te dit poliment que ce cours n'existe pas ; ça se compte dans http_requetes_total{code="404"}, ça s'écrit dans un log WARNING, et up reste à 1. Ici, Le délai de l'opération a expiré : personne ne répond, il n'y a ni code ni log côté API, et c'est up qui tombe à 0. Deux situations, deux signaux, deux endroits où chercher.

A.7 — Réparer

powershell
.\labo.ps1 reparer
text

== Réparation ==
  ✔ API redémarrée
  api             .. prêt (6 s)
  ✔ taux d'erreurs remis à 0.01, lenteur à 0 ms

Les alertes passent en « resolved » dans les minutes qui suivent (voir http://localhost:8090).

Le script a fait docker compose start api, attendu que /sante réponde, puis appelé /admin/reparer (utile pour les deux autres scénarios de panne). Vérifie que l'API travaille :

powershell
.\labo.ps1 journal api
text
labo-api  | {"horodatage": "2026-09-15T19:41:48.720+00:00", "niveau": "INFO", "id_requete": "6a4228a46a56", "methode": "POST", "route": "/inscriptions", "code": 201, "duree_ms": 15.6, "message": "POST /inscriptions -> 201"}
labo-api  | {"horodatage": "2026-09-15T19:41:48.914+00:00", "niveau": "WARNING", "id_requete": "b4ebe9fa32d8", "methode": "GET", "route": "inconnue", "code": 404, "duree_ms": 0.4, "message": "GET /inexistant -> 404"}
labo-api  | {"horodatage": "2026-09-15T19:41:48.937+00:00", "niveau": "INFO", "id_requete": "732adf61ee17", "methode": "GET", "route": "/cours/{id}", "code": 200, "duree_ms": 25.7, "message": "GET /cours/C0043 -> 200"}

Puis regarde l'alerte s'éteindre, dans le même ordre qu'elle s'est allumée. Chronométré sur la machine du cours après reparer :

text
t+15 s  : APIInjoignable firing   (Prometheus)   · webhook : firing
t+40 s  : APIInjoignable inactive (Prometheus)   · webhook : firing
t+55 s  : APIInjoignable inactive (Prometheus)   · webhook : resolved

Au premier scrape réussi, up{job="api"} revient à 1 et la règle repasse inactive ; Alertmanager envoie alors une notification resolved au webhook. Recharge http://localhost:8090 : deux lignes maintenant pour APIInjoignable, une firing et une resolved, et dans /alertes.json la seconde a un champ fin rempli :

json
{"recu_a":"2026-09-15T19:42:26+00:00","etat":"resolved","nom":"APIInjoignable",…,"debut":"2026-09-15T19:41:11.496Z","fin":"2026-09-15T19:41:56.496Z",}

fin moins debut : la panne a duré 45 secondes aux yeux de Prometheus. Note recu_a de la ligne resolved : troisième ligne de ton livrable.

A.8 — Provoquer une 404, pour comparer

L'API tourne. Demande-lui un cours qui n'existe pas :

powershell
Invoke-RestMethod http://localhost:8000/cours/C9999
text
Invoke-RestMethod : {"detail":"cours C9999 introuvable"}

C'est une erreur HTTP 404 : l'API a répondu. Pour voir le code lui-même :

powershell
try { Invoke-WebRequest http://localhost:8000/cours/C9999 -UseBasicParsing } catch { $_.Exception.Response.StatusCode.value__ }
text
404

Retape P4 dans Prometheus en remplaçant 500 par 404 : la série route="/cours/{id}" a augmenté de 1. Retape G1 en ajoutant |= "C9999" : ta requête est là, niveau WARNING, avec son id_requete. Rien de tout ça n'existe pour la panne de A.6 : une API arrêtée ne compte rien et n'écrit rien.

A.9 — Vérification finale

powershell
.\labo.ps1 etat
text

== Conteneurs ==
NAME                 SERVICE         STATUS
labo-alertmanager    alertmanager    Up 23 minutes (healthy)
labo-alloy           alloy           Up 23 minutes (healthy)
labo-api             api             Up 53 seconds (healthy)
labo-cadvisor        cadvisor        Up 23 minutes (healthy)
labo-charge          charge          Up 23 minutes (healthy)
labo-grafana         grafana         Up 23 minutes (healthy)
labo-loki            loki            Up 23 minutes (healthy)
labo-node-exporter   node-exporter   Up 23 minutes (healthy)
labo-prometheus      prometheus      Up 23 minutes (healthy)
labo-webhook         webhook         Up 23 minutes (healthy)

== Supervision ==
  ✔ Prometheus répond — cibles up : 8/8
     séries en mémoire : 15395
     alertes : 0 active(s), 0 en attente (pending)
  ✔ Alertmanager répond (http://localhost:9093)
  ✔ Grafana répond (http://localhost:3000)
  ✔ Loki répond (http://localhost:3100)
  ✔ API catalogue répond — version 1.0.0, 64 cours
  ✔ Webhook répond — 2 alerte(s) reçue(s) (http://localhost:8090)

Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.

Ce que tu dois avoir : labo-api de nouveau Up … (healthy) avec un temps plus court que les autres (il vient de redémarrer), 8/8, 0 active(s), 64 cours, 2 alerte(s) reçue(s) (la firing et la resolved), et la dernière ligne Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.. Sur la machine du cours, cette sortie portait encore 1 alertes actives et 3 alerte(s) reçue(s) à cause du TauxErreursEleve résiduel évoqué en A.6 ; chez toi, avec la seule panne api, les valeurs sont celles ci-dessus. Cette sortie et tes trois heures notées sont ton livrable. Tu peux laisser le labo tourner pour les ateliers 06 et 07, ou l'arrêter avec .\labo.ps1 arreter : les données sont conservées et demarrer reprend là où tu étais.

Annexe B — Pas à pas détaillé sous Linux, macOS, WSL 2 et Git Bash

Afficher le pas à pas Linux, macOS, WSL 2 et Git Bash

Toutes les commandes se tapent dans un terminal bash, depuis le dossier lab3. Sur macOS et Windows (WSL 2 ou Git Bash), Docker Desktop doit être lancé ; sur Linux natif, docker info doit répondre sans sudo (sinon sudo usermod -aG docker $USER, puis ouvre une nouvelle session). Le script bash a besoin de curl.

B.0 — Cloner le kit

bash
cd ~
git clone https://github.com/hrhouma2/aiopsatlas-observabilite-labo-fr.git lab3
cd lab3
ls

Tu dois voir docker-compose.yml, labo.sh, labo.ps1, README.md, et les dossiers alertmanager, alloy, api, charge, grafana, loki, modules, outils, prometheus, webhook. Sur Linux et macOS, rends le script exécutable une fois : chmod +x labo.sh (sans ça, ./labo.sh répond bash: ./labo.sh: Permission denied). Si tu as déjà cloné le kit à la leçon 03, saute cette étape et fais juste cd lab3.

B.1 — Vérifier les prérequis

bash
./labo.sh prerequis
text

== Prérequis ==
  ✔ docker : Docker version 29.3.1, build c2be9cc
  ✔ le démon Docker répond
  ✔ docker compose : 5.1.1
  ✔ curl : présent
  ✔ mémoire disponible pour Docker : 31 Go
  ✔ processeurs : 20
  ✔ port 9090 libre
  ✔ port 9093 libre
  ✔ port 3000 libre
  ✔ port 3100 libre
  ✔ port 12345 libre
  ✔ port 9100 libre
  ✔ port 8080 libre
  ✔ port 8000 libre
  ✔ port 8090 libre

Tout est prêt. Lancez : ./labo.sh demarrer

Point de contrôle : la dernière ligne est Tout est prêt.. Le script bash vérifie une ligne de plus que PowerShell, curl : présent. Les versions et la mémoire sont celles de la machine du cours (Git Bash sous Windows). Si un port est occupé, la leçon 03 explique quoi faire ; pour le 3000, GRAFANA_PORT=3001 ./labo.sh demarrer.

B.2 — Démarrer

bash
./labo.sh demarrer

Fin de la sortie attendue, une fois les images téléchargées :

text
== Attente que chaque service soit prêt ==
  prometheus       prêt (0 s)
  alertmanager     prêt (0 s)
  loki             prêt (0 s)
  alloy           .. prêt (6 s)
  node-exporter    prêt (0 s)
  cadvisor         prêt (0 s)
  api              prêt (0 s)
  webhook          prêt (0 s)
  charge           prêt (0 s)
  grafana         ... prêt (9 s)

Le labo est prêt.
  Grafana        http://localhost:3000   (utilisateur admin · mot de passe aiopsatlas2026)
  Prometheus     http://localhost:9090   (Status → Target health, puis onglet Graph)
  Alertmanager   http://localhost:9093
  API catalogue  http://localhost:8000/cours   ·   http://localhost:8000/metrics
  Webhook        http://localhost:8090   (les alertes reçues)
  Loki           http://localhost:3100/ready   ·   Alloy   http://localhost:12345
  node-exporter  http://localhost:9100/metrics   ·   cAdvisor   http://localhost:8080

Étape suivante : ./labo.sh etat   (laissez tourner 2 minutes pour avoir des courbes)

Point de contrôle : dix prêt, puis Le labo est prêt..

B.3 — Lire etat

bash
./labo.sh etat
text

== Conteneurs ==
NAME                 SERVICE         STATUS
labo-alertmanager    alertmanager    Up 11 minutes (healthy)
labo-alloy           alloy           Up 10 minutes (healthy)
labo-api             api             Up 4 minutes (healthy)
labo-cadvisor        cadvisor        Up 11 minutes (healthy)
labo-charge          charge          Up 10 minutes (healthy)
labo-grafana         grafana         Up 10 minutes (healthy)
labo-loki            loki            Up 11 minutes (healthy)
labo-node-exporter   node-exporter   Up 11 minutes (healthy)
labo-prometheus      prometheus      Up 11 minutes (healthy)
labo-webhook         webhook         Up 11 minutes (healthy)

== Supervision ==
  ✔ Prometheus répond — cibles up : 8/8
     séries en mémoire : 10602
     alertes : 0 active(s), 0 en attente (pending)
  ✔ Alertmanager répond (http://localhost:9093)
  ✔ Grafana répond (http://localhost:3000)
  ✔ Loki répond (http://localhost:3100)
  ✔ API catalogue répond — version 1.0.0, 64 cours
  ✔ Webhook répond — 0 alerte(s) reçue(s) (http://localhost:8090)

Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.

Point de contrôle : dix (healthy), 8/8, 64 cours, et la dernière ligne Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.. Le bloc Conteneurs vient de docker compose ps ; tu peux le taper toi-même. Troisième chemin, en curl :

bash
curl -s http://localhost:8000/sante
curl -s http://localhost:8090/sante
text
{"etat":"ok","version":"1.0.0","cours":64}
{"etat":"ok","alertes_en_memoire":0,"notifications":0,"alertes":0}

B.4 — Les huit cibles dans Prometheus

Dans le navigateur, http://localhost:9090StatusTarget health : huit blocs 1 / 1 up, état UP, colonne Last scrape toujours sous 15 secondes. En curl, avec python3 pour lire le JSON (ou jq si tu l'as) :

bash
curl -s http://localhost:9090/api/v1/targets | python3 -c 'import json,sys; [print(t["labels"]["job"].ljust(14), t["health"], t["scrapeUrl"]) for t in sorted(json.load(sys.stdin)["data"]["activeTargets"], key=lambda t: t["labels"]["job"])]'
text
alertmanager   up http://alertmanager:9093/metrics
alloy          up http://alloy:12345/metrics
api            up http://api:8000/metrics
cadvisor       up http://cadvisor:8080/metrics
grafana        up http://grafana:3000/metrics
loki           up http://loki:3100/metrics
node-exporter  up http://node-exporter:9100/metrics
prometheus     up http://localhost:9090/metrics

B.5 — Les requêtes P1 à P12 et G1 à G8

Attends deux minutes après demarrer, puis suis les sections Prometheus, dans l'onglet Graph et Grafana, dans Explore plus haut ; elles se font dans le navigateur, à l'identique sous tous les systèmes. Les requêtes sont prêtes à copier :

bash
cat modules/01-le-labo/requetes.txt
cat modules/01-le-labo/requetes-logql.txt

Troisième chemin, une requête PromQL en curl :

bash
curl -s 'http://localhost:9090/api/v1/query?query=up' | python3 -m json.tool | head -n 20

Tu reconnais dans le JSON les mêmes séries que dans l'onglet Table : "metric": {"__name__": "up", "instance": "localhost:9090", "job": "prometheus"} et "value": [1789500809.696, "1"].

Point de contrôle : P1 rend 8 séries à 1, P12 rend Empty query result, G1 rend des lignes JSON, G8 rend trois courbes.

B.6 — Casser : arrêter l'API

Note l'heure (date +%T), puis :

bash
./labo.sh casser api
text

== Panne : arrêt de l'API ==
 Container labo-api Stopping
 Container labo-api Stopped
  ✔ API arrêtée. La charge continue de frapper dans le vide.
  À observer : ./labo.sh etat  ·  http://localhost:9090/targets (api → down)
               http://localhost:9090/alerts (APIInjoignable : pending puis firing après 30 s)
               http://localhost:9093 et http://localhost:8090 (l'alerte arrive ~10 s après firing)

Pour tout remettre en ordre : ./labo.sh reparer

Le script a fait docker compose stop api. Tu as environ 70 secondes avant que l'alerte arrive au webhook. Observe par cinq chemins.

Chemin 1, etat :

bash
./labo.sh etat
text

== Conteneurs ==
NAME                 SERVICE         STATUS
labo-alertmanager    alertmanager    Up 22 minutes (healthy)
labo-alloy           alloy           Up 22 minutes (healthy)
labo-api             api             Exited (0) About a minute ago
labo-cadvisor        cadvisor        Up 22 minutes (healthy)
labo-charge          charge          Up 22 minutes (healthy)
labo-grafana         grafana         Up 22 minutes (healthy)
labo-loki            loki            Up 22 minutes (healthy)
labo-node-exporter   node-exporter   Up 22 minutes (healthy)
labo-prometheus      prometheus      Up 22 minutes (healthy)
labo-webhook         webhook         Up 22 minutes (healthy)

== Supervision ==
  ✔ Prometheus répond — cibles up : 7/8
  ✘ cible api (http://api:8000/metrics) : down — Get "http://api:8000/metrics": dial tcp: lookup api on 127.0.0.11:53: no such host
     séries en mémoire : 15116
     alertes : 2 active(s), 0 en attente (pending)
  ✘ APIInjoignable [critique] — L'API catalogue ne répond plus
  ✘ TauxErreursEleve [critique] — Plus de 5 % des requêtes de l'API échouent
  ✔ Alertmanager répond (http://localhost:9093)
  ✔ Grafana répond (http://localhost:3000)
  ✔ Loki répond (http://localhost:3100)
  ✘ API catalogue ne répond pas (http://localhost:8000)
  ✔ Webhook répond — 2 alerte(s) reçue(s) (http://localhost:8090)

Labo : 9/10 services, 7/8 cibles up, 2 alertes actives.

Ce qu'il faut lire : labo-api est Exited (0) (arrêt volontaire) ; Prometheus ne lit plus que 7/8 cibles et dit pourquoi (lookup api … no such host : le nom api a disparu du réseau Docker) ; APIInjoignable est active ; l'API ne répond pas ; le webhook a reçu l'alerte. Cette sortie a été capturée une minute après casser api, sur une machine où casser erreurs venait d'être joué : d'où la deuxième alerte TauxErreursEleve. Chez toi : 1 alertes actives, 1 alerte(s) reçue(s). Si tu lances etat dans les 30 premières secondes, l'alerte est encore pending et le webhook à 0 : relance une minute plus tard. Troisième chemin, en curl :

bash
curl -s http://localhost:8000/sante
text
curl: (7) Failed to connect to localhost:8000 after 2237 ms: Could not connect to server

Chemin 2, les cibles. Recharge http://localhost:9090StatusTarget health : le bloc api est à 0 / 1 up, état DOWN, colonne Error : Get "http://api:8000/metrics": dial tcp: lookup api on 127.0.0.11:53: no such host. Retape up dans Query : up{instance="api:8000", job="api", service="api"} est à 0. Puis up == 0 : une seule ligne.

Chemin 3, les alertes dans Prometheus. Menu Alerts. APIInjoignable change d'état en trois temps, chronométrés sur la machine du cours :

text
t+0 s   : APIInjoignable inactive      (Prometheus n'a pas encore rescrappé l'API)
t+40 s  : APIInjoignable pending       (up{job="api"} == 0 est vrai, le compte à rebours « for: 30s » tourne)
t+70 s  : APIInjoignable firing        (vrai depuis 30 s : Prometheus envoie à Alertmanager)
t+70 s  : APIInjoignable reçue par le webhook (firing)

Pourquoi 40 secondes avant pending : jusqu'à 15 secondes pour qu'un scrape échoue (scrape_interval: 15s), puis jusqu'à 15 secondes pour la prochaine évaluation des règles (evaluation_interval: 15s). Pourquoi 30 de plus : for: 30s dans alertes.yml. Retape ALERTS dans Query :

text
ALERTS{alertname="APIInjoignable", alertstate="pending", instance="api:8000", job="api", service="api", severite="critique"}    1

puis alertstate="firing". Note l'heure du firing : première ligne de ton livrable. En curl, la même chose :

bash
curl -s http://localhost:9090/api/v1/alerts | python3 -m json.tool

Chemin 4, Alertmanager. Ouvre http://localhost:9093 : la page Alerts montre un groupe alertname="APIInjoignable" service="api" (le group_by de alertmanager.yml) avec l'alerte, ses labels (instance="api:8000", job="api", labo="observabilite", severite="critique"), le résumé L'API catalogue ne répond plus. Le label labo="observabilite" vient des external_labels de prometheus.yml. En curl :

bash
curl -s http://localhost:9093/api/v2/alerts | python3 -c 'import json,sys; [print(a["labels"]["alertname"], a["status"]["state"], a["startsAt"]) for a in json.load(sys.stdin)]'
text
APIInjoignable active 2026-09-15T19:41:11.496Z

Chemin 5, le webhook. Ouvre http://localhost:8090 : une ligne APIInjoignable · critique · firing · api · L'API catalogue ne répond plus, en-tête 1 alerte(s) en mémoire · 1 notification(s) reçue(s). Le format brut :

bash
curl -s http://localhost:8090/alertes.json | python3 -m json.tool
json
[
    {
        "recu_a": "2026-09-15T19:41:26+00:00",
        "etat": "firing",
        "nom": "APIInjoignable",
        "severite": "critique",
        "service": "api",
        "resume": "L'API catalogue ne répond plus",
        "description": "Prometheus n'arrive plus à lire http://api:8000/metrics depuis 30 secondes (cible api:8000).",
        "debut": "2026-09-15T19:41:11.496Z",
        "fin": "0001-01-01T00:00:00Z",
        "labels": {
            "alertname": "APIInjoignable",
            "instance": "api:8000",
            "job": "api",
            "labo": "observabilite",
            "service": "api",
            "severite": "critique"
        }
    }
]

debut (19:41:11) est l'heure du firing dans Prometheus ; recu_a (19:41:26) arrive quinze secondes plus tard, dont les 10 secondes de group_wait. fin à l'an 0001 : pas encore terminée. Note recu_a : deuxième ligne de ton livrable.

Ce que voit la charge :

bash
./labo.sh journal charge
text
labo-charge  | {"horodatage": "2026-09-15T19:40:36.600+00:00", "niveau": "WARNING", "message": "API injoignable : ConnectionError"}
labo-charge  | {"horodatage": "2026-09-15T19:40:40.925+00:00", "niveau": "WARNING", "message": "API injoignable : ConnectionError"}
labo-charge  | {"horodatage": "2026-09-15T19:40:45.393+00:00", "niveau": "WARNING", "message": "API injoignable : ConnectionError"}

La différence essentielle entre un 404 et pas de réponse. À l'étape B.8, /cours/C9999 répondra 404 : l'API tourne et te dit que ce cours n'existe pas ; ça se compte dans http_requetes_total{code="404"}, ça s'écrit dans un log WARNING, et up reste à 1. Ici, curl: (7) Failed to connect : personne ne répond, il n'y a ni code ni log côté API, et c'est up qui tombe à 0.

B.7 — Réparer

bash
./labo.sh reparer
text

== Réparation ==
  ✔ API redémarrée
  api             .. prêt (6 s)
  ✔ taux d'erreurs remis à 0.01, lenteur à 0 ms

Les alertes passent en « resolved » dans les minutes qui suivent (voir http://localhost:8090).

Le script a fait docker compose start api, attendu /sante, puis appelé /admin/reparer. Vérifie que l'API travaille :

bash
./labo.sh journal api
text
labo-api  | {"horodatage": "2026-09-15T19:41:48.720+00:00", "niveau": "INFO", "id_requete": "6a4228a46a56", "methode": "POST", "route": "/inscriptions", "code": 201, "duree_ms": 15.6, "message": "POST /inscriptions -> 201"}
labo-api  | {"horodatage": "2026-09-15T19:41:48.914+00:00", "niveau": "WARNING", "id_requete": "b4ebe9fa32d8", "methode": "GET", "route": "inconnue", "code": 404, "duree_ms": 0.4, "message": "GET /inexistant -> 404"}
labo-api  | {"horodatage": "2026-09-15T19:41:48.937+00:00", "niveau": "INFO", "id_requete": "732adf61ee17", "methode": "GET", "route": "/cours/{id}", "code": 200, "duree_ms": 25.7, "message": "GET /cours/C0043 -> 200"}

Puis l'alerte s'éteint, chronométré sur la machine du cours après reparer :

text
t+15 s  : APIInjoignable firing   (Prometheus)   · webhook : firing
t+40 s  : APIInjoignable inactive (Prometheus)   · webhook : firing
t+55 s  : APIInjoignable inactive (Prometheus)   · webhook : resolved

Troisième chemin, surveiller en boucle :

bash
watch -n 5 'curl -s http://localhost:8090/alertes.json | python3 -c "import json,sys; [print(a[\"recu_a\"], a[\"nom\"], a[\"etat\"]) for a in json.load(sys.stdin)]"'
text
2026-09-15T19:42:26+00:00 APIInjoignable resolved
2026-09-15T19:41:26+00:00 APIInjoignable firing

La ligne resolved porte un champ fin rempli (2026-09-15T19:41:56.496Z) : 45 secondes de panne aux yeux de Prometheus. Note son recu_a : troisième ligne de ton livrable. Ctrl+C pour sortir de watch.

B.8 — Provoquer une 404, pour comparer

bash
curl -s -i http://localhost:8000/cours/C9999
text
HTTP/1.1 404 Not Found
date: Tue, 15 Sep 2026 20:50:01 GMT
server: uvicorn
content-length: 36
content-type: application/json
x-id-requete: bbc7be667f1d

{"detail":"cours C9999 introuvable"}

L'API a répondu : un code 404, un identifiant x-id-requete, un corps JSON. Retape P4 dans Prometheus avec 404 à la place de 500 : la série route="/cours/{id}" a augmenté de 1. Retape G1 en ajoutant |= "bbc7be667f1d" (ton propre identifiant, lu dans l'en-tête) : ta requête est là, niveau WARNING. Rien de tout ça n'existe pour la panne de B.6 : une API arrêtée ne compte rien et n'écrit rien.

B.9 — Vérification finale

bash
./labo.sh etat
text

== Conteneurs ==
NAME                 SERVICE         STATUS
labo-alertmanager    alertmanager    Up 23 minutes (healthy)
labo-alloy           alloy           Up 23 minutes (healthy)
labo-api             api             Up 53 seconds (healthy)
labo-cadvisor        cadvisor        Up 23 minutes (healthy)
labo-charge          charge          Up 23 minutes (healthy)
labo-grafana         grafana         Up 23 minutes (healthy)
labo-loki            loki            Up 23 minutes (healthy)
labo-node-exporter   node-exporter   Up 23 minutes (healthy)
labo-prometheus      prometheus      Up 23 minutes (healthy)
labo-webhook         webhook         Up 23 minutes (healthy)

== Supervision ==
  ✔ Prometheus répond — cibles up : 8/8
     séries en mémoire : 15395
     alertes : 0 active(s), 0 en attente (pending)
  ✔ Alertmanager répond (http://localhost:9093)
  ✔ Grafana répond (http://localhost:3000)
  ✔ Loki répond (http://localhost:3100)
  ✔ API catalogue répond — version 1.0.0, 64 cours
  ✔ Webhook répond — 2 alerte(s) reçue(s) (http://localhost:8090)

Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.

Ce que tu dois avoir : labo-api de nouveau Up … (healthy), 8/8, 0 active(s), 64 cours, 2 alerte(s) reçue(s) (la firing et la resolved), et Labo : 10/10 services, 8/8 cibles up, 0 alertes actives.. Sur la machine du cours, cette sortie portait encore 1 alertes actives et 3 alerte(s) reçue(s) à cause du TauxErreursEleve résiduel évoqué en B.6 ; chez toi, avec la seule panne api, les valeurs sont celles ci-dessus. Cette sortie et tes trois heures notées sont ton livrable. Tu peux laisser tourner pour les ateliers 06 et 07, ou ./labo.sh arreter : les données sont conservées.

Annexe C — Si ça coince (tous systèmes)

Afficher les cas où ça coince

etat dit 9/10 services alors que tu n'as rien cassé. Regarde quel conteneur n'est pas (healthy). Si c'est grafana ou alloy juste après demarrer avec (health: starting), attends trente secondes. Si c'est labo-api en Exited, quelqu'un (peut-être toi, à un essai précédent) a lancé casser api : reparer. Si un conteneur est Restarting, lis son journal : .\labo.ps1 journal <service> ou ./labo.sh journal <service>.

cibles up : 7/8 et APIInjoignable active, mais l'API répond sur http://localhost:8000. Prometheus lit l'API par le réseau Docker (http://api:8000/metrics), toi par le port publié (localhost:8000). Si l'API vient de redémarrer, Prometheus peut être en retard d'un scrape (15 s) et l'alerte reste firing jusqu'à la prochaine évaluation, puis quelques secondes de plus pour que le webhook reçoive le resolved. Attends une minute et relance etat.

Les alertes tardent : pending ne passe pas firing. APIInjoignable a for: 30s ; il faut donc jusqu'à 15 s (scrape) + 15 s (évaluation) + 30 s (for) = 60 à 70 s pour firing, puis 10 s de group_wait pour le webhook. Ce n'est pas lent, c'est réglé pour éviter les fausses alertes sur un raté isolé. Si au bout de deux minutes rien ne bouge, vérifie que labo-api est bien Exited (docker compose ps).

Le webhook reste à 0 alerte(s) reçue(s) alors qu'Alertmanager montre l'alerte. Ouvre http://localhost:9093Status : la section Config doit montrer receiver: webhook et url: http://webhook:8090/alertes. Puis journal webhook : tu dois voir une ligne POST /alertes à chaque notification. Si le conteneur labo-webhook n'est pas healthy, docker compose restart webhook.

Empty query result sur P3, P5 ou G1, juste après demarrer. Prometheus a besoin d'au moins un scrape (15 s) pour P3, de deux pour P5 (rate veut deux points dans [1m]), et Alloy met quelques secondes à envoyer la première ligne à Loki. Attends deux minutes après Le labo est prêt.. Si {service="api"} reste vide après cinq minutes, vérifie http://localhost:12345 (Alloy doit être ready et ses composants Healthy) et journal alloy.

P10 rend NaN. histogram_quantile rend NaN quand la fenêtre [5m] ne contient pas encore assez de points. Attends cinq minutes après le démarrage, ou remplace [5m] par [1m] pour voir une valeur plus tôt (moins stable).

G1 rend zéro ligne alors que l'API tourne. Vérifie d'abord la période (en haut à droite, Last 1 hour) et le nom du label (service, en minuscules). Puis http://localhost:12345 : Alloy doit répondre Alloy is ready. et journal alloy ne doit pas montrer d'erreur répétée. En dernier recours, docker compose restart alloy.

parse error dans Prometheus. Les trois plus fréquents, tous vus dans cette page : unexpected identifier "api" in label matching, expected string (guillemets oubliés : {job=api}) ; unexpected character inside braces: '5' ({code=500} au lieu de {code="500"}) ; expected type range vector in call to function "rate", got instant vector (fenêtre [1m] oubliée).

parse error dans Loki. syntax error: unexpected IDENTIFIER : tu as oublié les accolades (service="api" au lieu de {service="api"}). unexpected $end, expecting } or , : accolade fermante manquante. Zéro ligne sans erreur : nom ou casse du label ({service="API"}, {app="api"}).

Windows seulement — Invoke-RestMethod affiche des caractères bizarres (é) dans les titres de cours. C'est l'affichage de la console, pas l'API. [Console]::OutputEncoding = [Text.Encoding]::UTF8 avant la commande, ou lis dans le navigateur.

Windows seulement — .\labo.ps1 : « l'exécution de scripts est désactivée sur ce système ». Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, réponds O, relance.

Windows seulement — demarrer échoue avec port is already allocated sur 3000. Un autre programme écoute déjà (souvent une application Node). $env:GRAFANA_PORT = '3001' puis relance demarrer ; Grafana est alors sur http://localhost:3001 et etat l'affiche ainsi.

Linux natif seulement — permission denied while trying to connect to the Docker daemon socket. sudo usermod -aG docker $USER, ferme la session, rouvre-la, docker info doit répondre.

Linux natif et macOS — bash: ./labo.sh: Permission denied. Le fichier est enregistré sans le bit d'exécution dans le dépôt : chmod +x labo.sh une fois, ou lance bash labo.sh prerequis. Sous Git Bash (Windows), la question ne se pose pas.

Tu veux repartir de zéro. .\labo.ps1 reinitialiser ou ./labo.sh reinitialiser supprime les conteneurs et les volumes : Prometheus, Loki et Grafana repartent vides. Puis demarrer. À ne pas faire sur un labo partagé avec d'autres personnes.