ADR-0014: Application meters are scraped; auto-instrumentation is pushed
| Status |
Accepted |
| Date |
2026-09-07 |
| Deciders |
Solution Architect |
| Related |
ADR-0011, ADO #1141, ADO #1071 |
1. Context
ADR-0011 established OpenTelemetry as the instrumentation boundary: services export OTLP to one collector, and the collector decides destinations. That rule was written about where telemetry goes. It did not settle how a metric registered by application code gets there, and the omission produced a silent gap.
Signal & Sweep leader election was instrumented with six Micrometer meters registered on
the injected Spring MeterRegistry. None of them reached Prometheus, and nothing
reported an error. The dashboards and alerts that were supposed to consume them could
not be built.
The cause is specific and worth recording, because it looks like configuration but is
not. MetricsConfiguration.otelRegistry() searches Metrics.globalRegistry for an
OpenTelemetryMeterRegistry, removes it, and republishes it as a Spring bean — or
returns null when it is not there. That registry only exists when the OpenTelemetry
Java agent’s Micrometer bridge is running, and that bridge is off by default. The
flag otel.instrumentation.micrometer.enabled is set nowhere in the estate. So the bean
resolves to null, no OTel registry joins the Spring composite, and every application
meter goes only to the Prometheus registry — which nothing was scraping.
Confirmed empirically on the production cluster: no import_*, executor_* or
spring_security_* series exist in Prometheus, though all of them are present in the
service’s own /management/prometheus output. Every application metric that does
reach Prometheus carries a semantic-convention name — jvm_memory_used,
http_server_request_duration, db_client_connections_* — which is agent
auto-instrumentation, not Micrometer.
Two routes could close the gap, and the forces differ sharply.
Enabling the agent’s Micrometer bridge is a single flag and keeps one transport. But
OTLP metric series carry only job and instance, and instance is a UUID rather than
a pod name; pod identity and environment live on target_info and must be recovered
with a group_left join in every panel and every alert. It also exports the entire
Micrometer surface, so jvm_memory_used and jvm_memory_used_bytes would both land —
two differently-named measurements of the same thing in one Prometheus. Suppressing that
means adding a filter processor to a collector pipeline that also carries traces, logs
and span metrics for five services.
Scraping the actuator endpoint needs no application change — /management/prometheus
is already served and already permitAll() — and yields pod and namespace as native
target labels, plus up as free scrape health. Against that, the endpoint exposes
16,237 series per pod against a Prometheus holding roughly 256,000, so it cannot be
ingested unfiltered.
The deciding constraint came from the instrumentation itself. Lease metrics deliberately
carry no pod identifier — holderId appears only in logs and on /management/health —
so the question "which pod holds this role", which the alerting exists to answer, can
only be answered from scrape target labels.
2. Decision
Metrics registered by application code on the Spring MeterRegistry reach Prometheus by
scrape, from /management/prometheus via a ServiceMonitor, under Micrometer names.
Auto-instrumentation telemetry — JVM, HTTP, database, traces and logs — reaches the backend by OTLP push, under OpenTelemetry semantic-convention names, exactly as ADR-0011 requires.
Every ServiceMonitor added under this rule MUST carry a keep-only metricRelabelings
block naming the metric families it exists to collect. Scraping an actuator endpoint
without one admits the service’s entire Micrometer surface.
Do not enable the OpenTelemetry agent’s Micrometer bridge. It is the mechanism that would collapse these two routes into one, and doing so reintroduces the duplicate naming this ADR exists to prevent.
3. Consequences
3.1. Positive
-
Exactly one copy of every measurement. The two transports carry disjoint metric families, so no dashboard has to choose between two names for the same quantity.
-
Pod and namespace identity are native on application metrics, with no
target_infojoin in panels or alert expressions. -
upgives scrape health for free, which lets aServiceMonitorbe deployed and proven before the metrics it targets exist. -
ADR-0011 is unchanged. OTLP remains the instrumentation boundary for everything the agent produces; this only says where hand-written meters go.
-
The full Micrometer surface — Spring Data repository timings, cache statistics, pool internals — is available to be admitted later by widening a keep-list, deliberately and one family at a time.
3.2. Negative
-
Two transports to understand. Someone debugging a missing metric must first ask which kind it is. This ADR is the answer to that question and should be linked from dashboards that mix the two.
-
The keep-list is a maintenance obligation. A new meter does not appear in Prometheus until its family is added, and the failure mode is silence.
-
A scrape path can flood the head block if the keep-list is forgotten or written too broadly. Prefer a prefix regex over an exact-name list: a name drift then produces unexpected series rather than a silently empty scrape.
-
Scrape cost is real. Gauges backed by a query are evaluated on every scrape, so the interval is a load decision against the backing store, not only a resolution choice.