Skip to content

Spring Boot · Actuator & Observability

Custom Health Indicators and Micrometer Metrics in Spring Boot

8 min read Updated 2026-06-26 Share:

Practice Custom Health & Metrics interview questions

When "it's running" isn't the same as "it's healthy"

A process can be up and still be useless — its database is unreachable, its message queue is full, its downstream API is timing out. Spring Boot Actuator exists to answer the more interesting question: not "is the JVM alive?" but "is this instance actually able to do its job?" Two features carry that weight. Health indicators report whether your dependencies are working, and Micrometer metrics quantify how well everything is performing. Together they're what your load balancer, your Kubernetes cluster, and your Grafana dashboards all read from. Let's build both from the ground up.

Writing a custom HealthIndicator

The /actuator/health endpoint is an aggregate of many small checks, each contributed by a bean implementing HealthIndicator. You implement one method, run a quick probe, and return a Health object:

@Component                              // bean name "queue" → key under components
public class QueueHealthIndicator implements HealthIndicator {
    private final MessageQueue queue;

    @Override
    public Health health() {
        if (queue.isReachable()) {
            return Health.up()                      // contributes UP
                .withDetail("depth", queue.size())  // extra diagnostic info
                .build();
        }
        return Health.down()                        // contributes DOWN
            .withDetail("error", "queue unreachable")
            .build();
    }
}

Spring discovers every HealthIndicator bean automatically — no registration. The bean name minus the HealthIndicator suffix becomes the component key (queue here), and the withDetail map shows up under that key when details are exposed. The cardinal rule: keep the check fast and side-effect-free. The health endpoint may be hit every few seconds by a probe, so a slow check becomes a self-inflicted performance problem.

In a WebFlux application you implement ReactiveHealthIndicator instead, returning a Mono<Health> so the check stays non-blocking — and you convert errors into a DOWN with onErrorResume rather than letting the Mono fail.

The Health and Status model

Every Health carries a Status — a string code with four well-known values, ordered by severity: DOWN, OUT_OF_SERVICE, UP, and UNKNOWN. The overall endpoint status is computed by a StatusAggregator that takes the worst status among all contributors. That's the single most important thing to understand about health: one failing component drags the whole instance down.

# /actuator/health with details enabled
status: DOWN              # ← the worst of the components below
components:
  db:    { status: UP }
  redis: { status: DOWN } # this one alone makes the aggregate DOWN
  disk:  { status: UP }

Each status maps to an HTTP code through an HttpCodeStatusMapper: by default UP/UNKNOWN return 200 and DOWN/OUT_OF_SERVICE return 503. This matters enormously, because load balancers and probes react to the HTTP code, not the JSON body. If you define a custom status, map it explicitly:

management:
  endpoint:
    health:
      status:
        http-mapping:
          fatal: 500          # a custom Status you returned via Health.status("FATAL")
        order: FATAL,DOWN,OUT_OF_SERVICE,UP,UNKNOWN   # severity order for aggregation

Boot also auto-configures indicators for the infrastructure it detects — db (a validation query), redis, diskSpace, ping, mongo, kafka, and more — so most standard dependencies are covered before you write a line of code. You only hand-roll an indicator for something Boot doesn't already know about.

Health groups and Kubernetes probes

A single aggregate status is too blunt for orchestration. Kubernetes asks two different questions — "should I restart this pod?" (liveness) and "should I send it traffic?" (readiness) — and they need different answers. Health groups let you carve out subsets of indicators, each with its own endpoint and its own aggregated status:

management:
  endpoint:
    health:
      probes:
        enabled: true          # exposes livenessState & readinessState (auto-on under k8s)
      group:
        liveness:
          include: livenessState
        readiness:
          include: readinessState, db, redis

These serve at /actuator/health/liveness and /actuator/health/readiness. Point each Kubernetes probe at the matching one:

livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 8080 }
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 8080 }

The semantics are sharply different and easy to get wrong. A failed liveness probe restarts the pod — so it should reflect only internal, unrecoverable state. A failed readiness probe merely stops routing traffic — so that's where dependencies and warm-up belong. Put a flaky external API in your liveness check and a brief outage triggers an endless restart loop. Boot even flips readiness automatically during startup and graceful shutdown, draining in-flight requests cleanly before the process exits.

Micrometer: SLF4J for metrics

