← Tous les cours / Logs structurés & Loki Cours
00 — INTRODUCTION

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.

Format texte libre — style historique
[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.

JSON structuré — style moderne
{
  "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.

Log structuré
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.
💡 Pourquoi pas juste grep ? grep fonctionne. Mais dès que le format d'un message change légèrement — un espace en plus, un libellé reformulé — toutes tes commandes cassent. Avec des champs structurés, tu requêtes sur 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.

Application
Promtail
agent
Loki
stockage
Grafana
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.

01 — ANATOMIE D'UN LOG

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

Champs obligatoires — le minimum vital
ts · level · msg

Tout 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.
Champs contextuels — les données de l'événement
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.
🚫 Ne jamais logguer : mots de passe, tokens, numéros de carte, données personnelles sensibles (RGPD). Un log est souvent persisté longtemps et accessible à plusieurs personnes. Les données sensibles vont dans des systèmes sécurisés dédiés, pas dans les logs.

Labels Loki vs contenu de log

Loki fait une distinction fondamentale entre les champs d'un log :

CatégorieExemplesIndexé ?Règle
Label Lokiservice, env, levelOuiFaible cardinalité (2–50 valeurs)
Champ de loguser_id, order_id, trace_idNonHaute cardinalité — filtrable avec | json
💡 La règle du label : un label Loki doit avoir une faible cardinalité — peu de valeurs distinctes. 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.

02 — MODÈLE LOKI

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.

Prometheus — série métrique
http_requests_total{
  app="api",
  env="prod",
  method="POST"
} → valeurs numériques

Chaque combinaison unique de labels = une série = un stockage séparé.

Loki — stream de logs
{
  app="api",
  env="prod",
  level="error"
} → lignes de texte

Chaque combinaison unique de labels = un stream = un index séparé.

Stream
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.
Ce que Loki indexe — et ce qu'il ne fait pas
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.

⚠️ Piège de cardinalité : ne jamais mettre en label Loki ce qui varie par requête — user_id, order_id, request_id, trace_id, IP. Ces valeurs ont une cardinalité infinie et créent autant de streams. Ils vont dans le contenu du log, filtrable avec | 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

03 — COLLECTE

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.

App
/var/log/app.log
Promtail
pipeline stages
Loki
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

StageRôleRésultat
jsonParse la ligne JSON, extrait les champsValeurs accessibles aux stages suivants
regexParse une ligne texte avec une regex nomméeIdem, pour les logs non-JSON
labelsPromeut des champs extraits en labels LokiNouveaux labels sur le stream
timestampParse le champ timestamp et l'utilise comme timestamp LokiTri temporel correct
outputDéfinit quelle partie de la ligne est stockée comme logLa ligne finale dans Loki
dropIgnore certaines lignes (debug en prod)Ligne non envoyée à Loki
Alloy — le successeur
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

04 — LOGQL

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.

Log query — retourne des lignes
# 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
Metric query — retourne des nombres
# 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érateurExempleRôle
|=|= "error"Filtre : la ligne contient cette chaîne
!=!= "health"Exclut les lignes contenant cette chaîne
|~|~ "err.*timeout"Filtre regex
| json| jsonParse JSON, extrait tous les champs
| pattern| pattern "<ip> - <msg>"Parse avec un pattern nommé
| unwrap| unwrap duration_msExtrait un champ numérique pour les metric queries
💡 Performance : le sélecteur de stream ({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.

05 — CORRÉLATION

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.

trace_id — le pont universel
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.
Log d'erreur
trace_id: abc123
Grafana Explore
Derived Fields
Trace Tempo
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.

Grafana Derived Fields
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;
    }
}
💡 Sans OpenTelemetry : tu peux générer un trace_id simple dans ton middleware — un UUID v4 stocké dans la requête et injecté dans chaque log du contexte de cette requête via un Monolog Processor. C'est moins puissant (pas de spans), mais ça suffit pour corréler logs ↔ logs entre services.

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.

06 — SYNTHÈSE

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.

1. Structurer
JSON, champs fixes
2. Collecter
Promtail / Alloy
3. Indexer
labels Loki
4. Requêter
LogQL
5. Corréler
trace_id / labels

Logs vs métriques — quand utiliser quoi

QuestionOutil 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

⚠️ Labels à haute cardinalité — trace_id, user_id, request_id en label = explosion de streams. Ces champs vont dans le contenu du log, filtrables avec | json.
💡 Messages dynamiques"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.
💡 Rétention inadaptée — Loki stocke les logs compressés mais ils restent volumineux sur le long terme. Définit une règle de rétention adaptée à ton SLA : 30 jours pour l'investigation courante, 90 jours pour les audits si nécessaire.
🔑 Guide de choix — labels Loki
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

ComposantRôleAlternative
LokiStockage et index des logsElasticsearch, OpenSearch
PromtailAgent de collecte (fichiers, journald)Alloy (successeur), Fluentd, Vector
LogQLLangage de requêteKQL (Kibana), SPL (Splunk)
Grafana ExploreInterface de debug et corrélationKibana
Derived FieldsLiens logs → tracesConfiguration manuelle Jaeger/Tempo
💡 Prochain niveau : une fois la stack logs en place, l'étape suivante est l'instrumentation des traces distribuées avec OpenTelemetry — qui te donnera la décomposition span par span de chaque requête, et le lien automatique depuis tes logs via trace_id.