Metrics

Scrape StoatFlow's Prometheus /metrics endpoint — Micrometer integration, the key meters the engine emits, license metrics, and common tags.

The :runtime module exports metrics in Prometheus text format on GET /metrics. Internally it's a Micrometer PrometheusMeterRegistry — the engine reports events to a Micrometer instrumentation callback, the runtime binds JVM and Kafka-client metrics to the same registry, and the handler serves the registry's scrape output. Point your existing Prometheus stack at the endpoint and the StoatFlow meters appear alongside everything else.

Enable and scrape

Metrics are enabled by default and live under runtime.metrics:

runtime:
  http:
    enabled: true
    port: 8080            # default
  metrics:
    enabled: true         # default
    prefix: stoatflow     # default — prefix on all metric names
    recording-level: info # info | debug | trace (default: info)
    bind-jvm-metrics: true # default — JVM memory/GC/thread binders
    common-tags:          # extra tags applied to every meter
      environment: production
      service: word-count

Scrape it from the shell:

curl -s localhost:8080/metrics | head -40

A minimal Prometheus scrape config:

scrape_configs:
  - job_name: stoatflow
    metrics_path: /metrics
    static_configs:
      - targets: ["my-app:8080"]

The endpoint returns the standard exposition format, so any Prometheus-compatible collector (Prometheus, Grafana Agent, OpenTelemetry Collector with the Prometheus receiver, the VictoriaMetrics agent) scrapes it without extra translation. On Kubernetes, add prometheus.io/scrape / prometheus.io/path pod annotations or a ServiceMonitor pointing at the HTTP port — see Observability.

Disabling metrics (runtime.metrics.enabled: false) un-registers the /metrics endpoint — it returns 404 — and switches the engine to no-op instrumentation, so there's no measurement overhead when metrics are off.

Recording levels

recording-level controls how much detail the engine reports, mirroring Kafka Streams' levels. A higher level is a superset of the ones below it.

LevelWhat it addsUse
infoEssential operational meters — throughput, barrier/commit latency, watermark progress, restoration, consumer/producer, errors.Production default.
debugPer-store, per-partition, per-processor detail and dispatch internals. Higher cardinality.Targeted troubleshooting.
traceDeepest internals; highest overhead.Development only.

Tags like lane_id, store_name, topic, and partition multiply series count. On large topologies, keep production at info and switch to debug only while investigating.

Naming and tags

Migrating from Kafka Streams? These native stoatflow.* names are not KS names. Set runtime.metrics.naming: both to also emit Kafka Streams-named, KS-shaped series derived from these meters so your existing KS dashboards work — see Reusing your Kafka Streams dashboards. (One side effect while it's on: the mapped native timers' _seconds_max tracks a ~30 s window instead of the ~2 min default, and stoatflow.e2e.latency gains a {quantile="0.0"} series.)

All StoatFlow meters use a dotted name with the configured prefix (stoatflow by default). The Prometheus registry rewrites dots to underscores and appends the conventional suffix (_total for counters; _seconds_count/_seconds_sum/_seconds_max for timers). So stoatflow.barrier.completed.total is scraped as stoatflow_barrier_completed_total, and the timer stoatflow.barrier.commit.latency produces stoatflow_barrier_commit_latency_seconds_* series. The latency timers publish client-side percentiles as {quantile="0.5|0.95|0.99"} labels on the base _seconds name rather than _bucket histograms, so use rate(_sum)/rate(_count) (or the {quantile=...} series) — histogram_quantile() over _bucket won't match.

Two application tags are present on StoatFlow meters:

TagSourceNotes
applicationRegistry-level common tagCarries application-id; applied to every meter, including JVM and Kafka-client metrics.
application_idPer-meter tagApplied by the StoatFlow instrumentation callback.

Anything you add under common-tags (for example environment, region, service) is also applied to every meter — handy for selecting one deployment in a shared Prometheus. Per-meter tags vary by group: lane_id, store_name, topic, partition, error_type, trigger, type, result, and others are listed with each meter below.

