La semaine dernière, lithair apprenait à s’éteindre proprement — le premier pilier du chantier « être opéré ». Quatre jours plus tard, le deuxième pilier vient de fermer : l’observabilité. Deux PRs, et le serveur sait maintenant raconter ce qui lui arrive — à qui, quand, et combien de temps ça a pris.

Le problème : « ça a planté » ne suffit pas

Imaginez un client qui vous dit : « ma requête d’hier soir a échoué ». Que faites-vous ? Vous ouvrez les logs. Et vous trouvez… des milliers de lignes entremêlées, toutes les requêtes mélangées, sans moyen de savoir lesquelles appartiennent à cette requête-là. C’est comme chercher un colis perdu dans un entrepôt où aucun carton n’a d’étiquette.

La poste a résolu ce problème il y a longtemps : le numéro de suivi. Chaque colis reçoit un numéro à l’entrée, et chaque étape — pris en charge, trié, en transit, livré — est scannée avec ce numéro. Pour retrouver l’histoire d’un colis, on ne fouille pas l’entrepôt : on tape le numéro.

  Un colis                          Une requête HTTP

  numéro de suivi : 8X42…           X-Request-ID : 550e84…
  ─ pris en charge   09:01          ─ http_request reçue
  ─ trié             09:14          ─ event_append (écriture)
  ─ en transit       11:30          ─ snapshot_save
  ─ livré            14:02          ─ réponse 200 (ou 403, ou 500…)

  → on tape le numéro,              → on cherche l'ID dans les logs,
    on voit tout le trajet            on voit tout le trajet

C’est exactement ce que lithair fait désormais avec le X-Request-ID. Chaque requête entrante reçoit un identifiant — soit celui fourni par le client (s’il en envoie un), soit un UUID généré. Cet identifiant accompagne ensuite tout ce que la requête déclenche dans les logs, et repart dans l’en-tête de la réponse — quelle que soit l’issue : succès, 403 du firewall, 429 anti-DDoS, 404, 500. Toutes les branches. Le client peut alors vous dire « ma requête 550e84… a échoué », et vous avez son trajet complet en une recherche.

Petit détail qui n’en est pas un : l’ID entrant n’est accepté que s’il fait 1 à 128 octets d’ASCII visible. Tout le reste — vide, trop long, octets de contrôle, non-ASCII — est remplacé par un UUID. Pourquoi ? Parce qu’un en-tête qu’on reflète dans la réponse et dans les logs est une porte d’entrée classique pour l’injection. On ne renvoie jamais des octets hostiles tels quels.

Les scans : six points de passage

Le numéro de suivi ne sert à rien sans les scans. Côté lithair, les « scans » sont des spans tracing — des points de passage chronométrés, posés aux endroits qui comptent :

  http_request      la requête elle-même (méthode, chemin, request_id)
  event_append      une écriture dans le journal d'événements
  snapshot_save     la sauvegarde d'un instantané
  snapshot_load     son rechargement au démarrage
  event_replay      le rejeu des événements (reconstruction d'état)
  retention_evict   une éviction mémoire→disque (le tiering de v0.12)

Six spans chirurgicaux, pas une forêt. Chacun porte ses champs (le chemin, l’ID, les durées) et s’imbrique naturellement : une écriture déclenchée par une requête apparaît sous cette requête. L’histoire se lit toute seule.

Le tour de passe-passe : migrer 550 logs sans en toucher un seul

Voici le morceau d’ingénierie discret mais élégant. lithair utilisait la crate log — environ 550 appels log::info!, log::warn!… dispersés dans tout le code. Migrer vers tracing aurait pu signifier réécrire ces 550 lignes.

Au lieu de ça : LogTracer. C’est un pont — il s’installe comme backend de l’ancien système log, et reroute chaque appel existant vers le nouveau pipeline tracing :

  AVANT :   log::info!(...)  ──►  env_logger  ──►  stdout

  APRÈS :   log::info!(...)  ──►  LogTracer  ──►  tracing  ──►  stdout
            (les 550 appels         (le pont)                  (+ OTLP si activé)
             inchangés)

Zéro ligne de code applicatif modifiée. Les 550 appels continuent de fonctionner, mais ils débouchent maintenant dans le même flux que les spans — avec le request_id, les niveaux filtrables par RUST_LOG, et l’export OpenTelemetry. C’est le même principe que serve() devenu un cas particulier de serve_with_graceful_shutdown : on ne casse pas l’existant, on le fait passer par le nouveau chemin.

L’export : OpenTelemetry, en double opt-in

Dernière pièce (PR #119) : envoyer tout ça vers un vrai outil d’observabilité (Grafana, Jaeger…) via OTLP, le protocole standard d’OpenTelemetry. Avec un choix de design net — le double opt-in :

  1. À la compilation : la feature Cargo otel est OFF par défaut. Qui ne la demande pas n’embarque pas un octet d’OpenTelemetry dans son binaire.
  2. À l’exécution : même compilé avec otel, l’exporteur ne s’active que si LT_OTEL_ENDPOINT est définie.

Et l’init est fail-open : une URL malformée logue un avertissement et le serveur démarre quand même. L’observabilité est là pour aider à diagnostiquer les pannes — il serait absurde qu’elle en cause une. À l’arrêt, le flush des traces en attente est borné à 6 secondes : on n’attend pas indéfiniment un collecteur qui ne répond pas (la leçon du graceful shutdown, appliquée à l’export).

Ce qu’on a appris

Deux piliers en quatre jours — l’exploitation (#106), puis l’observabilité (#107) — et le même fil dans les deux : un serveur de production doit respecter ce qui se passe à l’intérieur de lui. La semaine dernière, c’était ne pas couper les requêtes en vol. Cette semaine, c’est pouvoir raconter leur histoire après coup.

Et la leçon d’ingénierie qui reste : les deux migrations (le shutdown, les logs) ont suivi le même pattern — l’ancien comportement devient un cas particulier du nouveau. serve() délègue à serve_with_graceful_shutdown(pending()) ; les 550 log::* débouchent dans tracing via un pont. Pas de big-bang, pas de breaking change, pas de réécriture. Le code existant ne remarque rien, et tout le monde hérite du nouveau chemin.

lithair est maintenant à 10 commits au-delà de v0.12.0, avec deux piliers fermés. Le prochain jalon se dessine.