Pourquoi les logs structurés
Ton application écrit des messages quelque part. La vraie question : sous quelle forme — pour que tu puisses les retrouver à 3h du matin.
Toute application produit des logs. La vraie question n'est pas si tu loggues, mais comment — et ce choix détermine si tu peux investiguer rapidement ou si tu passes des heures à grep des fichiers cryptiques.
[2024-01-15 14:32:07] INFO: User 42 logged in from 192.168.1.5 [2024-01-15 14:32:08] ERROR: Payment failed for order 1234 - timeout after 5000ms [2024-01-15 14:32:09] WARN: Retry 2/3 for order 1234
Trouver toutes les erreurs de paiement → regex fragile. Si le format du message change, ta regex casse.
{ "ts": "2024-01-15T14:32:08Z", "level": "error", "msg": "Payment failed", "order_id": 1234, "reason": "timeout", "duration_ms": 5000, "trace_id": "abc123ef456" }
Toutes les erreurs de paiement → {app="payments"} | json | level="error". Robuste, indexable, corrélable.
Un log où chaque information est un champ nommé plutôt qu'une position fixe dans une chaîne. Le format le plus courant est JSON. L'outil de collecte parse ces champs et peut les utiliser comme critères de filtre, d'agrégation et de corrélation — sans jamais écrire une regex.
level="error" ou order_id=1234 peu importe la formulation du message.
Loki dans la stack LGTM
Loki est la brique de stockage et d'interrogation des logs dans la stack Grafana LGTM (Loki · Grafana · Tempo · Mimir). Elle est conçue pour être économique : contrairement à Elasticsearch qui indexe chaque mot de chaque log, Loki n'indexe que les labels — des métadonnées légères que tu définis — et stocke les lignes de log compressées sans les indexer.
agent
stockage
visualisation
Le simulateur ci-dessous montre le même événement dans les deux formats en temps réel. Observe ce qu'il est possible d'extraire de chacun.
Le même événement applicatif, deux formats. Clique sur un champ JSON pour voir ce qu'il apporte.
TEXTE — format historique
JSON — format structuré
→ Sur le JSON, chaque champ est directement requêtable sans parser le texte.
Anatomie d'un log structuré
Un log structuré n'est qu'un objet JSON — mais chaque champ a un rôle précis, et Loki ne les traite pas tous de la même façon.
Un log structuré ressemble à n'importe quel objet JSON — mais les champs qu'il contient ne sont pas équivalents. Certains sont obligatoires, d'autres contextuels. Et Loki, en particulier, distingue deux catégories : ce qu'il indexe (les labels) et ce qu'il stocke (le reste de la ligne).
ts · level · msgTout log doit avoir un horodatage (ISO 8601 en UTC), un niveau de sévérité (debug/info/warn/error/critical) et un message fixe — pas de variables dans le message, celles-ci vont dans des champs séparés.
service · env · trace_id · user_id · order_id · duration_ms · …Ce qui rend ce log-ci unique. Chaque domaine a ses propres champs. La règle : si l'information aide à diagnostiquer ou à filtrer, elle mérite un champ.
Labels Loki vs contenu de log
Loki fait une distinction fondamentale entre les champs d'un log :
| Catégorie | Exemples | Indexé ? | Règle |
|---|---|---|---|
| Label Loki | service, env, level | Oui | Faible cardinalité (2–50 valeurs) |
| Champ de log | user_id, order_id, trace_id | Non | Haute cardinalité — filtrable avec | json |
env=prod c'est 3 valeurs max : parfait. user_id=42 c'est des millions de valeurs : catastrophique. Un label à haute cardinalité crée autant de streams que de valeurs, ce qui explose la mémoire de Loki.
Clique sur chaque champ du log ci-dessous pour comprendre son rôle et si Loki l'indexe.
← Clique sur un champ pour en savoir plus.
Loki et son modèle de données
Loki stocke les logs comme Prometheus stocke les métriques : par labels. Ce qui est indexé, c'est uniquement les labels — pas le contenu des lignes.
Le modèle de données de Loki est directement inspiré de Prometheus. Là où Prometheus stocke des séries temporelles identifiées par un ensemble de labels, Loki stocke des streams de logs identifiés de la même façon.
http_requests_total{
app="api",
env="prod",
method="POST"
} → valeurs numériques
Chaque combinaison unique de labels = une série = un stockage séparé.
{
app="api",
env="prod",
level="error"
} → lignes de texte
Chaque combinaison unique de labels = un stream = un index séparé.
Un stream est l'ensemble des lignes de log qui partagent exactement le même jeu de labels. C'est l'unité de stockage de base dans Loki.
{app="api", env="prod"} et {app="api", env="staging"} sont deux streams distincts.
Loki n'indexe que les labels des streams (un index léger, comme Prometheus). Le contenu des lignes de log est stocké compressé dans des chunks, mais pas indexé. Pour filtrer sur le contenu, Loki scan les chunks correspondant aux labels sélectionnés — ce qui est beaucoup plus rapide qu'un scan complet.
La cardinalité des labels — même règle que Prometheus
Si tu as 3 valeurs pour app, 2 pour env et 4 pour level, tu as 3 × 2 × 4 = 24 streams. C'est parfaitement gérable. Mais si tu ajoutes user_id avec 50 000 utilisateurs distincts, tu passes à 24 × 50 000 = 1 200 000 streams — et Loki s'effondre sous la pression mémoire.
| json dans LogQL.
Le simulateur ci-dessous te montre comment le nombre de streams explose quand tu ajoutes des labels à haute cardinalité. Clique sur les labels pour les activer ou désactiver.
Clique sur un label pour l'activer / désactiver. Observe le nombre de streams résultant.
Streams Loki
6
Sain
Collecter avec Promtail & Alloy
Les logs ne tombent pas dans Loki tout seuls. Il faut un agent qui les lise, les transforme, et les envoie.
Les logs de ton application atterrissent dans des fichiers ou dans journald. Pour qu'ils arrivent dans Loki, il faut un agent de collecte qui les lit, les transforme et les envoie. L'agent historique est Promtail ; son successeur dans l'écosystème Grafana est Alloy.
/var/log/app.log
pipeline stages
API push
Configuration Promtail
# promtail.yml clients: - url: http://loki:3100/loki/api/v1/push scrape_configs: - job_name: app-logs static_configs: - targets: [localhost] labels: app: my-api # label Loki statique env: production __path__: /var/log/app/*.log pipeline_stages: - json: # parse le JSON de chaque ligne expressions: level: level ts: ts - labels: # transforme en labels Loki level: - timestamp: # parse le timestamp source: ts format: RFC3339 - output: # définit la ligne finale stockée source: msg
Les pipeline stages — dans l'ordre
| Stage | Rôle | Résultat |
|---|---|---|
json | Parse la ligne JSON, extrait les champs | Valeurs accessibles aux stages suivants |
regex | Parse une ligne texte avec une regex nommée | Idem, pour les logs non-JSON |
labels | Promeut des champs extraits en labels Loki | Nouveaux labels sur le stream |
timestamp | Parse le champ timestamp et l'utilise comme timestamp Loki | Tri temporel correct |
output | Définit quelle partie de la ligne est stockée comme log | La ligne finale dans Loki |
drop | Ignore certaines lignes (debug en prod) | Ligne non envoyée à Loki |
Grafana Alloy est l'agent de collecte universel qui remplace Promtail, l'agent Prometheus Node Exporter push, et d'autres. Sa configuration utilise le langage River (HCL-like). Il gère les logs, métriques, et traces depuis un seul binaire. Promtail reste supporté et utilisable, mais les nouveaux projets devraient préférer Alloy.
Le simulateur ci-dessous montre une ligne de log brute traverser chaque stage de la pipeline. Clique sur "Étape suivante" pour avancer.
État courant
LogQL — requêter les logs
LogQL est au log ce que PromQL est aux métriques : un langage de requête en deux modes.
LogQL est le langage de requête de Loki. Il fonctionne en deux modes : le log query qui retourne des lignes de log correspondant à un filtre, et le metric query qui transforme ces lignes en une série temporelle de nombres.
# Sélectionner des streams {app="api", env="prod"} # Filtrer sur le contenu {app="api"} |= "error" # Parser et filtrer sur les champs {app="api"} | json | level = "error" | duration_ms > 1000
# Taux de lignes par seconde rate({app="api"}[5m]) # Taux d'erreurs uniquement rate({app="api"} |= "error" [5m]) # P95 latence depuis les logs quantile_over_time(0.95, {app="api"} | json | unwrap duration_ms [5m])
La syntaxe d'un log query
Un log query LogQL se lit de gauche à droite comme un pipeline de filtres :
{app="api", env="prod"} # 1. sélecteur de stream (obligatoire) | json # 2. parser : extrait les champs JSON | level = "error" # 3. filtre sur un champ extrait | duration_ms > 500 # 4. filtre numérique | line_format "{{.msg}} ({{.duration_ms}}ms)" # 5. reformater la ligne
| Opérateur | Exemple | Rôle |
|---|---|---|
|= | |= "error" | Filtre : la ligne contient cette chaîne |
!= | != "health" | Exclut les lignes contenant cette chaîne |
|~ | |~ "err.*timeout" | Filtre regex |
| json | | json | Parse JSON, extrait tous les champs |
| pattern | | pattern "<ip> - <msg>" | Parse avec un pattern nommé |
| unwrap | | unwrap duration_ms | Extrait un champ numérique pour les metric queries |
{app="api"}) est évalué en premier sur l'index — c'est très rapide. Les filtres sur le contenu (|= "error") scannent les chunks correspondants. Mets donc toujours des labels spécifiques dans le sélecteur pour réduire au maximum la quantité de chunks à scanner.
Le simulateur ci-dessous montre un flux de logs en temps réel. Active ou désactive les filtres pour voir quelles lignes passent.
Active les filtres LogQL et observe quelles lignes sont retenues.
Corrélation logs · métriques · traces
Un log sans contexte est une piste. Un log avec trace_id est une investigation complète.
Les logs, métriques et traces ont chacun leur rôle dans un scénario d'investigation. La vraie puissance vient quand on peut naviguer de l'un à l'autre sans friction — et c'est précisément ce que permet une stack LGTM bien configurée.
Un identifiant unique généré au début de chaque requête et propagé à travers tous les services qui la traitent. En l'ajoutant à chaque log produit pendant cette requête, tu crées un lien direct entre le log et la trace Tempo correspondante.
trace_id: abc123
Derived Fields
abc123
Labels communs — le pivot métriques ↔ logs
Si tes métriques Prometheus et tes logs Loki utilisent les mêmes labels (service, env, instance), Grafana peut superposer automatiquement des annotations de logs sur tes graphes de métriques — tu vois exactement quel log correspond à un pic d'erreurs.
Une configuration Grafana qui détecte automatiquement un pattern dans les logs (par exemple
trace_id=([a-f0-9]+)) et crée un lien cliquable vers Tempo. En un clic depuis un log, tu atterris sur la trace complète — avec tous les spans, les durées, et les attributs.
Injecter trace_id dans tes logs PHP
// Symfony — avec OpenTelemetry SDK ou Monolog Processor class TraceIdProcessor { public function __invoke(array $record): array { $span = OpenTelemetry\API\Globals::tracerProvider() ->getTracer('app') ->injectContext(); $record['extra']['trace_id'] = $span->getContext()->getTraceId(); $record['extra']['span_id'] = $span->getContext()->getSpanId(); return $record; } }
Le simulateur ci-dessous montre un flux de logs applicatifs. Clique sur une ligne d'erreur pour suivre son trace_id jusqu'à la trace distribuée.
Clique sur une ligne erreur pour voir la trace associée.
Synthèse — logs en production
Structurer, collecter, indexer, requêter, corréler — les cinq étapes d'une stack de logs robuste.
On a parcouru les cinq étapes d'une stack de logs moderne. Voici comment elles s'articulent en production.
JSON, champs fixes
Promtail / Alloy
labels Loki
LogQL
trace_id / labels
Logs vs métriques — quand utiliser quoi
| Question | Outil adapté | Raison |
|---|---|---|
| Combien d'erreurs par minute ? | Métriques (Prometheus) | Agrégation sur le temps, dashboards |
| Quelle erreur exactement, pour quel user ? | Logs (Loki) | Détail de l'événement, champs contextuels |
| Pourquoi la requête a pris 5s ? | Traces (Tempo) | Décomposition span par span |
| Est-ce que le service est up ? | Métriques (Prometheus) | Alerting, SLO |
| Qu'est-ce qui s'est passé à 14:32:08 ? | Logs (Loki) | Contexte exact de l'instant |
Les pièges classiques
| json.
"User 42 failed to pay order 1234" rend impossible le comptage des erreurs similaires. Préfère "msg": "Payment failed", "user_id": 42, "order_id": 1234.
Un champ mérite d'être un label si : (1) tu l'utiliseras souvent comme premier filtre, (2) il a moins de ~50 valeurs distinctes, (3) il ne varie pas par requête individuelle. Dans le doute, mets-le dans le contenu du log et filtre avec
| json.
Récap des composants
| Composant | Rôle | Alternative |
|---|---|---|
| Loki | Stockage et index des logs | Elasticsearch, OpenSearch |
| Promtail | Agent de collecte (fichiers, journald) | Alloy (successeur), Fluentd, Vector |
| LogQL | Langage de requête | KQL (Kibana), SPL (Splunk) |
| Grafana Explore | Interface de debug et corrélation | Kibana |
| Derived Fields | Liens logs → traces | Configuration manuelle Jaeger/Tempo |