Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Canal de diagnostic — le cycle de chaque requête publié sur diagnostics_channel

stable@nodefony/httpmis à jour 2026-10-24

Nodefony annonce le début et la fin de chaque requête HTTP et de chaque handshake WebSocket sur un canal natif de Node, nodefony.http.request. Un outil d’observation installé dans le même process — OpenTelemetry, Datadog, Sentry, ou votre propre code — s’y abonne et reçoit, sans monkey-patch, ce que Nodefony est seul à connaître : la route qui a répondu, son gabarit, le module signataire, le requestId, le statut réel et la durée. Personne n’écoute ? Le canal coûte une lecture de booléen par requête, et rien n’est alloué.

📍 Documentation › @nodefony/http › Canal de diagnostic

Schéma général#

Le même objet voyage de start à asyncEnd : l’abonné y lit à la fin ce qui n’était pas encore connu au début.

Lexique#

Terme Sens
diagnostics_channel Module natif de Node (node:diagnostics_channel) : un bus de publication NOMMÉ, interne au process, fait pour que des outils observent une bibliothèque.
Canal Un nom (tracing:nodefony.http.request:start) ; publieur et abonné le retrouvent par ce nom, sans s’importer l’un l’autre.
TracingChannel La forme « trace » du bus : cinq sous-canaux (start, end, asyncStart, asyncEnd, error) qui encadrent UNE opération, avec un objet partagé.
runStores / bindStore Un abonné lie un AsyncLocalStorage au canal (bindStore) ; le publieur entre dans ce store pour la durée de l’opération (runStores).
APM Application Performance Monitoring : agent (Datadog, New Relic, Elastic…) qui mesure et trace une application en production.
OTel OpenTelemetry : standard ouvert de traces, métriques et journaux, et ses conventions de nommage d’attributs (« semconv »).
Span Une opération chronométrée d’une trace distribuée — ici, une requête serveur.
Gabarit de route Le chemin déclaré (/users/{id}), pas l’URL reçue (/users/42) : faible cardinalité, donc agrégeable. C’est l’attribut http.route d’OTel.
requestId Identifiant de corrélation de la requête (X-Request-Id), voir Observabilité HTTP.

Qu’est-ce que c’est ?#

Un haut-parleur interne au process. Nodefony annonce « une requête commence », puis « elle est finie, voici son bilan ». Quiconque s’est mis à l’écoute de ce canal, dans le même process, reçoit l’annonce — immédiatement, sur le même fil, avec le même objet : pas de copie, pas de réseau, pas de fichier. Rien ne sort du process par lui-même : c’est l’abonné qui décide d’exporter, avec son propre transport (OTLP vers un collecteur, agent Datadog, Sentry).

Le problème qu’il résout : sans lui, un outil de traçage ne voit d’une application Nodefony que ce que node:http publie — une requête brute, sans route, sans module, sans requestId. Pour en savoir plus, il devrait patcher les classes du framework à l’exécution, ce qui casse à chaque version. Node publie bien ses propres canaux serveur (http.server.request.start, http.server.response.finish), mais ils ne portent que la requête et la réponse brutes : ce que Nodefony a décidé — quelle route, quel module, quel statut réel derrière un proxy — n’y figure pas.

La vision Nodefony#

Démarrage rapide#

Un abonné de dix lignes, dans un fichier chargé AVANT l’application — c’est ainsi que se chargent les agents d’APM :

// tracing.ts — chargé par `--import`, avant l'application
import diagnosticsChannel from "node:diagnostics_channel";
import { REQUEST_TRACE_CHANNEL } from "@nodefony/http";
import type { IRequestTrace } from "@nodefony/http";

const channel = diagnosticsChannel.tracingChannel<IRequestTrace>(
  REQUEST_TRACE_CHANNEL,
);

channel.subscribe({
  start: () => {},
  end: () => {},
  asyncStart: () => {},
  asyncEnd: (trace) => {
    console.log(
      `${trace.requestId} ${trace.method} ${trace.routePath ?? "-"} → ${trace.statusCode} en ${trace.durationMs?.toFixed(3)} ms (${trace.module})`,
    );
  },
  error: (trace) => {
    console.error(trace.requestId, trace.error);
  },
});

On le charge par NODE_OPTIONS, que les process lancés par la commande de développement héritent :

NODE_OPTIONS="--import ./tracing.ts" npx nodefony development
curl -k https://127.0.0.1:5152/

Ce qu’on observe, une ligne par requête servie — ici la page d’accueil d’une application générée :

6f1c…-9e2a GET / → 200 en 3.528 ms (app)

Importer REQUEST_TRACE_CHANNEL plutôt que recopier la chaîne : le nom vit en un seul endroit.

Le message — IRequestTrace#

Déclaré dans IRequestTrace.ts#IRequestTrace, exporté par @nodefony/http. Tout est en lecture seule pour l’abonné.

