Observabilité avec Micronaut : métriques, traces et le piège de la propagation de contexte

Le monitoring vous dit que quelque chose ne va pas. L’observabilité, c’est ce qui vous permet d’expliquer pourquoi, y compris pour des pannes que vous n’aviez jamais vues venir, sans redéployer une nouvelle version juste pour ajouter une ligne de log. Le test concret est simple. Quand une requête est lente ou fausse en production, êtes-vous capable de reconstituer ce qu’elle a fait, de bout en bout et à travers les services, à partir de la télémétrie que vous émettez déjà ? Si la réponse honnête passe par une connexion SSH sur une machine, vous n’avez pas encore d’observabilité.

Micronaut se prête bien à l’exercice. Comme l’injection de dépendances et l’essentiel de la mécanique du framework se font à la compilation, l’instrumentation est câblée au build au lieu d’être greffée à l’exécution par un agent Java. On y gagne un faible surcoût, un démarrage rapide, et une télémétrie qui survit à la compilation native GraalVM. Ce modèle a une contrepartie. Micronaut s’appuie largement sur l’exécution réactive et asynchrone, et dès que votre code franchit une frontière de thread que le framework n’a pas préparée, le contexte de trace disparaît. La majeure partie de cet article est la partie facile. La section sur la propagation de contexte est celle qui pose vraiment problème en production : c’est elle qui explique des traces parfaites en démo et pleines de trous sous charge.

La forme de la stack

Trois signaux, trois bibliothèques, un même jeu d’endpoints.

Micronaut expose la surface opérationnelle via son module management : /health, /metrics, /prometheus, /info, /loggers. Ce ne sont pas le produit. C’est par là que le produit se fait scraper, sonder et régler à chaud.

Métriques : Micrometer et un endpoint Prometheus

Ajoutez le module métriques de base et le registre Prometheus. Micrometer est une façade. Vous choisissez le registre qui correspond à votre backend, et le code d’instrumentation, lui, ne change jamais.

// build.gradle.kts
implementation("io.micronaut.micrometer:micronaut-micrometer-core")
implementation("io.micronaut.micrometer:micronaut-micrometer-registry-prometheus")
implementation("io.micronaut:micronaut-management")
# application.yml
micronaut:
  application:
    name: orders-api          # devient le tag 'application' sur chaque métrique
  metrics:
    enabled: true
    binders:                  # métriques JVM, système et pools, actives par défaut. Gardez-les.
      jvm.enabled: true
      web.enabled: true       # http.server.requests, http.client.requests
    export:
      prometheus:
        enabled: true
        step: PT1M
        descriptions: true

endpoints:
  prometheus:
    sensitive: false          # l'endpoint de scrape doit être joignable par Prometheus
  health:
    enabled: true
    details-visible: ANONYMOUS

D’emblée, cela vous donne déjà les métriques qui comptent pour la plupart des incidents. Il y a http.server.requests, un timer taggé par URI, méthode et statut, qui sert de base à tous les tableaux de bord de latence et de taux d’erreur que vous construirez. Il y a http.client.requests pour les appels sortants, le tas et le GC de la JVM, l’état des threads, et les jauges de pool de connexions si vous êtes sur HikariCP ou un client HTTP géré par Micronaut. Avant d’écrire la moindre métrique personnalisée, vous pouvez construire le tableau de bord RED (Rate, Errors, Duration, soit débit, erreurs et durée) entièrement à partir de http.server.requests.

Les métriques personnalisées se déclinent en deux variantes. Pour de l’instrumentation ponctuelle, injectez le MeterRegistry et enregistrez directement :

@Singleton
class OrderService {

    private final Counter ordersPlaced;
    private final MeterRegistry registry;

    OrderService(MeterRegistry registry) {
        this.registry = registry;
        this.ordersPlaced = registry.counter("orders.placed", "channel", "web");
    }

    void place(Order order) {
        // Timer.record mesure le temps écoulé autour du bloc
        registry.timer("orders.persist").record(() -> repository.save(order));
        ordersPlaced.increment();
    }
}

Pour le cas courant qui consiste à chronométrer une méthode, l’annotation est plus propre et se lit mieux à l’appel :

@Timed(value = "orders.checkout", description = "Temps de checkout de bout en bout", percentiles = {0.5, 0.95, 0.99})
public Receipt checkout(Cart cart) { ... }

