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 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.
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.
<!-- 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:
# 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: unrestrictedExposure 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:
// 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.
// 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.
// 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
}
}# application.yml
# Enables TimedAspect, CountedAspect and the other Micrometer annotation aspects
management:
observations:
annotations:
enabled: trueSince 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.
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.
// 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:
# 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-authorizedThe 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:
# 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: 3With 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.
# 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 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 * 100jvm_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:
// 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:
{
"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.
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.
# 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.
# 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:
// 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:
# 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: 1800000Query-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:
<!-- 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.
// 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();
}
}// 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
# 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
| Symptom | Cause | Fix |
|---|---|---|
/actuator/prometheus returns 404 | Endpoint not in the exposure list, or registry missing | Add 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 health | Open prometheus to the scraper's network or configure basic auth in the scrape config |
@Timed produces no metric | management.observations.annotations.enabled is false by default, or AspectJ is missing | Set the property to true and add spring-boot-starter-aspectj |
spring-boot-starter-aop not found after upgrade | Renamed in Spring Boot 4 | Use spring-boot-starter-aspectj |
Health or HealthIndicator import fails on Boot 4 | Package moved to org.springframework.boot.health.contributor | Update the imports |
Deprecation warnings on management.endpoint.*.enabled | On/off model replaced in Spring Boot 3.4 | Use management.endpoints.access.default and management.endpoint.<id>.access |
| Percentiles differ wildly between dashboard and reality | publishPercentiles values averaged across pods | Use 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
CounterandTimercode 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
- Spring Boot 4.0 Migration Guide:
spring-boot-starter-aoprenamed tospring-boot-starter-aspectj, probes enabled by default, newspring-boot-healthmodule - Spring Boot 3.4 Release Notes:
enabled-by-defaultandendpoint.<id>.enableddeprecated in favor of the access model - Spring Boot Actuator endpoints reference: access properties,
EndpointRequestpackage, health indicators inorg.springframework.boot.health.contributor, probe groups and additional paths - Spring Boot metrics reference:
DefaultServerRequestObservationConvention, HikariCP andjdbc.connectionsmetrics, simpleclient deprecation - Spring Boot observability reference:
management.observations.annotations.enabledand the AspectJ requirement - Micrometer Prometheus documentation: Prometheus Java client 1.x registry and the legacy simpleclient module
- datasource-micrometer on GitHub: 2.x for Spring Boot 4, 1.x for Spring Boot 3
- Grafana: AngularJS removal in Grafana 12: Graph and Singlestat panels replaced by Time series and Stat
- Spring Boot releases: 4.1.2 as the current GA release, 4.2.0-RC1 in pre-release
What to Retain About Spring Boot Micrometer and Prometheus
- Three steps to a working scrape: Actuator starter,
micrometer-registry-prometheus,prometheusin the exposure list - Access model since 3.4:
management.endpoints.access.default: noneplusread-onlyper endpoint replaces the deprecatedenabledflags - Spring Boot 4 moves:
spring-boot-starter-aspectj, health API inorg.springframework.boot.health.contributor,EndpointRequestinorg.springframework.boot.security.autoconfigure.actuate.web.servlet, probes on by default - Annotations need a switch:
management.observations.annotations.enabled=true, no manualTimedAspectbean - HTTP tags through the Observation API: extend
DefaultServerRequestObservationConvention, keep labels low-cardinality - Histograms over client percentiles:
publishPercentileHistogram()andsum by (le)in everyhistogram_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.
Can you spot the bug in Spring Boot?
One real snippet, one hidden bug, one attempt a day. No account needed to try.

Written by
Anthony Fillion-MailletFounder of SharpSkill
Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.
Updated on October 26, 2026
Tags
Share
Related articles

Spring Boot Observability in 2026: OpenTelemetry, Distributed Tracing and Interview Questions
Master Spring Boot observability with OpenTelemetry and Micrometer Tracing. Learn distributed tracing setup, the Observation API, OTLP export configuration, and prepare for technical interviews.

Spring Boot Structured Logging in 2026: JSON Logs for Production with Logback and OpenTelemetry
Complete guide to Spring Boot 4.x structured logging. Native JSON support, OpenTelemetry starter, MDC tracing, and ELK Stack integration for production observability.

Freelance Java Spring Boot developer jobs in 2026: where to find contracts, day rates, how to prepare
Where to find freelance Spring Boot contracts in 2026, current day rates across Europe, and how to prepare for technical interviews.