Suivi d’une application Spring avec Actuator

Spring Boot publie des informations et métriques sur le déploiement et le fonctionnement d’une application avec Actuator.

Configuration

Pour activer Actuator, on ajoute une dépendance vers son starter.

  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>

Actuator a nativement un ensemble de endpoints qu’il publie en Web ou en JMX. Par défaut, seul le endpoint Health est publié.

Web endpoints

Le endpoint racine /actuator donne la liste des endpoints actifs, avec leur URL. On l’appelle la page de découverte et elle peut être désactivée.

management:
  endpoints:
    web:
      discovery:
        enabled: false

Il faut noter que ce contexte est relatif au contexte racine de l’application défini par server.servlet.context-path. Il peut être modifié et cette modification est répercutée sur tous les endpoints.

management:
  endpoints:
    web:
      base-path: /management

On peut aussi modifier le port. Dans ce cas, l’URL d’accès ne contient plus le contexte racine de l’application, mais celui du management qui est vide par défaut.

management:
  server:
    port: 9999
    base-path: /actuator

Par défaut, seul le endpoint Health est publié, en version simplifiée. On peut publier explicitement chaque endpoint en Web, inclure l’ensemble de ceux qui sont actifs ou en exclure certains.

management:
  endpoints:
    web:
      exposure:
        include: metrics,info
        #ou
        include='*'
        exclude=health,beans

La liste des endpoints actifs par défaut dépend de l’application. Les suivants sont systématiquement (ou souvent) présents pour connaître l’état de l’application :

Les suivants sont systématiquement (ou souvent) présents pour le débogage d’un déploiement :

  • /info : informations statiques de l’application

  • /mappings : liste des endpoints applicatifs

  • /beans, /conditions, /configprops, /env : pour déboguer un déploiement, liste de composants Spring dans le contexte, évaluation des conditions pour l’auto-configuration, données utilisées pour les classes annotées avec @ConfigurationProperties et contenu du ConfigurableEnvironment

  • /scheduledtasks, /quartz : tâches planifiées

  • /loggers et /logfile : liste et configuration des loggers

  • /liquibase, /flyway : initialisations de la base de données par Liquibase ou Flyway

Les endpoints suivants ne sont publiés que dans certaines conditions.

  • /startup ; fournit les étapes de démarrage si l’application est configurée avec un BufferingApplicationStartup

  • /shutdown : arrêt de l’application avec une requête POST ; doit être activé explicitement et CSRF désactivé

  • /httpexchanges[1], /httptrace[2] : échanges requête/réponse HTTP, s’il y a un bean `HttpExchangeRepository`[1] ou `HttpTraceRepository`[2]

  • /sessions : gestion des sessions pour une application stateful

  • /auditevents : événement d’audit, s’il y a un bean AuditEventRepository

  • /integrationgraph : avec Spring Integration

Les chemins des différents endpoints peuvent aussi être reconfigurés. Par exemple pour /health :

management:
  endpoints:
    web:
      path-mapping:
        health: healthcheck

Chaque endpoint activé par défaut peut être désactivé dans la configuration. Par exemple pour /health:

management:
  endpoint:
    health:
      enabled: false

A contrario, un endpoint désactivé par défaut, peut être activé.

management:
  endpoint:
    shutdown:
      enabled: true

D’autres endpoints peuvent être ajoutés via des librairies ou développements personnalisés.

Health

Le endpoint /health est activé par défaut. Par défaut, ce endpoint ne donne qu’une information globale.

~$ curl http://localhost:9999/actuator/health

{"status":"UP"}

Détails

On peut le configurer pour avoir l’état des composants.

management:
  endpoint:
    health:
      show-components: always
~$ curl http://localhost:9999/actuator/health

{
  "status":"UP",
  "components": {
    "db":{
      "status":"UP",
    },
    "diskSpace": {
      "status":"UP",
    },
    "ping":{
      "status":"UP"
    }
  }
}

Dans ces conditions, on peut aussi demander l’état d’un composant.

~$ curl http://localhost:9999/actuator/health/db

{
  "status":"UP",
}

On peut aussi configurer le endpoint pour avoir plus de détails.

management:
  endpoint:
    health:
      show-details: always
~$ curl http://localhost:9999/actuator/health

{
  "status":"UP",
  "components": {
    "db":{
      "status":"UP",
      "details":{
        "database":"PostgreSQL",
        "validationQuery":"isValid()"
      }
    },
    "diskSpace": {
      "status":"UP",
      "details": {
        "total":502392610816,
        "free":381957959680,
        "threshold":10485760,
        "exists":true
      }
    },
    "ping":{
      "status":"UP"
    }
  }
}

