Aller au contenu

Observer la taverne en pleine effervescence - observabilité avec Quarkus

Quand la taverne déborde d’activité, il devient vital de comprendre ce qui s’y passe sans quitter le comptoir. Métriques, traces et logs offrent une vision claire pour diagnostiquer, mesurer et garder le contrôle, même lorsque le tumulte s’installe.

Observé la taverne en pleine effervescence

La taverne est pleine.
Les commandes s'enchaînent.
Les pièces tintent.
Et pourtant, une question demeure :

comment savoir si tout se passe bien sans être derrière le comptoir ?

Cet article s'adresse aux devs qui construisent l'API, aux profils ops qui doivent l'exploiter, et aux fullstack qui font les deux.
L'objectif est d'avoir un socle simple, lisible, et suffisant pour investiguer en production.

Dans ce module, on pose les bases d'une observabilité propre et exploitable :

  • /q/metrics et /q/health via Micrometer et SmallRye Health
  • OpenTelemetry pour le tracing distribué
  • Corrélation des logs avec traceId et spanId

L'objectif n'est pas d'ajouter des dépendances pour le plaisir, mais de construire un socle minimal, lisible et réutilisable dans d'autres services.

Où consulter quoi

Pour éviter de chercher au mauvais endroit :

  • Métriques : endpoint /q/metrics (scrapé par Prometheus en prod).
  • Traces : UI Jaeger via le collector.
  • Logs : console ou pipeline de logs (ELK, Loki, etc.).
voici à quoi ressemble /q/metrics

Schéma du flux

notre flux

Lexique express

Pour éviter les confusions, voici un mini lexique de la taverne :

  • Métrique : un chiffre agrégé dans le temps (ex : commandes/minute, latence p95).
    C'est le compteur qui dit « combien ».
  • Trace : l'histoire complète d'une requête, du comptoir à la cuisine.
    C'est la piste qui dit « où ça bloque ».
  • Span : un chapitre d'une trace (ex : préparation d'une boisson).
    C'est un morceau de narration.
  • Log corrélé : un log qui porte traceId et spanId.
    C'est la note du tavernier qui se relie à l'enquête.

Les dépendances nécessaires

Avant d'installer des tonneaux, il faut vérifier que la cave existe.
Ici, la cave, ce sont les extensions Quarkus qui activent santé, métriques et traces.

Le projet est déjà structuré avec un pom parent, donc on reste minimal : uniquement ce qui active les features.

<dependencies>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-micrometer-registry-prometheus</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-smallrye-health</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-opentelemetry</artifactId>
    </dependency>
</dependencies>

extrait du pom

Avec ces trois extensions, Quarkus expose /q/metrics, /q/health et génère des traces OTEL sans bricolage supplémentaire.

/q/health

La santé applicative

Quand la salle est remplie, ce qui compte d'abord, c'est de savoir si la taverne est prête à servir.
Le readiness check joue ce rôle : un signal simple pour un orchestrateur, un load balancer, ou un opérateur.

Dans notre module, on crée un check explicite :

@Readiness
@ApplicationScoped
public class TavernReadinessCheck implements HealthCheck {

    @Override
    public HealthCheckResponse call() {
        return HealthCheckResponse.up("tavern-readiness");
    }
}

le check de santé

Ce check remplit deux fonctions :

  • Afficher un statut lisible dans /q/health/ready
  • Donner un nom clair au diagnostic

Résultat : un incident en production commence par un réflexe simple, lire la readiness et vérifier si la taverne est prête ou non à servir.

La métrique métier : une approche factorisée

Les métriques techniques sont utiles, mais la vraie valeur est dans les métriques métier : nombre de commandes, temps de préparation, ratio succès.
Le problème, c'est la duplication : chaque service finit par réinventer les mêmes compteurs et timers.

Ici, on choisit une approche plus propre : un interceptor CDI qui standardise la création des métriques.
Le métier ne porte que l'intention, l'infrastructure gère l'exécution.

L'annotation métier

On définit une annotation qui sert de contrat :

@InterceptorBinding
@Retention(RUNTIME)
@Target({METHOD, TYPE})
public @interface BusinessTimed {

    @Nonbinding
    String value();

    @Nonbinding
    String description() default "";

    @Nonbinding
    MeterTag[] tags() default {};
}

on créer notre propre annotation

