Spring Boot Micrometer and Prometheus: Actuator Metrics in Production

Spring Boot Micrometer and Prometheus integration on Spring Boot 4: Actuator access model, scrape config, HTTP metrics, health probes and alert rules.

Spring Boot Actuator monitoring with Micrometer and Prometheus

Spring Boot Actuator exposes health, metrics and diagnostic endpoints, and Micrometer is the metrics facade behind them. Adding the micrometer-registry-prometheus dependency gives a /actuator/prometheus endpoint that Prometheus scrapes on a fixed interval. The examples below target Spring Boot 4.1 (current GA, with 4.2 in release candidate), Micrometer 1.16 and the Prometheus Java client 1.x.

The short answer

Spring Boot Micrometer integration with Prometheus takes three steps: add spring-boot-starter-actuator and micrometer-registry-prometheus, add prometheus to management.endpoints.web.exposure.include, then point a Prometheus scrape_config at /actuator/prometheus. JVM, HTTP, HikariCP and system metrics are published with no extra code.

Spring Boot Micrometer Setup: Actuator and the Prometheus Registry

Required Maven Dependencies

The Actuator starter enables the endpoints and pulls in Micrometer. The Prometheus registry formats every meter in the Prometheus exposition format. The AspectJ starter is only needed for the @Timed and @Counted annotations.

xml
<!-- pom.xml -->
<!-- Actuator + Micrometer + Prometheus (Spring Boot 4.x) -->
<dependencies>
    <!-- Spring Boot Actuator: monitoring endpoints, includes Micrometer -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <!-- Micrometer Registry Prometheus (Prometheus Java client 1.x) -->
    <!-- Exposes metrics in Prometheus format at /actuator/prometheus -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>

    <!-- AspectJ for @Timed and @Counted -->
    <!-- Spring Boot 4 renamed spring-boot-starter-aop to spring-boot-starter-aspectj -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aspectj</artifactId>
    </dependency>
</dependencies>

Two version traps show up in older tutorials. Since Micrometer 1.13 (Spring Boot 3.3), micrometer-registry-prometheus is built on the Prometheus Java client 1.x; the old 0.x code lives in the deprecated micrometer-registry-prometheus-simpleclient module, which the Micrometer Prometheus documentation presents as an interim solution only. And on Spring Boot 4, a pom.xml that still declares spring-boot-starter-aop no longer resolves: the Spring Boot 4.0 migration guide renames it to spring-boot-starter-aspectj.

Configuring Actuator Endpoints

By default, only the health endpoint is exposed over HTTP. The /actuator/prometheus endpoint exists as soon as the registry is on the classpath, but it returns 404 until it is added to the exposure list.

Spring Boot 3.4 replaced the on/off switches (management.endpoints.enabled-by-default, management.endpoint.<id>.enabled) with an access model, and the old properties are deprecated. The opt-in pattern sets the default access to none and grants read-only per endpoint:

yaml
# application.yml
# Actuator configuration for production (Spring Boot 3.4+ access model)
management:
  endpoints:
    # Every endpoint is inaccessible unless granted below
    access:
      default: none
    web:
      exposure:
        # Endpoints exposed over HTTP
        # health, info, prometheus are the minimum for monitoring
        include: health,info,prometheus,metrics,env,loggers
      base-path: /actuator
  endpoint:
    health:
      access: read-only
      show-details: when-authorized
      show-components: when-authorized
    info:
      access: read-only
    prometheus:
      access: read-only
    metrics:
      access: read-only
    env:
      access: read-only
      # Mask sensitive values for anonymous callers
      show-values: when-authorized
    loggers:
      # unrestricted allows POST to change a log level at runtime
      access: unrestricted

Exposure and access are two separate controls. exposure.include decides whether an endpoint is mapped over HTTP; access decides what the endpoint permits once mapped. A property file that still uses enabled: true keeps working with a deprecation warning, which is the moment to migrate.

The show-details: when-authorized option displays health details only to authenticated users with the appropriate role. The endpoints themselves are secured with a dedicated SecurityFilterChain:

ActuatorSecurityConfig.javajava
// Securing Actuator endpoints (Spring Boot 4, Spring Security 7)
package com.example.monitoring.config;