Kubernetes

Par ailleurs, le endpoint peut aussi publier des informations adaptées à Kubernetes.

management:
  endpoint:
    health:
      probes:
        enabled: true
~$ curl http://localhost:9999/actuator/health/liveness

{
    "status": "UP"
}
~$ curl http://localhost:9999/actuator/health/readiness

{
    "status": "UP"
}

Metrics

Le endpoint /metrics est activé par défaut. Sa racine fournit une liste de noms, sans valeur.

~$ curl http://localhost:9999/actuator/metrics

{
  "names": [
    "application.ready.time",
    "application.started.time",
    "disk.free",
    "disk.total",
    "executor.active",
    "executor.completed",
    "executor.pool.core",
    "executor.pool.max",
    "executor.pool.size",
    ...
  ]
}

La structure des noms est unidimensionnelle, à la façon de prometheus. Les informations les plus couramment utilisées concernent la ou les datasource(s) (hikaricp.connections.*), la mémoire (jvm.memory.*), le ramasse-miettes (jvm.gc.*) ou la CPU (process.cpu.usage). Chaque nom peut ensuite être utilisé pour accéder à une métrique unitaire.

~$ curl http://localhost:9999/actuator/metrics/jvm.memory.used

{
  "name": "jvm.memory.used",
  "description": "The amount of used memory",
  "baseUnit": "bytes",
  "measurements": [
    {
      "statistic": "VALUE",
      "value": 383617384
    }
  ],
  "availableTags": [
    {
      "tag": "area",
      "values": [
        "heap",
        "nonheap"
      ]
    },
    {
      "tag": "id",
      "values": [
        "G1 Survivor Space",
        "Compressed Class Space",
        "Metaspace",
        "CodeCache",
        "G1 Old Gen",
        "G1 Eden Space"
      ]
    }
  ]
}

Comme on le voit dans cet exemple, les métriques sont organisées via des tags, qu’on peut utiliser en paramètre des requêtes.

~$ curl http://localhost:9999/actuator/metrics/jvm.memory.used?tag=area:heap

{
  "name": "jvm.memory.used",
  "description": "The amount of used memory",
  "baseUnit": "bytes",
  "measurements": [
    {
      "statistic": "VALUE",
      "value": 132698240
    }
  ],
  "availableTags": [
    {
      "tag": "id",
      "values": [
        "G1 Survivor Space",
        "G1 Old Gen",
        "G1 Eden Space"
      ]
    }
  ]
}

Métriques Spring MVC

La métrique http.server.requests des requêtes traitées par Spring MVC est activée par défaut. Elle peut être désactivée dans la configuration.

management:
  metrics:
    web:
      server:
        request:
          autotime:
            enabled: false

Sans tag, elle fournit des statistique globales, avec le nombre de requêtes traitées, le temps total de traitement et la durée de traitement maximal pour une requête.

~$ curl http://localhost:9999/actuator/metrics/http.server.requests

{
  "name": "http.server.requests",
  "baseUnit": "seconds",
  "measurements": [
    {
      "statistic": "COUNT",
      "value": 754
    },
    {
      "statistic": "TOTAL_TIME",
      "value": 134.09375702
    },
    {
      "statistic": "MAX",
      "value": 0.76434196
    }
  ]
}

On peut ensuite accéder à des données plus précises via les tags method, uri et status.

~$ curl http://localhost:9999/actuator/metrics/http.server.requests?tag=uri:/secured/roles&tag=method:GET

{
  "name": "http.server.requests",
  "description": null,
  "baseUnit": "seconds",
  "measurements": [
    {
      "statistic": "COUNT",
      "value": 56
    },
    {
      "statistic": "TOTAL_TIME",
      "value": 5.121863713
    },
    {
      "statistic": "MAX",
      "value": 0.143918815
    }
  ]
}

C’est pas vraiment pratique pour détecter les requêtes lentes, mais on a pas mal de détails.

Métriques Spring Data

La métrique spring.data.repository.invocations des repositories de Spring Data est activée par défaut. Elle peut être désactivée dans la configuration.

management.metrics.data.repository.autotime.enabled=false

Son fonctionnement est similaire à celui de Spring MVC, avec une vue globale.

~$ curl http://localhost:9999/actuator/metrics/spring.data.repository.invocations