Un point sur les percentiles piège à peu près tout le monde. Écrire percentiles = {0.95} calcule le quantile à l’intérieur de l’instance, et ce nombre n’est pas agrégeable entre réplicas. Faire la moyenne de deux p95 ne veut rien dire. Si vous tournez sur plus d’une instance, ce qui est votre cas, et que vous voulez un p95 sur l’ensemble de la flotte, publiez plutôt un histogramme avec histogram = true (ou publishPercentileHistogram) et laissez Prometheus calculer le quantile à partir des buckets. Les percentiles par instance sont bons pour un coup d’œil sur un seul pod. Ils commencent à mentir dès qu’on somme sur la flotte.

Garder la cardinalité des tags sous contrôle

Le moyen le plus rapide de mettre à terre votre backend de métriques, et de faire gonfler la facture, c’est une valeur de tag non bornée. Chaque combinaison distincte de valeurs de tag est une série temporelle distincte, stockée pour toujours. order.id, user.id, une URL brute avec ses paramètres de chemin, un message d’exception complet : tout cela est à forte cardinalité et n’a rien à faire dans un tag de métrique. Sa place est dans une trace ou un log, là où le détail par événement est tout l’intérêt.

La discipline est simple et vaut la peine d’être imposée en revue. Les tags de métriques doivent provenir d’un ensemble petit, borné, et connu d’avance. status (une poignée de codes HTTP), region (une liste fixe), outcome (succès ou échec), channel : très bien. Tout ce que vous ne pouvez pas énumérer à l’avance est un signal d’alerte. Micronaut aide ici en taggant http.server.requests avec l’URI templatée (/orders/{id}) plutôt qu’avec le chemin résolu, si bien que /orders/1 et /orders/2 se rabattent sur une seule série au lieu d’exploser en millions. Préservez cette propriété dans votre propre instrumentation.

Traçage avec OpenTelemetry

L’intégration de traçage de Micronaut parle nativement OpenTelemetry. Ajoutez l’instrumentation HTTP et un exporteur OTLP :

implementation("io.micronaut.tracing:micronaut-tracing-opentelemetry-http")
runtimeOnly("io.opentelemetry:opentelemetry-exporter-otlp")

La configuration suit les conventions du SDK OpenTelemetry, ce qui veut dire que les réglages adaptés à la production sont des variables d’environnement. Pas de rebuild pour repointer votre collecteur ou changer l’échantillonnage :

OTEL_SERVICE_NAME=orders-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1            # échantillonne 10 % des traces racines à la source
# application.yml : gardez les healthchecks et l'endpoint de scrape hors de vos traces
otel:
  exclusions:
    - /health
    - /prometheus

Une fois cela en place, les requêtes HTTP entrantes, les appels sortants via le client HTTP Micronaut, et la propagation W3C traceparent entre services se font tout seuls. Le framework ouvre un span serveur par requête, le rattache au traceparent entrant s’il y en a un, et injecte l’en-tête sur les appels sortants pour que le service suivant poursuive la même trace.

Vous ajoutez des spans métier là où l’instrumentation automatique ne voit rien, autour d’une opération métier qui mobilise plusieurs collaborateurs, ou d’un calcul coûteux que vous voulez attribuer :

@NewSpan("inventory.reserve")
public Reservation reserve(@SpanTag("sku") String sku, int quantity) {
    // un nouveau span enfant nommé "inventory.reserve", taggé avec le SKU,
    // imbriqué sous le span actuellement actif
}

@NewSpan démarre un span enfant. @SpanTag remonte un argument de méthode dessus en attribut. @ContinueSpan ajoute des tags au span courant sans en créer de nouveau. C’est exactement là que le détail à forte cardinalité doit aller : un SKU, un identifiant de commande, un tenant, tout ce sur quoi vous voudrez filtrer ou pivoter quand vous fixerez une trace lente. La règle est l’inverse de celle des métriques. Les traces sont par requête, donc les identifiants par requête ne sont pas seulement acceptables ici, ils en sont toute la raison d’être.

