Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Pourquoi Telemetry comme M2, et pas comme simple cross-cutting

La pratique habituelle de l'observabilité est de l'instrumenter à la fin. On code la feature, ça marche, et ensuite on ajoute un tracer.startSpan() autour de la méthode parce que l'oncall a besoin de voir. Cette approche est rationnelle à court terme mais s'effondre à l'échelle : les spans sont placés inconsistemment, les noms sont éparpillés (api.handle, b2b.quote.handle, quotehandle.span), les attributs varient d'un dev à l'autre, le sampling est défini au runtime sans cohérence avec les SLO du M1.

Promouvoir Telemetry au rang de M2 first-class force la cohérence par construction. Quand le transformer émet un CommandHandler, il sait qu'il faut un @Span autour de sa méthode handle(). Quand il émet une Slice, il sait que le Pipeline contient un TelemetryBehaviour. Les noms sont dérivés du M1 (openstore.${HandlerName}.handle). Le sampling est défini dans le M1 (la capability BuyNow déclare slo: { p95Ms: 800 } ; le transformer en dérive un @Sampled avec ratio adaptatif). C'est de la télémétrie générée, pas ajoutée.

Pour OpenStore, cinq Concepts suffisent. La liste est dans assets/m2-telemetry.ts.

Le diagramme des Concepts

Diagram

Lecture critique du diagramme. Quatre choses importantes. Premièrement, Span est le pivot — il est central, il propage le contexte, il est filtré par sampling, il produit metrics et logs. Tous les autres Concepts existent par rapport à lui. Deuxièmement, Metric et Logged sont produits par le Span — c'est l'inversion habituelle : on ne log puis trace, on trace, et le span émet des logs et metrics quand pertinent. C'est la philosophie OpenTelemetry post-2022 (depuis l'unification du modèle de signal). Troisièmement, Sampled est à côté — pas un sous-type de Span, mais une décision qui s'applique au Span. Cela permet une politique de sampling distincte par classe d'opération (sampler tous les checkout B2C à 100%, sampler les browse à 1%). Quatrièmement, TraceContext est racine — il y a un seul TraceContext par requête entrante, et tous les Spans qu'elle déclenche en héritent.

Span — la primitive de tracing

Un Span représente une opération nommée, datée, optionnellement attribuée. Pour OpenStore : openstore.b2b.request-quote.handle, openstore.b2c.buy-now.checkout, openstore.repository.quote.find-by-id. Le pattern de nommage est normalisé par construction — le transformer dérive le nom du Span depuis le nom du Concept M1 + le nom de la méthode décorée.

Le decorator @Span est un NoopMethodDecorator : il marque la méthode comme conforme au Concept telemetry.Span. Le M2C émet le code d'instrumentation autour du body : const span = tracer.startSpan('openstore.…'); try { return await this.handle(...); } finally { span.end(); }. L'humain ou l'IA ne s'en préoccupe jamais — c'est dérivé.

Metric — counter, gauge, histogram

