Observabilité

@fougere/observability est optionnel au sens fort : le cœur ne contient aucun code de traçage, seulement un champ trace sur l'invocation qu'il transporte et ne lit jamais. Une app qui ne l'installe pas ne paie rien.

pnpm add @fougere/observability
import { trace, onSpan, otlp, metrics } from '@fougere/observability';

app.use(trace());                       // toutes les opérations
app.use('post', trace());               // celles d'une entité

C'est un middleware d'app ordinaire parce qu'une opération a déjà un cycle de vie et que c'est celui-là — un second système de hooks serait une seconde réponse à une question déjà tranchée.

Un span par opération

Un span porte la durée d'une op et son verdict :

onSpan((span) => console.log(span.frond, span.entity, span.operation, span.ms, span.error));
traceId / spanId / parentIdoù cette étape se situe dans l'arbre
frondquelle Frond possédait l'op — l'unité de déploiement, donc la première chose à regrouper
entity / operationce qui a été appelé
startedAtun instant, en millisecondes epoch
mscombien de temps
errorle code FougereError en cas de refus, absent quand elle a répondu

Deux horloges, et ce ne sont pas la même mesure : l'horloge murale dit quand, ce qui fait atterrir deux process sur une même frise ; la monotone dit combien de temps, sans être déplacée par une correction NTP en cours d'appel.

Rien n'est ouvert tant qu'aucun collecteur n'est posé — observer est une décision, et le coût de celle que personne n'a demandée est nul plutôt que petit. onSpan renvoie de quoi se retirer.

La trace survit au fil

Le parent voyage sur l'invocation, pas dans un en-tête :

app                     post.list        traceId a3f… spanId 01
  └─ blog (séparée)     post.list        traceId a3f… spanId 02, parent 01

Un en-tête appartient à HTTP seul, et le même appel sur une socket serait arrivé sans trace. L'invocation est ce que tout transport porte, donc la trace traverse quelle que soit la topologie devenue — et le format est W3C Trace Context, donc un collecteur la lit à côté de n'importe quoi d'autre.

Le même middleware tourne sur les deux moitiés d'une séparation : à la porte où un appel arrive, et à la doublure d'où il part. C'est pour ça que les chiffres se recoupent d'un process à l'autre — et pourquoi la différence entre les deux spans est ce qu'a coûté le fil.

Un cas que le fil ne sait pas décrire : un handler qui atteint une seconde Frond construit une invocation neuve, donc le parent n'est pas sur cet appel. Il est dans le contexte où le premier tourne encore, que currentSpan() lit.

Les quatre signaux, depuis le span qui existe déjà

const measured = metrics(app);
onSpan(measured.sink);

measured.snapshot();   // ce qu'il y a à publier maintenant

Débit, erreurs et durée sont une seule métrique — un histogramme de durées, dimensionné par l'op et par le fait qu'elle ait refusé ou non. Son compte est le débit, sa dimension le taux d'erreur, ses tranches la latence. La scinder en trois compteurs publierait trois fois les mêmes chiffres et les laisserait diverger.

Le quatrième, la saturation, ne peut pas venir d'un span terminé, puisqu'il parle de ceux qui ne le sont pas. Le middleware les compte et activeCalls() le lit.

La cardinalité est bornée par construction : frond × entité × opération × {ok, erreur}, plus le code d'erreur quand il y en a un — toutes déclarées dans le code. Rien qui porte un id, un utilisateur ou une trace ne devient jamais une dimension ; c'est la seule erreur dont une couche de métriques ne se remet pas.

Passer app est optionnel et ne nourrit que la topologie, qui est découverte et non déclarée, à partir des deux seules choses qu'un process peut dire honnêtement : une Frond qu'il a SCANNÉE tourne ici, une Frond qu'il a APPELÉE sans l'avoir scannée tourne ailleurs.

process catalog   catalog → local        il en détient le code
process shop      shop    → local
process shop      catalog → remote       il l'a appelée, il ne l'a jamais scannée

Délibérément pas lu depuis remotes: — une clé de config énonce une intention, alors qu'une Frond qui a répondu à un appel est un fait. Les deux divergent précisément quand quelque chose est mal configuré, c'est-à-dire au moment où un tableau de bord doit avoir raison.

Le troisième signal : les logs, et la seule chose qui en fait un

Un log expédié sans identifiant de trace est un log rangé ailleurs. Ce qui en fait le troisième pilier, c'est d'ouvrir une trace et d'y lire les lignes que cet appel a produites.

Le logger a une porte — onLog dans le cœur — qui remet un enregistrement structuré avant tout formatage, parce que ce qui arrive à la console est déjà cuit : codes ANSI, badge, horodatage. Le cœur émet l'enregistrement et ne sait rien de plus ; ce paquet y attache le span et parle OTLP.

import { onLog, loggerMiddleware, Logger } from '@fougere/core';
import { logs } from '@fougere/observability';