Un mot sur l’échantillonnage. parentbased_traceidratio est le défaut raisonnable. Il respecte une décision d’échantillonnage en amont, donc une trace est soit entièrement capturée, soit entièrement abandonnée sur tous les services, sans demi-traces, et il échantillonne les racines au ratio que vous choisissez. Commencez à 10 % sous trafic réel et ajustez ensuite. Si ce que vous voulez, c’est « toujours garder celles qui sont intéressantes », c’est de l’échantillonnage de queue (tail sampling), et il vit dans le collecteur plutôt que dans l’application. L’application ne peut pas savoir qu’une trace est intéressante avant que la trace soit terminée.

Le piège de la propagation de contexte

Voici l’endroit où les traces se cassent, sans erreur ni avertissement.

Un span et le MDC des logs vivent dans un contexte que Micronaut propage pour vous, mais uniquement à travers les frontières qu’il contrôle. Micronaut 4 modélise cela explicitement avec PropagatedContext. Tant que vous restez sur le thread de la requête, ou que vous utilisez un executor géré par Micronaut, le span actif et le MDC suivent votre code tout seuls. Les pipelines réactifs (Mono, Flux, Publisher) sont instrumentés, et @ExecuteOn(TaskExecutors.BLOCKING) délègue à un pool géré qui conserve le contexte.

Le piège, c’est toute frontière de thread que le framework n’a pas mise en place. Le coupable classique :

// CASSÉ : le span et le trace_id ne passent PAS dans ce thread
CompletableFuture.supplyAsync(() -> chargeCard(order));   // ForkJoinPool.commonPool

// Cassé aussi : un thread fait main, ou un ExecutorService brut que vous avez créé vous-même
new Thread(() -> sendReceipt(order)).start();

À l’intérieur de ce lambda, Span.current() est le span racine neutre (no-op), le MDC est vide, et tout le travail que vous y faites est silencieusement détaché de la trace. Pas d’erreur, pas d’avertissement. La trace s’arrête simplement à la frontière, et l’appel aval lent que vous cherchiez n’apparaît jamais. Si une trace Micronaut est mystérieusement incomplète, c’est presque toujours pour cette raison.

Le correctif consiste à capturer le contexte sur le thread appelant et à le rétablir à l’intérieur de la tâche :

PropagatedContext context = PropagatedContext.get();
CompletableFuture.supplyAsync(() -> {
    try (PropagatedContext.Scope ignored = context.propagate()) {
        return chargeCard(order);   // désormais imbriqué sous le bon span, MDC restauré
    }
});

Mieux encore, ne fabriquez pas vos executors à la main. Injectez-en un géré par Micronaut, ou annotez la méthode avec @ExecuteOn, pour que la propagation soit prise en charge, et gardez le propagate() manuel pour les cas réellement externes : un callback d’une bibliothèque tierce, un thread d’écoute de messages qui ne vous appartient pas. Le principe mérite d’être assimilé car il n’a rien de spécifique à Micronaut. Un contexte stocké en thread-local ne suit pas le travail à travers un saut de thread arbitraire, à moins que quelque chose le transporte explicitement. Tous les systèmes de traçage de la JVM ont une variante de cette règle.

Corréler les trois signaux

Métriques, traces et logs ne valent vraiment quelque chose ensemble que si on peut passer de l’un à l’autre. Deux réglages peu coûteux suffisent.

D’abord, mettez les identifiants de trace et de span dans chaque ligne de log. L’instrumentation Logback MDC d’OpenTelemetry remplit le MDC avec trace_id, span_id et trace_flags, et vous les référencez dans le pattern :

<!-- logback.xml -->
<dependency>io.opentelemetry.instrumentation:opentelemetry-logback-mdc-1.0</dependency>

<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
  <encoder>
    <pattern>%d{HH:mm:ss.SSS} %-5level [%X{trace_id:-},%X{span_id:-}] %logger{36} - %msg%n</pattern>
  </encoder>
</appender>

Désormais une ligne de log porte l’identifiant de trace, et une trace porte la fenêtre temporelle et le service, si bien que « cette erreur dans les logs » et « ce span dans l’UI de traçage » sont à un clic l’un de l’autre. Dans un pipeline de logs structurés (JSON), émettez trace_id comme un vrai champ et votre backend de logs pourra renvoyer directement vers la trace.

Ensuite, les exemplaires (exemplars). Un exemplar Prometheus attache un identifiant de trace à une observation unique dans un bucket d’histogramme, si bien que lorsqu’un tableau de bord de latence montre un pic dans le bucket du p99, vous pouvez sauter de ce bucket vers une trace réelle qui y a atterri. Le registre Prometheus de Micrometer émet des exemplaires dès que vous lui fournissez un SpanContextSupplier qui lit le span OpenTelemetry courant :