Lane IDs follow the Kafka Streams task-ID convention {subtopology}_{lane} — e.g. 0_0, 0_15, 1_7. Aggregate across a sub-topology in PromQL with a regex matcher: sum(stoatflow_lane_records_processed_total{lane_id=~"1_.*"}).
Sub-topology ids shifted in 1.0.0. StoatFlow now inserts a sub-topology boundary only where an operator genuinely needs the re-keyed record's lane affinity, rather than at every key change (ADR-138). Re-keying topologies therefore have fewer sub-topologies, and the ids renumber — which silently changes any dashboard, alert or recording rule that pins a sub_topology / subtopology_id value or a lane_id=~"N_.*" regex. Re-check those selectors against a live scrape after upgrading. Setting stoatflow.topology.sub-topology-split: eager restores the previous numbering. Processor-API topologies renumber a second time in 1.0.0: process() / processValues() no longer open a boundary after a re-key (stoatflow.topology.processor-api-key-affinity: off, matching Kafka Streams); presumed restores those.

Key meters

The dotted names below are the canonical metric IDs the engine emits. Remember the Prometheus rewrite (. → _, plus the type suffix) when writing queries. This is a working set, not the exhaustive list — the full catalogue is on the metrics reference, and your live scrape stays authoritative for exact tag sets.

Two families are deliberately left to the reference rather than repeated here: stoatflow.rocksdb.* (~43 series per store, opt-in via stoatflow.rocks-db.metrics.enabled — reach for it when tuning RocksDB, not on a general dashboard) and stoatflow.suppress.* (a suppression-tuning signal).

Throughput and end-to-end latency

MetricTypeLevelTagsMeaning
stoatflow.lane.records.processed.totalCounterinfolane_idRecords processed by a lane.
stoatflow.lane.bytes.processed.totalCounterinfolane_idBytes processed by a lane.
stoatflow.lane.process.latencyTimerinfolane_idPer-record processing latency.
stoatflow.lane.queue.sizeGaugeinfolane_idCurrent lane queue depth (backpressure signal).
stoatflow.lane.queue.capacityGaugeinfolane_idConfigured lane queue capacity.
stoatflow.lane.queue.full.totalCounterinfolane_idTimes a lane queue was full (backpressure events).
stoatflow.e2e.latencyTimerinfo—Per-record end-to-end latency, source timestamp to processing completion (analogous to KS record-e2e-latency).
# Total processing throughput (records/sec)
sum(rate(stoatflow_lane_records_processed_total[1m]))

# Average per-record processing latency (this timer emits _sum/_count/_max, not _bucket)
sum(rate(stoatflow_lane_process_latency_seconds_sum[5m])) / sum(rate(stoatflow_lane_process_latency_seconds_count[5m]))

Barrier commits

Commit barriers are StoatFlow's exactly-once mechanism (see Exactly-once). These meters track barrier lifecycle and commit-phase latency. The latency timers publish client-side P50/P95/P99 percentiles.