Champ Posé à Contenu
requestId start identifiant de corrélation (celui de X-Request-Id entrant, sinon généré)
protocol start "http" pour une requête, "websocket" pour un handshake
method start méthode de la requête ("WEBSOCKET" pour un handshake)
scheme start http, https, ws, wss — tel que vu par le client, proxy de confiance compris
url start URL complète, chaîne de requête comprise
route fin nom de la route qui a répondu, null sans route (fichier statique, 404)
routePath fin gabarit de la route, préfixe compris (/users/{id}) ; la racine vaut / ; null sans route
module fin module qui a signé la réponse — le même que celui de la ligne d’accès
statusCode fin statut rendu : celui de l’amont pour une requête relayée, 101 pour un handshake accepté, null si le client est parti
durationMs fin construction du contexte → dernier octet remis (fin du handshake en WebSocket) ; null si timing.enabled: false
error error l’exception attrapée par le pipeline, null sinon

Les champs « fin » valent null à start et end : c’est normal, la route et le statut ne sont pas encore connus. On les lit sur asyncEnd.

Correspondance avec OpenTelemetry#

Pour qui écrit un plugin OTel, la table des attributs du span serveur (conventions sémantiques HTTP) :

Attribut OTel Champ
http.request.method method
url.scheme scheme
url.path / url.query découpés de url par l’abonné (new URL(trace.url))
http.route routePath — jamais route, qui est un nom et pas un gabarit
http.response.status_code statusCode
error.type dérivé de error (nom de la classe d’erreur)
http.server.request.duration durationMs / 1000 (la métrique OTel est en secondes)

Un 4xx sans exception ne publie pas error : pour un span serveur, OTel demande de laisser le statut d’un 4xx non renseigné. C’est à l’abonné de classer un statut ; statusCode est là pour ça.

La séquence, sortie par sortie#

Situation Événements publiés statusCode
HTTP servi start · end · asyncStart · asyncEnd statut de la réponse
HTTP dont le pipeline lève start · end · error · asyncStart · asyncEnd statut de l’erreur
Handshake WebSocket accepté start · end · asyncStart · asyncEnd 101
Handshake refusé (origine, pare-feu, …) start · end · error · asyncStart · asyncEnd statut du refus
Client parti pendant la décision start · end · asyncStart · asyncEnd null

La trace d’un WebSocket couvre le handshake, pas la vie de la connexion : elle se ferme au 101. Les messages qui suivent ne sont pas publiés sur ce canal.

Architecture interne#

Configuration#

Aucune, à dessein. Lire un réglage à chaque requête coûterait autant que lire la garde elle-même, pour un résultat identique : sans abonné, rien n’est publié. Pour couper le canal, on ne s’y abonne pas — ou on se désabonne (channel.unsubscribe(subscribers)).

Normes appliquées#

Sécurité#

Performance & mémoire#

Mesuré au micro-banc (Node 26, médiane de 7 passages) :

Chemin Coût par requête
Personne n’écoute (le défaut) une lecture de hasSubscribers (~1,6 ns), un champ null sur le contexte (8 octets)
Un abonné un objet de message, l’appel des abonnés, l’entrée dans les stores liés

diagnostics_channel.hasSubscribers("nom") coûterait ~41 ns : la garde lit donc le TracingChannel gardé en mémoire de module, jamais une recherche par nom. Le gate mémoire (npm run test:memory) est vert sans abonné et avec un abonné armé : le message ne retient ni contexte ni scope.

Pièges#

Symptôme Cause Correction
L’abonné ne reçoit rien des premières requêtes il s’est abonné après leur début — règle des TracingChannel s’abonner AVANT l’application (--import)
route, statusCode à null dans le gestionnaire start ils ne sont connus qu’à la fin les lire dans asyncEnd
Le tableau de bord explose en cardinalité l’opération est nommée d’après url (/users/42, /users/43…) nommer par routePath (/users/{id})
Le serveur tombe sur une exception venue de l’agent un abonné a levé : Node la relance en exception non capturée try/catch dans chaque gestionnaire de l’abonné
Un 404 apparaît en erreur dans l’APM l’abonné classe sur l’événement error sans regarder le statut classer par statusCode ; error signale une exception, pas un statut
durationMs toujours null timing.enabled: false dans la configuration de l’application rétablir le défaut — la durée est mesurée partout, production comprise

Tests & couverture#

Type Où
Intégration — abonné réel integration/request-trace-channel.test.ts — séquences HTTP servi / en erreur, handshake accepté / refusé, abonné tardif sans fin orpheline, désabonnement, même objet de bout en bout
Mémoire http/memory.test.ts — le gate, rejoué sans abonné (le défaut) et avec la sonde armée

L’abonné vit dans le process du serveur : la sonde TraceChannelController du module test (/nodefony/test/trace-channel/{arm,disarm,events}) s’abonne à la demande. Ce qui manque : pas de banc unitaire isolé de requestTrace.ts (exercé de bout en bout par le banc d’intégration), et pas de cas « client parti pendant la décision » au banc (sortie couverte par lecture du code).

Suites : npm run test:integration (serveur requis), npm run test:memory. Skills associés : nodefony-check-memory-health, nodefony-load-test.

Pour aller plus loin#