import static org.springframework.security.web.access.IpAddressAuthorizationManager.hasIpAddress;

import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(proxyBeanMethods = false)
public class ActuatorSecurityConfig {

    @Bean
    SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                // Health and info public for load balancers
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                // Prometheus accessible from the internal network only
                .requestMatchers(EndpointRequest.to("prometheus")).access(hasIpAddress("10.0.0.0/8"))
                // Other endpoints restricted to admins
                .anyRequest().hasRole("ACTUATOR_ADMIN")
            )
            .httpBasic(basic -> {})
            .build();
    }
}

EndpointRequest moved in Spring Boot 4 to org.springframework.boot.security.autoconfigure.actuate.web.servlet, as shown in the Actuator endpoints reference. The Java AuthorizedUrl DSL of authorizeHttpRequests has no hasIpAddress method: the IP rule goes through IpAddressAuthorizationManager.hasIpAddress, available since Spring Security 6.3.

Custom Metrics with Micrometer

Application Counters, Gauges and Timers

Micrometer provides several metric types suited to different use cases. Counters measure cumulative events, gauges measure instantaneous values, and timers measure operation duration.

OrderMetricsService.javajava
// Custom business metrics service
package com.example.monitoring.metrics;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

@Service
public class OrderMetricsService {

    // Counter for orders created
    private final Counter ordersCreatedCounter;
    // Timer to measure processing duration
    private final Timer orderProcessingTimer;
    // Atomic value for pending orders gauge
    private final AtomicInteger pendingOrdersCount = new AtomicInteger(0);

    public OrderMetricsService(MeterRegistry registry) {
        // Exported as orders_created_total: the _total suffix is added by the registry
        this.ordersCreatedCounter = Counter.builder("orders.created")
            .description("Total number of orders created")
            .tag("channel", "web")
            .register(registry);

        // Timer with histogram buckets: percentiles computed server-side in PromQL
        this.orderProcessingTimer = Timer.builder("orders.processing.duration")
            .description("Order processing duration")
            .publishPercentileHistogram()
            .register(registry);

        // Gauge linked to atomic value
        // Read on each scrape
        Gauge.builder("orders.pending.count", pendingOrdersCount, AtomicInteger::get)
            .description("Number of orders pending processing")
            .register(registry);
    }

    public void recordOrderCreated() {
        ordersCreatedCounter.increment();
        pendingOrdersCount.incrementAndGet();
    }

    public void recordOrderProcessed(Runnable processingLogic) {
        // Measures execution duration
        orderProcessingTimer.record(processingLogic);
        pendingOrdersCount.decrementAndGet();
    }

    public <T> T recordOrderProcessedWithResult(Supplier<T> processingLogic) {
        return orderProcessingTimer.record(processingLogic);
    }
}

Two naming details matter with the Prometheus registry. Micrometer meter names use dots and are converted to snake case (orders.processing.duration becomes orders_processing_duration_seconds). Counters receive the _total suffix automatically, so naming a counter orders.created.total only adds noise.

publishPercentiles(0.5, 0.95, 0.99) computes percentiles inside each JVM, and those values cannot be aggregated across instances. publishPercentileHistogram() publishes buckets that histogram_quantile() aggregates across the whole fleet, which is the option to choose behind Prometheus.

@Timed and @Counted Annotations

To avoid boilerplate code, Micrometer provides annotations that instrument methods through AspectJ.

PaymentService.javajava
// Automatic instrumentation with annotations
package com.example.monitoring.service;

import io.micrometer.core.annotation.Counted;
import io.micrometer.core.annotation.Timed;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final PaymentValidator paymentValidator;

    public PaymentService(PaymentValidator paymentValidator) {
        this.paymentValidator = paymentValidator;
    }

    // @Timed creates a Timer
    // Measures each call and publishes count, sum, max and histogram buckets
    @Timed(
        value = "payment.process.duration",
        description = "Payment processing duration",
        histogram = true
    )
    public PaymentResult processPayment(PaymentRequest request) {
        // Call through another bean: the proxy intercepts it
        paymentValidator.validate(request);
        return executePayment(request);
    }

    // @Counted increments a counter on each call
    // Useful for discrete events
    @Counted(
        value = "payment.refunds",
        description = "Total number of refunds"
    )
    public void refundPayment(String transactionId) {
        // Refund logic
    }
}
yaml
# application.yml
# Enables TimedAspect, CountedAspect and the other Micrometer annotation aspects
management:
  observations:
    annotations:
      enabled: true