MetricTypeLevelTagsMeaning
stoatflow.barrier.initiated.totalCounterinfo—Barriers initiated.
stoatflow.barrier.completed.totalCounterinfo—Barriers successfully completed.
stoatflow.barrier.failed.totalCounterinfoerror_typeBarrier failures, by error type (e.g. timeout, producer_fenced, fatal_error for an Error thrown by user code inside the commit; full list in the metrics reference).
stoatflow.barrier.abandoned.totalCounterinforeasonBarriers deliberately not committed (pre_assignment, poison_abort, abort_teardown); the epoch replays on restart. Not a failure.
stoatflow.epoch.invariant.violations.totalCounterinfokindEpoch-isolation, offset-order and input-buffer-bound tripwires. Unreachable by design: any non-zero value is a bug to report (alert on it). One exception: partition_offset_regression also counts each broker-side seek-back (log truncation, an offset reset), after which the re-fetched records are processed again.
stoatflow.barrier.in.flightGaugeinfo—Currently pending barriers.
stoatflow.barrier.latencyTimerinfo—Initiation to completion.
stoatflow.barrier.alignment.latencyTimerinfo—Time for all lanes to receive the barrier.
stoatflow.lane.holdback.recordsDistributionSummaryinfosub_topologyRecords and timer fires held back and replayed per barrier at a sub-topology boundary (receiver-side epoch hold-back — keeps the commit a consistent cut across the cascade).
stoatflow.lane.holdback.drains.totalCounterinfosub_topologyNon-empty hold-back drains (a receiving lane replayed stashed ahead-of-epoch records or timer fires after crossing its barrier).
stoatflow.barrier.commit.latencyTimerinfo—Commit latency (producer drain + Kafka TX + state flush). Comparable to KS commit-latency.
stoatflow.barrier.commit.changelog.latencyTimerinfo—Aggregate changelog serialization+send latency per commit epoch.
stoatflow.barrier.commit.state.flush.latencyTimerinfo—Aggregate state-store flush latency per commit epoch.
stoatflow.barrier.commit.producer.flush.latencyTimerinfo—Pre-commit producer flush latency.
stoatflow.barrier.commit.send.offsets.latencyTimerinfo—sendOffsetsToTransaction RPC latency.
stoatflow.barrier.records.committed.totalCounterinfo—Records committed across barriers.
stoatflow.barrier.bytes.committed.totalCounterinfo—Bytes committed across barriers.
stoatflow.barrier.trigger.totalCounterinfotriggerBarrier creation count by trigger — TIME, RECORD_COUNT, MEMORY_PRESSURE, CACHE_PRESSURE (state-cache cap forced an early commit).
stoatflow.transaction.commit.totalCounterinfo—Kafka transactions committed.
stoatflow.transaction.abort.totalCounterinfo—Kafka transactions aborted.
stoatflow.transaction.commit.latencyTimerinfo—Transaction commit latency.
# Commit rate vs. failure rate
rate(stoatflow_barrier_completed_total[1m])
rate(stoatflow_barrier_failed_total[5m])
The commit cadence and epoch size self-tune within their configured bounds; the scheduling gauges (stoatflow.barrier.interval.target.ms, stoatflow.barrier.epoch.max.records, and related) report what the engine is currently doing so you can see the cadence on a dashboard. The bound knobs themselves are documented under Tuning and the configuration reference.

Watermarks

Event-time progress for windowing and late-event detection (see Event time and watermarks).

MetricTypeLevelTagsMeaning
stoatflow.watermark.currentGaugeinfo—Current global watermark (epoch ms).
stoatflow.watermark.lag.msGaugeinfo—Wall-clock time minus the global watermark (event-time lag). Sampled ~1 Hz regardless of whether the watermark advanced, so it climbs during a stall; -1 until the first watermark is emitted.
stoatflow.watermark.advance.totalCounterinfo—Watermark advancement events.
stoatflow.watermark.preview.partitionsGaugeinfo—Partitions whose next record waits behind older records of other partitions and counts toward the global watermark as if it had been processed, so event time keeps moving (see records stamped in the future). Typical causes: a record stamped in the future, or a partition far ahead of the others during a skewed catch-up or replay. Sampled ~1 Hz; idle partitions are not counted, nor one whose own watermark is already as high. Non-zero for long with no catch-up under way usually means a producer clock or a timestamp unit needs checking — /watermarks names the partition. It can also read non-zero while the application is paused or fully backpressured.
stoatflow.watermark.late.records.totalCounterdebugtopic, partitionRecords arriving after the watermark.
stoatflow.watermark.alignment.held.time.totalCounterinfogroupSeconds during which a watermark-alignment hold in the group stopped all dispatch, waiting for a lagging source with nothing buffered. rate() is the fraction of time the application waited on the group; a sustained climb means the drift is sized too small for the source's gaps between records.
# Alert when event time falls more than 5 minutes behind wall clock while the app is running (RUNNING == 4).
# The lag meter climbs during a stall — a lagging-but-live partition pins the global watermark and every
# window/session store stops expiring, so segments accumulate (see stoatflow_store_segments below).
stoatflow_watermark_lag_ms > 300000 and stoatflow_client_state == 4

# Fraction of time watermark alignment kept the application waiting, per group (0..1).
rate(stoatflow_watermark_alignment_held_time_total[5m])

Changelog writes and state restoration

Changelog write counters track state durability traffic; restoration meters track recovery progress on startup (see State stores and State and thread safety).

