Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Actuator & Observability Endpoints

Every Actuator endpoint worth exposing, writing custom health indicators for Kubernetes probes, adding Micrometer metrics that answer real questions, and securing it all.

Intermediate5 min readUpdated
On this page

Actuator turns a Spring Boot application from a black box into something you can interrogate. Out of the box it answers "is it up", "what is it configured with" and "what is it doing right now" — and with a little work it answers questions specific to your domain, which is where the real value is.

Key Takeaways

  • Only health and info are exposed over HTTP by default; everything else is opt-in.
  • Liveness and readiness are different questions — conflating them causes restart storms during dependency outages.
  • A custom HealthIndicator is about ten lines and is what makes readiness meaningful.
  • Micrometer meters are dimensional: tag them, but never with unbounded values like a user ID.
  • Run management endpoints on a separate port in production.

The endpoints worth knowing

EndpointAnswersExpose publicly?
/actuator/healthIs the app up, and are its dependencies reachable?Yes (without details)
/actuator/infoWhich build and commit is this?Yes
/actuator/metricsWhat are the numbers?No
/actuator/prometheusSame, in scrape formatInternal only
/actuator/loggersCurrent log levels — and changes them at runtimeNo
/actuator/envEffective configuration and its sourceNo
/actuator/configpropsBound @ConfigurationProperties valuesNo
/actuator/beansEvery bean in the contextNo
/actuator/conditionsThe auto-configuration reportNo
/actuator/mappingsEvery request mappingNo
/actuator/threaddumpLive thread statesNo
/actuator/heapdumpA full heap dump fileNever

/actuator/loggers deserves special mention: it lets you raise a package to DEBUG on a running instance and drop it back, with no redeploy.

terminal
curl -X POST localhost:8080/actuator/loggers/com.acme.payments \
  -H 'Content-Type: application/json' \
  -d '{"configuredLevel":"DEBUG"}'

Health, liveness and readiness

Dependency health belongs to readiness. Putting it in liveness restarts every pod during an outage the restart cannot fix.

Enable the probe groups explicitly:

application.yml
management:
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true
      group:
        readiness:
          include: 'readinessState,db,redis'
        liveness:
          include: 'livenessState'
  endpoints:
    web:
      exposure:
        include: 'health,info,prometheus'
  server:
    port: 9090          # separate management port

That gives you /actuator/health/liveness and /actuator/health/readiness as distinct endpoints with distinct contributor sets.

A custom health indicator

PaymentGatewayHealthIndicator.java
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
 
    private final RestClient client;
 
    public PaymentGatewayHealthIndicator(RestClient.Builder builder,
                                         @Value("${app.gateway.base-url}") String baseUrl) {
        this.client = builder.baseUrl(baseUrl).build();
    }
 
    @Override
    public Health health() {
        long start = System.nanoTime();
        try {
            var status = client.get().uri("/ping").retrieve().toBodilessEntity();
            long millis = (System.nanoTime() - start) / 1_000_000;
 
            // Slow but reachable is still degraded — surface it rather than
            // reporting a flat UP and waiting for user complaints.
            return (millis > 500 ? Health.status("DEGRADED") : Health.up())
                    .withDetail("latencyMillis", millis)
                    .withDetail("httpStatus", status.getStatusCode().value())
                    .build();
 
        } catch (RestClientException ex) {
            return Health.down(ex).withDetail("baseUrl", client.toString()).build();
        }
    }
}

The bean name determines the key in the JSON: PaymentGatewayHealthIndicator becomes paymentGateway. Keep the check cheap and give it a timeout — a health endpoint that blocks for thirty seconds turns a partial outage into a total one, because the orchestrator's probe times out too.

Metrics that answer questions

Micrometer is a facade over the metrics backend, in the same way SLF4J is over logging. Four instrument types cover almost everything:

  • Counter — a monotonically increasing count. Orders placed, retries attempted.
  • Gauge — a value sampled on scrape. Queue depth, cache size.
  • Timer — duration plus count, with percentiles. Any operation you care about the latency of.
  • DistributionSummary — like a timer but for non-time values. Payload sizes, basket totals.