Since Spring Boot 3.2, declaring TimedAspect and CountedAspect beans by hand is no longer necessary: setting management.observations.annotations.enabled=true with spring-boot-starter-aspectj on the classpath auto-configures them. The property defaults to false, which explains most "my @Timed does nothing" reports.

AOP Limitation

The @Timed and @Counted annotations only work on public methods of Spring beans called through the proxy. A call from one method to another in the same class bypasses the proxy and is not instrumented, and an annotation on a private method is ignored. Annotating a Spring MVC controller that is already instrumented produces duplicate observations.

Ready to ace your Spring Boot interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Custom Health Endpoints

Business Health Indicators

Health Indicators verify the state of external dependencies and critical business components. Spring Boot provides default indicators for databases, Redis, and other common services. In Spring Boot 4, the health API lives in its own spring-boot-health module, and Health and HealthIndicator moved from org.springframework.boot.actuate.health to org.springframework.boot.health.contributor.

PaymentGatewayHealthIndicator.javajava
// Health indicator for payment gateway (Spring Boot 4 packages)
package com.example.monitoring.health;

import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.time.Duration;
import java.time.Instant;

@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {

    private final RestClient restClient;
    private final String gatewayHealthUrl;

    public PaymentGatewayHealthIndicator(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.build();
        this.gatewayHealthUrl = "https://api.payment-gateway.com/health";
    }

    @Override
    public Health health() {
        Instant start = Instant.now();

        try {
            // Call gateway health endpoint
            var response = restClient.get()
                .uri(gatewayHealthUrl)
                .retrieve()
                .toBodilessEntity();

            Duration responseTime = Duration.between(start, Instant.now());

            if (response.getStatusCode().is2xxSuccessful()) {
                return Health.up()
                    .withDetail("responseTime", responseTime.toMillis() + "ms")
                    .withDetail("statusCode", response.getStatusCode().value())
                    .build();
            } else {
                return Health.down()
                    .withDetail("statusCode", response.getStatusCode().value())
                    .withDetail("reason", "Unexpected status code")
                    .build();
            }
        } catch (Exception e) {
            Duration responseTime = Duration.between(start, Instant.now());

            return Health.down()
                .withDetail("error", e.getClass().getSimpleName())
                .withDetail("message", e.getMessage())
                .withDetail("responseTime", responseTime.toMillis() + "ms")
                .build();
        }
    }
}

This indicator appears in /actuator/health under the name paymentGateway: the bean name without the HealthIndicator suffix.

Health Groups for Kubernetes

Spring Boot 4 enables the liveness and readiness probes by default, on every platform and not only when it detects Kubernetes. The /actuator/health/liveness and /actuator/health/readiness groups exist without any configuration, and declaring LivenessStateHealthIndicator or ReadinessStateHealthIndicator beans by hand is unnecessary. Configuration is only needed to add components to a group or to define custom groups:

yaml
# application.yml
# Health groups configuration for Kubernetes
management:
  endpoint:
    health:
      probes:
        # true by default in Spring Boot 4
        enabled: true
        # Also serve the groups at /livez and /readyz on the main server port
        add-additional-paths: true
      group:
        # Readiness: can the application receive traffic?
        readiness:
          include: readinessState,db
        # Custom group for critical dependencies
        critical:
          include: db,paymentGateway
          show-details: when-authorized

The liveness group should stay limited to livenessState. Adding a database or an external gateway to liveness makes Kubernetes restart every pod when that dependency goes down, which turns a partial outage into a full one. External dependencies belong in readiness, and only when the application cannot serve any request without them.

Kubernetes probes then point to dedicated endpoints:

yaml
# kubernetes-deployment.yml
# Kubernetes probes configuration
spec:
  containers:
    - name: order-service
      livenessProbe:
        httpGet:
          path: /livez
          port: 8080
        initialDelaySeconds: 30
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        initialDelaySeconds: 10
        periodSeconds: 5
        failureThreshold: 3