MetricTypeLevelTagsMeaning
stoatflow.store.changelog.records.written.totalCounterinfostore_nameChangelog records written.
stoatflow.store.changelog.bytes.written.totalCounterinfostore_nameChangelog bytes written.
stoatflow.store.expired.recordsCounterinfostore_name, store_typeRecords event-time-expired past retention from an in-memory window or session store. A sudden climb usually means watermarks jumped — worth checking during an incident.
stoatflow.store.segmentsGaugeinfostore_name, store_typeLive segment count of a segmented RocksDB window/session store (one RocksDB instance, ~7 file descriptors per segment). In steady state the count is phase-dependent and bounded by ceil(retention / segmentInterval) + 1 (wider pre-existing segments can raise it through a retention change); it climbs without bound while a lagging-but-live partition pins the watermark — alert when it stays above that bound (RS-03).
stoatflow.store.versioned.sweep.lag.msGaugeinfostore_nameMilliseconds since a versioned store's last completed history-reclamation pass. Its retention is a minimum bound — expired versions stop being readable at once, but their bytes are reclaimed by a rolling sweep. Alert when this exceeds stoatflow.state.versioned.sweep-period-ms for a sustained interval: reclamation is not keeping up and the store will keep growing. Reclamation runs normally on a quiet application too — an idle app still commits on a slow cadence, so the sweep keeps running until it has nothing left to reclaim, then stops.
stoatflow.store.versioned.sweep.budget.exhausted.totalCounterinfostore_nameSweep ticks that hit a per-barrier cap before finishing their slice. Isolated increments under a burst are normal; a steady rate means the configured sweep period is not achievable at this store's size — raise stoatflow.state.versioned.max-scan-bytes-per-barrier or accept a longer period.
stoatflow.store.versioned.sweep.failures.totalCounterinfo—Reclamation ticks that failed. The tick catches everything by design (a sweep failure has no business in the commit protocol), so without this a persistently-broken sweep is invisible outside the log while the process looks healthy. Any sustained increase means reclamation is not running at all.
stoatflow.store.versioned.sweep.reclaimed.totalCounterinfostore_nameVersions the retention sweep removed from the local store.
stoatflow.store.versioned.sweep.tombstones.totalCounterinfostore_nameReclamation tombstones handed to the changelog writer. Alert when this falls persistently behind sweep.reclaimed.total: the local store is shedding but the topic is not.
stoatflow.fk.enumeration.behind.scansCounterinfostore_nameForeign-key join evaluations that had to scan the subscription store as-of an earlier committed epoch. Sustained non-zero means FK joins are paying the slower enumeration path.
stoatflow.restoration.in.progressGaugeinfo—1 while restoration is running, 0 otherwise.
stoatflow.restoration.stores.totalGaugeinfo—Total stores to restore.
stoatflow.restoration.stores.completedGaugeinfo—Stores that have finished restoring.
stoatflow.restoration.records.restored.totalCounterinfostore_nameRecords restored, per store.
stoatflow.restoration.bytes.restored.totalCounterinfostore_nameBytes restored, per store.
stoatflow.restoration.remaining.recordsGaugeinfostore_nameChangelog records still to restore, per store (0 once restored).
stoatflow.restoration.duration.secondsTimerinfostore_namePer-store restoration duration.
# Restoration progress as a fraction of stores completed
stoatflow_restoration_stores_completed / stoatflow_restoration_stores_total

Consumer, producer, and dispatch

MetricTypeLevelTagsMeaning
stoatflow.consumer.records.consumed.totalCounterinfotopic, partitionRecords consumed.
stoatflow.consumer.bytes.consumed.totalCounterinfotopic, partitionBytes consumed.
stoatflow.consumer.lag.recordsGaugeinfotopic, partitionConsumer lag, in records.
stoatflow.consumer.poll.latencyTimerinfo—Consumer poll latency.
stoatflow.consumer.assigned.partitionsGaugeinfo—Number of assigned partitions.
stoatflow.consumer.partition.forgotten.totalCounterinforeasonSource partitions dropped from offset tracking because their topic is gone. reason=topic_deleted — a pattern-matched topic was deleted on the default subscribe() path and the engine restarted in place to converge on the rest; reason=ha_source_shrink — the HA assign()-mode active's detector confirmed a shrink and dropped the offsets in place.
stoatflow.producer.records.produced.totalCounterinfotopicRecords produced.
stoatflow.producer.bytes.produced.totalCounterinfotopicBytes produced.
stoatflow.dispatcher.dispatch.latencyTimerinfo—Per-batch dispatch latency.
# Worst-case consumer lag across partitions
max(stoatflow_consumer_lag_records)