{
  "name": "spring.data.repository.invocations",
  "description": null,
  "baseUnit": "seconds",
  "measurements": [
    {
      "statistic": "COUNT",
      "value": 293
    },
    {
      "statistic": "TOTAL_TIME",
      "value": 2.293597305
    },
    {
      "statistic": "MAX",
      "value": 0
    }
  ]
}

On retrouve aussi des tags pour entrer dans les détails.

~$ curl http://localhost:9999/actuator/metrics/spring.data.repository.invocations?tag=repository:ClientRepository'&'method=findAll

Statistiques Hibernate

Les statistiques d’Hibernate sont désactivées par défaut. Pour les avoir dans les métriques, il faut les activer et ajouter l’extension Micrometer.

Pour activer les statistiques Hibernate, sans les publier dans actuator:

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

Puis pour les publier, il faut ajouter une dépendance vers hibernate-micrometer.

    <dependency>
        <groupId>org.hibernate</groupId>
        <artifactId>hibernate-micrometer</artifactId>
        <version>${hibernate.version}</version>
    </dependency>

Micrometer

La mécanique interne des métriques de l'actuator s’appuie sur Micrometer, avec la publication sur le endpoint /actuator/metrics.

Grâce à Micrometer, il est aussi possible d’ajouter des métriques personnalisées. Depuis n’importe quel bean, en injectant MeterRegistry, on peut ajouter les métriques de son choix.

  private final Counter createClientCallCounter;

  public ClientService(MeterRegistry meterRegistry) {
      Counter createClientCallCounter = Counter.builder("jtips.client.create")
                                              .description("Nombre de création de Client")
                                              .register(meterRegistry);
  }

  public Client create(Client client) {
    createClientCallCounter.increment();
    ...
  }

L’autre solution, plus modulaire, c’est de publier un bean qui implémente MeterBinder et qui publie un ensemble de meters.

Grâce à Micrometer, les métriques peuvent être publiées sur de nombreux outils de monitoring externes comme Graphite, Prometheus ou Elastic.

JMX

Les métriques peuvent être publiées en JMX, en plus du endpoint. Pour l’activer', il faut ajouter une dépendance vers io.micrometer:micrometer-registry-jmx.

  <dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-jmx</artifactId>
    <version>${micrometer.version}</version>
  </dependency>

Par défaut, les informations sont dans le domaine metrics, ce qui peut être modifié.

management:
  jmx:
    metrics:
      export:
        domain: info.jtips

Prometheus

Les métriques peuvent aussi être publiées au format Prometheus. Il suffit d’ajouter une dépendance vers io.micrometer:micrometer-registry-prometheus pour activer le endpoint /prometheus.

  <dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
    <version>1.11.3</version>
  </dependency>

Les données exposées ne sont pas les mêmes que pour le endpoint /metrics. Elles viennent d’un registre spécifique.

Dump

Le endpoint /threaddump publie un export de l’ensemble des threads au format JSON. On peut demander une réponse au format traditionnel avec l’en-tête "Accept": "text/plain".

curl http://127.0.0.1:9999/actuator/threaddump --header 'Accept: text/plain'

Le endpoint /heapdump publie un export de la mémoire au format binaire.

curl http://127.0.0.1:9999/actuator/heapdump -o heap.dump

Info

Le endpoint /info publie des informations statiques de l’application. Les informations sont publiées par l’intermédiaire de contributeurs qui sont tous désactivés par défaut. De ce fait, sans autre configuration, le résultat reste vide.

Le contributeur env publie l’ensemble des propriétés info.*. Il était activé par défaut jusqu’à Spring Boot 2.5 et a été désactivé dans les versions suivantes (attention aux migrations).

management:
  info:
    env:
      enabled: true

info:
  application:
    name: JTips examples
    version: 1.2.3
~$ curl http://localhost:9999/actuator/info

{
  "application": {
    "name": "JTips examples",
    "version": "1.2.3"
  }
}

Le contributeur java publie des informations du runtime Java.

management.info.java.enabled=true
~$ curl http://localhost:9999/actuator/info

{
  "java": {
    "vendor": "Private Build",
    "version": "17.0.5",
    "runtime": {
      "name": "OpenJDK Runtime Environment",
      "version": "17.0.5"
    },
    "jvm": {
      "name": "OpenJDK 64-Bit Server VM",
      "vendor": "Private Build",
      "version": "17.0.5"
    }
  }
}

Le contributeur build publie les informations du fichier /META-INF/build-info.properties généré au moment du build.

Ça se configure avec Maven (ou gradle).

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <executions>
          <execution>
            <goals>
              <goal>build-info</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

Ce contributeur est activé par défaut et publie les informations à condition que le fichier soit présent.