With add-additional-paths: true, the probes stay on the application port even when Actuator runs on a separate management port, so a broken main server is detected by the probe.

Spring Boot Prometheus Integration and Grafana

Prometheus Scrape Configuration

Prometheus collects metrics by periodically querying the /actuator/prometheus endpoint. The scrape configuration defines targets and the metrics path, since Prometheus defaults to /metrics.

yaml
# prometheus.yml
# Prometheus configuration for Spring Boot
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'spring-boot-apps'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 10s
    static_configs:
      - targets:
          - 'order-service:9090'
          - 'payment-service:9090'
          - 'inventory-service:9090'
    # Relabeling to strip the port from the instance label
    relabel_configs:
      - source_labels: [__address__]
        target_label: instance
        regex: '([^:]+):\d+'
        replacement: '${1}'

  # Kubernetes service discovery
  - job_name: 'kubernetes-pods'
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      # Only scrape pods with annotation
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: true
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
        action: replace
        target_label: __metrics_path__
        regex: (.+)

The targets use port 9090 because the production configuration further down moves Actuator to a separate management port. Without management.server.port, the scrape target is the application port.

Spring Boot Actuator Metrics Published by Default

Actuator with Micrometer exposes JVM, process, HikariCP and HTTP metrics without any code. These PromQL queries cover the ones watched first in production:

promql
# PromQL queries for JVM monitoring

# Heap memory usage, summed across heap pools
sum by (instance) (jvm_memory_used_bytes{area="heap"})

# Heap usage percentage
sum by (instance) (jvm_memory_used_bytes{area="heap"})
  / sum by (instance) (jvm_memory_max_bytes{area="heap"}) * 100

# Live threads
jvm_threads_live_threads

# Garbage collection: seconds spent paused per second
rate(jvm_gc_pause_seconds_sum[5m])

# GC pauses per minute
rate(jvm_gc_pause_seconds_count[1m]) * 60

# CPU used by the JVM process
process_cpu_usage

# Active database connections
hikaricp_connections_active

# Connection pool utilization
hikaricp_connections_active / hikaricp_connections_max * 100

jvm_memory_used_bytes{area="heap"} returns one series per memory pool (Eden, Survivor, Old Gen with G1). Dividing it by the max without sum by (instance) produces per-pool ratios that look wrong on a dashboard.

A MeterBinder bean adds application-level gauges that the defaults do not cover:

CustomJvmMetrics.javajava
// Additional JVM metrics
package com.example.monitoring.metrics;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import org.springframework.stereotype.Component;

import java.lang.management.ManagementFactory;
import java.lang.management.OperatingSystemMXBean;

@Component
public class CustomJvmMetrics implements MeterBinder {

    @Override
    public void bindTo(MeterRegistry registry) {
        OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();

        // System load average
        Gauge.builder("system.load.average", osBean, OperatingSystemMXBean::getSystemLoadAverage)
            .description("System load average over 1 minute")
            .register(registry);

        // Available processors count
        Gauge.builder("system.cpu.count", osBean, OperatingSystemMXBean::getAvailableProcessors)
            .description("Number of available processors")
            .register(registry);

        // Application uptime
        Gauge.builder("application.uptime.seconds",
                ManagementFactory.getRuntimeMXBean(),
                bean -> bean.getUptime() / 1000.0)
            .description("Application uptime in seconds")
            .register(registry);
    }
}

system.cpu.count and process.uptime are already published by Micrometer's built-in binders. The example shows the pattern, and a real binder should only register what the defaults lack.

Ready-to-Use Grafana Dashboards

Grafana offers community dashboards for Spring Boot that work on the standard Actuator metrics. A hand-written dashboard uses the timeseries and stat panel types:

