Observability
Health under /actuator/health, metrics through whatever
MeterRegistry the application already has, and counters on
each listener.
Health
With Actuator on the classpath, the health indicator is registered automatically — on Spring Boot 3 and Spring Boot 4 alike. The starter ships one small module per line and activates whichever matches; see one artifact, both Boot lines for why that is two modules rather than one class.
{
"status": "UP",
"components": {
"aceMq": {
"status": "UP",
"details": {
"transport": "rabbitmq",
"open": true,
"blocked": false,
"inFlight": 3
}
}
}
}Four facts, chosen because they are the four worth having during an incident: whether the connection is open, whether the broker has applied back pressure and why, how many publishes are in flight, and which transport this is.
Turn it off with management.health.acemq.enabled: false.
The details are visible over HTTP only when Actuator's own
management.endpoint.health.show-details allows it.
A blocked connection is reported up
When RabbitMQ hits a memory or disk alarm it blocks publishing connections. 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 more dramatic choice and the wrong one. A blocked
connection is the broker protecting itself; an application that fails
its own health check for it gets restarted by an orchestrator into the
same blocked broker, having thrown away whatever it was holding. The
blockage is the broker's problem to clear, and
blocked: true is what an alert should watch.
Down means what it says: the connection is not open.
Metrics
Put a MeterRegistry in the context — every Boot
application with spring-boot-starter-actuator and a
registry dependency has one — and the library's metrics go to it.
Nothing else is needed.
| Meter | Type | Tags |
|---|---|---|
acemq.publish.duration |
timer | exchange, routing.key,
transport, message.type,
outcome |
acemq.publish.total |
counter | as above |
acemq.consume.duration |
timer | queue, transport,
message.type, outcome |
acemq.consume.total |
counter | as above |
acemq.consume.attempts |
distribution | queue |
acemq.consume.in.flight |
gauge | queue |
acemq.messages.retried.total |
counter | queue |
acemq.messages.dead.lettered.total |
counter | queue |
acemq.request.duration / .total |
timer, counter | request-response |
acemq.outbox.lag / .total |
gauge, counter | the outbox pattern |
acemq.pipeline.run.duration / .total |
timer, counter | pipelines |
The outcome tag is where most of the value is:
confirmed, unroutable and failed
on the publish side; acked, retried,
dead_lettered and rejected on the consume
side.
Three things worth graphing on day one:
# Publishes the broker could not route: a binding is wrong, and no exception was thrown
rate(acemq_publish_total{outcome="unroutable"}[5m])
# The retry ladder working, or a downstream that is down
rate(acemq_messages_retried_total[5m])
# Handler time, which is what prefetch and concurrency should be chosen from
histogram_quantile(0.99, rate(acemq_consume_duration_seconds_bucket[5m]))
Without Micrometer
If Micrometer is not on the classpath, the starter falls back to the library's own auto-detection, which finds OpenTelemetry when it is there and is otherwise a no-op. Nothing fails and nothing is logged about it, because an application without a metrics library has made a choice.
To take over entirely, define a Telemetry bean; the
starter backs off.
Tracing
Metrics say the application is slow. A trace says which message, through which four services, and where the time went — and for a message-driven application that is the harder question, because the caller and the callee are not on the same stack.
The starter's Telemetry bean is
@ConditionalOnMissingBean(Telemetry.class), and it resolves
to Micrometer when a MeterRegistry is present. It does
not wire OpenTelemetry tracing for you, even with the
OTel SDK on the classpath, because with Micrometer also present the
Micrometer branch wins. Say what you want:
@Configuration
class Tracing {
/**
* Replaces the auto-configured Telemetry. Composite rather than either one alone: metrics
* and traces are both wanted, and the library's own auto-detection would give whichever it
* happened to find.
*/
@Bean
Telemetry aceMqTelemetry(MeterRegistry meters, OpenTelemetry openTelemetry) {
return Telemetries.composite(
MicrometerSupport.telemetry(meters, "rabbitmq"),
OpenTelemetrySupport.telemetry(openTelemetry, "rabbitmq"));
}
}OpenTelemetrySupport lives in
acemq-amqp-core and needs
io.opentelemetry:opentelemetry-api on the classpath, which
the OTel Spring Boot starter brings. The transport name is a tag and a
span attribute; rabbitmq is what
acemq-transport-rabbitmq reports for itself.
What the library then produces: a span around every publish and every
consume, and traceparent and tracestate
propagated in the message headers — so a consume span in one service is
a child of the publish span in another, without either service passing a
context by hand.
Telemetry telemetry = mq.telemetry();
telemetry.propagationHeaders(); // what would be attached to a message published nowUseful in a test that asserts propagation, and useful when a span appears unparented and the question is whether the headers were ever attached.
Beyond publish and consume, the Telemetry interface has
hooks for the patterns — messageRetried,
messageDeadLettered, messageParked,
outboxPublished, outboxFailed,
pipelineRunFinished, retryRungMissing,
setAsideFailed. Both supplied implementations fill them in,
and a Telemetry of your own only has to implement what it
cares about: the rest are default methods that do
nothing.
One span per attempt, not one span for the attempt
chain. A message retried four times over a minute produces four
consume spans rather than one span held open for the minute. That is
deliberate: the ladder's waits happen on the broker, and a span spanning
them would be a minute of nothing with no thread behind it. The
traceparent header survives the retry republish, so the
four spans stay in one trace; acemq.consume.attempts is the
meter that says how many there were.
The acemq endpoint
With Actuator on the classpath the starter registers an
acemq endpoint. Like every endpoint but health it is served
over HTTP only once exposed:
management:
endpoints:
web:
exposure:
include: health,acemqGET /actuator/acemq lists every
@AceListener by id; GET /actuator/acemq/{id}
is one.
{
"listeners": {
"orders": {
"queue": "orders.new",
"bean": "orderListener",
"state": "running",
"autoStartup": true,
"concurrency": 4,
"prefetch": 40,
"counters": { "inFlight": 3, "acknowledged": 1180, "rejected": 2, "retried": 17 }
}
}
}concurrency and prefetch are what the
running group has — after a scaleTo, the new size — or what
the listener is configured with when it is stopped.
counters belong to the listener's consumers since it last
started, and are null while it is stopped.
POST /actuator/acemq/{id} with
{"state": "stopped"} stops the listener: it stops taking
messages, waits up to acemq.listener.shutdown-timeout for
the ones in flight, and closes. {"state": "running"} starts
it again — or starts one registered with
autoStartup = "false", or one left stopped by
acemq.listener.missing-queue: warn. Starting asks the
broker whether the queue exists first and answers 400, naming the queue,
when it does not.
Writes are allowed unless
management.endpoint.acemq.access: read-only says otherwise,
which is worth setting wherever the management port is reachable by more
people than should be able to stop consumption. The endpoint has the
same shape on Spring Boot 3 and 4.
Listener counters
AceListenerRegistry hands out the running
ConsumerGroup for each listener id, and the group carries
counters that are per-listener rather than per-application — the same
ones the endpoint above reports:
listeners.get("orders").ifPresent(group -> {
log.info("acked {}, rejected {}, retried {}, in flight {}",
group.acknowledged(), group.rejected(), group.retried(), group.inFlight());
});These are the numbers to reach for when metrics say the application is slow and the question is which listener.
Logging
Two lines matter at startup, both at INFO:
INFO o.a.s.b.AceMqTopologyInitializer : topology (CREATE_ONLY):
CREATE queue orders.new (quorum)
INFO o.a.s.b.AceListenerRegistry : listener orders consuming orders.new with 4 consumer(s), prefetch 40
and one at shutdown, when a drain does not finish:
WARN o.a.s.b.AceListenerRegistry : listener orders still had 3 message(s) in flight after PT30S; they will be redelivered
That warning is the one to alert on after a deployment: it is the difference between a clean redeploy and the duplicate burst that nobody attributes to shutdown.
Set logging.level.org.acemq=DEBUG for the rest, which
includes the "nothing to do" topology line and each listener that was
not started because its auto-startup is off.