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 / parentId | où cette étape se situe dans l'arbre |
frond | quelle Frond possédait l'op — l'unité de déploiement, donc la première chose à regrouper |
entity / operation | ce qui a été appelé |
startedAt | un instant, en millisecondes epoch |
ms | combien de temps |
error | le 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.
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.
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.
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 compte —
trace() 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 :
| panneau | requête |
|---|---|
| Débit | sum(rate(fougere_operation_duration_seconds_count[1m])) |
| Taux d'erreur | la même, sur fougere_outcome="error", divisée par le total |
| Latence p95 | histogram_quantile(0.95, sum by (le) (rate(…_bucket[1m]))) |
| Saturation | fougere_operations_active |
| Par Frond | n'importe laquelle des précédentes, sum by (fougere_frond) |
| Graphe de services | fougere_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.
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.