json
{
  "annotations": {
    "list": []
  },
  "panels": [
    {
      "title": "Request Rate",
      "type": "timeseries",
      "targets": [
        {
          "expr": "sum by (method, uri, status) (rate(http_server_requests_seconds_count{application=\"$application\"}[5m]))",
          "legendFormat": "{{method}} {{uri}} - {{status}}"
        }
      ]
    },
    {
      "title": "Response Time P99",
      "type": "timeseries",
      "targets": [
        {
          "expr": "histogram_quantile(0.99, sum by (le, method, uri) (rate(http_server_requests_seconds_bucket{application=\"$application\"}[5m])))",
          "legendFormat": "{{method}} {{uri}}"
        }
      ]
    },
    {
      "title": "Error Rate",
      "type": "stat",
      "targets": [
        {
          "expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{application=\"$application\"}[5m])) * 100"
        }
      ]
    }
  ]
}

Many dashboards copied from older tutorials still use the graph and singlestat panel types. Singlestat was removed in Grafana 8, and Grafana 12 removed AngularJS support entirely, so those panels only render after migration to timeseries and stat.

Grafana Import

To import a community dashboard: Grafana > Dashboards > New > Import, then enter ID 4701 (JVM Micrometer) or 12900 (SpringBoot APM). Check the panel types after import: older revisions of community dashboards may still contain Angular panels that Grafana 12 has to migrate.

Alerting with Prometheus

Essential Alert Rules

Prometheus alert rules trigger notifications when metrics exceed critical thresholds.

yaml
# alerting-rules.yml
# Alert rules for Spring Boot applications
groups:
  - name: spring-boot-alerts
    rules:
      # Alert if application is down
      - alert: ApplicationDown
        expr: up{job="spring-boot-apps"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Application {{ $labels.instance }} is down"
          description: "{{ $labels.instance }} has been down for more than 1 minute"

      # Alert on HTTP error rate
      - alert: HighErrorRate
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) by (application)
          /
          sum(rate(http_server_requests_seconds_count[5m])) by (application)
          > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High error rate on {{ $labels.application }}"
          description: "Error rate is {{ $value | humanizePercentage }}"

      # Alert on P99 latency, aggregated per application
      - alert: HighLatency
        expr: |
          histogram_quantile(0.99,
            sum by (le, application) (rate(http_server_requests_seconds_bucket[5m]))
          ) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High latency on {{ $labels.application }}"
          description: "P99 latency is {{ $value | humanizeDuration }}"

      # Heap memory alert
      - alert: HighHeapUsage
        expr: |
          sum by (instance) (jvm_memory_used_bytes{area="heap"})
          / sum by (instance) (jvm_memory_max_bytes{area="heap"})
          > 0.85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High heap memory usage on {{ $labels.instance }}"
          description: "Heap usage is at {{ $value | humanizePercentage }}"

      # Database connection pool exhausted alert
      - alert: DatabaseConnectionPoolExhausted
        expr: |
          hikaricp_connections_active
          / hikaricp_connections_max
          > 0.9
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Database connection pool nearly exhausted"
          description: "{{ $value | humanizePercentage }} of connections in use"

      # Excessive GC alert
      - alert: HighGCPause
        expr: |
          rate(jvm_gc_pause_seconds_sum[5m])
          / rate(jvm_gc_pause_seconds_count[5m])
          > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High GC pause time"
          description: "Average GC pause is {{ $value | humanizeDuration }}"

These alerts cover the most common production issues: availability, performance, and resources. The HighLatency rule needs sum by (le, ...): without it, histogram_quantile computes one P99 per series (per URI, method and status), and a single slow endpoint with three calls fires the alert.

Spring Boot HTTP Metrics and Database Metrics

Automatic HTTP Request Instrumentation

Spring Boot instruments every incoming Spring MVC and WebFlux request through the Observation API and publishes the http.server.requests timer, tagged with method, uri, status, outcome and exception.

yaml
# application.yml
# HTTP metrics configuration
management:
  metrics:
    distribution:
      # Histogram buckets for server-side percentiles
      percentiles-histogram:
        http.server.requests: true
      # SLO buckets: count requests faster than each boundary
      slo:
        http.server.requests: 100ms, 500ms, 1s, 2s
    tags:
      # Common tags added to every meter
      application: ${spring.application.name}
      environment: ${spring.profiles.active:default}

The WebMvcTagsContributor and WebMvcTagsProvider interfaces found in many examples were removed in Spring Boot 3.0. Custom tags are now added by extending DefaultServerRequestObservationConvention, as the Spring Boot metrics reference documents:

