Il y a cinq ans, je publiais ici une série de trois articles sur le profiling des applications Node.js : l’analyse CPU, l’analyse de la mémoire et l’analyse des traitements asynchrones. À l’époque, le workflow était clair : j’ouvrais Chrome DevTools, Clinic.js ou un flamegraph, repérais le point chaud, recoupais les signaux, puis décidais quoi corriger.
Cinq ans plus tard, ce n’est plus toujours moi qui fait ce travail. C’est de plus en plus souvent un agent IA qui mène l’enquête de performance. Sauf qu’il y a un hic : un profiler classique mesure, il n’analyse pas. Le passage de la mesure au diagnostic, c’est nous qui le faisons. Un agent IA, lui, doit le reconstruire à partir de données brutes.
C’est ce constat qui m’a poussé à développer Lanterna, un profiler Node.js dont la sortie n’est pas un profil brut à analyser, mais un rapport déjà structuré, prêt à être exploité par un agent. Cet article vient donc compléter la série initiale avec un quatrième volet, cette fois centré sur le profiling à l’ère des agents IA.
Lanterna est un outil open source que j’ai développé et publié sur npm. Cet article n’est pas un tutoriel exhaustif, c’est une présentation : pourquoi je l’ai construit, ce qu’il fait, et comment il fonctionne sous le capot. La documentation complète se trouve sur le dépôt GitHub.
Le problème : un profiler mesure, il n’analyse pas
Reprenez les outils de profiling Node.js classiques. node --prof génère un log V8 qu’il faut ensuite passer dans --prof-process pour le rendre exploitable. 0x produit un flamegraph HTML interactif. Clinic.js propose plusieurs sous-outils (Doctor, Flame, Bubbleprof) qui sortent chacun leur propre vue. Chrome DevTools, lui, ouvre une interface interactive pour explorer un .cpuprofile ou un snapshot mémoire. Côté mesure, ils font très bien le travail : ils vous révèlent quelles parties du code monopolisent le CPU, comment la mémoire évolue, ce qui retient la boucle d’événements. Mais passer de la mesure au diagnostic, ça, ils vous le laissent. Quelques-uns proposent bien des pistes, mais l’essentiel de l’interprétation reste à votre charge.
Ce n’est pas un défaut en soi. Pendant des années, la répartition était claire et efficace : l’outil produisait les mesures, le développeur en tirait le diagnostic. Pour qui sait lire un profil, ces outils restent parfaitement adaptés.
Ce qui change aujourd’hui, ce n’est pas le profiler, c’est son consommateur. L’agent ne dispose que de mesures brutes, dans des formats pensés pour l’œil humain (flamegraphs, interfaces interactives, tableaux de bord), et doit reconstruire seul tout le travail d’interprétation qu’un développeur ferait sans même y penser.
Lanterna : un profiler pensé pour les agents
Lanterna est un profiler CPU, mémoire et async (expérimental pour le moment) pour Node.js. Il lance votre programme ou s’attache à un processus vivant, capture les profils demandés ainsi que des signaux runtime, et émet un rapport structuré.
L’outil s’appuie sur trois idées clés :
-
Un rapport structuré, pas un profil brut. Le rapport rassemble les zones chaudes CPU, les principaux allocateurs mémoire, les chaînes async, les pauses GC, la latence de la boucle d’événements et les constats produits par les détecteurs. L’agent reçoit un rapport directement exploitable : des sections clairement identifiées, des localisations (fichier, ligne, fonction), des scores de confiance sur chaque constat et une liste de pistes déjà ordonnées.
-
Des détecteurs intégrés. Lanterna ne se contente pas de dire “voici les mesures”. Il essaye de transformer certains signaux connus en constats : crypto synchrone trop coûteuse, entrée/sortie bloquante, mémoire qui grossit, attente async trop longue, dépendance trop présente dans le CPU, etc.
-
CPU + mémoire + async dans la même fenêtre. Certains problèmes deviennent évidents seulement quand on regarde plusieurs signaux ensemble. Une fonction qui consomme beaucoup de CPU et alloue beaucoup de mémoire n’a pas le même poids qu’une fonction simplement visible dans un flamegraph. C’est ce genre de recoupement que je voulais rendre explicite.
Voilà où je situe Lanterna par rapport aux outils que j’utilise déjà :
| Outil | Type de sortie | Ce qui reste à faire |
|---|---|---|
node --prof | Log V8 brut (isolate-*.log) | Lancer --prof-process, lire le résumé, prioriser |
node --cpu-prof / --heap-prof | .cpuprofile / .heapprofile | Ouvrir dans Chrome DevTools, explorer manuellement |
| 0x | Flamegraph HTML interactif | Explorer visuellement les fonctions coûteuses |
| Clinic.js | Doctor / Flame / Bubbleprof (rapports HTML) | Choisir le bon sous-outil, interpréter les symptômes |
| Chrome DevTools | Interface interactive (CPU, mémoire, allocations) | Inspecter, filtrer, corréler à la main |
| Lanterna | JSON structuré (+ rendus text / markdown / agent) | Vérifier les constats et agir dessus |
L’objectif n’est pas de remplacer 0x ou Chrome DevTools. Tant qu’un humain reste devant son écran à analyser un profil, ces outils font très bien le travail. Lanterna devient pertinent quand cette boucle disparaît, quand c’est un agent qui doit tirer des conclusions d’un profil, et qu’il a donc besoin d’une sortie déjà digérée.
Prise en main
L’installation se fait via npm :
# installation globalenpm install -g @lanterna-profiler/cli
# ou sans rien installernpx -y @lanterna-profiler/cli --helpLanterna propose deux modes de capture. lanterna run -- <commande> démarre votre programme et le profile dès le lancement. lanterna attach se branche sur un processus déjà en cours via l’inspecteur Node.js (--pid pour cibler un PID local, --inspect-url pour une URL d’inspection distante).
Prenons un cas concret. Le premier article de la série partait d’un cas d’école : une route de login qui hache un mot de passe avec crypto.pbkdf2Sync. À l’époque, j’avais sorti Chrome DevTools, lancé un benchmark autocannon, lu un flamegraph, repéré que pbkdf2Sync bloquait la boucle d’événements, puis remplacé l’appel par sa version asynchrone. Tout ce travail d’interprétation, c’était moi.
Voici le même antipattern, réduit à l’essentiel :
import { pbkdf2Sync } from 'node:crypto';
function hashPassword(password, salt) { return pbkdf2Sync(password, salt, 100_000, 32, 'sha256').toString('hex');}
function processBatch(size) { const out = []; for (let i = 0; i < size; i++) { out.push(hashPassword(`user-${i}`, `salt-${i}`)); } return out;}
const start = Date.now();let total = 0;while (Date.now() - start < 25_000) { total += processBatch(20).length;}console.log(`hashed ${total} passwords in 25s`);On le profile en deux étapes : capturer, puis rendre le rapport au format destiné aux agents.
# 1. capturenpx -y @lanterna-profiler/cli run --duration 30s --output report.json -- node app.js
# 2. rendu du rapport pour un agentnpx -y @lanterna-profiler/cli report report.json --format agent --output report.agent.mdVoici ce que ça donne :
---mode: spawnpid: 540128command: "node app.js"duration_ms: 25230.564cwd: /tmp/lanterna-blog-demokinds: [cpu]lanterna_version: "2.3.0"cpu_quality: highmemory_quality: absentmemory_signal: absentasync_quality: absentintegrity: okrerun_required: falsesourcemap_coverage: 1sourcemap_status: not-applicablesourcemap_maps_loaded: 0blocking_caveats: []degrading_caveats: []---
## Findings
| # | id | kind | prio | sev | conf | proof | decision | location | impact || --- | ----------------------- | ---- | ----- | -------- | ------ | ----------------- | ---------- | -------- | ---------- || 1 | event-loop-stall | cpu | 30259 | critical | high | correlated-window | actionable | app.js:7 | 64.7% self || 2 | sync-crypto-on-hot-path | cpu | 9987 | critical | medium | direct-sample | hypothesis | app.js:4 | 25199ms |
## Finding 1 — event-loop-stall
- title: Event loop stalled (max 25216ms)- location: app.js:7- user_stack: (anonymous) at app.js:1 -> processBatch at app.js:7 (64.7% stack)- observed: p99LagMs=25216.156 maxLagMs=25216.156- thresholds: p99=100 max=200 critical=500 strongCorrelationOverlapPct=60- impact: 64.7% self- why: The event loop spent up to 25216ms (p99 25216ms) without being able to pick up tasks. During those measured stall windows, `processBatch` accounted for 64.8% of the user-code CPU samples.- suggestion: Identify the hottest user-code function in this report and move its work off the main thread. Use `worker_threads` or `piscina` for CPU-bound work; chunk long loops with `setImmediate` or a queue; prefer streaming JSON for large payloads.- remediation: none
## Finding 2 — sync-crypto-on-hot-path
- title: Synchronous crypto on hot path (pbkdf2Sync)- location: app.js:4- user_caller: hashPassword at app.js:4 (medium, cpu-sample-path, support 35.2%, distance 1)- candidate_callers: processBatch at app.js:7 (high, cpu-sample-path, support 100.0%, distance 1); hashPassword at app.js:3 (medium, cpu-sample-path, support 35.2%, distance 1); (anonymous) at app.js:1 (high, cpu-sample-path, support 100.0%, distance 2)- user_stack: (anonymous) at app.js:1 -> processBatch at app.js:7 -> hashPassword at app.js:3 (35.2% stack, leaf pbkdf2Sync at node:internal/crypto/pbkdf2:62)- observed: selfPct=99.848 totalPct=99.874 categoryTotalPct=99.874- thresholds: minTotalPct=1 criticalPct=10 categoryTotalPct=3- impact: 25199ms- why: `pbkdf2Sync` is a synchronous crypto primitive that blocks the event loop for the duration of the computation. On a server it pauses all other requests.- suggestion: Switch to the async variant (e.g. `crypto.pbkdf2` / `crypto.scrypt` with a callback or promisified) and/or offload to a worker pool (piscina). For PBKDF2/scrypt which are CPU-bound by design, worker_threads is the right answer above a few hundred reqs/s.- remediation: kind=async-variant replace=pbkdf2Sync with=pbkdf2 module=node:crypto notes=crypto.pbkdf2 is callback-based async; use util.promisify(pbkdf2) if the caller wants a Promise. PBKDF2 is CPU-bound — at high load also consider offloading to a worker pool (piscina).
## Kind Review — cpu
- quality: high- top_request_entry: processBatch at app.js:7 (99.9% total)- hotspots: | # | function | location | self% | total% | user_caller | | --- | ------------------------ | --------------------------------- | ----- | ------ | --------------- | | 1 | pbkdf2Sync | node:internal/crypto/pbkdf2:62 | 99.8% | 99.9% | app.js:7 (high) | | 2 | hexSlice | hexSlice:0 | 0.02% | 0.02% | app.js:7 (high) | | 3 | processBatch | app.js:7 | 0.01% | 99.9% | — | | 4 | writeString | writeString:0 | 0.01% | 0.01% | — | | 5 | compileForInternalLoader | node:internal/bootstrap/realm:383 | 0.01% | 0.02% | app.js:1 (low) |- hot_stacks: | # | anchor | location | weight% | | --- | ---------- | ------------------------------ | ------- | | 1 | pbkdf2Sync | node:internal/crypto/pbkdf2:62 | 64.7% | | 2 | pbkdf2Sync | node:internal/crypto/pbkdf2:62 | 35.2% |- hot_stack_clusters: | # | anchor | location | weight% | | --- | ------------ | -------- | ------- | | 1 | processBatch | app.js:7 | 64.7% | | 2 | hashPassword | app.js:3 | 35.2% |
## Files To Read First
| location | reason | source | signal | decision || -------- | ---------------- | ------- | ----------- | ------------------ || app.js:7 | finding location | finding | 64.7% self | read-first || app.js:1 | CPU user stack | finding | 64.7% stack | supporting-context || app.js:4 | finding location | finding | 25199ms | inspect-lead || app.js:3 | CPU user stack | finding | 35.2% stack | supporting-context |Le rapport rendu pour un agent (--format agent) suit toujours le même contrat : un frontmatter YAML, un tableau Findings, un bloc de détail par finding, une revue par kind, puis une section d’orientation Files To Read First.
Le frontmatter
Le frontmatter (le bloc entre ---) résume le contexte de la capture : durée, types de profil enregistrés (CPU, mémoire, async), et indicateurs de qualité du signal. C’est la première chose à lire, avant même de descendre dans les findings, parce que sans ce contexte on risque de diagnostiquer du bruit. Si la capture est trop courte, ou si le programme n’a presque rien fait pendant la mesure, mieux vaut relancer une capture représentative que de tirer des conclusions hâtives.
En pratique, un agent n’a besoin que de quatre champs pour décider s’il diagnostique ou s’il relance : integrity, rerun_required, les *_quality, et blocking_caveats. Le reste sert à comprendre ce qui a été mesuré et comment.
| Champ | Rôle |
|---|---|
mode | Comment Lanterna a obtenu les mesures : spawn (lancement du processus) ou attach (connexion à un processus existant). |
pid | L’identifiant du processus profilé. |
command | La commande réellement profilée. |
duration_ms | La durée de capture effective. Notre exécution s’est arrêtée d’elle-même à 25 s, avant la durée demandée de 30 s. |
cwd | Le répertoire de travail utilisé pour classer les frames du code utilisateur. |
kinds | Les types de profil capturés. Ici seulement cpu. Un rapport plus large peut contenir memory et async. |
lanterna_version | La version de Lanterna utilisée pour la capture. |
cpu_quality | La qualité du signal CPU (high / medium / low / absent), calculée à partir de la durée, du nombre d’échantillons et de la part de code exploitable. |
memory_quality | La qualité du signal mémoire, même échelle que cpu_quality. Vaut absent si le kind mémoire n’a pas été demandé. |
memory_signal | État du flux process.memoryUsage() côté mémoire : present, usage-unavailable ou absent. Complète memory_quality en distinguant un kind absent d’un kind présent mais sans série exploitable. |
async_quality | La qualité du signal async, même échelle que cpu_quality. |
integrity | L’état global de la capture. ok veut dire que Lanterna n’a pas détecté de rupture majeure. |
rerun_required | Le drapeau de pilotage : à true, le signal n’est pas assez bon pour conclure, et l’agent doit relancer une capture en suivant les blocking_caveats ou les findings decision: rerun. C’est la porte d’entrée principale d’un agent dans le rapport. |
sourcemap_coverage | Ratio des frames effectivement résolues vers leur source d’origine quand des source maps étaient attendues. Vaut 1 par convention quand elles ne s’appliquent pas (notre cas) ou quand aucune frame n’a eu besoin d’être résolue : c’est une sentinelle qui empêche une couverture vide d’être interprétée à tort comme un échec. À lire en regardant aussi sourcemap_status. |
sourcemap_status | ok, partial, failed, ou not-applicable quand on tourne sur du JS sans sourceMappingURL (notre cas). C’est ce statut qui évite qu’une couverture nulle soit interprétée à tort comme un échec. |
sourcemap_maps_loaded | Nombre de source maps effectivement chargées pendant la capture. |
blocking_caveats | Les réserves qui doivent bloquer le diagnostic : capture trop courte, absence de charge, profil vide, capture interrompue, etc. |
degrading_caveats | Les réserves moins graves : elles ne bloquent pas, mais doivent au moins être mentionnées (typiquement une couverture de source maps trop faible quand celles-ci sont attendues). |
La table des findings
La section Findings liste les constats produits par les détecteurs, triés par priorité décroissante. Chaque ligne est une piste : où regarder, à quel point c’est solide, et quelle décision elle suggère.
| Colonne | Signification |
|---|---|
id | Le nom du détecteur, parfois complété par la fonction, le paquet ou l’API concernée. |
kind | Le type de profil qui a produit le constat : cpu, memory ou async. |
prio | Un score de priorité calculé par Lanterna pour trier les constats. Plus il est haut, plus le sujet doit être regardé tôt. |
sev | La sévérité estimée : critical, warning ou info. Elle décrit l’impact probable, pas la solidité de la preuve. |
conf | La confiance du détecteur : high, medium ou low. Une confiance basse donne une piste, pas une cause racine. |
proof | Le proofLevel du JSON, raccourci dans le rendu agent. Il explique d’où vient le signal (voir ci-dessous). |
decision | La décision proposée au lecteur du rapport : actionable s’il peut lire le code et agir, hypothesis s’il doit d’abord vérifier, rerun si une nouvelle capture est préférable. |
location | Le meilleur point d’entrée dans le code. Quand le coût vient d’une API native ou d’une dépendance, Lanterna essaie de remonter au caller utilisateur. |
impact | Une estimation chiffrée de l’impact. Selon le détecteur, c’est un pourcentage de samples (64.1% self), un temps cumulé (25019ms), une croissance, etc. |
Le point que je trouve le plus important ici, c’est proofLevel. Je voulais séparer la gravité supposée d’un problème et la solidité de la preuve. Une piste peut être grave mais faible, ou très bien prouvée mais peu prioritaire.
proofLevel | Interprétation |
|---|---|
direct-sample | Le profil contient directement des échantillons CPU ou mémoire sur cette pile d’appels, c’est-à-dire la suite de fonctions qui a mené à ce point. C’est la preuve la plus simple à exploiter. |
correlated-window | Le constat vient d’une corrélation temporelle : par exemple une fonction coûteuse apparaît pendant les fenêtres de latence ou les pauses GC. |
trace-only | Le signal vient d’une trace diagnostique que Lanterna ne récolte qu’en mode --deep (en pratique, aujourd’hui, les traces de déoptimisation V8 utilisées par le détecteur deopt-loop). Utile, mais à confirmer dans le code ou avec une autre mesure. |
heuristic | Le constat vient d’une tendance ou d’un seuil. C’est une bonne piste de triage, pas une preuve suffisante à elle seule. |
Déoptimisation V8 ? Quand V8 optimise une fonction, il fait des paris sur la forme des données qu’elle manipule. Si un de ces paris s’avère faux à l’exécution (un argument change de type, une propriété d’objet apparaît ou disparaît), V8 jette la version optimisée et revient à une version interprétée, beaucoup plus lente. C’est une déoptimisation (deopt). Quand le cycle se répète sans cesse sur la même fonction, on parle de deopt loop : la fonction n’arrête pas de basculer entre les deux mondes, et le coût devient visible dans le profil.
Le détail par finding
À chaque ligne du tableau correspond un bloc Finding N — <id> plus bas dans le rapport. Il complète la vue tabulaire avec ce dont l’agent a besoin pour agir :
| Champ | Rôle |
|---|---|
title | Description courte du constat. |
location | Où l’agent doit aller voir en premier. Quand le coût vient clairement d’une ligne de code utilisateur (attribution directe, distance 1 en confiance haute), location pointe sur cette ligne. C’est le cas le plus simple. Quand le coût vient d’une fonction native ou d’une dépendance, location désigne cette frame-là et c’est user_caller qui fournit la ligne utilisateur la plus probable. |
user_caller | (Optionnel) La fonction utilisateur qui a déclenché ce coût, avec la confiance de l’attribution, la base utilisée (cpu-sample-path, heap-sample-path…) et la distance jusqu’au frame fautif. distance 1 = appelant immédiat, c’est généralement le bon endroit où patcher. |
candidate_callers | (Optionnel) Les autres user callers candidats, classés par proximité puis par support. Quand user_caller ne suffit pas, ce champ permet à un agent de remonter la chaîne d’appel d’un cran. |
user_stack | (Optionnel) La pile d’appels utilisateur la plus représentative pour ce finding, du haut de la pile vers le frame fautif, avec son poids dans le profil. Utile pour visualiser le chemin entier sans avoir à ouvrir le bloc Kind Review. |
correlated_allocator | (Optionnel) Sur les findings cross-kind (typiquement alloc-in-hot-path), le frame allocateur mémoire qui correspond à la zone chaude CPU, avec sa localisation et son éventuel user_caller. C’est ce champ qui matérialise le recoupement CPU + mémoire dans le détail. |
entry_frame | (Optionnel) Frame d’entrée distinct de location, fourni par certains détecteurs (souvent async ou cross-kind) quand le point d’attaque pertinent n’est pas la location du finding mais le frame qui a déclenché la chaîne. |
observed | Les mesures brutes qui ont fait déclencher le détecteur : latence p99, part CPU, slope mémoire, etc. |
thresholds | Les seuils du détecteur. Posés à côté des observed, ils expliquent pourquoi la règle s’est déclenchée. |
impact | L’impact estimé, repris du tableau Findings. |
why | Pourquoi ce schéma pose problème, en une phrase ou deux. |
suggestion | La piste de correction concrète proposée par le détecteur. |
remediation | Un patch structuré, quand le détecteur sait en proposer un. Souvent none, parfois un objet machine-readable qu’un agent peut appliquer directement (voir le finding 2 dans l’exemple : kind=async-variant replace=pbkdf2Sync with=pbkdf2 module=node:crypto). |
Les deux findings du rapport se complètent. event-loop-stall (actionable) dit que la boucle d’événements est restée bloquée jusqu’à 25 s et pointe processBatch à app.js:7. sync-crypto-on-hot-path (hypothesis) pointe pbkdf2Sync et le rattache à hashPassword à app.js:4, avec processBatch un cran plus haut. Pris ensemble, ils désignent la même chaîne d’exécution : processBatch → hashPassword → pbkdf2Sync. C’est la lecture que je voulais offrir à un agent : ouvrir app.js:7, voir l’enchaînement, et trouver dans le rapport la suggestion de bascule asynchrone ainsi que la remediation machine-readable. Hot path, qu’on retrouve un peu partout, désigne ce chemin d’exécution coûteux.
La revue par kind
Après les findings, la section Kind Review — cpu (et memory / async quand ils sont capturés) donne une synthèse pour ce type de profil : la qualité du signal, la fonction qui domine le profil, et les tableaux des fonctions et piles d’appels les plus coûteuses. C’est là que l’agent va chercher du contexte quand un finding ne suffit pas. Sur notre exemple, cette vue resserre le diagnostic : pbkdf2Sync à 99,8 % du CPU, rattaché à processBatch et hashPassword.
Les fichiers à lire en premier
Le rapport se ferme par la section Files To Read First : la liste des fichiers à ouvrir, dans l’ordre, avec pour chacun la raison et la décision (read-first, inspect-lead, supporting-context). L’agent n’a pas à choisir, il suit l’ordre. Sur notre exemple, app.js:7 arrive en tête (la location du finding principal), suivi des autres lignes du même fichier qui complètent le contexte.
Les détecteurs intégrés
Les détecteurs intégrés couvrent aujourd’hui trois grandes familles :
| Famille | Exemples de signaux |
|---|---|
| CPU | Crypto synchrone sur un chemin chaud, I/O bloquante, JSON trop coûteux, GC excessif, dépendance dominante, déoptimisations V8 en mode --deep, et un filet cpu-hotspot qui rattrape les frames chaudes que les autres détecteurs n’ont pas expliquées. |
| Mémoire | Croissance soutenue du RSS ou du heap, gros allocateur, pression hors tas avec Buffer / ArrayBuffer. |
| Async | await très long, ressources async orphelines, chaînes async profondes, microtâches trop nombreuses, contexte async associé à du CPU. |
Il y en a 18 dans la version actuelle, dont deux cross-kind : alloc-in-hot-path (CPU + mémoire), qui ne déclenche que si une même fonction est à la fois chaude en CPU et grosse allocatrice, et hot-async-context (CPU + async), qui pointe les contextes async qui dominent aussi le CPU. La liste exhaustive, les seuils et les options de configuration sont dans la doc du dépôt. Le principe est toujours le même : chaque détecteur lit la vue enrichie d’un kind, en sort un constat normalisé, et laisse au rapport le soin de dire si c’est actionnable, hypothétique ou à remesurer.
Profiler un serveur sous charge
Le cas qu’on vient de voir est le plus simple qu’on puisse imaginer : un script qui boucle, qu’on lance, qui termine seul. Profiler un vrai serveur (Express, Fastify, Hono, peu importe) demande un peu plus de préparation. Il faut attendre qu’il soit prêt avant de commencer à mesurer, lui envoyer une charge représentative pendant la capture, et souvent capturer plusieurs types de profils en même temps (CPU + mémoire par exemple) pour ne pas passer à côté des recoupements.
lanterna run \ --kind cpu --kind memory \ --duration 30s \ --wait-for-url http://127.0.0.1:3000/health \ --workload "npx -y autocannon http://127.0.0.1:3000" \ --output report.json \ -- node server.jsQuatre options à connaître :
--kind cpu --kind memoryactive les deux profils au lieu du seul CPU par défaut. C’est la condition pour qu’un détecteur cross-kind commealloc-in-hot-pathpuisse identifier une fonction à la fois coûteuse en CPU et grosse consommatrice mémoire.--duration 30sborne la fenêtre de capture. Pour un serveur sous charge, 20 à 30 secondes donnent en général assez d’échantillons pour avoir un signal stable.--wait-for-urlretarde le début de la capture jusqu’à ce que l’URL réponde en 2xx (timeout par défaut 30 s, ajustable avec--wait-timeout, plus un délai optionnel--capture-delay). Sans ça, la fenêtre de mesure démarre pendant l’initialisation et le profil contient surtout du chargement de modules.--workloadlance une commande en parallèle de la capture, depuis le même répertoire et le même environnement que Lanterna. C’est typiquement un générateur de trafic (autocannon, artillery, un script maison), mais ça peut être n’importe quel programme qui sollicite le programme cible. Si le workload sort en erreur, Lanterna écrit quand même le rapport avant de remonter l’échec.
Ces options peuvent aussi être mises dans un fichier .lanterna.json à la racine du projet plutôt que d’être répétées sur la ligne de commande à chaque capture.
La skill Lanterna
Un rapport bien structuré ne fait pas tout. Encore faut-il que l’agent sache par où commencer, ce qu’il faut recouper, et à quel moment s’arrêter. C’est ce que vient compléter la skill livrée avec Lanterna.
On l’installe dans l’espace de travail d’un agent avec :
npx skills add arkerone/lanterna --skill lanterna-profilerÀ partir de là, l’agent ne reçoit pas qu’un rapport, il reçoit un protocole d’investigation. Et la première règle donne le ton :
Votre travail n’est pas de résumer le rapport. C’est de mener une enquête de performance interactive jusqu’à ce que la cause la plus probable, les preuves manquantes et la prochaine mesure soient claires.
Concrètement, la skill impose une discipline en quatre points :
- Capturer d’abord si nécessaire. Si aucun rapport n’est fourni, l’agent capture lui-même à partir de la cible (commande, PID ou URL d’inspecteur) avant tout diagnostic.
- Jauger la qualité du signal avant tout. Il lit le frontmatter (types de profils, qualité, intégrité). Si le frontmatter contient des réserves bloquantes ou que la capture est trop inactive, il ne diagnostique pas, il réclame une nouvelle capture avec une charge représentative.
- Aucune cause racine sans preuve. Chaque recommandation s’appuie sur une observation concrète du rapport ou du code source. Si l’agent n’a pas de preuve, il présente sa piste comme une hypothèse plutôt qu’une certitude.
- Savoir s’arrêter. La skill définit des conditions d’arrêt (signal trop faible, cible non-Node.js, code source inaccessible, type de profil manquant pour le symptôme…). Dans ces cas, l’agent s’arrête et pose une question plutôt que d’inventer une réponse.
Le reste du workflow suit la même logique : on lit le rapport dans un ordre fixe pour construire une carte des preuves, on diagnostique sous-système par sous-système (CPU, boucle d’événements, mémoire, async), on va lire les fichiers que le rapport pointe, puis on formule l’hypothèse la plus simple à tester, avec la mesure qui permettrait de la valider ou de l’écarter.
Sans la skill, l’agent reçoit un rapport bien formé mais doit improviser ses prochains pas. Avec, il a un mode d’emploi. C’est ce qui sépare Lanterna d’un profiler classique : ce dernier livre les données et vous laisse vous débrouiller.
Sous le capot
Côté implémentation, Lanterna fonctionne en deux phases : une capture, puis un enrichissement.
flowchart LR
A["Processus Node.js cible"] -->|"probes + signaux runtime"| B[CaptureBundle]
B -->|"contributeurs de kind + détecteurs"| C[LanternaReport]
subgraph capture["1. Capture"]
A
B
end
subgraph enrich["2. Enrichissement"]
C
end
La capture : l’inspecteur V8 comme canal de coordination
Lanterna s’appuie sur l’inspecteur V8, l’API de débogage et de profiling exposée par le moteur, accessible via le protocole CDP (Chrome DevTools Protocol). C’est la même API que celle utilisée par Chrome DevTools.
flowchart LR
L[Lanterna]
subgraph cible["Processus Node.js cible"]
P["Preload .cjs (signaux runtime)"]
V["Inspecteur V8"]
end
L -->|spawn| cible
L <-->|"CDP / WebSocket"| V
P -->|"événements JSON, FD 3"| L
En mode run, voici le déroulé :
- Préparation du preload. Lanterna compose un script
.cjsqui regroupe les hooks des kinds actifs et un installateur de signaux runtime, toujours présent. - Lancement du processus cible.
--inspect-brk=0démarre l’inspecteur V8 sur un port libre choisi par l’OS, et met le processus en pause avant le code utilisateur le temps que Lanterna se connecte.--require=<preload>charge le preload. Le preload communique avec Lanterna via un canal dédié, le FD 3 : un pipe ouvert par Lanterna et transmis au processus enfant, séparé destdout/stderrpour ne pas se mélanger aux logs de l’application. Si un gestionnaire de processus le ferme, la capture continue en mode dégradé avec une réserve d’intégrité explicite. - Connexion CDP. Lanterna attend l’URL WebSocket de l’inspecteur, s’y connecte, pilote les probes (la probe CPU enchaîne
Profiler.enable/start/stop), libère le processus en pause, et interroge les variables globales publiées par le preload. - Signaux runtime. Pendant que l’inspecteur V8 enregistre les samples CPU, le preload mesure en parallèle d’autres choses : à quel point la boucle d’événements répond (heartbeats ~20 ms + histogramme
monitorEventLoopDelay), combien de temps passe en pauses GC (viaPerformanceObserver), et les événements de démarrage / arrêt du processus. Ces signaux servent à corréler les hotspots CPU avec les stalls (les périodes où la boucle d’événements est bloquée et ne peut plus traiter de nouvelles tâches) et les pauses GC. - Charge optionnelle.
--wait-for-urlattend que le serveur soit prêt avant de démarrer.--workloadlance une charge en parallèle pour éviter de ne profiler que le démarrage ou l’inactivité. - Fin de capture. Les signaux s’accumulent, puis tout s’arrête à la fin de la durée demandée, à la sortie du processus, ou sur
SIGINT/SIGTERM. Dans tous les cas, un rapport complet est produit.
Le mode attach réutilise le même pipeline d’enrichissement. Seule la capture diffère : pas de pause au démarrage, pas de FD 3 (les hooks sont injectés via des variables globales par-dessus CDP), pas de --trace-deopt, et une capture async partielle. Le schéma du rapport, lui, est identique.
L’enrichissement : du sample brut au finding
À la fin de la capture, Lanterna a accumulé une grande quantité de samples bruts qui, seuls, ne disent pas grand-chose. C’est cette matière première qui passe par plusieurs étapes pour devenir le rapport final : chaque type de profil (CPU, mémoire, async) construit sa section, puis les détecteurs lisent l’ensemble et produisent leurs findings, et chaque section est ré-ajustée à la fin selon ce qui a été retenu.
Deux mécaniques en particulier :
Classifier les frames. Chaque fonction échantillonnée est rangée dans une catégorie : code utilisateur, internals de Node.js, code natif (bindings C++), node_modules, GC, boucle d’événements, ou bruit (l’instrumentation de Lanterna elle-même, filtrée par défaut). C’est cette classification qui permet à un agent de savoir tout de suite si une pile chaude vient de votre code ou d’une dépendance, une information qu’il faudrait sinon reconstruire à la main en regardant chaque chemin de fichier.
Corréler dans le temps. Un profil CPU classique vous dit où le temps CPU est dépensé, mais pas vraiment à quel moment la latence est apparue. Lanterna délimite les fenêtres où la boucle d’événements a été bloquée et où le GC a tourné, puis regarde quelles fonctions s’exécutaient à ces moments-là. Le rapport peut alors dire des choses comme « cette fonction couvre la plupart des fenêtres de stall ». Quand rien ne se détache vraiment, Lanterna liste plusieurs candidats plutôt que de désigner un coupable.
Étendre Lanterna
L’écriture d’un nouveau détecteur est volontairement simple : c’est une fonction qui reçoit le rapport déjà enrichi et qui peut émettre un ou plusieurs Finding. Les 18 détecteurs livrés avec Lanterna utilisent exactement la même API que ceux qu’on peut écrire soi-même.
La façon la plus directe d’en écrire un est le KindScopedDetector. On y déclare quels types de profils on veut consulter (cpu, memory, async), et Lanterna passe à la fonction detect un snapshot déjà restreint à ces données. Voici à quoi ça ressemble pour signaler un cas concret, une fonction du client Prisma qui consomme trop de CPU :
import type { KindScopedDetector, Finding } from '@lanterna-profiler/core';
// Seuils internes au détecteurconst MIN_TOTAL_PCT = 5; // sous ce seuil, on ignoreconst CRITICAL_TOTAL_PCT = 15; // au-dessus, c'est critical
export const prismaHotspotDetector: KindScopedDetector<'cpu'> = { id: 'prisma-hotspot', kindIds: ['cpu'],
detect({ cpu }): Finding[] { const findings: Finding[] = [];
for (const hotspot of cpu.report.hotspots) { // On ne s'intéresse qu'aux frames du client Prisma. if (hotspot.category !== 'node_modules') continue; if (!hotspot.file.includes('@prisma/client')) continue;
// Et seulement si la frame pèse vraiment. if (hotspot.totalPct < MIN_TOTAL_PCT) continue;
findings.push({ id: `prisma-hotspot:${hotspot.function}`, profileKind: 'cpu', category: 'prisma-hotspot', severity: hotspot.totalPct >= CRITICAL_TOTAL_PCT ? 'critical' : 'warning', confidence: hotspot.userCaller?.confidence === 'high' ? 'high' : 'medium', proofLevel: 'direct-sample', title: `Prisma client dominates the CPU profile (${hotspot.function})`, evidence: { file: hotspot.userCaller?.file ?? hotspot.file, line: hotspot.userCaller?.line ?? hotspot.line, function: hotspot.userCaller?.function ?? hotspot.function, selfPct: hotspot.totalPct, }, measurements: { observed: { selfPct: hotspot.selfPct, totalPct: hotspot.totalPct }, thresholds: { minTotalPct: MIN_TOTAL_PCT, criticalTotalPct: CRITICAL_TOTAL_PCT }, }, why: `A Prisma client frame is dominating the CPU profile. That usually means queries are too heavy, too frequent, or load too many columns/relations.`, suggestion: `Inspect the call site, reduce input size with select/include, batch with prisma.$transaction, or cache hot queries.`, references: ['https://www.prisma.io/docs/orm/prisma-client/queries/query-optimization-performance'], }); }
return findings; },};Pour finir…
La série de 2020 n’est pas devenue obsolète, loin de là. Comprendre la boucle d’événements, le garbage collector, la mémoire d’un process Node.js, ça reste indispensable, et les profilers classiques font toujours un excellent travail entre les mains d’un humain qui sait les lire. Ce qui a changé, c’est le consommateur du rapport. Quand un agent mène l’enquête, le format de sortie d’un profiler cesse d’être un détail, il devient l’interface. Et une interface sans mode d’emploi ne sert pas à grand-chose.
C’est ce que j’ai essayé de faire avec Lanterna : un rapport structuré, pensé pour être exploité par un agent, et livré avec une skill qui lui apprend à s’en servir.
Lanterna est loin d’être fini. J’itère dessus à chaque vrai cas que je profile, et il y a sans doute des angles morts que je n’ai pas encore croisés. C’est aussi pour ça que les retours m’intéressent autant : chaque rapport déroutant ou chaque finding qui rate sa cible alimente la version suivante.
C’est open source (licence MIT), publié sur npm sous @lanterna-profiler/cli, et le code est sur GitHub : arkerone/lanterna. Si vous profilez du Node.js avec un agent, j’aimerais beaucoup savoir ce qui marche, et ce qui manque.
Partage ta réflexion, pose une question ou laisse un retour.