Tutorial 4: health and metrics
Twenty minutes. Continues from Tutorial 3.
By the end: a health endpoint that says something useful, Prometheus metrics, three graphs worth having on day one, and a blocked broker that does not fail the health check.
Health
Actuator is already a dependency of the tutorial project. Expose the endpoint with details:
management:
endpoints:
web:
exposure:
include: health,prometheus
endpoint:
health:
show-details: alwayscurl -s localhost:8080/actuator/health | jq .components.aceMq{
"status": "UP",
"details": {
"transport": "rabbitmq",
"open": true,
"blocked": false,
"inFlight": 0
}
}Stop the broker and ask again:
docker stop rabbit
curl -s localhost:8080/actuator/health | jq '.status, .components.aceMq.details.open'"DOWN"
false
Start it again — docker start rabbit — and within a few
seconds the connection recovers and the status returns to UP. The
library reconnects; the health indicator reports what it finds.
A blocked broker is up
RabbitMQ blocks publishing connections when it hits a memory or disk alarm. This indicator reports that as up, with the reason:
{
"status": "UP",
"details": { "open": true, "blocked": true, "blockedReason": "low on disk space" }
}DOWN would be the dramatic choice and the wrong one. A blocked connection is the broker protecting itself, and an application that fails its readiness check for it gets removed from its load balancer, restarted by its orchestrator, and brought back into the same blocked broker — having thrown away whatever it was holding. The blockage is the broker's problem to clear.
blocked: true is what to alert on. It is a fact about
the broker, and it belongs in an alert, not in a liveness probe.
Reproducing it is possible with
rabbitmqctl set_vm_memory_high_watermark 0.0001 inside the
container, and worth doing once against a scratch broker to see the
shape of it.
Metrics
Add the Prometheus registry:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>That is all. The starter finds the MeterRegistry in the
context and gives the library telemetry backed by it; no configuration
and no bean of your own.
Post a few orders, then:
curl -s localhost:8080/actuator/prometheus | grep '^acemq' | head -20acemq_publish_total{exchange="orders",routing_key="order.created",transport="rabbitmq",outcome="confirmed"} 6.0
acemq_publish_duration_seconds_count{...} 6.0
acemq_consume_total{queue="orders.new",transport="rabbitmq",outcome="acked"} 2.0
acemq_consume_total{queue="orders.new",transport="rabbitmq",outcome="retried"} 4.0
acemq_messages_retried_total{queue="orders.new"} 4.0
acemq_consume_in_flight{queue="orders.new"} 0.0
The outcome tag is where the value is.
confirmed, unroutable and failed
on the publish side; acked, retried,
dead_lettered and rejected on the consume
side.
Three graphs
Unroutable publishes. The failure from Tutorial 1: a message the broker confirmed and dropped because no binding matched. It is silent everywhere else.
rate(acemq_publish_total{outcome="unroutable"}[5m])
Anything above zero is a binding that is wrong, and it should page.
Retries. The ladder working, or a downstream that is down.
rate(acemq_messages_retried_total[5m])
A steady low rate is normal. A step change is a downstream, and it arrives before the dead-letter count does — which is the whole reason to watch it.
Handler time. The number prefetch and concurrency should be chosen from.
histogram_quantile(0.99, rate(acemq_consume_duration_seconds_bucket[5m]))
Prefetch is a buffer measured in messages; what matters is how long that buffer takes to drain, which is p99 handler time times prefetch. At 50ms and prefetch 100, a consumer is holding five seconds of work — fine for throughput, and five seconds of redelivery on an unclean shutdown.
Per-listener counters
Metrics are per queue. When the question is which listener,
ask the acemq actuator endpoint. It needs nothing but
exposing — add it to the list from the start of this tutorial:
management:
endpoints:
web:
exposure:
include: health,prometheus,acemqcurl -s localhost:8080/actuator/acemq | jq{
"listeners": {
"orderListener#onOrder": {
"queue": "orders.new",
"bean": "orderListener",
"state": "running",
"autoStartup": true,
"concurrency": 1,
"prefetch": 100,
"counters": { "inFlight": 0, "acknowledged": 2, "rejected": 0, "retried": 4 }
}
}
}/actuator/acemq/orderListener%23onOrder is the one
listener (the # in a default id is %23 in a
URL; an explicit @AceListener(id = "orders") avoids that).
The counters are the listener's consumers' own, since the listener last
started; counters is null while it is
stopped.
The endpoint can also stop a listener — it stops taking messages, finishes the ones in flight, and closes — and start it again:
curl -s -X POST localhost:8080/actuator/acemq/orderListener%23onOrder \
-H 'Content-Type: application/json' -d '{"state":"stopped"}' | jq .state
# ... the downstream recovers ...
curl -s -X POST localhost:8080/actuator/acemq/orderListener%23onOrder \
-H 'Content-Type: application/json' -d '{"state":"running"}' | jq .stateStopping waits up to acemq.listener.shutdown-timeout for
the messages in flight, so the request can take that long. If writes
from the management port are more than you want,
management.endpoint.acemq.access: read-only keeps the reads
and refuses the writes.
For finer control than stop and start, the registry hands out the running consumer group, which can throttle a listener that is overwhelming a downstream:
listeners.get("orderListener#onOrder").ifPresent(group -> {
group.scaleTo(1);
group.prefetch(10);
});which is a smaller and much faster intervention than a redeploy.
The one log line to alert on
WARN o.a.s.b.AceListenerRegistry : listener orders still had 3 message(s) in flight after PT30S; they will be redelivered
That is a shutdown that could not drain. Every message it names will be redelivered, and if the handlers are not idempotent, some of them will be handled twice. It appears at deployment time, which is exactly when nobody is reading logs — so alert on it.
acemq.listener.shutdown-timeout is the dial: it needs to
be longer than p99 handler time times prefetch, and shorter than the
orchestrator's grace period. Both numbers are knowable.
Next: testing it, with and without Docker.