Errors

Error classification for alerting (see Error handling).

MetricTypeLevelTagsMeaning
stoatflow.error.totalCounterinfoerror_type, topicErrors by type (and topic where applicable).
stoatflow.error.typed.totalCounterinfocategory, error_typeCategorised errors (e.g. deserialization, processing, production).
stoatflow.queue.offer.failure.totalCounterinfoqueue_typeInternal queue offer rejections.
stoatflow.dlq.poison.replays.totalCounterinfo—Poison-epoch replays — in-place engine restarts that reprocess an aborted epoch with the poison skipped. The one to alert on: the replay budget is in-memory, so it bounds continue per process, not end to end, and a non-converging poison will crash-loop the pod.
stoatflow.dlq.poison.quarantined.totalCounterinfo—Source records quarantined as poison ahead of a replay. Each one loses its own outputs across all its sinks — bounded loss, not zero.
stoatflow.dlq.poison.replay.budget.exhausted.totalCounterinfo—The replay budget ran out and the instance shut down with the offsets held. Terminal — alert on it too: …replays.total counts replays, so it goes quiet precisely when a non-converging poison starts crash-looping the pod.
stoatflow.dlq.poison.quarantine.sizeGaugeinfo—Source offsets a replay is currently skipping. 0 in steady state; non-zero only between a poison abort and the replay's first clean commit.
stoatflow.timer.lane.mismatch.totalCounterinfoprocessorRecord keys whose re-serialization with the processor's timer key serde picks another lane than the record's, so the key's timers would run on another lane (see Timer key serde). Pre-registered at 0 per timer-declaring Processor API processor. A split for a timer on the record's own key stops the app by default (TimerLaneAffinityException, timer.lane-affinity.guard); a sampled split is WARN-only by default (timer.lane-affinity.sampled-check). Alert on any increase and on application state — a fatal detection can stop the app before the next scrape. The fix is a declared key serde.

Client state

MetricTypeLevelTagsMeaning
stoatflow.client.stateGaugeinfo—Application state ordinal: CREATED=0, STARTING=1, VALIDATING_STATE=2, RESTORING=3, RUNNING=4, DRAINING=5, PAUSED=6, STOPPING=7, STOPPED=8, ERROR=9.
stoatflow.lanesGaugeinfo—Configured lane count.
stoatflow.client.lane.aliveGaugeinfo—Currently running lanes.
stoatflow.client.uptime.secondsGaugeinfo—Seconds since the client was created.
stoatflow.engine.restart.totalCounterinfodisposition, target, triggerSuccessful in-place engine restarts (fault recovery via REPLACE_THREAD, HA demote-in-place).

High availability

Bound only when hot standby is enabled (ha.mode != off).

MetricTypeLevelTagsMeaning
stoatflow.ha.roleGaugeinfo—1 when this pod is the active, 0 when standby.
stoatflow.ha.standby.replication.lag.recordsGaugeinfo—Standby replication lag in records (0 when active or caught up).
stoatflow.ha.standby.replication.applied.recordsCounterinfo—Changelog records the standby applier has applied. rate() gives standby replication throughput — a flat line while lag is non-zero means the applier is stuck.
stoatflow.ha.standby.replication.lag.msGaugeinfo—Standby replication lag in milliseconds.
stoatflow.ha.peer.freshness.msGaugeinfo—Time since the freshest observed peer's last heartbeat.
stoatflow.ha.peersGaugeinfo—Number of observed HA peers.
stoatflow.ha.redundancy.ready.standbysGaugeinfo—Caught-up (READY_STANDBY) standby count, including self.
stoatflow.ha.redundancy.below.desiredGaugeinfo—1 when the active has fewer ready standby spares than desired-standbys.
stoatflow.ha.token.epochGaugeinfo—Current promotion-token epoch (increments once per election).
stoatflow.ha.election.outcomeCounterinfooutcomeCumulative election outcomes (won / reclaimed / stood_down_fresh / stood_down_lost).
stoatflow.ha.election.latency.msGaugeinfo—Last claim→ACTIVE election latency in ms (-1 until the first promotion).
stoatflow.ha.restart.requiredGaugeinfo—1 when this pod requires a restart (e.g. source-topic partition count changed).
stoatflow.ha.restartingGaugeinfo—1 while this pod is restarting its engine in place to absorb a processing fault (it stays ACTIVE, so stoatflow.ha.role does not move).