Cette annotation permet :

  • De nommer la métrique métier à générer
  • D'ajouter un texte explicite pour la documentation
  • De préparer les tags quand on en aura besoin

Elle place l'intention au bon endroit : sur le code métier, sans le polluer.

L'interceptor

L'interceptor réalise le travail : créer un compteur, un timer, et standardiser la logique.

@BusinessTimed("")
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class BusinessTimedInterceptor {

    private static final String COUNTER_SUFFIX = ".count";

    @Inject
    MeterRegistry meterRegistry;

    @AroundInvoke
    Object track(InvocationContext context) throws Exception {
        BusinessTimed timed = resolveAnnotation(context);
        if (timed == null || timed.value().isBlank()) {
            return context.proceed();
        }

        List<Tag> tags = toTags(timed.tags());
        Counter counter = Counter.builder(timed.value() + COUNTER_SUFFIX)
            .description(description(timed.description(), "Number of calls"))
            .tags(tags)
            .register(meterRegistry);
        Timer timer = Timer.builder(timed.value())
            .description(description(timed.description(), "Execution time"))
            .publishPercentileHistogram()
            .tags(tags)
            .register(meterRegistry);

        counter.increment();
        return timer.recordCallable(context::proceed);
    }

    private BusinessTimed resolveAnnotation(InvocationContext context) {
        BusinessTimed timed = context.getMethod().getAnnotation(BusinessTimed.class);
        if (timed != null) {
            return timed;
        }
        return context.getTarget().getClass().getAnnotation(BusinessTimed.class);
    }

    private List<Tag> toTags(MeterTag[] meterTags) {
        List<Tag> tags = new ArrayList<>();
        if (meterTags == null) {
            return tags;
        }
        for (MeterTag meterTag : meterTags) {
            tags.add(Tag.of(meterTag.key(), meterTag.value()));
        }
        return tags;
    }

    private String description(String provided, String fallback) {
        return (provided == null || provided.isBlank()) ? fallback : provided;
    }
}

l'intercepteur

Ce bloc garantit trois choses :

  • Un compteur *.count généré automatiquement
  • Un timer * exposé avec histogramme
  • Une logique unique pour tous les services

Ainsi, tu peux faire évoluer la stratégie de métriques sans toucher au métier.

L'usage côté service

Dans le service, l'usage devient trivial et lisible :

@BusinessTimed(value = "tavern.order", description = "Drink order processing")
public OrderResponse placeOrder(String drink) {
    String finalDrink = (drink == null || drink.isBlank()) ? "ale" : drink;
    return brewDrink(finalDrink);
}

Le service ne gère plus les compteurs.
Il exprime seulement quoi mesurer, pas comment.

Exemple concret de métrique exposée

Dans les tests, on vérifie que /q/metrics expose bien la métrique issue du timer :

.body(containsString("tavern_order_seconds_count"))

Interprétation : cette métrique donne le nombre d'exécutions du timer tavern.order.
Elle permet de répondre simplement à la question : combien de commandes ont été préparées ?

ici après 3 appels

Bonnes pratiques de nommage des métriques

Un bon nom de métrique évite bien des confusions en production.
Voici les règles simples qui tiennent bien dans le temps :

  • Snake case côté exposition Prometheus (les points deviennent des underscores).
  • Suffixes clairs : _seconds pour les durées, _total pour les compteurs.
  • Nom métier avant technique : tavern.order est plus lisible que service.timer.order.
  • Unité explicite : si tu mesures une durée, assure-toi que le suffixe est en secondes.

Tracing distribué

Les métriques répondent à la question combien.
Le tracing répond à la question et pourquoi.

On ajoute un span métier autour d'une étape significative :

@WithSpan("brew-drink")
OrderResponse brewDrink(String drink) {
    Span.current().setAttribute("tavern.drink", drink);
    simulateBrew();
    LOG.infof("Order served drink=%s", drink);
    return new OrderResponse(drink, "SERVED", Instant.now());
}

Ce span apporte trois bénéfices immédiats :

  • Un nom métier lisible dans Jaeger
  • Un attribut qui enrichit la trace (tavern.drink)
  • Une granularité utile sans surcharger le code

Dans une enquête de performance, ce type de span fait toute la différence.

Stratégie d'échantillonnage OTEL

Tout tracer en permanence peut coûter cher en volumétrie et en performance.
En développement, on peut tout garder. En production, on échantillonne.