OrderMetrics.java
@Service
public class OrderService {
 
    private final Counter placed;
    private final Timer settlement;
    private final MeterRegistry registry;
 
    public OrderService(MeterRegistry registry, OrderQueue queue) {
        this.registry = registry;
 
        this.placed = Counter.builder("orders.placed")
                .description("Orders accepted for processing")
                .register(registry);
 
        this.settlement = Timer.builder("orders.settlement")
                .publishPercentiles(0.5, 0.95, 0.99)
                .register(registry);
 
        // A gauge holds a weak reference to the object it samples. Keep the
        // referent alive, or the gauge quietly starts reporting NaN.
        Gauge.builder("orders.queue.depth", queue, OrderQueue::size).register(registry);
    }
 
    public Receipt place(Order order) {
        return settlement.record(() -> {
            Receipt receipt = doPlace(order);
            // Tag with a bounded dimension: a handful of channels, not a user id.
            registry.counter("orders.placed", "channel", order.channel()).increment();
            placed.increment();
            return receipt;
        });
    }
}

The cardinality rule is not a style preference. Every distinct combination of tag values creates a separate time series in Prometheus. Tagging by userId on a site with a million users creates a million series and will take the monitoring stack down before it takes your application down.

For simple cases the annotations are enough — @Timed and @Counted on a method, provided you have registered a TimedAspect bean.

Common tags and build info

MetricsConfig.java
@Configuration
public class MetricsConfig {
 
    @Bean
    MeterRegistryCustomizer<MeterRegistry> commonTags(
            @Value("${spring.application.name}") String app,
            @Value("${app.environment}") String env) {
        return registry -> registry.config().commonTags("application", app, "env", env);
    }
}

Common tags let one Grafana dashboard serve every service. Pair it with build information in /actuator/info so a dashboard can show which commit is running:

application.yml
management:
  info:
    env:
      enabled: true
    git:
      mode: full
info:
  app:
    name: '@project.artifactId@'
    version: '@project.version@'

Securing the endpoints

Two layers, both worth having:

ActuatorSecurity.java
@Bean
SecurityFilterChain actuator(HttpSecurity http) throws Exception {
    http.securityMatcher(EndpointRequest.toAnyEndpoint())
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class)).permitAll()
            .anyRequest().hasRole("OPS"))
        .httpBasic(Customizer.withDefaults())
        .csrf(AbstractHttpConfigurer::disable);
    return http.build();
}

The second layer is the network: management.server.port moves everything except your application traffic onto a port your ingress does not route. Even if the security configuration is wrong, the endpoints are unreachable from outside the cluster.

What to take away

Expose health and info publicly, everything else on an internal port behind authentication. Write health indicators for the dependencies whose failure should stop traffic, and put them in the readiness group only. Add timers and counters for the operations you would want a graph of during an incident — and keep the tags bounded.

Frequently Asked Questions

What is the difference between liveness and readiness?
Liveness answers "is this process broken beyond recovery?" — a failure gets the container restarted. Readiness answers "can this instance serve traffic right now?" — a failure removes it from the load balancer but leaves it running. A database outage should fail readiness, never liveness, otherwise Kubernetes restarts every pod during an outage it cannot fix.
Is it safe to expose /actuator publicly?
No. /actuator/env, /actuator/configprops and /actuator/heapdump leak configuration and memory contents. Expose only health and info publicly, and move the rest to a separate management port that is not routed from the internet.
Why are my custom metrics missing from Prometheus?
Two usual causes. Either the meter was registered but never observed a value — Micrometer only publishes a meter after first use — or the Prometheus endpoint is not in management.endpoints.web.exposure.include. Check /actuator/metrics first to confirm the meter exists at all.

Related tutorials