Files
spring-boot-demo/actuator-in-production/docs/05-custom-health-indicators.md
Ankur Mhatre 958b401f0f Spring Boot startup time: bean-by-bean diagnosis, and one directory per post
Adds spring-boot-startup-time/, the companion project for BLOG-618: a runnable
Spring Boot 4.1.1 application on JDK 25 that installs BufferingApplicationStartup
and FlightRecorderApplicationStartup behind a system property, and a /diag/startup
endpoint that computes step self time -- the number /actuator/startup does not give
you and the one that names the actual culprits.

Captured under docs/output/: the step tree sorted both ways, the same startup as JFR
events, a +5000-class experiment putting 0.11 ms per scanned class on the classpath
scan tax, the silent truncation a 2048-step buffer performs, and JDK 25 AOT cache
timings (6.93 s to 4.82 s). Post body and metadata live in post/.

Moves the existing Actuator project into actuator-in-production/ so the repository
holds one directory per article; the root README is now an index.
2026-09-05 00:17:37 +05:30

4.5 KiB

← 04 Securing Actuator · 05 · Custom health indicators · 06 Failure modes →

05 — Custom health indicators

Three indicators, three different jobs.

Bean Class Checks
ordersDatabase OrdersDatabaseHealthIndicator the query the application depends on
kafka KafkaHealthIndicator AdminClient.describeCluster
externalApi ExternalApiHealthIndicator an HTTP dependency, with a budget

The shape

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.

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 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:

{"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.

  • ordersDatabasejdbc.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:

{"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 has the full nine-component response. Flipping the stub upstream to DOWN and changing nothing else:

{
  "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 fixes that.


← 04 · 05 · 06 Failure modes →