TenantObservationConvention.javajava
// Adds custom tags to http.server.requests (Spring Boot 3.0+)
package com.example.monitoring.config;

import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.http.server.observation.DefaultServerRequestObservationConvention;
import org.springframework.http.server.observation.ServerRequestObservationContext;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;

@Component
public class TenantObservationConvention extends DefaultServerRequestObservationConvention {

    @Override
    public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) {
        HttpServletRequest request = context.getCarrier();
        String userId = request.getHeader("X-User-Id");
        String tenantId = request.getHeader("X-Tenant-Id");

        // Keep the default tags and add two bounded ones
        return super.getLowCardinalityKeyValues(context).and(
            KeyValue.of("user.type", userId != null ? "authenticated" : "anonymous"),
            KeyValue.of("tenant", tenantId != null ? tenantId : "default")
        );
    }
}

Only low-cardinality key-values become Prometheus labels. A tenant tag is acceptable with a few dozen tenants and becomes a cardinality problem with thousands. High-cardinality values (user ID, order ID) belong in getHighCardinalityKeyValues, which feeds traces only. The tracing side of the Observation API is detailed in Spring Boot observability with OpenTelemetry.

HikariCP and SQL Query Metrics

HikariCP pool metrics are exposed automatically with the hikaricp prefix, tagged by pool name, alongside the generic jdbc.connections gauges. Setting the pool name makes the tag readable:

yaml
# application.yml
# HikariCP pool configuration
spring:
  datasource:
    hikari:
      # Becomes the "pool" tag on hikaricp_* metrics
      pool-name: OrderServicePool
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

Query-level timing is not covered by Spring Boot. The datasource-micrometer project wraps the DataSource and creates an observation per connection, query and result set, which yields both metrics and trace spans. Its 2.x line targets Spring Boot 4, the 1.x line Spring Boot 3:

xml
<!-- pom.xml -->
<!-- JDBC observations: 2.x for Spring Boot 4, 1.x for Spring Boot 3 -->
<dependency>
    <groupId>net.ttddyy.observation</groupId>
    <artifactId>datasource-micrometer-spring-boot</artifactId>
    <version>2.0.1</version>
</dependency>

This replaces the manual approach of wrapping the DataSource in a ProxyDataSourceBuilder with a hand-written listener, which bypassed Spring Boot's DataSource auto-configuration and had to be kept in sync with every Boot upgrade. Slow queries usually point to an N+1 pattern, covered in Spring Data JPA N+1 query solutions.

Production Best Practices

Metrics Cardinality

Excessive cardinality degrades Prometheus performance. Each unique tag combination creates a distinct time series.

AntiPatternHighCardinality.javajava
// AVOID: unbounded cardinality
package com.example.monitoring.antipattern;

@Service
public class AntiPatternHighCardinality {

    private final MeterRegistry registry;

    // BAD: userId creates one series per user
    public void trackUserAction(String userId, String action) {
        Counter.builder("user.actions")
            .tag("userId", userId)  // Millions of possible values
            .tag("action", action)
            .register(registry)
            .increment();
    }
}
GoodPracticeCardinality.javajava
// Controlled cardinality
package com.example.monitoring.bestpractice;

@Service
public class GoodPracticeCardinality {

    private final MeterRegistry registry;

    // GOOD: user category instead of ID
    public void trackUserAction(User user, String action) {
        Counter.builder("user.actions")
            .tag("userType", user.getSubscriptionType())  // FREE, PREMIUM, ENTERPRISE
            .tag("action", action)
            .register(registry)
            .increment();
    }

    // GOOD: let a timer histogram bucket durations
    public void trackResponseTime(long responseTimeMs) {
        Timer.builder("response.time")
            .publishPercentileHistogram()
            .register(registry)
            .record(Duration.ofMillis(responseTimeMs));
    }
}

A Timer with histogram buckets answers latency questions better than a counter with a hand-made bucket tag: PromQL can compute any percentile from the buckets, and the bucket boundaries stay consistent across services.

Production-Ready Configuration