Un Metric est un signal agrégé. Trois variantes principales : counter (monotone croissant — nombre d'événements), gauge (valeur instantanée — taille de la queue), histogram (distribution — latence p95). Pour OpenStore : openstore.quote.requested (counter), openstore.quote.request.duration_ms (histogram).

Le M2C dérive automatiquement des metrics standard pour chaque CommandHandler : un counter d'invocations, un histogram de latence, un counter d'échecs par classe d'erreur. Les metrics additionnelles (métier-spécifiques, comme openstore.quote.requested.with-consolidation) sont déclarées explicitement par le développeur et le M2C les wire dans le TelemetryBehaviour.

Logged et Sampled

@Logged est un decorator de méthode qui demande au M2C de produire un log structuré autour de l'invocation. Pas systématique — souvent contre-productif sur les hot paths. Activé sélectivement.

@Sampled est un decorator de classe (typiquement TraceContext) qui porte une politique de sampling. Pour OpenStore, le OpenStoreTelemetryContext déclare samplingRatio: 0.1 — un span sur dix est exporté. Pour les capabilities critiques (BuyNow avec son SLO p95), le transformer génère un override à 100% — l'observabilité doit être totale sur le chemin critique.

TraceContext — la racine

Un TraceContext porte les attributs globaux d'une requête : serviceName, tenantId (essentiel pour le filtrage multi-tenant des dashboards), traceId, spanId parent, baggage. Le M2C émet le code de propagation : injection dans les headers HTTP outbound, extraction depuis les headers HTTP inbound, propagation dans les contextes async (via AsyncLocalStorage Node.js).

Le TraceContext est unique par requête — il n'y en a pas deux pour une même invocation HTTP. C'est ce qui rend la corrélation possible entre les Spans d'un même flux.

Le M2Schema déclaratif

L'asset assets/m2-telemetry.ts :

export const TelemetrySchema: M2Schema = {
  id: 'telemetry',
  concepts: [/* Span, Metric, Logged, Sampled, TraceContext */],
  decorators: [/* @Span (method), @Logged (method), @Sampled (class), @TraceContext (class), @Metric (class) */],
  relations: [
    { id: 'context-propagates-span', kind: 'composes',   fromConceptId: 'TraceContext', toConceptId: 'Span',   cardinality: '1..*' },
    { id: 'span-produces-metric',    kind: 'contributes',fromConceptId: 'Span',         toConceptId: 'Metric', cardinality: '0..*' },
    { id: 'sampled-filters-span',    kind: 'associates', fromConceptId: 'Sampled',      toConceptId: 'Span',   cardinality: '1..*' },
    /* ... */
  ],
  constraints: [
    { id: 'span-must-be-named', conceptId: 'Span', check: () => [] },
  ],
};

Exemple d'usage M1

@Sampled
@TraceContext
export class OpenStoreTelemetryContext {
  readonly serviceName = 'openstore.api' as const;
  readonly samplingRatio = 0.1 as const;
}

@Metric
export class QuoteRequestCounter {
  readonly name = 'openstore.quote.requested' as const;
  readonly kind = 'counter' as const;
}

export class RequestQuoteHandlerWithSpan {
  @Span
  @Logged
  async handle(_command: { tenantId: string; customerId: string }): Promise<{ quoteId: string }> {
    throw new Error('@impl');
  }
}

Le composé @Sampled @TraceContext class OpenStoreTelemetryContext montre une double décoration : la classe est à la fois un TraceContext (racine) et l'objet sur lequel s'applique la politique de sampling. C'est compositionnel : le M2 Telemetry assume que Sampled peut décorer toute classe portant déjà TraceContext.

La CrossRelation principale : Span enveloppe CommandHandler

Le M2 Telemetry, seul, ne dit pas comment les Spans sont rattachés aux opérations métier. C'est la CrossRelation CQRS.CommandHandler ─wrappedBy→ Telemetry.Span (Part 08) qui le dit. Sans cette CrossRelation, on aurait deux M2 disjoints : un côté métier, un côté observabilité. C'est précisément ce que la pratique habituelle fait, et c'est précisément le problème — l'observabilité reste séparée du métier, donc inconsistante.

Avec la CrossRelation, le M3 garantit qu'à chaque émission de CommandHandler corresponde une émission de Span, avec un nom dérivé canoniquement. La traçabilité bout-en-bout n'est pas un bonus, c'est structurel.

Ce qu'on assume de ne pas reprendre

  • Profiling continu (Pyroscope, Parca) — un autre signal, qui mériterait un M2 séparé Profiling. Pas dans OpenStore.
  • RUM (Real User Monitoring) — concerne le front office côté navigateur, hors périmètre des Spans serveur de ce M2.
  • Synthetic monitoring — orchestration externe (Datadog, Grafana), pas un Concept à embarquer dans le métamodèle.

Pour aller plus loin

⬇ Download