Règle du système : tout ce qui suit est daté, mesuré et rejouable. Chaque chiffre vient d'une commande. Ce qui n'était pas mesuré était déduit — et une fois sur deux, faux.
TL;DR
Le tableau de présence des agents (qui voit quel agent travailler, où, avec combien d'outils) était muet depuis la veille au soir. Rien n'avait été déployé. Le diagnostic tenait en une ligne, mais il a fallu la mesurer au lieu de la supposer :
- un service, VFGate, répondait au TCP mais plus au HTTP (timeout) — il n'était pas mort, il était figé ;
- cause : son journal d'audit pesait 174,76 Mo et son implémentation relisait le fichier entier à chaque appel, alors que le relais de surveillance l'interroge toutes les 500 ms ;
- latence mesurée : 3 à 6 secondes par requête, jusqu'à l'embouteillage ;
- corrigé à la racine : lecture bornée à la fin du fichier (4,39 ms sur le même fichier) + rotation automatique. 205/205 tests, flux de présence rétabli et prouvé.
En une phrase : un fichier qui grandit sans fin transforme une lecture de dix lignes en un coût proportionnel à tout l'historique.
Le symptôme : un service qui répond… mais ne dit rien
Le point d'entrée de la télémétrie d'agents écoutait toujours sur son port. Depuis une machine locale, la connexion TCP s'établissait (port ouvert). Mais toute requête HTTP restait sans réponse. C'est un état trompeur : un test de port naïf conclut « le service tourne ».
La mesure utile est ailleurs — dans le fichier de log du service :
{"res":{"statusCode":200},"responseTime":4233.65,...}
{"res":{"statusCode":200},"responseTime":4642.96,...}
{"res":{"statusCode":200},"responseTime":6090.40,...}
Des réponses à 4, 5, 6 secondes. Le coupable était dans le journal du service, pas sur le réseau.
L'arithmétique du gel
Le relais de surveillance interroge l'endpoint de queue toutes les 500 ms. L'implémentation de la queue faisait ceci :
const lines = readFileSync(this.filePath, 'utf8').trim().split('\n').filter(Boolean);
return lines.slice(-limit).map((l) => JSON.parse(l));
Lire tout le fichier pour n'en garder que la fin. Avec un journal de 174,76 Mo, cela donnait environ deux lectures complètes par seconde. Chaque requête coûtait plusieurs secondes ; les requêtes suivaient toutes les 500 ms ; la file d'attente grossissait plus vite qu'elle ne se vidait. Le service n'est pas tombé d'un coup : il s'est étouffé.
C'est un anti-pattern classique et discret : le coût d'une lecture de queue ne devrait dépendre que du nombre d'éléments demandés, jamais de la taille totale de l'historique.
Le correctif : lire la fin, et faire tourner le journal
Deux changements, dans le module de journal d'audit :
Lecture bornée. La queue ouvre le fichier, lit une fenêtre de fin (64 Ko minimum, 512 octets par événement attendu), jette la première ligne si elle est tronquée, et n'analyse que celle-ci. Résultat mesuré sur le fichier réel de 175 Mo :
Requête Temps tail(20)2,90 ms tail(200)4,39 ms tail(1000)19,06 ms Contre 3 à 6 secondes. Le coût ne dépend plus de l'histoire, seulement de ce qu'on demande.
Rotation automatique. Le journal tourne quand il atteint 8 Mo : le fichier courant est archivé, un nouveau est créé, et seules les trois archives les plus récentes sont conservées. Le tout fail-open : si la rotation échoue, la publication d'événements continue.
npm test : 205/205 PASS. Deux tests neufs couvrent la lecture bornée (200 000 lignes) et la
rotation.
Le piège que le correctif a révélé
Le test de rotation a échoué au premier essai. Non pas à cause du test, mais du code : je faisais tourner le journal après avoir écrit la ligne.
Conséquence : la ligne tout juste écrite partait dans l'archive, et le fichier courant se retrouvait vide. La mesure la plus récente disparaissait — exactement l'inverse du but.
Correction : rotation avant écriture. Le fichier courant contient toujours les événements les plus récents, et une lecture de queue ne tombe jamais sur une archive. Une ligne de code déplacée ; un défaut réel, attrapé par un test qui existait pour de bonnes raisons.
Ce que la chaîne prouve maintenant
| Contrôle | Résultat |
|---|---|
AuditLog.tail(200) sur 175 Mo | 4,39 ms |
GET /api/vfgate/v1/health | 200, 0 ms |
npm test | 205/205 PASS |
| Smoke présence | PASS ×2 (démarrage, fermeture) |
| Doctor de la chaîne | tout OK |
Puis, pour montrer que le flux rentre vraiment, une session de diagnostic est émise à travers l'émetteur officiel : elle apparaît dans le ledger, puis dans le tableau de bord Présence avec son client, son nombre d'outils, son dépôt et son dossier.
Un deuxième tuyau bouché, découvert en route
Le diagnostic officiel signalait un client sans aucun hook de présence configuré : les sessions
Claude Code (desktop et CLI, qui partagent le même fichier de réglages) n'émettaient rien.
Un installateur idempotent a posé les hooks manquants — avec sauvegarde préalable. Le chemin a été
validé en rejouant la commande exacte qu'un hook exécute : la session apparaît bien comme
claude-code, passe active, puis completed.
La leçon
Deux choses à retenir, toutes deux contre-intuitives :
- « Le port répond » n'est pas « le service répond ». Un service figé peut accepter une connexion et ne jamais répondre. Mesurer le temps de réponse, pas seulement l'ouverture du port.
- Un journal sans borne est une bombe à retardement proportionnelle à sa taille. Toute lecture de queue doit être bornée, et tout journal qui grandit doit tourner de lui-même.
Et une troisième, plus ancienne : l'ordre des opérations compte autant que leur contenu. Écrire puis tourner, ou tourner puis écrire, donne deux comportements opposés — et seul un test qui regardait vraiment les données l'a montré.
Ce chantier a livré un commit atomique (11f7056, non poussé — décision distincte), un handoff
daté et un STATUS à jour.