Home / Spring Boot / Production and Testing / Actuator and Monitoring

Actuator and Monitoring

Actuator gives your application production features: health checks, metrics, environment information and more, all as HTTP endpoints. It is the base for monitoring with Prometheus and Grafana.

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management.endpoints.web.exposure.include=health,info,metrics,prometheus,loggers
management.endpoint.health.show-details=when-authorized
management.endpoint.health.probes.enabled=true
# run management endpoints on a separate internal port
management.server.port=9090

Important endpoints

EndpointShows
/actuator/healthUP or DOWN, plus details for database, disk, and so on
/actuator/infoApplication information (name, version, git commit)
/actuator/metricsMetrics such as jvm.memory.used, http.server.requests
/actuator/prometheusAll metrics in Prometheus format
/actuator/loggersView and change log levels at runtime
/actuator/env, /configpropsConfiguration values (sensitive, protect them)
/actuator/beans, /mappings, /conditionsAll beans, URL mappings and auto-configuration decisions
/actuator/threaddump, /heapdumpDiagnostics (sensitive, protect them)

By default only health is exposed over HTTP. Expose only what you need and protect the rest with Spring Security or a separate management port.

Liveness and readiness (Kubernetes)

GET /actuator/health/liveness   → is the process alive? (restart if DOWN)
GET /actuator/health/readiness  → can it accept traffic? (remove from load balancer if DOWN)

Custom health indicator

// Boot 3.x imports. In Boot 4 the health classes moved to org.springframework.boot.health.*
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;

@Component
public class PaymentGatewayHealth implements HealthIndicator {

    private final PaymentClient client;
    public PaymentGatewayHealth(PaymentClient client) { this.client = client; }

    @Override
    public Health health() {
        try {
            client.ping();
            return Health.up().withDetail("gateway", "reachable").build();
        } catch (Exception ex) {
            return Health.down(ex).withDetail("gateway", "unreachable").build();
        }
    }
}

Custom metrics with Micrometer

@Service
public class CheckoutService {

    private final Counter orders;
    private final Timer timer;

    public CheckoutService(MeterRegistry registry) {
        this.orders = Counter.builder("shop.orders.created").description("Orders created").register(registry);
        this.timer = Timer.builder("shop.checkout.time").register(registry);
    }

    public void checkout() {
        timer.record(() -> {
            // business work
            orders.increment();
        });
    }
}
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
    <scope>runtime</scope>
</dependency>
# prometheus.yml
scrape_configs:
  - job_name: shop
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ['localhost:9090']

Prometheus collects the metrics and Grafana draws dashboards. Alert on error rate, response time (p95, p99), JVM memory, and database pool usage (hikaricp.connections.active).

Info endpoint

management.info.env.enabled=true
info.app.name=shop
info.app.version=1.0.0

How endpoints work internally

@Endpoint(id = "features")  ─►  technology neutral endpoint model (operations: read, write, delete)
                                       ├─ web exposure:  WebEndpointDiscoverer  ─► /actuator/features   (HTTP, when exposed via management.endpoints.web.exposure.include)
                                       └─ JMX exposure:  JmxEndpointDiscoverer  ─► MBean
Two switches per endpoint: ENABLED (exists) and EXPOSED (reachable over HTTP or JMX). Only health is exposed over HTTP by default.

Write your own endpoint

@Component
@Endpoint(id = "features")
public class FeaturesEndpoint {

    private final Map<String, Boolean> flags = new ConcurrentHashMap<>(Map.of("new-checkout", true, "beta-search", false));

    @ReadOperation                                                      // GET /actuator/features
    public Map<String, Boolean> all() { return flags; }

    @ReadOperation                                                      // GET /actuator/features/new-checkout
    public Boolean one(@Selector String name) { return flags.get(name); }

    @WriteOperation                                                     // POST /actuator/features/new-checkout  {"enabled": false}
    public void set(@Selector String name, boolean enabled) { flags.put(name, enabled); }
}
// management.endpoints.web.exposure.include=health,info,features

Health groups and probes