License metrics

When a license is configured, the runtime binds license meters to the same registry (see License configuration). These use the <prefix>.license prefix (default stoatflow.license, tracking runtime.metrics.prefix) and carry the registry common tags (application, plus your common-tags). They're exported even when recording-level is info.

MetricTypeTagsMeaning
stoatflow.license.validGaugetier1 when operating under a VALID or GRACE_PERIOD license, else 0 — including the deferred-validation PENDING window (the first ~5 minutes after every start).
stoatflow.license.expiry_timestamp_secondsGauge—Unix epoch seconds of the entitlement deadline.
stoatflow.license.days_remainingGauge—Whole days until expiry; negative within the post-expiry grace window.
stoatflow.license.grace_remaining_secondsGauge—Seconds left in the offline-grace budget; 0 when exhausted.
stoatflow.license.heartbeat_consecutive_failuresGauge—Heartbeat ticks failed since the last success; resets to 0 on success.
stoatflow.license.heartbeat_last_success_timestamp_secondsGauge—Unix epoch seconds of the most recent successful heartbeat.
stoatflow.license.validations_totalCounterresultValidation outcomes, by result label — success, authoritative reject codes, plus the deferred-validation outcomes network_failure, environment_required, and unexpected_error.
stoatflow.license.heartbeats_totalCounterresultHeartbeat tick outcomes, by result label.
# Alert when the license is not valid, or when expiry is within 14 days.
# Use a `for:` clause of at least 10m on the valid==0 rule: every deploy
# reads 0 for the ~5-minute deferred-validation PENDING window.
stoatflow_license_valid == 0
stoatflow_license_days_remaining < 14
License meters carry tier and timing detail. Scrape /metrics only from your in-cluster monitoring stack, not through internet-facing ingress. See Kubernetes.

JVM and Kafka-client metrics

With bind-jvm-metrics: true (the default), the runtime binds Micrometer's standard JVM binders to the registry, so the usual jvm_* and system_* series appear on the same endpoint:

  • jvm_memory_used_bytes / jvm_memory_max_bytes (heap and non-heap)
  • jvm_gc_pause_seconds and related GC metrics
  • jvm_threads_live_threads, jvm_threads_states_threads
  • jvm_classes_loaded_classes
  • system_cpu_usage, process_cpu_usage, system_load_average_1m

These carry the registry common tags (application + your common-tags) but not application_id. Native Kafka client metrics are also surfaced through Micrometer, so producer/consumer client internals appear alongside the StoatFlow meters.

Dashboards and alerts

A starting overview combines throughput, latency, backpressure, and commit health:

# Throughput
sum(rate(stoatflow_lane_records_processed_total[1m]))

# Backpressure — total queued vs. capacity
sum(stoatflow_lane_queue_size) / sum(stoatflow_lane_queue_capacity)

# Commit health
rate(stoatflow_barrier_completed_total[1m])
rate(stoatflow_barrier_failed_total[5m])

# Event-time lag
stoatflow_watermark_lag_ms

Useful alert conditions:

AlertConditionSeverity
Barrier failuresrate(stoatflow_barrier_failed_total[5m]) > 0Critical
License invalidstoatflow_license_valid == 0 (for: 10m — every deploy reads 0 during the ~5-min PENDING window)Critical
High consumer lagmax(stoatflow_consumer_lag_records) > 100000Warning
Watermark stalldelta(stoatflow_watermark_current[5m]) == 0 and stoatflow_client_state == 4Warning
Backpressuresum(stoatflow_lane_queue_size) > 0.8 * sum(stoatflow_lane_queue_capacity)Warning
Timer lane mismatchincrease(stoatflow_timer_lane_mismatch_total[5m]) > 0Critical

Next steps

  • Observability — wiring /metrics, logs, and probes into a monitoring stack.
  • Health checks — the readiness and liveness signals that complement metrics.
  • Tuning — which bound knobs the self-tuning cadence respects.
  • The REST API — the other operational endpoints on the same HTTP server.