Dans application.properties, on définit un ratio de traces :

quarkus.otel.exporter.otlp.endpoint=http://localhost:4317
quarkus.otel.traces.sampler=parentbased_traceidratio
quarkus.otel.traces.sampler.arg=0.2

les propriété pour l'échantillonnage

Ici, environ 20 % des traces sont conservées, tout en respectant le contexte parent.
C'est un compromis efficace pour garder de la visibilité sans exploser les volumes.

Corrélation des logs

La trace explique la chronologie.
Le log explique le contexte.

Pour les relier, on injecte traceId et spanId dans le MDC :

@Provider
@Priority(Priorities.USER)
public class TraceLogFilter implements ContainerRequestFilter, ContainerResponseFilter {

    @Override
    public void filter(ContainerRequestContext requestContext) {
        SpanContext spanContext = Span.current().getSpanContext();
        if (spanContext.isValid()) {
            MDC.put("traceId", spanContext.getTraceId());
            MDC.put("spanId", spanContext.getSpanId());
        }
    }

    @Override
    public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
        MDC.remove("traceId");
        MDC.remove("spanId");
    }
}

le filtre des logs

Puis on adapte le format de log :

quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p traceId=%X{traceId} spanId=%X{spanId} [%c{2.}] (%t) %s%e%n

propriété pour le format de log

Résultat : un log suffit pour retrouver la trace complète dans Jaeger.
C'est un vrai gain dans les investigations de prod.

Coût et volumétrie

Tout instrumenter a un coût.
L'objectif n'est pas de tout mesurer, mais de mesurer ce qui compte.

  • Métriques : coût faible, volume faible, très rentables.
  • Traces : coût moyen, volume élevé si on trace tout.
  • Logs : coût variable, attention aux logs trop verbeux.

Une bonne stratégie consiste à :

  • garder les métriques en continu,
  • échantillonner les traces en production,
  • corréler les logs sans surcharger le niveau de détail.

Le collector et Jaeger

Pour garder une stack légère, on démarre un collector OTEL minimal et une UI Jaeger.
L'idée est d'avoir une UI de trace sans dépendance lourde.

Compose :

services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.113.0
    command: ["--config=/etc/otel-collector-config.yml"]
    volumes:
      - ./otel-collector-config.yml:/etc/otel-collector-config.yml:ro
    ports:
      - "4317:4317"
      - "4318:4318"
  jaeger:
    image: jaegertracing/all-in-one:1.60
    environment:
      - COLLECTOR_OTLP_ENABLED=true
    ports:
      - "16686:16686"
      - "4319:4317"

Collector :

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

exporters:
  debug:
    verbosity: detailed
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true

service:
  telemetry:
    logs:
      level: debug
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug, otlp/jaeger]

On garde un export debug pour vérifier les traces, et un export OTLP vers Jaeger pour l'UI.

Erreurs classiques et diagnostics

Même avec une base propre, l'observabilité peut échouer pour des raisons simples.
Voici les pièges rencontrés le plus souvent et comment les reconnaître.

Export OTLP en échec

Symptôme : logs Quarkus avec Failed to export TraceRequestMarshaler ou Connection was closed.
Cause fréquente : le collector n'écoute pas sur le bon port ou le protocole attendu.

Ce qu'il faut vérifier :

  • Le collector écoute bien sur 0.0.0.0:4317 (gRPC) et 0.0.0.0:4318 (HTTP)
  • La propriété quarkus.otel.exporter.otlp.endpoint pointe vers le bon port
  • Le container expose bien les ports vers l'hôte

Conflit de port

Symptôme : Bind for 0.0.0.0:4317 failed: port is already allocated.
Cause : un autre service occupe déjà le port OTLP.

Solution :

  • Changer le port exposé côté host, par exemple 4319:4317 pour Jaeger
  • Laisser le collector communiquer avec Jaeger via le réseau Docker interne

Jaeger vide malgré des traces

Symptôme : les traces apparaissent dans les logs du collector mais pas dans l'UI Jaeger.
Cause : exporter Jaeger mal configuré ou absence d'OTLP côté Jaeger.

Solution :

  • Vérifier COLLECTOR_OTLP_ENABLED=true côté Jaeger
  • Vérifier l'exporter otlp/jaeger dans le collector

@WithSpan sans effet

