Metrics
M5 adds the queue-depth, queue-age, outbox-row, outbox-age, command-ack-latency, result-buffer-depth and Hazelcast-connectivity meters this page introduces below; every meter that already existed through M4 is listed here too, because a scrape configuration is written from this one table and not from the source. Endpoint: GET /actuator/prometheus, gated by management.endpoints.web.exposure.include: health,info,prometheus, on the same port 8080 as the REST API — there is no separate metrics port. See Deployment for the Helm chart’s two discovery styles (pod annotations and an optional ServiceMonitor).
Names below are the Prometheus-rendered names — what a scrape configuration and an alert rule actually match against — not the Micrometer names the source code registers. The convention throughout is fcc_<area>_<what>, from the Micrometer convention fcc.<area>.<what>.
How a Micrometer Name Becomes a Prometheus Name
Two independent transformations happen between Counter.builder("fcc.legacy.import.anchors.created") and the line a scrape sees, and both are worth knowing before writing an alert rule, not after one silently matches nothing.
-
Dots become underscores.
fcc.queue.depthrenders asfcc_queue_depth. Mechanical, and the only step most engineers expect. -
A name ending in a reserved OpenMetrics suffix has that suffix stripped before exposition, and a
Counterthen has_totalappended back on unconditionally.PrometheusNaming.sanitizeMetricName(io.prometheus.metrics.model.snapshots, the library Micrometer’s Prometheus registry is built on since Spring Boot 3.5’s Micrometer line) strips a trailing member of a fixed reserved-suffix list —_total,_created,_bucket,_info,_sum,_countamong them — repeatedly, until none apply.fcc.legacy.import.anchors.createdtherefore loses its trailing_createdduring sanitisation, and the PrometheusCountertype then appends the_totalevery OpenMetrics counter is required to end in. The result isfcc_legacy_import_anchors_total, notfcc_legacy_import_anchors_created_total. The same happens tofcc.legacy.import.activations.created→fcc_legacy_import_activations_total.This was verified by decompiling the exact dependency versions this project resolves (
micrometer-registry-prometheusandprometheus-metrics-model, both pulled transitively throughspring-boot-starter-parent3.5.6) rather than by observing a running scrape —PrometheusNamingConvention.name()callsPrometheusNaming.prometheusName()thenPrometheusNaming.sanitizeMetricName(), and the latter’s bytecode is the loop described above, operating on the exactRESERVED_METRIC_NAME_SUFFIXESarray quoted here. It was not additionally confirmed against a live/actuator/prometheusresponse from a running instance; if a scrape ever shows something different from this table, trust the scrape and re-open this note.No other meter name in the table below collides with a reserved suffix —
fcc.legacy.import.rows.read/.written/.skipped,.batches,.devices.snapshotted/.unresolved,.passes.completed/.failedall render as plain_totalcounters with no stripping. -
A
Gaugewhose Micrometer name already ends in a word matching itsbaseUnit(fcc.queue.age.secondswithbaseUnit("seconds"),fcc.outbox.age.secondslikewise) is not suffixed a second time — the convention only appends_<baseUnit>when the name does not already end with it, so these render exactly as spelled:fcc_queue_age_seconds,fcc_outbox_age_seconds. -
A
Timeralways renders as a family of samples with a_secondsbase (Micrometer’s Prometheus registry standardises timers on seconds regardless of the unit passed toDuration), plus the OpenMetrics summary suffixes:_seconds_count,_seconds_sum, and_seconds_max(the rolling time-window maximum Micrometer publishes by default for everyTimer, since no percentile histogram is configured on this one).
Queue Metrics
Tag queue, five values: fccCommandQueue, fccCommandAck, fccResultQueue, fccDeviceStateQueue, fccActivationEventQueue (FccQueueNames).
| Name | Type | Tags | Measures | Alert? |
|---|---|---|---|---|
|
Gauge |
|
|
Yes — a sustained rise on |
|
Gauge |
|
Age of the head element: |
Yes, on the three queues where it is observable (see Where |
Outbox Metrics
Tag state. fcc_outbox_rows carries all five live fcc_acs_outbox.state values (FccOutboxStateEnum); fcc_outbox_age_seconds carries only PENDING and SENT — an ABANDONED row is not waiting for anything, so it has no age.
| Name | Type | Tags | Measures | Alert? |
|---|---|---|---|---|
|
Gauge |
|
|
Yes on |
|
Gauge |
|
Age of the oldest row in that state, measured entirely against this JVM’s own clock ( |
Yes — this is the metric to alert on for the command path, not |
Command Acknowledgement Latency
| Name | Type | Tags | Measures | Alert? |
|---|---|---|---|---|
|
Timer (count component) |
|
Number of acks applied, tagged by the wire result — a republished command’s |
Not on its own; read alongside |
|
Timer (sum component) |
|
Cumulative seconds from offering the command to |
No. |
|
Timer (rolling max component) |
|
Rolling time-window maximum ack latency. |
Yes on |
|
Counter |
(none) |
Acks applied that could not be timed because their outbox row’s |
Yes if non-zero and rising — it means an ack is being applied for a row the normal outbox path did not produce. |
None of the three tag values of fcc_command_ack_latency_seconds_* is pre-registered: a Timer with no observations is simply absent from the scrape (the normal behaviour for a timer), rather than a fake zero polluting a latency distribution an alert reads.
Recording this timer is now guarded in FccAckService, deliberately, because of where it sits rather than what it measures. The call happens inside the same @Transactional method that has just marked the outbox row ACKED and moved the activation on — and the ack itself is not replayable, because the ACS already considers it delivered. Before this guard, a RuntimeException escaping the meter call (a registry failure, however unlikely with a bounded tag cardinality of three FccAckResultEnum values) would have rolled back both writes, stranding the run in DISPATCHING for a reason that has nothing to do with the ack itself. Losing one observation of a distribution is a cost nobody can measure; losing an ack that the ACS will never resend is not.
Result Buffer and Hazelcast Connectivity
| Name | Type | Tags | Measures | Alert? |
|---|---|---|---|---|
|
Gauge |
(none) |
Measurements currently held in the in-memory buffer that doubles as the ClickHouse retry queue ( |
Yes — approaching the capacity is the leading indicator before measurements start landing in |
|
Gauge |
(none) |
|
Yes — and it is the metric that explains the ten |
Where NaN Appears, and What It Means
Every gauge above is sampled on a timer, never computed on scrape (Why the Gauges Are Sampled on a Timer, Never Computed on Scrape), and an unknown value is reported as NaN, never 0 — 0 is itself a statement ("the queue is empty", "the outbox is drained"), the exact opposite of what is true when the sample could not be taken at all. A NaN on a dashboard therefore reads as a bug unless a reader already knows these four cases:
| Series | Why it is NaN |
|---|---|
All ten |
The whole sampler loop short-circuits on a disconnected client: |
|
Not observable from inside this service, and nothing substitutes for it. The age of a queue’s head is |
|
An empty table (no rows in a state) reports |
(not a NaN case, but the paired fact) three of the five queue ages are observable, and carry clock skew |
|
Why the Gauges Are Sampled on a Timer, Never Computed on Scrape
A single @Scheduled sampler, FccMetricsSampler (ft.fcc.metrics.sample-interval, FCC_METRICS_SAMPLE_INTERVAL, default 15s, one of the eight @Scheduled ticks this service runs on virtual threads with no shared pool to starve — see the scheduling note), refreshes a set of in-memory holders the gauges read from. Every tag value is registered at construction time, not on first observation, so a drained queue or an empty outbox state still produces a series — a series that vanishes when a backlog clears is indistinguishable from a dead scrape target, which is precisely the failure mode a monitoring system exists to catch.
Two independent reasons this is not computed inline on every scrape:
-
IQueue.peek()deserializes the head element on every call, and the head offccResultQueuein particular is a batch of up to a hundred measurements (FccResultBatchDto). Paying that deserialization cost on every Prometheus scrape — which may run far more often than the sample interval, or from more than one scraper — is a real, avoidable cost on a busy installation. -
A scrape must never block. A disconnected Hazelcast client and a database in failover are both states this service is explicitly designed to survive without going
DOWN(Health); a gauge that queried either of them inline would hang the whole/actuator/prometheusresponse behind a connection timeout, taking every other meter on the page down with it.
The sampler never throws: a sampler that died on a database outage would freeze every gauge at its last value, which is the one moment an operator is actually looking at them.
|
Since M5, not throwing is not enough on its own — the sampler also bounds how long each dependency is allowed to take, because exception isolation alone left a hung call free to freeze gauges that had nothing to do with it. As the sampler first shipped, its outbox query carried no timeout of its own, and the Hazelcast client — present since long before the sampler, and shared with the queue consumers — had never had its invocation timeout set, so it sat at Hazelcast’s own default of 120 seconds; while either was hanging, every gauge in the tick — including the two that depend on neither — held its last value rather than reporting
One correction worth stating plainly, because it is the one figure this page would otherwise get wrong by carrying over the pre-M5 number: 120 seconds was never the bound on a completely silent socket. It was the client’s retry ceiling. A member that stops responding without closing the connection is actually bounded by the client’s heartbeat timeout, which is 60 seconds and was deliberately not lowered alongside the invocation timeout — an aggressive heartbeat causes false reconnects on an ordinary GC pause, which is a worse failure than a slow sample. So the worst case for the three queue gauges that can still block is about one heartbeat interval, and what actually protects the other gauges from it is the ordering above, not the invocation timeout. |
Everything Else on /actuator/prometheus
Every meter that existed before M5, listed so a scrape configuration is complete without reading the source.
| Name | Type | Tags | Measures |
|---|---|---|---|
|
Counter |
(none) |
M5. Campaigns written into a |
|
Counter |
(none) |
M5. Configuration documents read into a campaign form by |
|
Counter |
(none) |
Speed result rows written to ClickHouse. |
|
Counter |
(none) |
Speed result rows whose ClickHouse insert failed. |
|
Counter |
(none) |
Latency result rows written to ClickHouse. |
|
Counter |
(none) |
Latency result rows whose ClickHouse insert failed. Rising while |
|
Counter |
(none) |
Hazelcast queue messages skipped because they could not be deserialized as the expected DTO type. |
|
Counter |
(none) |
Failures of a queue consumer |
|
Counter |
(none) |
Queue messages given up on after the bounded consume retry. |
|
Counter |
(none) |
Runs the watchdog closed as |
|
Counter |
(none) |
Runs the watchdog force-closed to |
|
Counter |
(none) |
Commands accepted by |
|
Counter |
(none) |
Publish attempts that failed and will be retried. |
|
Counter |
(none) |
Commands given up on after |
|
Counter |
(none) |
|
|
Counter |
(none) |
Activation events discarded because they name no run this service knows (the mirror of |
|
Counter |
(none) |
Daily runs a campaign owed that were never created because its previous run never reached |
|
Counter |
(none) |
Planned reactivation runs never sent because their campaign’s settings were no longer runnable at dispatch time. |
|
Counter |
(none) |
Device states written to |
|
Counter |
(none) |
Device states discarded because they name a run this service does not know. |
|
Counter |
(none) |
Device states discarded because the message is missing a field it is keyed on — a malformed ACS message. |
|
Counter |
(none) |
Legacy FCC result rows read from |
|
Counter |
(none) |
Legacy FCC result rows written to ClickHouse. |
|
Counter |
(none) |
Legacy rows skipped because crash recovery found them already in ClickHouse. |
|
Counter |
(none) |
Batches of legacy results committed. |
|
Counter |
(none) |
Anchor campaigns ( |
|
Counter |
(none) |
Synthetic activations created for legacy day-windows. Same stripping as the row above. |
|
Counter |
(none) |
Devices added to the frozen fleet of an imported anchor campaign. |
|
Counter |
(none) |
Legacy serials no live |
|
Counter |
(none) |
Legacy import passes that reached the end of their result kind. |
|
Counter |
(none) |
Legacy import passes that ended on an unexpected failure. |
|
Counter |
(none) |
Results written to |
|
Counter |
(none) |
Results that could not even be parked — genuinely gone. The one counter here worth paging on. |
|
Counter |
(none) |
Results handed back from the dead letter table into the ingest path by |
|
Counter |
(none) |
Dead letter rows given up on because their payload could not be read back. |
|
Counter |
(none) |
Dead letter rows a resend could not use because they carry |
|
Counter |
(none) |
Results already stored under their natural key — an at-least-once redelivery correctly discarded. |
|
Counter |
(none) |
Results discarded because they name no activation this service knows. |
|
Counter |
(none) |
Results that arrived after their run’s stop grace and were excluded from the run on purpose — see The After-Stop Window. |
Not Exposed Here
rowsLateSkipped and rowsInvalid of the legacy import status response have no Micrometer counter — they are visible on POST /FCC/admin/legacy-import/status and in the log only (Watch Progress). A scrape configuration built from this page alone will not see them; that is by design, not an omission this page should paper over.