yaml
# application-production.yml
# Production configuration (Spring Boot 4)
management:
  server:
    # Separate port for management endpoints, not exposed publicly
    port: 9090
  endpoints:
    access:
      default: none
    web:
      exposure:
        include: health,info,prometheus
  endpoint:
    health:
      access: read-only
      show-details: when-authorized
      probes:
        add-additional-paths: true
    info:
      access: read-only
    prometheus:
      access: read-only
  observations:
    annotations:
      enabled: true
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      minimum-expected-value:
        http.server.requests: 1ms
      maximum-expected-value:
        http.server.requests: 30s
    tags:
      application: ${spring.application.name}
      environment: production
      version: ${app.version:unknown}

With access.default: none, endpoints such as env, beans, configprops and mappings are inaccessible without listing them one by one. management.prometheus.metrics.export.enabled defaults to true, and a step setting has no effect on Prometheus: it is a pull-based registry, and the scrape interval is set in prometheus.yml.

Common Errors with Actuator and Prometheus

SymptomCauseFix
/actuator/prometheus returns 404Endpoint not in the exposure list, or registry missingAdd prometheus to management.endpoints.web.exposure.include and check micrometer-registry-prometheus is on the classpath
Prometheus target shows "server returned HTTP status 401"Spring Security protects every endpoint except healthOpen prometheus to the scraper's network or configure basic auth in the scrape config
@Timed produces no metricmanagement.observations.annotations.enabled is false by default, or AspectJ is missingSet the property to true and add spring-boot-starter-aspectj
spring-boot-starter-aop not found after upgradeRenamed in Spring Boot 4Use spring-boot-starter-aspectj
Health or HealthIndicator import fails on Boot 4Package moved to org.springframework.boot.health.contributorUpdate the imports
Deprecation warnings on management.endpoint.*.enabledOn/off model replaced in Spring Boot 3.4Use management.endpoints.access.default and management.endpoint.<id>.access
Percentiles differ wildly between dashboard and realitypublishPercentiles values averaged across podsUse publishPercentileHistogram() and histogram_quantile()

Interview Questions on Spring Boot Micrometer

Actuator comes up in most Spring Boot interviews for mid-level and senior roles. The answers that separate a confirmed profile from a junior one:

  • Difference between liveness and readiness: liveness tells Kubernetes to restart the pod, readiness tells it to stop routing traffic. A candidate who puts the database in the liveness group has never run it through an outage.
  • Why Micrometer and not the Prometheus client directly: Micrometer is a facade. The same Counter and Timer code exports to Prometheus, Datadog or OTLP by changing a dependency, and Spring's own instrumentation (HTTP, JDBC pool, caches) is written against it.
  • Client-side percentiles versus histograms: client-side percentiles cannot be aggregated across instances, histograms can. This is the most common trick question on the topic.
  • What cardinality means and how it breaks Prometheus: one time series per unique label combination; a user ID tag multiplies memory use by the number of users.
  • How to secure Actuator: limit exposure, set access levels, run management on a separate port, and protect it with a dedicated SecurityFilterChain.

More questions of this kind are collected in the Spring Boot Actuator interview questions module.

Sources

What to Retain About Spring Boot Micrometer and Prometheus

  • Three steps to a working scrape: Actuator starter, micrometer-registry-prometheus, prometheus in the exposure list
  • Access model since 3.4: management.endpoints.access.default: none plus read-only per endpoint replaces the deprecated enabled flags
  • Spring Boot 4 moves: spring-boot-starter-aspectj, health API in org.springframework.boot.health.contributor, EndpointRequest in org.springframework.boot.security.autoconfigure.actuate.web.servlet, probes on by default
  • Annotations need a switch: management.observations.annotations.enabled=true, no manual TimedAspect bean
  • HTTP tags through the Observation API: extend DefaultServerRequestObservationConvention, keep labels low-cardinality
  • Histograms over client percentiles: publishPercentileHistogram() and sum by (le) in every histogram_quantile
  • Liveness stays local: external dependencies go in readiness or a custom group, never in liveness

These defaults give a Spring Boot 4 service metrics that aggregate correctly across pods, endpoints that leak nothing to the public network, and alerts that fire on real incidents rather than on one slow URI.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Daily challenge

Can you spot the bug in Spring Boot?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on October 26, 2026

Tags

#spring boot actuator
#micrometer
#prometheus
#monitoring
#observability

Share

Related articles