Lab 2 — Instrumentation zero-code d'un micro-service Java

L’équipe Java a livré review-service, un micro-service Spring Boot de gestion des avis produits (API REST + PostgreSQL). Il n’émet aucune télémétrie. Dans ce lab, vous allez le rendre observable sans toucher à son code, de deux manières :

  • Partie 1 : avec l’agent Java OpenTelemetry (-javaagent) ;
  • Partie 2 : avec le Spring Boot Starter OpenTelemetry (dépendance Maven).

Le code du service est dans apps/review-service/. Le script ./scripts/deploy.sh fait tout le cycle : docker build → kind load → kubectl apply → attente du rollout. Pas de registry d’images : l’image est chargée directement dans le cluster Kind.

⚠️ Chaque build produit un tag d’image unique (<profil>-<user>-<horodatage>) : Kind ne re-télécharge jamais un tag qu’il connaît déjà, un tag fixe comme latest ne serait donc jamais mis à jour.

Prérequis

  • Lab 1 terminé : la stack tourne dans le namespace otel-demo.
  • Les accès ouverts : ./scripts/open-ui.sh (une fois, il rend la main). Il expose les UIs et le review-service, et rouvre les accès tout seul à chaque redéploiement.
  • Les variables de la formation chargées dans votre shell. Les commandes ci-dessous accèdent au service en direct (pas via le frontend-proxy), sur le port local $APP_PORT. Sourcez scripts/env.sh une fois en début de session :
. ./scripts/env.sh   # exporte APP_PORT, UI_PORT, PROM_PORT... et PF_HOST
echo "$APP_PORT"     # 8090, pour tout le monde

$PF_HOST est le nom par lequel vous joignez vos services : localhost sur un poste individuel, mais sur le serveur partagé votre compte a le sien (student3 → localhost3), ce qui permet à tous les participants d’utiliser les mêmes ports sans se marcher dessus. Gardez donc $PF_HOST dans les URLs plutôt que localhost en dur.

  • Le load generator en marche. Vous l’avez arrêté au Lab 1 pour isoler votre commande ; ce lab a besoin de son trafic de fond, sans quoi les services de la démo (ad, checkout…) n’émettent plus rien à comparer. Vérifiez-le :
curl -s http://$PF_HOST:$UI_PORT/loadgen/stats/requests |
  grep -o '"state": "[a-z]*"\|"user_count": [0-9]*'

La réponse attendue, en deux lignes :

"state": "running"
"user_count": 10