management.endpoint.health.probes.enabled=true
management.endpoint.health.group.readiness.include=readinessState,db,redis      # what makes the app "not ready"
management.endpoint.health.group.liveness.include=livenessState                 # keep liveness simple: only "the process works"
management.endpoint.health.show-components=always
management.health.diskspace.enabled=true

Never put the database in the liveness group. A database outage would make Kubernetes restart every pod, which only makes things worse. Put it in readiness so traffic stops but pods stay alive.

Info: build and git data

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions><execution><goals><goal>build-info</goal></goals></execution></executions>   <!-- META-INF/build-info.properties -->
</plugin>
<plugin>
    <groupId>io.github.git-commit-id</groupId>
    <artifactId>git-commit-id-maven-plugin</artifactId>                                     <!-- git.properties -->
</plugin>
management.info.git.mode=full
management.info.build.enabled=true
management.info.env.enabled=true
info.team=payments

Micrometer meter types

@Component
public class QueueMetrics {

    public QueueMetrics(MeterRegistry registry, BlockingQueue<Job> queue) {
        Gauge.builder("jobs.queue.size", queue, BlockingQueue::size).description("Jobs waiting").register(registry);   // current value, goes up and down
    }
}

@Service
public class PaymentService {
    private final Counter failures;
    private final Timer latency;
    private final DistributionSummary amounts;

    public PaymentService(MeterRegistry registry) {
        this.failures = Counter.builder("payments.failed").tag("gateway", "razorpay").register(registry);           // only increases
        this.latency = Timer.builder("payments.latency").publishPercentileHistogram().register(registry);          // duration and count, percentiles
        this.amounts = DistributionSummary.builder("payments.amount").baseUnit("inr").register(registry);          // size distribution
    }

    public void pay(BigDecimal amount) {
        latency.record(() -> {
            try { /* call gateway */ amounts.record(amount.doubleValue()); }
            catch (RuntimeException e) { failures.increment(); throw e; }
        });
    }
}
management.metrics.distribution.percentiles-histogram.http.server.requests=true    # enables histogram buckets for p95/p99 in Prometheus
management.metrics.tags.application=${spring.application.name}                         # tag on every metric

Secure Actuator with Spring Security

@Bean
@Order(1)
SecurityFilterChain actuator(HttpSecurity http) throws Exception {
    http.securityMatcher(EndpointRequest.toAnyEndpoint())
        .authorizeHttpRequests(a -> a
            .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
            .requestMatchers(EndpointRequest.to("prometheus")).hasRole("MONITORING")
            .anyRequest().hasRole("ADMIN"))
        .httpBasic(Customizer.withDefaults());
    return http.build();
}

Alert rules that matter (Prometheus)

groups:
- name: shop
  rules:
  - alert: HighErrorRate
    expr: sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m])) > 0.02
    for: 5m
  - alert: SlowRequestsP99
    expr: histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) > 1
    for: 10m
  - alert: DbPoolWaiting
    expr: hikaricp_connections_pending > 0
    for: 2m
  - alert: HeapAlmostFull
    expr: sum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"}) > 0.9
    for: 10m

Common mistake: Exposing all Actuator endpoints (include=*) on the public internet. env, heapdump and beans can leak secrets and internals. Expose only what you need and secure them.

Interview questions

What is the difference between liveness and readiness?

Liveness tells whether the application should be restarted. Readiness tells whether it can receive traffic right now, for example after startup or during a database outage.

What is Micrometer?

The metrics facade used by Spring Boot, like SLF4J for metrics. It sends the same metrics to Prometheus, Datadog, CloudWatch and others.

How do I trace a request across services?

Add Micrometer Tracing with OpenTelemetry. Every request gets a trace id that is passed between services and shown in the logs.

Why should the readiness group contain the database but liveness not?

If liveness fails, the platform restarts the pod. A database outage would restart all pods without fixing anything. Readiness only removes the pod from load balancing.

Is /actuator/prometheus safe to expose?

It leaks internal names and traffic data. Expose it only on a private management port or protect it with authentication.