Symptôme : erreur Quarkus indiquant que l'interception ne s'applique pas.
Cause : la méthode est private.

Solution :

  • Rendre la méthode package-private ou public
  • Éviter les méthodes private si elles sont interceptées

Metrics présentes mais non métier

Symptôme : /q/metrics fonctionne, mais pas de métriques métier.
Cause : l'annotation @BusinessTimed n'est pas posée ou mal nommée.

Solution :

  • Vérifier l'annotation côté service
  • Vérifier le nom de métrique attendu (tavern.order)

Cette section peut devenir un vrai guide de terrain, n'hésite pas à l'enrichir avec tes incidents réels.

Comparatif rapide avec Spring Boot

Si tu viens de Spring Boot, la logique est la même, mais les points d'entrée changent :

  • Actuator devient /q/metrics et /q/health
  • Micrometer reste au cœur des métriques
  • Sleuth/OTEL devient l'extension quarkus-opentelemetry
  • Log correlation se fait via MDC et format de log, pas via auto-magie

En clair : Quarkus propose les mêmes briques, mais les expose de façon plus explicite et plus légère.

Ce que l'on gagne vraiment en production

Dans une taverne calme, tout paraît simple.
Mais quand la salle se remplit, ce sont les détails qui font tenir la maison.

Ce socle d'observabilité apporte des bénéfices très concrets :

  • Voir immédiatement si la taverne est prête à ouvrir
  • Mesurer l'activité réelle sans lire un seul log
  • Comprendre où se perd le temps dans une commande
  • Relier une erreur à une trace, puis à une ligne de log

Autrement dit : moins d'aveuglement, moins d'intuition, plus de faits.

Et après : pistes d’amélioration

Ce socle couvre l’essentiel pour comprendre ce qui se passe en production.
Mais selon la taille de la taverne et l’affluence, plusieurs évolutions peuvent être envisagées :

  • Dashboards dédiés avec Grafana pour suivre l’activité métier en temps réel (commandes, latence, erreurs).
  • Centralisation des métriques avec Prometheus et mise en place de règles d’alerte simples.
  • Alerting (latence, taux d’erreur, indisponibilité) pour être prévenu avant que la situation ne dégénère.
  • Sampling adaptatif côté OpenTelemetry pour affiner la collecte des traces selon la charge.
  • Enrichissement des spans avec davantage d’attributs métier pour faciliter les investigations.
  • Structuration des logs (JSON) pour une meilleure exploitation dans des outils comme Elastic Stack ou Grafana Loki.
  • Dashboards orientés produit (et non techniques) pour rapprocher observabilité et usage réel.
  • SLO / SLA : définir ce qu’est un service “acceptable” (latence, disponibilité) et le mesurer dans le temps.

Ces pistes ne sont pas à implémenter d’un bloc.
Elles s’ajoutent progressivement, en fonction des besoins réels et des incidents rencontrés.

Conclusion

Dans une taverne, ce qui compte n'est pas seulement le goût de la bière,
mais la capacité à servir vite, à comprendre ce qui bloque, et à expliquer ce qui s'est passé.

Ce socle d'observabilité Quarkus apporte exactement cela :

  • La santé pour savoir si la taverne peut ouvrir
  • Les métriques métier pour suivre l'activité réelle
  • Les traces pour diagnostiquer sans tâtonner
  • Les logs corrélés pour raconter l'histoire complète

Mais surtout, il pose une base saine et durable.

Car en production, les problèmes ne préviennent pas.
Ils apparaissent sous charge, dans le bruit, au pire moment.

Sans observabilité, on cherche.
Avec une observabilité maîtrisée, on comprend.

C’est cette différence qui permet :

  • de diagnostiquer rapidement plutôt que supposer,
  • de corriger précisément plutôt que bricoler,
  • et d’améliorer continuellement plutôt que subir.

Ce que tu as construit ici n’est pas un empilement d’outils,
mais une manière de travailler : mesurer, comprendre, décider.

Et quand la taverne se remplit,
c’est ce qui fait la différence entre tenir la soirée…
et tenir la maison dans la durée.


Tout le code relatif à cet article peut être trouvé ici pour plus de détail :

GitHub - ErwanLT/quarkus-demo: Demo project for quarkus possibility
Demo project for quarkus possibility. Contribute to ErwanLT/quarkus-demo development by creating an account on GitHub.

Dernier