Sinon, ouvrez l’UI du load generator (http://$PF_HOST:$UI_PORT/loadgen/), cliquez New en haut à droite, puis Start dans la fenêtre Start new load test (laissez les valeurs proposées).

🔧 Si l’UI reste bloquée sur spawning avec 0 utilisateur, le processus Locust est planté — ça arrive. Cliquer Start ne suffit pas : il faut relancer le pod.

kubectl logs -n otel-demo deploy/load-generator --tail=20     # confirmer le plantage
kubectl rollout restart deployment/load-generator -n otel-demo
kubectl rollout status deployment/load-generator -n otel-demo

Le trafic redémarre tout seul, sans rien cliquer : le déploiement porte LOCUST_AUTOSTART=true et LOCUST_USERS=10. Comptez une trentaine de secondes, puis revérifiez avec le curl ci-dessus.

Étapes

Partie 1 — L’agent Java

  1. Déployer le service tel que livré (non instrumenté) :
./scripts/deploy.sh

Le script construit l’image, la charge dans Kind et applique apps/review-service/k8s/review-service.yaml dans le namespace otel-demo.

  1. Générer du trafic vers l’API :
. ./scripts/env.sh   # si ce n'est pas déjà fait dans ce terminal
curl http://$PF_HOST:$APP_PORT/api/reviews
curl http://$PF_HOST:$APP_PORT/api/reviews/product/OLJCESPC7Z
curl -X POST http://$PF_HOST:$APP_PORT/api/reviews \
  -H "Content-Type: application/json" \
  -d '{"productId": "OLJCESPC7Z", "rating": 5, "comment": "Superbe lunette !", "userEmail": "jean.dupont@example.com", "userName": "Jean Dupont"}'

💡 Bonus — la page web du service. Le même accès donne une petite page à la racine, http://$PF_HOST:$APP_PORT/ : elle liste les avis et permet d’en poster un d’un clic, sans JSON à taper. Vous vous en servirez au Lab 3. Pour ce lab, restez-en aux curl ci-dessus : la page ajoute ses propres requêtes dans Jaeger, et les traces que vous allez comparer se lisent mieux sans elles.

  1. Chercher review-service dans Jaeger. Que constatez-vous ?
Réponse

Rien. Le service répond, il écrit en base… mais il est invisible : aucune trace, car aucune instrumentation n’est active. C’est l’état de la plupart des applications avant OpenTelemetry.

  1. Activer l’agent Java.

L’image contient déjà l’agent (/otel/opentelemetry-javaagent.jar), téléchargé au build — regardez le Dockerfile. Il ne manque que le flag JVM. Éditez apps/review-service/k8s/review-service.yaml et décommentez :

            - name: JAVA_TOOL_OPTIONS
              value: "-javaagent:/otel/opentelemetry-javaagent.jar"

Observez aussi les variables OTEL_* déjà présentes dans le manifest. À quoi sert chacune ?

Réponse
  • OTEL_SERVICE_NAME=review-service — le nom sous lequel le service apparaîtra dans Jaeger/Grafana (convention sémantique service.name) ;
  • OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 — où envoyer la télémétrie : le collecteur de la démo, en OTLP ;
  • OTEL_EXPORTER_OTLP_PROTOCOL=grpc — variante gRPC du protocole OTLP (port 4317 ; le port 4318 sert la variante HTTP) ;
  • OTEL_RESOURCE_ATTRIBUTES=service.namespace=otel-demo — attributs de ressource additionnels attachés à toute la télémétrie. service.namespace regroupe logiquement les services : couplé à service.name, il range review-service avec les autres services de la démo dans Jaeger/Grafana.

⚠️ Pourquoi le fixer à la main, ce n’est pas déductible ? Attention à ne pas confondre service.namespace (OTel) et k8s.namespace.name (Kubernetes) :

  • l’agent/SDK ne voit que le processus, pas Kubernetes — il ignore le namespace K8s du pod, donc il ne peut rien déduire seul ;
  • ce qui peut être injecté automatiquement, c’est k8s.namespace.name (via le k8sattributes processor du collecteur, l’OTel Operator, ou la Downward API fieldRef: metadata.namespace). Mais c’est un attribut d’infrastructure ;
  • service.namespace est un attribut logique / métier : un choix de regroupement, pas un fait de l’infra. Ici il vaut otel-demo par coïncidence (même nom que le namespace K8s), mais rien ne les oblige à coïncider.

D’où l’approche explicite d’une ligne dans le manifest — sans dépendre du collecteur ni de l’Operator.

JAVA_TOOL_OPTIONS est lue par la JVM au démarrage : c’est le moyen standard d’injecter -javaagent sans modifier ni le code ni la commande de lancement.

  1. Redéployer et re-générer du trafic :
. ./scripts/env.sh   # si ce n'est pas déjà fait dans ce terminal
./scripts/deploy.sh
curl http://$PF_HOST:$APP_PORT/api/reviews
  1. Retrouver la trace dans Jaeger.

Cherchez le service review-service. Ouvrez une trace de GET /api/reviews. Quels spans l’agent a-t-il créés automatiquement, sans une ligne de code ?

Notez le nombre de spans de cette trace (Jaeger l’affiche en haut, Total Spans) : vous le comparerez à celui du starter en partie 2.

⏳ Laissez-lui une quinzaine de secondes. Le pod vient d’être remplacé, l’accès se rouvre sur le nouveau, et le SDK exporte par lots. Une requête envoyée dans la foulée du deploy.sh peut n’apparaître qu’après — voire pas du tout si elle a été servie par l’ancien pod. Si review-service est bien dans la liste mais que GET /api/reviews manque, ne changez rien : refaites un curl, attendez, et regardez à nouveau.

Réponse

Cinq spans, parfois six — et surtout quatre bibliothèques différentes, que l’attribut otel.scope.name de chaque span vous nomme :

GET /api/reviews                    io.opentelemetry.tomcat-10.0        <- le serveur HTTP
ReviewRepository.findAll            io.opentelemetry.spring-data-1.8    <- le dépôt Spring Data
SELECT fr.k8sschool.reviews.Review  io.opentelemetry.hibernate-6.0      <- la requête JPA
SELECT otel                         io.opentelemetry.jdbc               <- le SQL réellement exécuté
Transaction.commit                  io.opentelemetry.hibernate-6.0      <- la validation

Le span serveur porte http.request.method, http.route, http.response.status_code… ; les spans JDBC portent db.system, db.name, db.statement, db.sql.table. Un sixième span apparaît par intermittence, l’acquisition d’une connexion dans le pool.

Personne n’a écrit une ligne pour cela : l’agent instrumente par manipulation de bytecode les bibliothèques qu’il reconnaît au chargement (Tomcat, Spring, Hibernate, JDBC, Kafka, clients HTTP… plus de 100 frameworks). Retenez ce niveau de détail : la partie 2 le compare à celui du starter.

💡 Deux surprises dans le waterfall.

Un span s’appelle otel. Ce n’est pas OpenTelemetry qui parle de lui-même : otel est le nom de la base de données (DB_NAME dans le manifest). La convention de nommage JDBC est {opération} {base} — d’où SELECT otel. Ce span-là n’a ni db.operation ni db.statement : l’instrumentation n’a pas su classer l’opération (acquisition de connexion, commit), il ne reste donc que le nom de la base.

Le nom du span SQL a été réécrit en route. Dépliez-le : il porte un attribut unsanitized_span_name = SELECT otel.reviews. Son nom d’origine mentionnait la table, et c’est le collecteur qui l’a raccourci, avec une règle OTTL que vous lirez au Lab 3. La normalisation des noms de spans sert à contenir la cardinalité — mais elle vous fait perdre ici une information utile. Une décision de plateforme, visible dans vos traces avant même que vous ne sachiez qu’elle existe.

Partie 2 — Le Spring Boot Starter

Contrairement à l’agent (un simple flag JVM au runtime), le Starter est une dépendance compilée dans l’application : il faut donc rebâtir l’image. Avant de lancer la commande, suivez la chaîne d’activation — de l’option deploy.sh jusqu’au pom.xml.

  1. Comprendre comment le profil starter s’active. Ouvrez scripts/deploy.sh, apps/review-service/Dockerfile et apps/review-service/pom.xml, puis répondez :

    a. Dans pom.xml, qu’ajoute le profil Maven starter que le profil default n’a pas ?

    b. Dans le Dockerfile, comment le profil est-il choisi au moment du build ? Quelle commande Maven le consomme ?

    c. Dans deploy.sh, comment l’option -p starter parvient-elle jusqu’au Dockerfile ?

Réponse

La chaîne d’activation, du plus haut au plus bas niveau :

./scripts/deploy.sh -p starter
 └─ docker build --build-arg MAVEN_PROFILE=starter ...
     └─ Dockerfile : ARG MAVEN_PROFILE → RUN mvn -P ${MAVEN_PROFILE} -DskipTests package
         └─ pom.xml : le profil <id>starter</id> ajoute la dépendance
                      opentelemetry-spring-boot-starter (+ le BOM qui fixe sa version)
  • pom.xml — le profil default (activeByDefault) n’ajoute rien : l’appli n’appelle que l’API OTel, qui reste no-op tant qu’aucun SDK n’est installé. Le opentelemetry-sdk présent dans le pom sert à compiler le masquage PII du Lab 8 et ne s’installe pas de lui-même. Le profil starter ajoute la dépendance opentelemetry-spring-boot-starter et importe le opentelemetry-instrumentation-bom (qui aligne les versions OTel). C’est cette dépendance qui embarque le SDK + les auto-configurations Spring.
  • Dockerfile — ARG MAVEN_PROFILE=default déclare la variable de build, consommée à l’étape de compilation : RUN mvn -P ${MAVEN_PROFILE} -DskipTests package. Changer le profil change donc le .jar produit → rebuild obligatoire (l’agent, lui, ne touchait pas au build).
  • deploy.sh — -p starter positionne PROFILE=starter, passé à Docker via docker build --build-arg MAVEN_PROFILE=starter. Cette même valeur sert de tag d’image (starter-<user>-<horodatage>), pour que Kind recharge bien la nouvelle image.

À retenir : l’agent est toujours présent dans l’image mais inactif sans JAVA_TOOL_OPTIONS ; le Starter, lui, est actif dès qu’il est compilé. D’où la règle : jamais les deux ensemble.

  1. Rebâtir avec le Starter.

🛑 Avant la commande : re-commentez JAVA_TOOL_OPTIONS dans apps/review-service/k8s/review-service.yaml. Agent et starter sont deux alternatives, pas deux compléments.

Rien ne vous préviendra si vous l’oubliez : l’application démarre et les traces arrivent. Elles ne sont pas dupliquées — mesuré : avec les deux en place, on obtient 6 spans, pas 9 — mais ce sont celles de l’agent : il détecte le starter et le neutralise. Vous croiriez donc observer le starter en regardant l’agent, et la comparaison de l’étape 9 tomberait à plat.

Deux façons de vérifier après coup. Côté pod : kubectl logs -n otel-demo deploy/review-service | grep javaagent — s’il affiche opentelemetry-javaagent - version, l’agent est encore là. Côté trace : le span serveur porte otel.scope.name = io.opentelemetry.tomcat-10.0 avec l’agent, et io.opentelemetry.spring-webmvc-6.0 avec le starter.

./scripts/deploy.sh -p starter

Dans les logs de build, repérez la ligne Maven qui confirme le profil actif, puis re-générez du trafic — ce sont les mêmes commandes qu’à l’étape 2 :

. ./scripts/env.sh   # si ce n'est pas déjà fait dans ce terminal
curl http://$PF_HOST:$APP_PORT/api/reviews
curl http://$PF_HOST:$APP_PORT/api/reviews/product/OLJCESPC7Z
curl -X POST http://$PF_HOST:$APP_PORT/api/reviews \
  -H "Content-Type: application/json" \
  -d '{"productId": "OLJCESPC7Z", "rating": 4, "comment": "Bonne optique", "userEmail": "marie.martin@example.com", "userName": "Marie Martin"}'

Sans ces requêtes, aucune trace du starter n’arrivera : le service ne parle que lorsqu’on l’appelle.

  1. Comparer les traces.

Ouvrez une trace GET /api/reviews fraîche — celle du starter — et relevez son nombre de spans. Comparez-le à celui que vous avez noté à l’étape 6, du temps de l’agent. Combien en moins ? Lesquels ont disparu ?

💾 Ne cherchez pas votre ancienne trace, elle a peut-être déjà disparu. Jaeger, dans cette démo, garde ses traces en mémoire et plafonne à 25 000 (MEMORY_MAX_TRACES) : les plus anciennes sont écrasées par les nouvelles. Avec le trafic du load generator, cela représente une quinzaine de minutes d’historique — moins si le trafic monte. C’est pour cela que l’on note le chiffre au passage plutôt que de compter sur une comparaison côte à côte. Et pourquoi une trace qui vous intéresse se capture tout de suite : capture d’écran, ou son trace ID copié quelque part.

Réponse

Les deux produisent le span serveur HTTP et les spans JDBC — mais par des mécanismes opposés, et c’est ce qui explique tout le reste :

  • Agent = un programme externe attaché à la JVM (-javaagent) qui réécrit le bytecode des bibliothèques connues au chargement, sans que l’appli ni son build ne le sachent.
  • Starter = une dépendance compilée dans l’appli, qui se branche sur les points d’extension de Spring (auto-configuration) — pas de manipulation de bytecode.

Et la différence se voit tout de suite dans le waterfall — l’agent produit une trace deux fois plus détaillée (mesuré sur le cluster, 10 requêtes de chaque côté) :

AGENT — 5 à 6 spans
  GET /api/reviews                    server     io.opentelemetry.tomcat-10.0
  ReviewRepository.findAll            internal   io.opentelemetry.spring-data-1.8
  SELECT fr.k8sschool.reviews.Review  internal   io.opentelemetry.hibernate-6.0
  SELECT otel                         client     io.opentelemetry.jdbc
  Transaction.commit                  internal   io.opentelemetry.hibernate-6.0

STARTER — 3 spans
  GET /api/reviews                    server     io.opentelemetry.spring-webmvc-6.0
  HikariDataSource.getConnection      internal   io.opentelemetry.jdbc
  SELECT otel                         client     io.opentelemetry.jdbc

Chaque ligne trahit son mécanisme. L’agent instrumente Tomcat (le serveur, sous Spring), plus Hibernate et Spring Data : trois bibliothèques qu’il a reconnues au chargement, et qui lui donnent la requête JPA et le commit. Le starter instrumente Spring WebMVC — un point d’extension du framework, pas le serveur — et s’arrête au JDBC. Même endpoint, même base, deux niveaux de détail.

L’attribut de ressource le dit aussi, sans ouvrir un seul span : telemetry.distro.name vaut opentelemetry-java-instrumentation avec l’agent, opentelemetry-spring-boot-starter avec le starter.

Reste que le cas courant est couvert des deux côtés : requête HTTP tracée, requête SQL tracée, contexte propagé. Le choix se joue donc moins sur ce waterfall que sur les propriétés opérationnelles :

Agent JavaSpring Boot Starter
Mise en œuvreflag JVM, aucun changement de builddépendance Maven, rebuild nécessaire
Couverture~toutes les libs Java connues (bytecode)instrumentations Spring + libs principales
Démarrageplus lent (bytecode réécrit au chargement)plus rapide, compatible GraalVM native
Granularitétrès détailléeplus ciblée Spring

GraalVM native = compilation ahead-of-time de l’appli en exécutable natif (démarrage en millisecondes, faible mémoire). Comme le binaire est figé au build, aucune réécriture de bytecode n’est possible au runtime → l’agent ne marche pas, mais le Starter (déjà compilé dedans) oui.

ℹ️ GraalVM n’est pas utilisé dans ce lab : review-service tourne sur une JVM classique (Temurin 21, un simple .jar). C’est un argument en faveur du Starter pour la prod native, pas quelque chose à activer ici.

Quand préférer le starter ? Images natives (GraalVM), maîtrise fine des dépendances, ou politique interdisant les agents JVM. Quand préférer l’agent ? Instrumenter un parc existant sans toucher aux builds.

Livrable

Le nombre de spans relevé de chaque côté sur GET /api/reviews — avec l’agent, puis avec le starter — et une capture d’écran de la trace produite par le starter.

Si vous voulez la preuve de ce qui produit la trace, dépliez son span serveur : otel.scope.name vaut io.opentelemetry.spring-webmvc-6.0 avec le starter, io.opentelemetry.tomcat-10.0 avec l’agent.