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.