Health tells you up or down; metrics tell you the shape of everything in between. Spring Boot's metrics layer is Micrometer, a vendor-neutral facade — the same role SLF4J plays for logging. Your code records measurements against Micrometer's API, and a registry implementation forwards them to whatever backend you pick — Prometheus, Datadog, CloudWatch — with no change to application code.

// Your code talks to Micrometer, never to Prometheus directly:
registry.counter("orders.placed").increment();
// Switching monitoring vendors = swapping the registry dependency, nothing else.

Actuator auto-configures a MeterRegistry and instruments huge swathes of the framework for free: http.server.requests (per-endpoint latency and error rates), jvm.memory.used, jvm.gc.pause, system.cpu.usage, connection-pool stats, and more. Before writing any custom metric, browse /actuator/metrics — what you need is often already there.

Meter types and custom metrics

When you do need your own, inject the MeterRegistry and choose the right meter for the measurement's shape:

@Service
public class OrderService {
    private final Counter placed;
    private final Timer processTimer;

    public OrderService(MeterRegistry registry) {
        // Counter: a value that only goes up
        this.placed = registry.counter("orders.placed", "channel", "web");
        // Timer: duration + frequency of short events
        this.processTimer = registry.timer("orders.process.time");
        // Gauge: an instantaneous value sampled from a live object
        registry.gauge("orders.queue.depth", queue, OrderQueue::size);
    }

    public void place(Order o) {
        processTimer.record(() -> repository.save(o));  // times the lambda
        placed.increment();                             // bumps the counter
    }
}

The four core types: Counter (monotonic counts), Gauge (a current value that rises and falls, sampled rather than set), Timer (latency of short operations), and DistributionSummary (distributions of non-time values like payload sizes). Register meters once and reuse the reference — don't re-create them on every call. For coarse method-level instrumentation, the @Timed and @Counted annotations (backed by TimedAspect/CountedAspect beans) do the same job declaratively.

Tags, cardinality, and the explosion to avoid

Tags (a.k.a. dimensions or labels) are what make metrics powerful — they let you slice a single metric by uri, status, region. But there's a trap that bites everyone eventually: every unique combination of tag values creates a separate time series.

// GOOD — a small, bounded set of values:
registry.counter("http.requests", "status", "200", "method", "GET").increment();

// BAD — unbounded values explode into millions of series:
registry.counter("http.requests", "userId", userId).increment();   // ✗ cardinality bomb

Put a high-cardinality value — user IDs, order IDs, raw URLs with path parameters, timestamps — into a tag and you get a cardinality explosion: memory balloons in both your app and the monitoring backend, queries crawl, and storage costs spike. This is precisely why Spring's built-in http.server.requests tags the templated path (/orders/{id}) rather than the actual URL. Keep tag values drawn from a small, known set, always.

To package a reusable group of related metrics — a subsystem's gauges and counters — implement MeterBinder; Boot applies any MeterBinder bean to every registry, keeping instrumentation decoupled from business logic (it's how Boot's own JVM and system metrics are wired).

Exporting to Prometheus

To get those metrics into Prometheus, add the registry starter and expose the scrape endpoint:

# build.gradle: implementation 'io.micrometer:micrometer-registry-prometheus'
management:
  endpoints:
    web:
      exposure:
        include: health, prometheus
  metrics:
    tags:
      application: order-service       # a common tag on every metric

Prometheus is pull-based — it scrapes /actuator/prometheus on an interval rather than your app pushing data out. The endpoint renders every registered meter, JVM and HTTP and custom alike, in Prometheus' annotated text format. The common application tag lets a multi-service Grafana dashboard tell instances apart. And when you want traces beside your metrics, Micrometer's Observation API can instrument an operation once and emit both a Timer and a tracing span; add micrometer-tracing to ship those spans to Zipkin or Tempo.

Recap

Health and metrics are the two halves of Spring Boot observability. A HealthIndicator returns a Health with a Status, and the endpoint reports the worst component — which an HttpCodeStatusMapper turns into the HTTP code your probes and load balancers act on. Health groups split liveness (restart) from readiness (traffic) so Kubernetes behaves sanely. On the metrics side, Micrometer is a vendor-neutral facade: inject the MeterRegistry, pick the right meter — Counter, Gauge, Timer, DistributionSummary — and keep tags low-cardinality to avoid blowing up your backend. Expose /actuator/prometheus for scraping, lean on the metrics Boot already auto-provides, and reach for the Observation API when you want tracing alongside. Instrument once, observe everywhere.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel