Observability

Spring DDD integrates with Spring Boot Actuator and Micrometer to make event-store, consumer, topic and saga state observable at runtime. One add-on starter contributes, for each part of Spring DDD the application uses, an actuator endpoint, a health indicator and a set of Micrometer metrics. Metrics flow automatically to whatever Micrometer registry the application configures — Prometheus, Datadog, or any other — without additional setup.

Adding the starter

implementation("de.dwittkoetter:spring-ddd-starter-observability:0.0.1-SNAPSHOT")

The starter brings Spring Boot Actuator with it and is bundled into no other starter. It adds no Spring DDD module of its own: each section below appears when the application already uses the part it observes. The event-store section appears when the application has an event store. The saga section appears whenever the application runs sagas. The consumers section appears with the consumer runtime, which every starter running projections or routes brings; it lists them and any consumers of the application’s own once they have started. The topics section appears with the same consumer runtime.

Web endpoints must be exposed explicitly. For example, to expose all five:

management.endpoints.web.exposure.include=eventstore,consumers,instances,topics,sagas

Health indicators appear under /actuator/health without extra exposure configuration.

Switching a section off

Each part of a section is switched off with Spring Boot’s own properties:

Endpoint

management.endpoint.eventstore.access=none, management.endpoint.consumers.access=none, management.endpoint.instances.access=none, management.endpoint.topics.access=none, management.endpoint.sagas.access=none

Health indicator

management.health.eventstore.enabled=false, management.health.consumers.enabled=false, management.health.sagastore.enabled=false; management.health.defaults.enabled=false switches off every health indicator that is not switched on by name

Metrics

management.metrics.enable.eventstore=false, management.metrics.enable.consumer=false and management.metrics.enable.instance=false, management.metrics.enable.topic=false, management.metrics.enable.saga=false

Apply observations

management.observations.enable.consumer.apply=false

Event store

Endpoint — eventstore

The endpoint is read-only. It appears with the JDBC event store, spring-ddd-eventsourcing-jdbc; the health indicator and the metrics work without it.

Read the event store

GET /actuator/eventstore

{ position, aggregates: [{ type, descriptors }] }: the global position and a list of all known aggregate types.

Read one aggregate type

GET /actuator/eventstore/bank-account

{ type, descriptors, eventCount, latestPosition, metrics }: per-type statistics; 404 when the type is unknown.

Health — eventStore

The component appears under /actuator/health/eventStore.

  • UP — includes a position detail with the current maximum position from the event store.

  • DOWN — if reading that position throws an exception, the exception detail is included in the response.

Metrics

eventstore.position

Global maximum event-store position (the high-water mark). A gauge.

eventstore.appends, tag aggregate.type (e.g. bank-account)

Number of events appended per aggregate type. A counter.

eventstore.append.latency, tag aggregate.type

Duration of each append. A timer.

eventstore.append.conflicts, tag aggregate.type

Number of optimistic-lock conflicts during append. A counter.

Consumers

Endpoint — consumers

Reports every consumer the application runs — projections, route consumers, and consumers of its own — and runs what an operator does to one. A consumer is named by its type and name: projection and the projection’s name, or route and the destination’s name. Each report is the one ConsumerLifecycle returns: the consumer’s state and since when, how often it was rebuilt, its halt reason, its checkpoint and lag in every source and partition, who runs each partition, its undelivered messages per status, and, for a projection whose store can count them, its read models. See Consumers for what these figures mean, and Errors and halts for what each action does.

The endpoint is read-only by default. Its actions change what the application’s read side shows, so they answer only once allowed explicitly; secure the endpoint as any actuator endpoint that writes:

management.endpoint.consumers.access=unrestricted

List the consumers

GET /actuator/consumers

{ consumers, failures }: every consumer’s report, ordered by type, then name, and every consumer whose report could not be read, with its key and error. A failure, say a database that cannot be reached, is logged as a warning and leaves the other consumers listed; the answer is still 200.

Read one consumer

GET /actuator/consumers/projection/account-summary

One consumer’s report, its partitions included; 404 when no consumer has that type and name.

Act on a consumer

POST /actuator/consumers/projection/account-summary/<action>
pause, resume

See Pausing and resuming.

resume-past-gap, resume-past-poison-message, retry-dead-letters

See Getting a consumer going again.

rebuild

Projections only; answers 202; see Rebuilding.

unsubscribe

From every topic the consumer has checkpoints on; with /unsubscribe/<topic>, from one: capture-topic, event-store, or read-model: and a projection’s name. A topic given to any other action answers 404. See Unsubscribing a consumer.

List undelivered messages

GET /actuator/consumers/projection/account-summary/undelivered?status=DEAD_LETTER_KEY&limit=100

Its undelivered messages, of one status (FAILED, WAITING, HALTED, DEAD_LETTER, DEAD_LETTER_KEY) when given, at most limit (100 by default), in position order. DEAD_LETTER_KEY lists a dead-lettered key’s first dead letter and the key’s later messages dead behind it; a later message has a failure count of 0 and no failure reason.

Retry or delete an undelivered message

POST /actuator/consumers/projection/account-summary/undelivered/<id>

Retries one failed undelivered message or dead letter.

DELETE /actuator/consumers/projection/account-summary/undelivered/<id>

Deletes one undelivered message: the consumer never applies it for its key.

Answers

Every action answers { accepted, reason, result }: whether it was accepted, the reason when it was not, and what it returned when it was:

{
  "accepted": false,
  "reason": "Projection 'account-summary' (AccountSummaryProjection) is paused, and a rebuild never lifts an operator's pause: resume it, then rebuild it",
  "result": null
}
  • 200: accepted; result holds what the action returned.

  • 202: a rebuild has begun; result holds how often the projection has been rebuilt.

  • 409: refused; reason says why.

  • 404: no consumer or undelivered message of that name, or no checkpoints on the topic to unsubscribe from.

  • 400: an unknown action, or a parameter that is not valid.

Every action is kept with the consumer’s checkpoints, so every instance running the consumer follows on its next poll, whichever instance answered the request. An in-memory projection is the exception: each instance keeps its checkpoints, status and undelivered messages to itself, so every report and every action reaches the instance that answers the request only, and a restart of that instance forgets them, a pause included.

Endpoint — instances

Lists every instance running a consumer shared across instances, one spread across instances or run by one at a time, with the host it runs on, whether it is live, the consumers it runs and the partitions it holds of each. Every instance answers the same, and the list includes consumers that only another application on the same database runs. A standby runs a consumer but holds none of its partitions: a single-owner consumer’s other instances, say, ready to take over when its owner stops. An instance that stopped stays listed with live false until another instance removes it, and after that for as long as it still holds partitions, then without its host. Those partitions are the orphaned ones, until other instances take them over. See When an instance joins or leaves. A consumer that runs on every instance on its own, an in-memory projection, is not listed. The list is empty while the application runs no consumer shared across instances.

The endpoint is read-only; expose it like any other, by adding instances to management.endpoints.web.exposure.include; see Adding the starter.

GET /actuator/instances

It answers:

{
  "instances": [
    {
      "instanceId": "3f2c9a1e-…",
      "host": "pod-a",
      "live": true,
      "consumers": [
        { "key": { "type": "projection", "name": "account-summary" }, "partitions": [0, 3] },
        { "key": { "type": "route", "name": "audit" }, "partitions": [] }
      ]
    }
  ]
}

Each report of the consumers endpoint lists the same under instances: every instance running that consumer, with its host, whether it is live and the partitions it holds.

Health — consumers

The component appears under /actuator/health/consumers. What makes it DOWN, the lag threshold, and why to keep it out of a load balancer’s health check are described in Health.

spring.ddd.metric.consumer.max-age, default 10s

How long health and metrics reuse one read of the consumers, counted from the end of the read; not negative.

Metrics

Every meter here is a gauge read when scraped; the consumers are read at most once per max-age, whatever the number of gauges or scrapes. The timers of each apply attempt are described below, under Apply observations. Every meter but consumer.count and instance.count is tagged with the consumer’s type and name. A value that cannot be given is NaN: a lag whose high-water mark could not be read, say, or every meter of a consumer whose report could not be read.

consumer.count

Consumers the application runs, those whose report could not be read included.

consumer.state, tag state

1 for the consumer’s state, RUNNING, PAUSED, REBUILDING or HALTED, 0 for the others.

consumer.epoch

How often the consumer was rebuilt.

consumer.checkpoint, tag source

The lowest checkpoint over the source’s partitions.

consumer.lag, tag source

The lag of the source’s slowest partition.

consumer.partitions, tag state (OWNED, ORPHANED, UNCLAIMED)

Partitions per ownership state; consumers shared across instances only.

consumer.undelivered-messages, tag status (FAILED, WAITING, HALTED, DEAD_LETTER, DEAD_LETTER_KEY)

Undelivered messages per status; the last three are dead letters, see Undelivered messages.

consumer.read-models

Read models in the store; in-memory projections only, whose store is their instance’s own.

consumer.instance.partitions

Partitions of a consumer shared across instances that this instance holds.

consumer.instance.lag, tag source

The slowest lag among the partitions this instance holds; 0 when it holds none.

consumer.instances

Live instances running a consumer shared across instances, standbys included.

instance.count

Live instances running any of the application’s consumers shared across instances.

Every meter but the two consumer.instance.* meters reports what all instances share, so every instance exports the same value; aggregate them with max, not sum. instance.count misses an instance that runs none of the application’s consumers shared across instances; the instances endpoint lists it when it runs another application’s. An in-memory projection is the exception: each instance runs it on its own, so all its meters, its state and checkpoints included, are that instance’s own. A consumer started after the registry is bound gets its meters on the next scrape.

Apply observations

Every attempt of a consumer to apply a message is a Micrometer observation named consumer.apply: one transaction applying the records of one position, or one delivery retry of an undelivered message. With Spring Boot’s observation support, which Spring Boot Actuator brings, each attempt is timed by the timer consumer.apply, and the attempts in flight by the long-task timer consumer.apply.active; with a tracing bridge, each attempt is a span named <consumer name> apply. The consumer runtime observes its applies itself, so an application gets these timers without this starter. Each instance records the attempts it makes itself, so aggregate consumer.apply across instances with sum.

Its keys:

type, name

The consumer’s type and name, as on the consumer meters.

source

The topic the message was read from.

outcome

How the attempt ended, from the list below.

error

Added by Micrometer: the simple class name of the exception the attempt ended with, or none.

partition, position

Where the message is; on the span only, never on a timer.

How an attempt ended, its outcome:

applied

Committed. A message the consumer has nothing to do with, such as an event none of its listeners takes, is applied too.

undelivered

Committed with a key undelivered: to be retried, waiting behind an earlier undelivered message of its key, or a dead letter, DEAD_LETTER_KEY or DEAD_LETTER. An undelivered message’s delivery retry that fails again is recorded as undelivered too.

skipped

Committed with a key whose error handler decided SKIP.

halted

Committed with a key whose error handler decided HALT; the consumer halts.

retried

Rolled back, and the message is applied again: after a write conflict, without a key whose apply failed, or on a later poll.

When the keys of one message end differently, the attempt reports the worst of them: halted, then skipped, then undelivered. Every outcome but applied carries the exception that caused it, where there is one; a span records it as its error. management.observations.enable.consumer.apply=false switches the observation off; management.metrics.enable.consumer=false removes its timers together with the consumer meters. With a tracing bridge, every attempt is a span, and a message without a trace context starts a trace of its own: a rebuild or replay of millions of events emits sampled root traces. Lower management.tracing.sampling.probability, or register an ObservationPredicate bean that rejects consumer.apply, or set management.observations.enable.consumer.apply=false.

Trace context

A consumer’s apply continues the trace of the work that wrote the message, when the message carries that trace’s context. Messages carry it once the application turns propagation on, which takes Micrometer Tracing with a bridge, such as micrometer-tracing-bridge-otel:

spring.ddd.trace.propagate=true
spring.ddd.trace.propagate, default false

Write the current trace context into the metadata of every message the application dispatches or appends, under the key trace-context: the fields the application’s propagator uses, such as traceparent and tracestate.

Each event a request appends then carries the request’s trace, and each consumer’s apply of it is a child span of the request’s span. With the property on and no Propagator bean, a warning names the property once the application is ready.

An event store keeps its events for good, and with them the trace context, and any baggage the tracing setup propagates, written into their metadata. Turn propagation on only where keeping these in events is acceptable.

Reading takes no setting: a consumer continues the trace of any event that carries trace-context, whichever application wrote it. A projection that listens to another projection’s read models starts a trace of its own. The metadata key trace-context is the framework’s; a @MessageMetadataProvider of the application cannot use it as well.

Topics

The capture topic and the JDBC read-model topics, observed in their own right: how many messages each holds, how far its slowest consumer trails, and what retention did to it. A growing backlog, or a retention policy a consumer holds back, shows here before it shows as a projection’s lag. See Topics and partitions and Retention.

Endpoint — topics

Reports every topic, and runs retention on one on demand. A topic is named capture-topic, or read-model: and the projection’s name. Each report holds:

  • start, the position the topic is retained after, and highWaterMark, the position through which every message has committed (see The high-water mark).

  • messages, how many messages the topic holds between the two.

  • consumers, every consumer with checkpoints on the topic, slowest first, each with its lowest checkpoint and its lag. Consumers of other applications on the same database and route consumers are listed too, since each holds the topic’s retention.

  • lastRetention, the last retention run on the instance that answered: when it ran, how many messages it deleted, how far the start moved, and limitedBy, what stopped it.

The endpoint is read-only by default; a retention run answers only once allowed explicitly:

management.endpoint.topics.access=unrestricted

List the topics

GET /actuator/topics

