Grafana Stack đ 1. ObservabilitĂ© avec Spring Boot 3
Ă mes heures perdues, je travaille sur une application de veille techno qui me permet de faire la mienne (veille) comme jâai envie. RĂ©cemment, jâai entendu parler dâOpentelemetry un collecteur de tĂ©lĂ©mĂ©trie. Et jâai eu envie de le tester pour voir si je pouvais rendre mon application observable.
Il existe une multitude de stack de tĂ©lĂ©mĂ©trie, mais Grafana est open source et permet dâavoir assez facilement toutes les mĂ©triques sur la mĂȘme application de rendu. De plus, je voulais essayer Loki en comparaisons de ELK que jâutilise dĂ©jĂ au travail.
Pour en apprendre plus sur l’observabilitĂ©, allez jeter un oeil sur la documentation de StĂ©phane Robert concernant l’ObservabilitĂ© .
VoilĂ donc une sĂ©rie dâarticles dĂ©taillants comment mettre en place lâobservabilitĂ© sur une application Spring Boot 3.
Les autres articles de la série :
- Observabilité avec Spring Boot 3
- Collecte des métriques avec OpenTelemetry
- Collecte des logs avec OpenTelemetry
- DĂ©ploiement dâun Grafana
La Stack Grafana
Avant de commencer, parlons un peu de la stack grafana. Elle est composĂ©e de plusieurs Ă©lĂ©ments. Dans mon boulot prĂ©cĂ©dent et dans mon poste actuel, jâai beaucoup travaillĂ© avec la stack ELK. Elle est trĂšs efficace, mais jâai toujours trouvĂ© quâelle Ă©tait compliquĂ©e Ă mettre en place et Ă configurer. Câest lâoccasion de tester la stack Grafana plus simple Ă mettre en Ćuvre.
Prometheus
Prometheus est le moteur de stockage de mĂ©triques. Il sâagit dâun moteur de mĂ©triques dimensionnel. Chaque mĂ©trique est reprĂ©sentĂ©e par un nom et par des attributs, un ensemble de clĂ©/valeur qui spĂ©cialise la donnĂ©e. Cela permet de faire des requĂȘtes puissantes, mais il faut faire attention Ă ne pas avoir dâattributs dont lâensemble de valeurs possible est trop important sans quoi les performances et lâespace de stockage vont exploser.
Loki
Loki est le moteur de stockage de logs. Mais, contrairement Ă Elastic qui va indexer tout le contenu des logs, Loki ne va indexer que certains attributs. Loki stocke les logs comme prometheus les mĂ©triques. Chaque log possĂšde un ensemble dâattributs clĂ©/valeur qui sont indexĂ©s, le reste du message ne lâest pas. Comme pour les mĂ©triques on ne doit pas utiliser dâattribut avec un ensemble de valeurs trop grand sous peine de problĂšmes de performance et dâexplosion du stockage. LâintĂ©rĂȘt de cette approche est que lâempreinte sur le disque est bien plus faible que pour un Elastic. LâinconvĂ©nient est quâil nâest pas possible de faire de recherche sur les champs non indexĂ©s et donc sur le contenu du message de log.
Tempo
Tempo quant Ă lui, va stocker les traces. Câest-Ă -dire le moyen de rapprocher les logs et les mĂ©triques dans un environnement micro-service. Cela fera lâobjet dâun autre article.
Grafana
Enfin Grafana propose une interface unifiée pour visualiser toute cette télémétrie.
Spring Boot et lâobservabilitĂ©
LâobservabilitĂ© regroupe les 3 Ă©lĂ©ments suivants :
- Les métriques
- Les logs
- Les traces
Sur les derniĂšres versions du framework, lâĂ©quipe de Spring a ajoutĂ© la fonctionnalitĂ© dâobservabilitĂ© qui est particuliĂšrement bien intĂ©grĂ© au modĂšle Spring Webflux et Ă la programmation rĂ©active grĂące Ă Micrometer et Reactor.
Avec trĂšs peu de code additionnel il est maintenant possible dâobtenir des mĂ©triques dĂ©taillĂ©es pour chaque exĂ©cution de stream que lâon souhaite observer.
Déploiement des métriques
Ajout des dépendences
Tout dâabord dans le pom.xml
:
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core-micrometer</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
reactor-core-micrometer
est le plugin dâobservabilitĂ© de micrometer qui lui permet de sâintĂ©grer Ă reactor.micrometer-registry-prometheus
reprĂ©sente le format de restitution des mĂ©triques. En effet, il existe plusieurs approches quant Ă la collecte de mĂ©triques. Lâapproche choisie dĂ©termine le format de restitution. Dans notre cas, on va utiliser un Stack Grafana pour stocker et visualiser nos mĂ©triques, câest donc le format prometheus (le moteur de stockage de mĂ©triques de grafana) qui correspond Ă notre choix.spring-boot-starter-actuator
lâactuator va nous permettre de mettre Ă disposition un API pour collecter les mĂ©triques
Configuration Spring Boot
Maintenant, il reste Ă ajouter la configuration de lâactuator dans le fichier application.yaml
:
spring:
application.name: MyApplication
management:
endpoints.web.exposure.include: prometheus,health
metrics:
distribution.percentiles-histogram:
http.server.requests: true
tags:
application: ${spring.application.name}
Le nom de lâapplication va permettre dâavoir un contexte dans tous les logs et toutes les mĂ©triques. Ainsi, si vous avez plusieurs applications spring qui gĂ©nĂšre des mĂ©triques cela vous permettra de les diffĂ©rentier. De la mĂȘme façon si vous avez un cluster de plusieurs nĆuds, il sera intĂ©ressant dâajouter ici lâidentifiant du nĆuds.
Pour le reste, la ligne importante est endpoints.web.exposure.include: prometheus
qui va activer lâapi de collecte pour prometheus. Le reste des paramĂštres permet d’avoir un peu plus de dĂ©tails dans les mĂ©triques collectĂ©es.
Ă partir de lĂ , lâapplication est dĂ©jĂ capable de fournir une grosse quantitĂ© de mĂ©triques sur le fonctionnement de Spring et de la JVM. DĂ©marrer lâapplication et, avec un postman
par exemple, faire une requĂȘte GET /actuator/prometheus
qui est la route par défaut pour Prometheus.
# HELP jvm_threads_peak_threads The peak live thread count since the Java virtual machine started or peak was reset
# TYPE jvm_threads_peak_threads gauge
jvm_threads_peak_threads{context="MyApplication",} 45.0
# HELP jvm_gc_overhead_percent An approximation of the percent of CPU time used by GC activities over the last lookback period or since monitoring began, whichever is shorter, in the range [0..1]
# TYPE jvm_gc_overhead_percent gauge
jvm_gc_overhead_percent{context="MyApplication",} 0.00497286035688716
# HELP system_cpu_usage The "recent cpu usage" of the system the application is running in
# TYPE system_cpu_usage gauge
system_cpu_usage{context="MyApplication",} 0.0
# HELP process_files_max_files The maximum file descriptor count
# TYPE process_files_max_files gauge
process_files_max_files{context="MyApplication",} 1048576.0
# HELP bw_news_count Total number of News
# TYPE bw_news_count gauge
bw_news_count{context="MyApplication",} 0.0
# HELP jvm_gc_live_data_size_bytes Size of long-lived heap memory pool after reclamation
# TYPE jvm_gc_live_data_size_bytes gauge
jvm_gc_live_data_size_bytes{context="MyApplication",} 1.33460992E8
# HELP hikaricp_connections_max Max connections
# TYPE hikaricp_connections_max gauge
hikaricp_connections_max{context="MyApplication",pool="HikariPool-1",} 10.0
# HELP spring_security_authorizations_seconds
# TYPE spring_security_authorizations_seconds summary
spring_security_authorizations_seconds_count{context="MyApplication",error="none",spring_security_authentication_type="UsernamePasswordAuthenticationToken",spring_security_authorization_decision="true",spring_security_object="exchange",} 1.0
spring_security_authorizations_seconds_sum{context="MyApplication",error="none",spring_security_authentication_type="UsernamePasswordAuthenticationToken",spring_security_authorization_decision="true",spring_security_object="exchange",} 0.005212113
Ce nâest quâun exemple des mĂ©triques fournis de base par Spring, mais il y en a sur beaucoup dâaspects : La mĂ©moire, la consommation CPU, les routes appelĂ©es, le temps de dĂ©marrage, …
Ajout de la premiÚre métrique
Dans le cadre de lâapplication de veille techno, jâai un process de scraping des feed de news qui se dĂ©clenche toutes les heures et ça mâintĂ©resserait bien de lâobserver.
Le code de ce processus est un flux Reactor qui exécute toutes les étapes, lancé à intervales réguliers par un Scheduler :
@Override
public void run() {
log.info("Start scraping ...");
scraperService.scrap(properties.conservation())
.subscribe();
}
Jâai simplifiĂ© le code rĂ©el, mais lâidĂ©e est lĂ .
// Injection de lâObservationRegistry via le constructeur
private final ObservationRegistry observationRegistry;
@Override
public void run() {
log.info("Start scraping ...");
scraperService.scrap(properties.conservation())
.name("bw_scraping_process")
.tap(Micrometer.observation(observationRegistry))
.subscribe();
}
Si on relance lâapplication maintenant et que lâon attend que le process de scraping se termine. Puis, que lâon interroge Ă nouveau la route GET /actuator/prometheus
. On obtient les données supplémentaires suivantes.
# HELP bw_scraping_process_seconds
# TYPE bw_scraping_process_seconds summary
bw_scraping_process_seconds_count{context="MyApplication",error="none",reactor_status="completed",reactor_type="Mono",} 1.0
bw_scraping_process_seconds_sum{context="MyApplication",error="none",reactor_status="completed",reactor_type="Mono",} 17.026397336
# HELP bw_scraping_process_seconds_max
# TYPE bw_scraping_process_seconds_max gauge
bw_scraping_process_seconds_max{context="MyApplication",error="none",reactor_status="completed",reactor_type="Mono",} 17.026397336
# HELP bw_scraping_process_active_seconds_max
# TYPE bw_scraping_process_active_seconds_max gauge
bw_scraping_process_active_seconds_max{context="MyApplication",reactor_type="Mono",} 0.0
# HELP bw_scraping_process_active_seconds
# TYPE bw_scraping_process_active_seconds summary
bw_scraping_process_active_seconds_active_count{context="MyApplication",reactor_type="Mono",} 0.0
bw_scraping_process_active_seconds_duration_sum{context="MyApplication",reactor_type="Mono",} 0.0
LâobservabilitĂ© de reactor produit en tout 2 groupes de 3 mĂ©triques :
- Les mĂ©triques dâexĂ©cution
- le nombre total dâappels
- la durée maximale
- la somme des durées
- Les mĂ©triques dâexĂ©cution longue (active) qui donne les mĂȘmes compteurs pour des opĂ©rations de plus longues durĂ©es qui ne seraient pas terminĂ©es.
Utilisation dâune Gauge
Ces mĂ©triques sont pratiques, mais pas simple Ă interprĂ©ter. Finalement, si vous souhaitez voir lâĂ©volution de la durĂ©e du scraping au fil du temps, cela nâest pas possible. Au mieux, vous avez la durĂ©e moyenne. Câest pour cela quâil peut ĂȘtre intĂ©ressant de dĂ©clarer une Gauge qui va permettre cette observation.
private final AtomicLong lastScrapingDuration = new AtomicLong(0);
public ScraperTaskScheduler(MeterRegistry registry) {
TimeGauge.builder("bw_scraping_process", lastScrapingDuration::get, TimeUnit.MILLISECONDS)
.description("Last scraping duration")
.register(registry);
}
@Override
public void run() {
log.info("Start scraping ...");
long startTime = System.currentTimeMillis();
scraperService.scrap(properties.conservation())
.doFinally(s -> lastScrapingDuration.set(System.currentTimeMillis() - startTime))
.subscribe();
}
Les TimeGauge
permettent dâajouter au compteur une unitĂ© de temps.
Maintenant si on relance lâapplication pour voir les compteurs, voilĂ ce que lâon a.
# HELP bw_scraping_process_seconds Last scraping duration
# TYPE bw_scraping_process_seconds gauge
bw_scraping_process_seconds{context="MyApplication",} 14.201
Ce nâest pas flagrant comme changement mais, dans le cas dâune gauge, chaque nouvelle valeur vient remplacer la prĂ©cĂ©dente. Contrairement Ă un timer ou un compteur qui additionne chaque nouvelle valeur avec la prĂ©cĂ©dente.
Ajout du contexte
Il est possible dâajouter du contexte dans les mĂ©triques programmatiquement en utilisant le MeterRegistryCustomizer
. Il sera cependant plus simple dâutiliser les paramĂštres de configuration vu au dĂ©but de cet article.
@Configuration
public class SpringConfiguration {
@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags(@Value("${spring.application.name}") String application) {
return registry -> registry.config()
.commonTags("context", application.toLowerCase());
}
}
SĂ©curisation
Dernier point important, la sĂ©curisation du point dâaccĂšs aux mĂ©triques. Pensez Ă sĂ©curiser ce point d’accĂšs, mĂȘme si y accĂ©der ne suffira pas Ă pirater lâapplication, les mĂ©triques laissent passer bon nombre dâinformations exploitables qui permettrait Ă une personne mal intentionnĂ©e de dĂ©nicher dâĂ©ventuelles failles de sĂ©curitĂ©s.
Amélioration des logs
Les logs par défaut de Spring sont vraiment appréciables et bien formatés. Mais des logs au format texte restent un enfer à parser. Tous ceux qui ont travaillé un peu avec Logstash ont leurs collections de grok bien au chaud pour ce genre de chose.
Le plus simple est de faire en sorte que Spring sorte les logs en JSON, dĂ©jĂ parsĂ©, elles seront directement lisibles par le collecteur. LâidĂ©al serait que lâon puisse rĂ©gler ça grĂące Ă une variable dâenvironnement, ce qui permettrait de garder les logs “humain” pendant le dĂ©veloppement et dâutiliser le json pour la production.
Logback possĂšde un plugin qui permet dâobtenir ce rĂ©sultat.
Ajouter les dépendances
<dependency>
<groupId>ch.qos.logback.contrib</groupId>
<artifactId>logback-json-classic</artifactId>
<version>0.1.5</version>
</dependency>
<dependency>
<groupId>ch.qos.logback.contrib</groupId>
<artifactId>logback-jackson</artifactId>
<version>0.1.5</version>
</dependency>
Configuration logback
Ensuite on configure logback comme suit dans un fichier logback-spring.xml
:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<springProperty scope="context" name="appName" source="spring.application.name"/>
<springProperty scope="context" name="rootLevel" source="logging.level.root"/>
<springProfile name="json-logging">
<contextName>${appName}</contextName>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.contrib.json.classic.JsonLayout">
<jsonFormatter class="ch.qos.logback.contrib.jackson.JacksonJsonFormatter"/>
<timestampFormat>${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSS'Z'}</timestampFormat>
<appendLineSeparator>true</appendLineSeparator>
<prettyPrint>false</prettyPrint>
</layout>
</appender>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
</springProfile>
<springProfile name="!json-logging">
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
</springProfile>
<root level="${rootLevel}">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
LâintĂ©rĂȘt de cette configuration, c’est quâelle est attachĂ©e au profil. Il est donc facile de passer de cette configuration Ă la configuration par dĂ©faut des logs via la variable d’environnement SPRING_PROFILES_ACTIVE=json-logging
.
Ă noter lâutilisation de spring.application.name
que lâon a mis Ă jour dans les propriĂ©tĂ©s de lâapplication et qui va se retrouver dans le contexte. Ce qui permettra de distinguer les logs de notre application dâautres logs dans loki et qui mettra ainsi le mĂȘme contexte sur nos mĂ©triques et sur les logs.
Relancer lâapplication
Si on relance lâapplication avec la configuration que lâon vient de mettre en place, voilĂ ce que cela va donner :
{"timestamp":"2023-05-30T23:07:12.880Z","level":"INFO","thread":"main","logger":"fr.ght1pc9kc.myapp.MyApplication","message":"Starting MyApplication using Java 17.0.6 with PID 31428 ( started by marthym in )","context":"MyApplication"}
{"timestamp":"2023-05-30T23:07:12.893Z","level":"DEBUG","thread":"main","logger":"fr.ght1pc9kc.myapp.MyApplication","message":"Running with Spring Boot v3.1.0, Spring v6.0.9","context":"MyApplication"}
{"timestamp":"2023-05-30T23:07:12.894Z","level":"INFO","thread":"main","logger":"fr.ght1pc9kc.myapp.MyApplication","message":"The following 1 profile is active: \"json-logging\"","context":"MyApplication"}
{"timestamp":"2023-05-30T23:07:13.973Z","level":"INFO","thread":"main","logger":"org.flywaydb.core.internal.license.VersionPrinter","message":"Flyway Community Edition 9.16.3 by Redgate","context":"MyApplication"}
{"timestamp":"2023-05-30T23:07:13.973Z","level":"INFO","thread":"main","logger":"org.flywaydb.core.internal.license.VersionPrinter","message":"See release notes here: https://rd.gt/416ObMi","context":"MyApplication"}
{"timestamp":"2023-05-30T23:07:13.973Z","level":"INFO","thread":"main","logger":"org.flywaydb.core.internal.license.VersionPrinter","message":"","context":"MyApplication"}
{"timestamp":"2023-05-30T23:07:13.980Z","level":"INFO","thread":"main","logger":"com.zaxxer.hikari.HikariDataSource","message":"HikariPool-1 - Starting...","context":"MyApplication"}
{"timestamp":"2023-05-30T23:07:14.067Z","level":"INFO","thread":"main","logger":"com.zaxxer.hikari.pool.HikariPool","message":"HikariPool-1 - Added connection org.sqlite.jdbc4.JDBC4Connection@6f8667bb","context":"MyApplication"}
{"timestamp":"2023-05-30T23:07:14.068Z","level":"INFO","thread":"main","logger":"com.zaxxer.hikari.HikariDataSource","message":"HikariPool-1 - Start completed.","context":"MyApplication"}
Beaucoup plus difficile Ă lire pour un humain, mais bien plus simple Ă parser.
Conclusion
VoilĂ les quelques amĂ©liorations Ă mettre en place dans une application Spring pour simplifier la collecte des mĂ©triques. Dans le prochain article, nous verrons comment mettre en place un collecteur OpenTelemetry et rĂ©cupĂ©rer les mĂ©triques que lâon vient de configurer.