~$ curl http://localhost:9999/actuator/info

{
  "build": {
    "artifact": "spring-boot-example",
    "name": "JTips examples with Spring Boot",
    "time": 1672075975.823000000,
    "version": "1.2.3",
    "group": "info.jtips"
  }
}

Le contributeur git publie les informations présentes dans le fichier git.properties généré au moment du build.

  <build>
    <plugins>
      <plugin>
        <groupId>io.github.git-commit-id</groupId>
        <artifactId>git-commit-id-maven-plugin</artifactId>
        <version>5.0.0</version>
        <executions>
          <execution>
            <goals>
              <goal>revision</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <generateGitPropertiesFile>true</generateGitPropertiesFile>
        </configuration>
      </plugin>
    </plugins>
  </build>

Ce contributeur est activé par défaut et publie les informations à condition que le fichier soit présent.

~$ curl http://localhost:9999/actuator/info

{
  "git": {
    "branch": "main",
    "commit": {
      "id": "0cff7b1",
      "time": 1671009439.000000000
    }
  }
}

Les informations publiées par ce endpoint sont disponibles sous forme de beans, de type EnvironmentInfoContributor, GitInfoContributor, BuildInfoContributor et JavaInfoContributor.

Il est possible d’ajouter des informations personnalisées en publiant un bean de type InfoContributor.

Env

Depuis Spring Boot 3, les valeurs fournies par le endpoint /env sont cachées. Pour les afficher, il faut configurer la propriété management.endpoint.env.show-values avec pour valeurs possibles:

  • ALWAYS, pour voir les valeurs,

  • NEVER, par défaut,

  • WHEN_AUTHORIZED, si la requête est authentifiée.

Il y a le même comportement pour /configprops, et la propriété management.endpoint.configprops.show-values équivalente.

Lorsqu’on décide d’afficher les valeurs, on se retrouve avec des mots de passe ou des secrets qu’on ne souhaiterait pas montrer. Pour choisir quelles valeurs doivent être cachées malgré tout, il faut créer un bean qui implémente SanitizingFunction.

@Configuration
public class ActuatorConfiguration {
  @Value("${application.actuator.keys-to-sanitize:}")
  private String[] excludedKeys;

  @Bean
  public SanitizingFunction actuatorSanitizingFunction {
    return data -> {
      if (Arrays.stream(excludedKeys)
                .anyMatch(excludedKey -> data.getKey().equals(excludedKey))) {
          return data.withValue(SanitizableData.SANITIZED_VALUE);
      }
      return data;
    };
  }
}

HttpExchanges

Le endpoint /httpexchanges de Spring Boot 3 remplace /httptrace de Spring Boot 2. Il permet de consulter le détail des requêtes HTTP reçues par l’application.

Dans les deux cas, le endpoint n’est pas activé par défaut. Il faut déclarer un bean de type HttpExchangeRepository.

@Configuration
public class ActuatorConfiguration {
  @Bean
  public HttpExchangeRepository httpExchangeRepository() {
    return new InMemoryHttpExchangeRepository();
  }

  ...
}

InMemoryHttpExchangeRepository est la seule implémentation fournie pas Spring. Elle conserve les 100 denières requêtes en mémoire.

AuditEvents

Le endpoint /auditevents permet de consulter les événements d’authentification. Il s’active de la même façon que /httpexchanges, en déclarant un bean de type AuditEventRepository.

@Configuration
public class ActuatorConfiguration {
  ...

  @Bean
  public AuditEventRepository auditEventRepository() {
    return new InMemoryAuditEventRepository();
  }
}

InMemoryAuditEventRepository est la seule implémentation fournie pas Spring. Elle conserve les 100 deniers événements en mémoire.

Loggers

Le endpoint /loggers publie les détails de tous les loggers de l’application.

Avec une requête GET, on peut obtenir la configuration d’un logger unique.

~$ curl http://localhost:8081/actuator/loggers/com.meshimer.cfws

{
    "configuredLevel": "INFO",
    "effectiveLevel": "INFO"
}

Avec une requête POST, on peut changer la configuration d’un logger, à chaud.

~$ curl --request POST http://localhost:8081/actuator/loggers/info.jtips   \
        --data '{ "configuredLevel": "DEBUG" }'

Après cette requête, les logs de debug sont écrits, jusqu’au prochain redémarrage. Pour l’annuler, on peut envoyer la requête suivante:

~$ curl --request POST http://localhost:8081/actuator/loggers/info.jtips   \
        --data '{}'

Si le système de logs est configuré avec une sortie fichier (propriété logging.file.name), on peut en demander le contenu sur le endpoint /logfile.

~$ curl --header 'Accept: text/plain' http://localhost:8081/actuator/logfile

2023-12-08T21:25:48.866Z  INFO 632981 --- [restartedMain] info.jtips.Application    : Starting Application using Java 17 with PID 632981
2023-12-08T21:25:48.868Z  INFO 632981 --- [restartedMain] info.jtips.Application    : The following 1 profile is active: "local"
2023-12-08T21:25:49.738Z  INFO 632981 --- [restartedMain] o.s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2023-12-08T21:25:50.058Z  INFO 632981 --- [restartedMain] o.s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 310 ms. Found 65 JPA repository interfaces.
...

Endpoint personnalisé

On peut aussi créer nos propres endpoints.

Technique

Pour ça, il faut créer un bean annoté avec @Endpoint, avec au moins une opération, annotatée avec @ReadOperation, @WriteOperation ou @DeleteOperation.

@Component
@Endpoint(id = "echo")
public class EchoEndpoint {
    @ReadOperation
    public String echo(@Selector String input) {
        return "Hello " + input;
    }
}

L’annotation @Selector permet de gérer des path parameters, alors qu’un parameter de méthode sans annotation gère un paramètre de requête.

Comme pour un endpoint applicatif, on peut affiner ses capacités. Par exemple, on peut choisir le type de réponse dans l’annotation @ReadOperation, faire une opération par type accepté.

Plutôt que de retourner un objet simple, on peut retourner un WebEndpointResponse<?>, en choisissant le status code. Par contre, on ne peut pas modifier les headers de la réponse.

@Component
@Endpoint(id = "echo")
public class EchoEndpoint {
    @ReadOperation(produces = "text/plain")
    public WebEndpointResponse<String> echoSimple(@Selector String input) {
        return new WebEndpointResponse<>("Hello " + input);
    }

    @ReadOperation(produces = "application/json")
    public WebEndpointResponse<Message> echoJson(@Selector String input) {
        return new WebEndpointResponse<>(new Message("Hello", input));
    }
    public record Message(String hi, String dest) {}
}

Exemple

J’ai utilisé cette technique pour créer un endpoint de métriques qui utilise le registre Prometheus et qui est capable d’exposer les données au format Prometheus, en JSON ou en CSV.

/
 * An @Endpoint for exposing aggregated metrics from Prometheus registry.
 * 
* Can be requested in JSON format (default), in CSV (text/csv) or in Prometheus format ("text/plain"). */ @Component @Endpoint(id = "metrix") public class MetrixEndpoint { private final PrometheusScrapeEndpoint prometheusScrapeEndpoint; private final CollectorRegistry collectorRegistry; public MetrixEndpoint(PrometheusScrapeEndpoint prometheusScrapeEndpoint, CollectorRegistry collectorRegistry) { this.prometheusScrapeEndpoint = prometheusScrapeEndpoint; this.collectorRegistry = collectorRegistry; } /
* JSON format, default */ @ReadOperation() public List json(@Nullable Set includedNames) { Enumeration samples = (includedNames != null) ? this.collectorRegistry.filteredMetricFamilySamples(includedNames) : this.collectorRegistry.metricFamilySamples(); return Collections.list(samples); } / * Prometheus format ("text/plain") */ @ReadOperation(producesFrom = TextOutputFormat.class) public WebEndpointResponse prometheus(TextOutputFormat format, @Nullable Set includedNames) { return prometheusScrapeEndpoint.scrape(format, includedNames); } / * CSV format ("text/csv") */ @ReadOperation(produces = "text/csv") public String csv(@Nullable Set includedNames) { return json(includedNames).stream() .flatMap(metric -> metric.samples.stream().map(sample -> new MetricSample(metric, sample))) .map(MetricSample::toCsv) .collect(Collectors.joining()); } @ReadOperation public Collector.MetricFamilySamples single(@Selector String requiredMetricName) { return this.collectorRegistry.filteredMetricFamilySamples(Set.of(requiredMetricName)).nextElement(); } private record MetricSample(Collector.MetricFamilySamples metric, Collector.MetricFamilySamples.Sample sample) { String toCsv() { Stream labels = StreamUtils.zip( sample.labelNames.stream(), sample.labelValues.stream(), (name, value) -> String.format("%s=\"%s\"", name, value)); return "%s;%s;%s;%s%n" .formatted(sample.name, labels.collect(Collectors.joining(",")), sample.value, metric.unit); } } }

1. Spring Boot 3
2. Spring Boot 2