@Singleton
class OtelSpanContextSupplier implements SpanContextSupplier {
    public String getTraceId()  { return Span.current().getSpanContext().getTraceId(); }
    public String getSpanId()   { return Span.current().getSpanContext().getSpanId(); }
    public boolean isSampled()  { return Span.current().getSpanContext().isSampled(); }
}

Avec les exemplaires en place, un p99 qui monte se remonte en quelques minutes : pic, exemplar, trace, et le span lent exact.

Des healthchecks qui veulent dire quelque chose

Le /health de Micronaut agrège les beans HealthIndicator. Ceux fournis couvrent le disque, le pool JDBC et les clients configurés. Écrivez les vôtres pour les dépendances qui décident si votre service peut réellement faire son travail :

@Singleton
class PaymentGatewayHealth implements HealthIndicator {
    public Publisher<HealthResult> getResult() {
        return Mono.fromCallable(() -> gateway.ping()
            ? HealthResult.builder("payment-gateway", HealthStatus.UP).build()
            : HealthResult.builder("payment-gateway", HealthStatus.DOWN).build());
    }
}

Sous Kubernetes, une distinction compte : liveness contre readiness. La liveness demande « le processus est-il bloqué et faut-il le redémarrer », donc gardez-la peu coûteuse et sans dépendance, sinon une base de données instable déclenchera une boucle de redémarrages inutile. La readiness demande « cette instance doit-elle recevoir du trafic maintenant », et là il est correct d’échouer quand un service aval critique est injoignable, pour que le pod soit retiré du load balancer au lieu de servir des erreurs. Confondez les deux et une panne transitoire se transforme en boucle de crash auto-infligée. Micronaut expose les sondes readiness et liveness ; mappez-les sur les bonnes sondes Kubernetes et résistez à l’envie de rendre la liveness « exhaustive ».

Une note sur l’image native

Si vous compilez en image native GraalVM, ce qui est une grande partie de la raison pour laquelle on choisit Micronaut au départ, l’observabilité fonctionne pour l’essentiel telle quelle, justement parce que l’instrumentation est résolue au build et non via un agent à l’exécution. Les intégrations officielles Micrometer et OpenTelemetry embarquent les métadonnées d’atteignabilité dont le compilateur natif a besoin. Deux réserves quand même. Un binder de métriques ou un exporteur tiers qui s’appuie sur la réflexion peut nécessiter des indications dans votre reachability-metadata (ou @ReflectionConfig), et vous devriez préférer le scrape Prometheus en pull, qui colle mieux à un processus natif à démarrage court qu’une approche en push. C’est un avantage réel de Micronaut sur une configuration à base d’agent. Il n’y a pas d’agent à attacher, donc rien qui échoue à s’attacher à l’intérieur d’un binaire natif.

Quoi instrumenter, vraiment

N’instrumentez pas tout. Partez des questions qu’on vous posera pendant le prochain incident. La méthode RED vous donne la vue « traitement des requêtes » quasi gratuitement à partir de http.server.requests : Rate, Errors, Duration par endpoint. La méthode USE (Utilization, Saturation, Errors) couvre vos ressources, et les binders JVM et pools l’alimentent déjà. N’ajoutez des métriques personnalisées que pour les événements métier sur lesquels vous n’auriez sinon aucun signal : commandes passées, paiements refusés, profondeur de file. N’ajoutez des spans personnalisés qu’autour des opérations dont vous ne pouvez attribuer la latence d’aucune autre manière. Tout le reste est du bruit que vous paierez à stocker pour ensuite l’ignorer.

Le coût est modeste : quelques millisecondes de surcoût à un échantillonnage raisonnable, un peu de stockage, et la discipline de borner la cardinalité et de corréler les signaux. En échange, le prochain incident commence par une requête dans vos outils, pas par une session SSH. Sur Micronaut, tout cela est câblé à la compilation et passe en natif sans agent, à une condition près, celle sur laquelle cet article insiste depuis le début : respectez la frontière du contexte. Propagation correcte, signaux corrélés, cardinalité bornée, et la télémétrie tient le jour où vous en avez besoin.