{ topics, failures }: every topic’s report, ordered by name, and every topic whose report could not be read, with its name and error. A failure is logged as a warning and leaves the other topics listed; the answer is still 200.

Read one topic

GET /actuator/topics/capture-topic

One topic’s report; 404 when no topic has that name.

Run retention

POST /actuator/topics/capture-topic/retention

Runs retention on the topic now and answers the run’s report; 404 for an unknown topic, 500 with the error when the run fails. Without a configured policy it deletes nothing and answers limitedBy NO_POLICY.

A report’s limitedBy says what stopped the run:

  • POLICY: the run deleted everything the policy allows.

  • CHECKPOINT: the consumer named in slowest held the run back. The policy is configured, but does not take effect while that consumer trails. If the consumer is no longer part of the application, or no longer reads the topic, unsubscribe it from the topic; see Unsubscribing a consumer.

  • HIGH_WATER_MARK_STORE: the capture topic’s high-water mark stopped the run, since a lower position may still commit.

  • COMPACTION: on a read-model topic, compaction has not yet caught up with the removed read models old enough to delete. A read-model topic’s compaction is not a consumer and cannot be unsubscribed.

  • NO_POLICY: no retention is configured for the topic, or retention does not select it.

The last run is per instance, and only since the instance started: each instance runs its own schedule, so ask the instance whose run you want to see, or trigger one.

Metrics

Every meter but topic.retention.deleted is a gauge read when scraped; the topics are read at most once per spring.ddd.metric.topic.max-age, one minute by default, since counting a topic’s messages is costly. With max-age at 0 every gauge reads the topics again, so one scrape counts each topic’s messages once per gauge; keep it above 0 outside tests. Every meter but topic.count is tagged with the topic’s name as topic. A gauge whose topic could not be read is NaN.

topic.count

Topics the application reports, those that could not be read included.

topic.messages, tag topic

Messages the topic holds.

topic.start, tag topic

The position the topic is retained after.

topic.high-water-mark, tag topic

The position through which every message has committed.

topic.lag, tag topic

The lag of the topic’s slowest consumer; 0 without consumers.

topic.consumers, tag topic

Consumers with checkpoints on the topic.

topic.retention.deleted, tag topic

A counter: messages this instance’s retention runs deleted from the topic.

The gauges report what all instances share, so every instance exports the same value; aggregate them with max. The counter is each instance’s own; aggregate it with sum.

Sagas

Endpoint — sagas

The endpoint is read-only. It reports every saga type the application declares, whether or not the type has an instance yet. A type shows the state and halt reason of the consumer that runs it on this instance, its lag per source (one for each topic the saga’s events come from) and its undelivered messages per status; these are null while no consumer runs the type on this instance. The instance counts active and completed, and pendingDeadlines, appear only where the saga store provides statistics: the JDBC saga store does, a saga store of the application’s own does not, and they are null then. pendingDeadlines is also null when spring.ddd.saga.deadline.enabled=false. Pausing, resuming and the other actions are on the consumers endpoint, under /actuator/consumers/saga/transfer-saga; the lag is the metric consumer.lag.

List the saga types

GET /actuator/sagas
{
  "sagas": [
    {
      "type": "transfer-saga",
      "consumer": { "type": "saga", "name": "transfer-saga" },
      "state": "RUNNING",
      "haltReason": null,
      "lag": { "capture-topic": 0 },
      "undelivered": { "FAILED": 0, "WAITING": 0, "HALTED": 0, "DEAD_LETTER": 0, "DEAD_LETTER_KEY": 0 },
      "active": 3,
      "completed": 41,
      "pendingDeadlines": 3
    }
  ],
  "failures": []
}

{ sagas, failures }: a type whose report could not be read, say because its checkpoint store’s database cannot be reached, is listed under failures with its type and error, logged as a warning, and leaves the other types listed; the answer is still 200.

Read one saga type

GET /actuator/sagas/transfer-saga

One type’s report, in the shape above; 404 when the application declares no such type.

Health — sagaStore

The component appears under /actuator/health/sagaStore where the saga store provides statistics.

  • UP — includes a sagaTypes detail with the number of known saga types.

  • DOWN — if the underlying query throws an exception (for example, the schema is not yet provisioned), the exception detail is included.

Metrics

The gauges below, like the health indicator, need the saga store’s statistics; an application’s own saga store provides none. Per-type gauges are snapshotted from the set of known saga types at startup; values are read live on every scrape.

saga.instances, tags type (e.g. transfer-saga), state (active, completed)

Number of saga instances per type and lifecycle state. A gauge.

saga.deadlines.pending, tag type

Number of pending deadline callbacks per saga type; only emitted when spring.ddd.saga.deadline.enabled=true. A gauge.