Files
spring-boot-demo/docs/05-custom-health-indicators.md
Ankur Mhatre 4b6cefa60a Spring Boot 4 Actuator in production: endpoints, security, custom health indicators
Companion repository for the ankurm.com article. Every transcript in docs/output/
was produced by running this project; scripts/run-all.sh regenerates all of them.

Verified against Spring Boot 4.1.1 / Framework 7.0.9 / Security 7.1.1 /
Micrometer 1.17.1 / kafka-clients 4.2.1 on Temurin JDK 25.0.4.1+1.
2026-09-04 10:40:04 +05:30

113 lines
4.5 KiB
Markdown

[← 04 Securing Actuator](04-securing-actuator.md) · **05 · Custom health indicators** · [06 Failure modes →](06-health-indicator-failure-modes.md)
# 05 — Custom health indicators
Three indicators, three different jobs.
| Bean | Class | Checks |
|---|---|---|
| `ordersDatabase` | [`OrdersDatabaseHealthIndicator`](../src/main/java/com/ankurm/actuator/health/OrdersDatabaseHealthIndicator.java) | the query the application depends on |
| `kafka` | [`KafkaHealthIndicator`](../src/main/java/com/ankurm/actuator/health/KafkaHealthIndicator.java) | `AdminClient.describeCluster` |
| `externalApi` | [`ExternalApiHealthIndicator`](../src/main/java/com/ankurm/actuator/health/ExternalApiHealthIndicator.java) | an HTTP dependency, with a budget |
## The shape
```java
import org.springframework.boot.health.contributor.AbstractHealthIndicator;
import org.springframework.boot.health.contributor.Health;
@Component("ordersDatabase")
public class OrdersDatabaseHealthIndicator extends AbstractHealthIndicator {
@Override
protected void doHealthCheck(Health.Builder builder) throws Exception {
// throw, or call builder.up() / down() / status("DEGRADED")
}
}
```
Note the package — `org.springframework.boot.health.contributor`, new in Boot 4. See
[chapter 02](02-boot-4-changes.md).
`AbstractHealthIndicator` over the bare interface, because it catches your exceptions and turns
them into `DOWN` with the error recorded, rather than letting one throwing indicator take down
the whole `/actuator/health` response.
## The bean name is the JSON key
`@Component("ordersDatabase")` produces `"ordersDatabase"` in the response. Boot strips a
trailing `HealthIndicator` from the bean name, so `ordersDatabaseHealthIndicator` gives the same
key. Rename the bean and every dashboard, alert and health-group `include:` that referenced the
old key silently stops matching — which is why
[`HealthGroupTests`](../src/test/java/com/ankurm/actuator/HealthGroupTests.java) asserts on the
names.
## Check what the application needs, not what the connection can do
Boot's built-in `db` indicator runs a validation query and answers "can I reach the database".
That is rarely the question. `ordersDatabase` runs the query the service actually depends on and
adds a latency judgement:
```json
{"details":{"orders":3,"queryMs":0,"slowThresholdMs":250},"status":"UP"}
```
It returns a custom `DEGRADED` status above the threshold. Custom statuses are legal;
`management.endpoint.health.status.order` and `.http-mapping` control how they aggregate and
what HTTP code they produce. An unmapped custom status aggregates as `UNKNOWN` and maps to 200,
which is usually not what you meant.
## Every indicator needs a budget
All three carry an explicit timeout, and the reason is [chapter 06](06-health-indicator-failure-modes.md).
- `ordersDatabase``jdbc.setQueryTimeout(2)`
- `externalApi` — connect and read timeouts of 750 ms on the `RestClient` request factory
- `kafka` — four separate Kafka bounds plus an outer `KafkaFuture.get(timeout)`
## Credentials
`externalApi` sends HTTP Basic. The first captured run of this repository did not, and reported:
```json
{"error":"HttpClientErrorException$Unauthorized: 401 : [no body]","status":"DOWN"}
```
which looks exactly like an upstream outage and was a missing header. If your indicator
authenticates, make the failure message distinguish "they are down" from "we are unauthorised" —
future you will be reading it at 3am.
## Reuse the client
`KafkaHealthIndicator` creates one `AdminClient` in its constructor and closes it in
`close()`. Creating one per probe opens a fresh set of broker connections on every probe
interval; across a fleet of any size that is a denial-of-service against your own brokers.
## What it all looks like
[`output/07-custom-health-indicators.txt`](output/07-custom-health-indicators.txt) has the full
nine-component response. Flipping the stub upstream to `DOWN` and changing nothing else:
```json
{
"status": "DOWN",
"externalApi": {
"details": {
"error": "HttpServerErrorException$ServiceUnavailable: 503 : \"upstream unavailable\"",
"url": "http://localhost:8080/stub/upstream/ping",
"latencyMs": 101,
"timeoutMs": 750
},
"status": "DOWN"
}
}
```
`/actuator/health` now answers 503. If that URL is your readiness probe, every pod in the
deployment just left the load balancer because a third party had a bad minute.
[Chapter 07](07-groups-and-probes.md) fixes that.
---
[← 04](04-securing-actuator.md) · **05** · [06 Failure modes →](06-health-indicator-failure-modes.md)