app.use(trace());                                  // d'abord — le span doit exister
app.use(loggerMiddleware(new Logger('blog')));     // une ligne à l'entrée, une à la sortie

onLog(logs({ service: 'blog' }).sink);

Le span est lu au moment où la ligne est écrite, jamais au moment de l'envoi : à ce moment-là l'appel est terminé et le contexte appartient à quelqu'un d'autre. Une ligne écrite hors de tout appel (un démarrage, un arrêt) part sans identifiant de trace plutôt qu'avec un identifiant nul : absent veut dire « hors appel », et un zéro forgé rassemblerait tous les démarrages de tous les process en une trace fantôme.

Le transfert est un ajout, jamais un remplacement : la console garde sa ligne, et un collecteur qui lève ne coûte la sienne à personne. setLogLevel filtre en amont, donc un niveau qui n'a jamais été imprimé n'est jamais expédié non plus — un seul endroit où le niveau vit.

Chaque niveau prend maintenant sa propre méthode de console (debug, info, warn, error). Les deux premiers tombaient sur console.log, donc rien en aval — un filtre de terminal, un collecteur — ne pouvait distinguer une ligne de debug d'une ligne d'info.

L'envoyer quelque part

const exporter = otlp({ service: 'blog', metrics: measured });
onSpan(exporter.sink);

OTLP sur HTTP, par lots chaque seconde, en encodage JSON — pas de protobuf, aucune dépendance. Les endpoints prennent la convention par défaut (http://localhost:4318/v1/traces, et celui des métriques avec son dernier segment échangé) — nommez metricsUrl quand traces et métriques vont vers deux moteurs différents plutôt que vers un collecteur en façade. Les trois signaux sont trois chemins sur la même porte : /v1/traces, /v1/metrics, /v1/logs.

Une métrique sans point de données fait rejeter le lot entier par un collecteur. Mesuré face à Prometheus : il répond 500 et toutes les autres métriques de l'envoi sont perdues avec — donc un process qui n'appelle personne, et n'a donc aucune arête, ne publie pas de métrique d'arête plutôt qu'une métrique vide.

Un process sur le point de sortir appelle flush() ; stop() arrête le timer et envoie ce qui reste. onError est prévenu quand un lot n'a pas pu partir, et vaut le silence par défaut : une trace ne doit jamais casser un appel, et un collecteur qui lève est un exportateur cassé, pas un appel cassé.

Les métriques partent à chaque battement même si aucun span ne s'est terminé — une jauge qui cesse d'être publiée se lit « disparue », pas « au repos ».

Le câbler, une fois

Tout ce qui est sur cette page tient derrière un membre de la montée de l'app :

const app = await createApp({
  root,
  createContainer,
  extensions: [observability({ service: 'blog', otlp: 'http://localhost:4318' })],
});

up installe le middleware, l'accumulateur et les puits dans l'ordre qui comptetrace() ouvre le span que chaque ligne de log écrite dans l'appel portera, donc installés dans l'autre sens les lignes partent non corrélées. down les retire et vide ce qui est en tampon, à l'intérieur de dispose().

Ce relâchement n'est pas décoratif. onSpan et onLog ont toujours RENDU leur retrait et personne ne l'appelait : une app jetée continuait d'alimenter les puits de l'app qui l'avait remplacée, donc chaque métrique comptait deux fois. Invisible jusqu'à ce qu'on tourne l'anneau, et définitif ensuite.

Sans otlp, rien ne quitte le processus — et la topologie ci-dessous répond quand même.

Sous un hôte qui écrit lui-même le fichier de boot, une extension se nomme au lieu de se passer : c'est une fonction et le module écrit un fichier, donc les options voyagent à côté du nom, en données.

// nuxt.config.ts
fougere: {
  observability: { service: 'blog', otlp: 'http://localhost:4318' },
  calls: { panel: true },        // `false` éteint une clé ; absente, c'est pareil
}

Les clés sont déclarées, pas ouvertes : une par paquet d'extension, la clé étant à la fois le suffixe du paquet et le nom de l'export — calls, c'est import { calls } from '@fougere/calls'. Un enregistrement ouvert accepterait callz: {} en silence.

Le panneau de dev — ce que ce processus a dispatché

@fougere/calls est la seconde extension optionnelle, et elle observe au lieu de participer :

pnpm add -D @fougere/calls
extensions: [calls({ max: 500, panel: true })],

Elle s'abonne à app.observe — passif, et sa propre panne est avalée — donc elle voit ce qu'un middleware ne peut pas voir : un appel refusé avant tout handler (une route inconnue, une entité hébergée ailleurs, un appel arrivé pendant que la porte se vide), et la nature de route de chaque appel, si bien qu'une exécution locale et un saut vers un autre processus se lisent pareil. À côté des appels, elle tient un anneau borné de lignes de log, d'erreurs, et — quand @fougere/adapter-sql est là — des requêtes que chaque appel a émises.

Rien n'est stocké et aucun port n'est ouvert tant que panel ne le dit pas. L'anneau est servi sous rpc.calls, et le lecteur est fougere devtools via /_fougere/call, comme n'importe quel autre consommateur. Une app qui ne l'a jamais installée répond Unknown rpc operation 'calls'. It serves discover.

La forme du système, sur le fil

On peut demander à une app dans quelle forme elle est, sur le même fil que tout le reste :

await call({ entity: 'rpc', op: 'topology' });
// { fronds: [{ frond: 'shop', placement: 'local', entities: 2, doors: 2 },
//             { frond: 'catalog', placement: 'remote', entities: 0, doors: 0 }],
//   edges:  [{ from: 'shop', to: 'catalog', count: 12, errors: 1 }],
//   active: 0, since: 1755861234567 }

Rien là-dedans n'est déclaré. Un Frond est local parce que ce processus l'a scanné, remote parce qu'il a répondu à un appel que personne ici n'héberge — délibérément pas lu depuis remotes:, qui énonce une intention. Les deux divergent exactement quand quelque chose est mal configuré, c'est-à-dire quand un tableau de bord doit avoir raison.

rpc.topology est déclarée par ce paquet, pas par le cœur — donc une app qui ne l'a jamais installé refuse l'op par son nom, et ce refus est toute la dégradation dont un lecteur a besoin. Elle est à côté de rpc.discover : la carte dit ce qu'un processus HÉBERGE, celle-ci dit dans quelle forme il est.

Un entities: 0, doors: 0 sur un distant n'est pas un Frond vide — sa forme est publiée par le processus qui le possède, sous son propre nom de service.

@fougere/admin la lit sur sa page Topologie, qui est la même réponse dessinée : ce qui tourne ici, ce qui a répondu d'ailleurs, et chaque chemin d'appel observé entre les deux.

Ce qui est du signal, et ce qui n'en est pas

Les spans sont nommés pour le sujet plutôt que pour la lecture qu'on en fait aujourd'hui : une durée et un verdict sont matière à métrique autant qu'à trace, et un exportateur pour l'une ou l'autre se branche sur le même onSpan. C'est pour ça que les collecteurs sont une liste — les deux exportateurs lisent la même valeur au lieu que le middleware la produise deux fois.

L'appelant d'un appel séparé est établi, non prétendu : dès qu'un récepteur vérifie une enveloppe, invocation.caller nomme la Frond qui a signé, et absent veut dire que rien n'a été établi — jamais « pair inconnu ».

Rien ici ne le lit encore. Un graphe de services tiré d'un appelant vérifié serait un fait plutôt qu'une inférence depuis une adresse, et la dimension est bornée comme les autres, donc les deux s'emboîtent — mais le span ne porte aucun caller aujourd'hui, et cette page préfère le dire plutôt que de vous le faire chercher.

Sur un tableau de bord

Tout ce qui parle OTLP lit ceci tel quel. Deux collecteurs ont été essayés pendant l'écriture de cette page — Jaeger avec Prometheus, puis SigNoz — et passer de l'un à l'autre a changé deux chaînes d'URL : aucune instrumentation, aucun champ de span, aucune métrique. C'est ce que rapporte le fait de suivre le standard, et c'est le même marché que remotes: passe pour la topologie.

pnpm -C demos/observability dev      # trois Fronds, trois process
pnpm -C demos/observability load     # k6, par paliers
pnpm -C demos/observability signoz   # un collecteur, OTLP sur 4318, UI sur 8080

demos/observability, c'est cette page en marche : un cart.checkout qui atteint un catalog et une Frond shipping dans deux autres process, sous charge par paliers. Son code métier ne dit rien du fait d'être observé.

Ce qu'il faut y construire, dans l'ordre où un lecteur en a besoin :

panneaurequête
Débitsum(rate(fougere_operation_duration_seconds_count[1m]))
Taux d'erreurla même, sur fougere_outcome="error", divisée par le total
Latence p95histogram_quantile(0.95, sum by (le) (rate(…_bucket[1m])))
Saturationfougere_operations_active
Par Frondn'importe laquelle des précédentes, sum by (fougere_frond)
Graphe de servicesfougere_calls_total par fougere_from / fougere_to

Deux choses valent mieux qu'un cinquième graphique. Une heatmap de sum by (le) (rate(…_bucket[1m])) montre où vivent réellement les appels — un p95 masque une distribution bimodale, une heatmap ne le peut pas. Et une table des opérations triée par p95 : une seule opération lente à 20 % du trafic tire un quantile global loin de tout ce qui est réel, et seul le tri dit laquelle.

Prometheus renomme les métriques OTLP à l'ingestion : fougere.operation.duration en unité s devient fougere_operation_duration_seconds, et fougere.frond devient le label fougere_frond. Lisez les noms sur /api/v1/label/__name__/values plutôt que de les deviner.
Construit avec Fougere — ce site tourne sur le framework qu'il documente.