Skip to main content

spate_core/metrics/
mod.rs

1//! Metrics: exporter installation and pre-registered handle structs for
2//! every pipeline stage.
3//!
4//! Spate instruments through the [`metrics`] facade — pipeline authors
5//! register custom metrics with the same macros and they are exported
6//! alongside the framework's. [`install`] wires the exporter selected by
7//! configuration; the taxonomy contract lives in `docs/METRICS.md` and its
8//! names in [`names`].
9//!
10//! # Connector- and user-owned families
11//!
12//! Beyond the fixed handle structs, a [`Meter`] mints
13//! `Counter`/`Gauge`/`Histogram` handles that inherit the three standard
14//! labels (`pipeline`, `component`, `component_type`), so a connector's or
15//! pipeline author's own series join cleanly against the framework's. The
16//! handle types are re-exported here so a connector can store them without a
17//! direct `metrics` dependency.
18//!
19//! # One pipeline per process
20//!
21//! The exporter installs a **process-global** recorder (the `metrics`
22//! facade has one global recorder), matching the framework's
23//! one-pipeline-per-process deployment model. [`install`] therefore
24//! succeeds at most once per process; a second call returns
25//! [`MetricsError::AlreadyInstalled`].
26//!
27//! # Series ownership
28//!
29//! Counters aggregate under a label collision; gauges do not. So every handle
30//! struct that owns gauges claims its series at construction, and a second
31//! struct resolving the same series becomes a **shadow**: it still counts, but
32//! it publishes no gauge, leaving the owner's readings truthful. The pipeline
33//! builder and runtime take the fallible constructors (`try_new`) and refuse
34//! to start on a collision; direct construction (`new`) logs and shadows. The
35//! contract is written up under "Series ownership" in `docs/METRICS.md`.
36//!
37//! # Hot-path discipline
38//!
39//! All handles are pre-registered at pipeline build time via the structs in
40//! this module ([`SourceMetrics`], [`SinkShardMetrics`], ...). The record
41//! loop only ever touches resolved `Counter`/`Gauge`/`Histogram` handles,
42//! and methods take per-batch aggregates.
43
44mod backpressure;
45mod checkpoint;
46mod coordination;
47mod deser;
48mod labels;
49mod meter;
50pub mod names;
51mod operator;
52mod ownership;
53mod pipeline;
54mod queue;
55mod sink;
56mod source;
57
58pub use backpressure::BackpressureMetrics;
59pub use checkpoint::CheckpointMetrics;
60pub use coordination::{
61    AcquireReason, CoordinationMetrics, ReplanOutcome, RevocationOutcome, SplitLossReason, StoreOp,
62    WriteOutcome,
63};
64pub use deser::DeserMetrics;
65pub use labels::ComponentLabels;
66pub use meter::Meter;
67// Role is derived by the runtime/builder from wiring position, never named by
68// connectors — crate-internal only (see `Meter::for_component`).
69pub(crate) use meter::MetricRole;
70pub use operator::OperatorMetrics;
71pub use pipeline::{PipelineMetrics, PipelineState};
72pub use queue::QueueMetrics;
73pub use sink::{BackoffGuard, FlushReason, SinkShardMetrics};
74// Labels a family the sink worker alone observes; no connector can produce a
75// write attempt of its own, so this stays crate-internal like `MetricRole`.
76pub(crate) use sink::AttemptOutcome;
77pub use source::SourceMetrics;
78
79// The framework's instrumentation API *is* the `metrics` facade, so its
80// handle types are part of this crate's public surface — a connector storing
81// a [`Meter`]-minted handle in its own struct names them without taking a
82// direct `metrics` dependency, keeping one facade version across the tree.
83// This is the one sanctioned 0.x public-API exception (see `docs/DESIGN.md`).
84pub use metrics::{Counter, Gauge, Histogram, SharedString};
85
86use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder, PrometheusHandle};
87use std::net::{Ipv4Addr, SocketAddr};
88use std::sync::{Arc, Mutex, PoisonError};
89use std::time::Duration;
90
91/// Buckets for `*_duration_seconds` histograms and
92/// `spate_e2e_latency_seconds` (1 ms .. 60 s, roughly exponential).
93pub const DURATION_SECONDS_BUCKETS: &[f64] = &[
94    0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0,
95];
96
97/// Buckets for `spate_sink_batch_rows` (powers of 4, 64 .. 1Mi rows).
98pub const BATCH_ROWS_BUCKETS: &[f64] = &[
99    64.0, 256.0, 1024.0, 4096.0, 16384.0, 65536.0, 262144.0, 1048576.0,
100];
101
102/// Buckets for `spate_sink_batch_bytes` (powers of 4, 4 KiB .. 256 MiB).
103pub const BATCH_BYTES_BUCKETS: &[f64] = &[
104    4096.0,
105    16384.0,
106    65536.0,
107    262144.0,
108    1048576.0,
109    4194304.0,
110    16777216.0,
111    67108864.0,
112    268435456.0,
113];
114
115/// Which exporter to install.
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum Exporter {
119    /// Prometheus scrape endpoint, served by the admin server.
120    #[default]
121    Prometheus,
122    /// No export; all handles become no-ops.
123    None,
124}
125
126/// Time basis for `spate_e2e_latency_seconds`.
127#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
128#[non_exhaustive]
129pub enum E2eBasis {
130    /// Framework ingest time — clock-skew free (default).
131    #[default]
132    Ingest,
133    /// The record's event time (e.g. Kafka message timestamp) —
134    /// clock-skew sensitive but reflects true upstream delay.
135    Event,
136}
137
138/// Exporter settings, mapped from the `metrics` config section by the
139/// pipeline runtime. Defined here (not in `config`) so this module has no
140/// config dependency.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct MetricsSettings {
143    /// Which exporter to install.
144    pub exporter: Exporter,
145    /// Admin-server listen address (`/metrics`, `/healthz`, `/readyz`).
146    pub listen: SocketAddr,
147    /// Enable cardinality-sensitive per-partition series.
148    pub per_partition_detail: bool,
149    /// Time basis for end-to-end latency.
150    pub e2e_basis: E2eBasis,
151}
152
153impl Default for MetricsSettings {
154    fn default() -> Self {
155        MetricsSettings {
156            exporter: Exporter::Prometheus,
157            listen: SocketAddr::from((Ipv4Addr::UNSPECIFIED, 9090)),
158            per_partition_detail: false,
159            e2e_basis: E2eBasis::Ingest,
160        }
161    }
162}
163
164/// Exporter installation failed.
165#[derive(Debug, thiserror::Error)]
166#[non_exhaustive]
167pub enum MetricsError {
168    /// A global recorder is already installed in this process (one pipeline
169    /// per process).
170    #[error("a metrics recorder is already installed in this process")]
171    AlreadyInstalled,
172    /// The exporter rejected its configuration.
173    #[error("failed to build the metrics exporter: {0}")]
174    Build(String),
175    /// Another live handle set already owns this gauge series — two
176    /// pipelines, or two components sharing a name, in one process (see the
177    /// "Series ownership" section of `docs/METRICS.md`).
178    #[error(
179        "metric series {0} already has a live owner in this process; \
180         gauge series cannot be shared (rename the component or the pipeline)"
181    )]
182    DuplicateSeries(String),
183}
184
185/// Handle to the installed exporter. Cheap to clone.
186#[derive(Clone, Debug)]
187pub struct MetricsHandle {
188    inner: Inner,
189    process: Option<Arc<metrics_process::Collector>>,
190}
191
192#[derive(Clone, Debug)]
193enum Inner {
194    Prometheus(PrometheusHandle),
195    Noop,
196}
197
198impl MetricsHandle {
199    /// Render the current exposition-format snapshot (empty for the no-op
200    /// exporter).
201    #[must_use]
202    pub fn render(&self) -> String {
203        match &self.inner {
204            Inner::Prometheus(handle) => {
205                if let Some(process) = &self.process {
206                    process.collect();
207                }
208                handle.render()
209            }
210            Inner::Noop => String::new(),
211        }
212    }
213
214    /// The render function seam handed to the admin server, keeping it
215    /// independent of exporter internals.
216    #[must_use]
217    pub fn render_fn(&self) -> Arc<dyn Fn() -> String + Send + Sync> {
218        let this = self.clone();
219        Arc::new(move || this.render())
220    }
221
222    /// One maintenance tick: drains histogram state and refreshes process
223    /// metrics. Cheap; call on an interval.
224    pub fn upkeep_tick(&self) {
225        if let Inner::Prometheus(handle) = &self.inner {
226            handle.run_upkeep();
227        }
228        if let Some(process) = &self.process {
229            process.collect();
230        }
231    }
232
233    /// Spawn the periodic upkeep task on the current tokio runtime.
234    #[must_use]
235    pub fn spawn_upkeep(&self, period: Duration) -> tokio::task::JoinHandle<()> {
236        let this = self.clone();
237        tokio::spawn(async move {
238            let mut tick = tokio::time::interval(period);
239            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
240            loop {
241                tick.tick().await;
242                this.upkeep_tick();
243            }
244        })
245    }
246}
247
248/// A Prometheus builder pre-configured with the bucket layout from
249/// `docs/METRICS.md`.
250fn configured_builder() -> Result<PrometheusBuilder, BuildError> {
251    PrometheusBuilder::new()
252        .set_buckets_for_metric(
253            Matcher::Suffix("_duration_seconds".into()),
254            DURATION_SECONDS_BUCKETS,
255        )?
256        .set_buckets_for_metric(
257            Matcher::Full(names::E2E_LATENCY_SECONDS.into()),
258            DURATION_SECONDS_BUCKETS,
259        )?
260        // Ends in `_latency_seconds`, not `_duration_seconds`, so the
261        // suffix matcher misses it — matched by name like the E2E latency
262        // above, so it gets the same second-scale buckets as every other
263        // coordination timing rather than the library defaults.
264        .set_buckets_for_metric(
265            Matcher::Full(names::COORDINATION_ASSIGNMENT_LATENCY_SECONDS.into()),
266            DURATION_SECONDS_BUCKETS,
267        )?
268        .set_buckets_for_metric(
269            Matcher::Full(names::SINK_BATCH_ROWS.into()),
270            BATCH_ROWS_BUCKETS,
271        )?
272        .set_buckets_for_metric(
273            Matcher::Full(names::SINK_BATCH_BYTES.into()),
274            BATCH_BYTES_BUCKETS,
275        )
276}
277
278/// The handle from this process's successful [`install`]. Installation is
279/// once-per-process (the recorder is global); later `install` calls reuse
280/// this handle instead of failing, so assembly code can install the
281/// exporter *before* pre-registering metric handles and the runtime's own
282/// install becomes a no-op.
283static INSTALLED: std::sync::OnceLock<MetricsHandle> = std::sync::OnceLock::new();
284
285/// The settings of the first successful [`install`], kept so later calls
286/// with different settings can warn that theirs are ignored.
287static INSTALLED_SETTINGS: std::sync::OnceLock<MetricsSettings> = std::sync::OnceLock::new();
288
289/// Install the configured exporter as this process's global recorder and
290/// return the handle the admin server renders from.
291///
292/// **Call this before constructing any metric handle structs**
293/// ([`SinkShardMetrics`](crate::metrics::SinkShardMetrics) and friends):
294/// handles bind to the recorder present at construction, and handles built
295/// earlier record into the void. Idempotent — a second call returns the
296/// first call's handle (with a warning when the requested settings differ).
297/// [`MetricsError::AlreadyInstalled`] is only returned when a *foreign*
298/// global recorder (not installed through this function) already exists.
299///
300/// For [`Exporter::Prometheus`] this also registers the `process_*`
301/// collector (CPU, memory, fds). No HTTP listener is spawned here — the
302/// admin server owns the socket.
303pub fn install(settings: &MetricsSettings) -> Result<MetricsHandle, MetricsError> {
304    // Serialized: the check-then-install below is not atomic on its own, and
305    // two threads racing it both find the slot empty, both call
306    // `install_recorder`, and the loser reports `AlreadyInstalled` — against
307    // *our own* recorder, which the very next call would have reused. One
308    // uncontended lock on a once-per-process path.
309    static INSTALL: Mutex<()> = Mutex::new(());
310    let _serialized = INSTALL.lock().unwrap_or_else(PoisonError::into_inner);
311    // Exporter::None installs no global recorder at all, so it neither
312    // claims nor consults the once-per-process slot — a later Prometheus
313    // install still works (and tests with metrics disabled stay isolated).
314    if settings.exporter == Exporter::None {
315        return Ok(MetricsHandle {
316            inner: Inner::Noop,
317            process: None,
318        });
319    }
320    if let Some(existing) = INSTALLED.get() {
321        if INSTALLED_SETTINGS
322            .get()
323            .is_some_and(|first| first != settings)
324        {
325            tracing::warn!(
326                requested = ?settings,
327                active = ?INSTALLED_SETTINGS.get(),
328                "metrics exporter already installed with different settings; \
329                 the first install's exporter stays in effect"
330            );
331        }
332        return Ok(existing.clone());
333    }
334    let builder = configured_builder().map_err(|e| MetricsError::Build(e.to_string()))?;
335    let handle = builder.install_recorder().map_err(|e| match e {
336        BuildError::FailedToSetGlobalRecorder(_) => MetricsError::AlreadyInstalled,
337        other => MetricsError::Build(other.to_string()),
338    })?;
339    let process = metrics_process::Collector::new("process_");
340    process.describe();
341    process.collect();
342    let handle = MetricsHandle {
343        inner: Inner::Prometheus(handle),
344        process: Some(Arc::new(process)),
345    };
346    let _ = INSTALLED_SETTINGS.set(settings.clone());
347    Ok(INSTALLED.get_or_init(|| handle).clone())
348}
349
350#[cfg(all(test, not(loom)))] // exporter internals (quanta) are loom-aware; not our model
351mod tests {
352    use super::*;
353    use crate::error::ErrorClass;
354    use crate::record::PartitionId;
355
356    /// Build a local (non-global) recorder with the production bucket
357    /// configuration, run `f` against it, and return the rendered output.
358    fn render_with_local_recorder(f: impl FnOnce()) -> String {
359        let recorder = configured_builder()
360            .expect("bucket config must be valid")
361            .build_recorder();
362        let handle = recorder.handle();
363        metrics::with_local_recorder(&recorder, f);
364        handle.run_upkeep();
365        handle.render()
366    }
367
368    /// Labels for one test's handle sets.
369    ///
370    /// Every test passes its own `component`: gauge series have one live owner
371    /// per process (see [`ownership`]), and under `cargo test` these tests run
372    /// concurrently in one process, so a shared label set would leave all but
373    /// the first test's handles shadowed and publishing nothing. Local
374    /// recorders do not help — the claim registry is process-wide by design.
375    fn labels(component: &str) -> ComponentLabels {
376        ComponentLabels::new("orders", component.to_owned(), "kafka")
377    }
378
379    #[test]
380    fn handle_structs_register_and_render_the_taxonomy() {
381        let rendered = render_with_local_recorder(|| {
382            let src = SourceMetrics::new(&labels("orders_kafka"));
383            src.batch(512, 131_072);
384            src.poll_duration(Duration::from_millis(3));
385            src.set_partition_lag(PartitionId(7), 40);
386            src.rebalance_assigned();
387            src.set_lanes_active(4);
388
389            let deser = DeserMetrics::new(&labels("orders_kafka"));
390            deser.batch(510, 2, Duration::from_millis(1));
391            deser.dropped(2);
392
393            let op = OperatorMetrics::new(&labels("orders_kafka"));
394            op.batch(510, 380, Duration::from_micros(600));
395            op.filtered(130);
396            op.errors(ErrorClass::RecordLevel, 1);
397
398            let q = QueueMetrics::new(&labels("orders_kafka"), "chain->sink/0", 4096);
399            q.set_depth(17);
400            q.full_events(1);
401
402            let bp = BackpressureMetrics::new(&labels("orders_kafka"));
403            bp.pause_started();
404            bp.pause_ended(Duration::from_millis(250));
405            bp.set_inflight_bytes(1 << 20);
406
407            let shard = SinkShardMetrics::new(
408                &labels("orders_kafka"),
409                3,
410                &["ch-3-0".into(), "ch-3-1".into()],
411                E2eBasis::Ingest,
412            );
413            shard.flushed(
414                FlushReason::Rows,
415                500_000,
416                64 << 20,
417                Duration::from_millis(90),
418            );
419            shard.retries(1);
420            shard.errors(ErrorClass::Retryable, 1);
421            shard.set_inflight(2);
422            shard.set_replica_healthy(1, false);
423            shard.breaker_opened(1);
424            shard.replica_error(1);
425            shard.set_shard_healthy(false);
426            shard.abandoned(0);
427            shard.drain_overrun();
428
429            let cp = CheckpointMetrics::new(&labels("orders_kafka"), false);
430            cp.set_pending_max(12);
431            cp.commit(true, Duration::from_millis(4));
432            cp.set_watermark_age(Duration::from_secs(1));
433
434            let coord = CoordinationMetrics::new(&labels("orders_kafka"));
435            coord.set_splits_owned(3);
436            coord.set_splits_completed(1);
437            coord.set_splits_quarantined(1);
438            coord.set_live_workers(2);
439            coord.set_leader(true);
440            coord.set_idle(false);
441            coord.acquired(AcquireReason::Expired);
442            coord.acquired(AcquireReason::Reassigned);
443            coord.lost(SplitLossReason::Fenced);
444            coord.released(1);
445            coord.revocation(RevocationOutcome::Requested);
446            coord.revocation(RevocationOutcome::Drained);
447            coord.revocation(RevocationOutcome::Forced);
448            coord.revocation(RevocationOutcome::Cancelled);
449            coord.assignment_latency(Duration::from_millis(900));
450            coord.drain_duration(Duration::from_millis(120));
451            coord.set_splits_draining(2);
452            coord.planned(8);
453            coord.replan(ReplanOutcome::Noop, Duration::from_millis(20));
454            coord.failed();
455            coord.quarantined();
456            coord.write(WriteOutcome::Conflict, Duration::from_millis(8));
457            coord.reconcile(Duration::from_millis(15));
458            coord.store_op(StoreOp::Put, Duration::from_micros(600));
459
460            let pl = PipelineMetrics::new(&labels("orders_kafka"), "0.1.0");
461            pl.set_state(PipelineState::Running);
462            pl.set_threads(4);
463        });
464
465        // Spot-check one series per stage, with labels.
466        for needle in [
467            r#"spate_source_records_total{pipeline="orders",component="orders_kafka",component_type="kafka"} 512"#,
468            r#"partition="7""#,
469            r#"spate_deser_records_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="ok"} 510"#,
470            r#"spate_operator_records_dropped_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="filtered"} 130"#,
471            r#"spate_queue_capacity{pipeline="orders",component="orders_kafka",component_type="kafka",queue="chain->sink/0"} 4096"#,
472            r#"spate_backpressure_pause_events_total{pipeline="orders",component="orders_kafka",component_type="kafka"} 1"#,
473            r#"spate_sink_flushes_total{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3",reason="rows"} 1"#,
474            r#"spate_sink_replica_healthy{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3",replica="ch-3-1"} 0"#,
475            r#"spate_sink_replica_errors_total{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3",replica="ch-3-1"} 1"#,
476            r#"spate_sink_shard_healthy{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3"} 0"#,
477            r#"spate_checkpoint_commits_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="ok"} 1"#,
478            r#"spate_coordination_acquisitions_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="expired"} 1"#,
479            r#"spate_coordination_acquisitions_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="reassigned"} 1"#,
480            r#"spate_coordination_split_losses_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="fenced"} 1"#,
481            r#"spate_coordination_revocations_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="requested"} 1"#,
482            r#"spate_coordination_revocations_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="drained"} 1"#,
483            r#"spate_coordination_revocations_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="forced"} 1"#,
484            r#"spate_coordination_revocations_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="cancelled"} 1"#,
485            r#"spate_coordination_splits_draining{pipeline="orders",component="orders_kafka",component_type="kafka"} 2"#,
486            r#"spate_coordination_replans_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="noop"} 1"#,
487            r#"spate_coordination_writes_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="conflict"} 1"#,
488            r#"spate_coordination_leader{pipeline="orders",component="orders_kafka",component_type="kafka"} 1"#,
489            r#"spate_pipeline_state{pipeline="orders",component="orders_kafka",component_type="kafka",state="running"} 1"#,
490            r#"spate_pipeline_info{pipeline="orders",component="orders_kafka",component_type="kafka",version="0.1.0"} 1"#,
491        ] {
492            assert!(
493                rendered.contains(needle),
494                "rendered output missing `{needle}`:\n{rendered}"
495            );
496        }
497    }
498
499    /// A second handle set on a live shard's labels must not reset its
500    /// gauges — the defect this ownership machinery exists to close.
501    ///
502    /// The scenario is the one that hurts: shard 0 has every replica
503    /// quarantined and is asleep on a 600s backoff. A second `SinkShardMetrics`
504    /// for the same component and shard appears (a pipeline rebuilt in-process,
505    /// or a component name used twice) and its constructor publishes the
506    /// defaults of a fresh shard — `healthy = 1`, `backoff = 0`. Both of the
507    /// real writers are edge-triggered, so nothing would ever put the truth
508    /// back: the exposition would report a healthy, idle shard for the length
509    /// of the outage.
510    ///
511    /// Counters are the deliberate contrast. They aggregate correctly across
512    /// instances, so the shadow keeps counting; only the gauges are withheld.
513    #[test]
514    fn a_second_handle_set_cannot_reset_a_live_shards_gauges() {
515        let recorder = configured_builder()
516            .expect("bucket config must be valid")
517            .build_recorder();
518        let handle = recorder.handle();
519        metrics::with_local_recorder(&recorder, || {
520            let owner = SinkShardMetrics::new(
521                &labels("clobbered_shard"),
522                0,
523                &["r0".into()],
524                E2eBasis::Ingest,
525            );
526            let healthy = || gauge_value(&handle.render(), names::SINK_SHARD_HEALTHY);
527            let backoff = || gauge_value(&handle.render(), names::SINK_RETRY_BACKOFF_SECONDS);
528            let written = || gauge_value(&handle.render(), names::SINK_RECORDS_TOTAL);
529
530            // The outage: quarantined and sleeping on its ceiling.
531            owner.set_shard_healthy(false);
532            owner.set_replica_healthy(0, false);
533            let _sleeping = owner.backing_off(1, Duration::from_secs(600));
534            owner.flushed(FlushReason::Rows, 5, 50, Duration::from_millis(1));
535            assert_eq!(healthy(), 0.0);
536            assert_eq!(backoff(), 600.0);
537
538            // The colliding handle set. Its constructor publishes `1` and `0`
539            // for a shard it believes is fresh; both must be withheld.
540            let shadow = SinkShardMetrics::new(
541                &labels("clobbered_shard"),
542                0,
543                &["r0".into()],
544                E2eBasis::Ingest,
545            );
546            assert_eq!(healthy(), 0.0, "a second handle set reset shard health");
547            assert_eq!(backoff(), 600.0, "a second handle set reset the backoff");
548
549            // And its later writes stay off the series too — construction is
550            // not the only way it would lie.
551            shadow.set_shard_healthy(true);
552            shadow.set_replica_healthy(0, true);
553            let _shadow_sleep = shadow.backing_off(9, Duration::from_secs(1));
554            assert_eq!(healthy(), 0.0, "the shadow published a gauge");
555            assert_eq!(backoff(), 600.0, "the shadow published a gauge");
556
557            // Counters still sum: the shadow's records are real work.
558            shadow.flushed(FlushReason::Rows, 7, 70, Duration::from_millis(1));
559            assert_eq!(
560                written(),
561                12.0,
562                "counters must aggregate across handle sets, not be suppressed"
563            );
564        });
565    }
566
567    /// Ownership is process-wide and deliberately blind to which recorder a
568    /// handle set resolves against: the `metrics` facade gives no way to key a
569    /// claim by recorder, and the framework installs exactly one. This is the
570    /// rule that forces test helpers to carry per-test labels — recorder
571    /// isolation does not buy test independence here.
572    #[test]
573    fn ownership_is_process_wide_not_per_recorder() {
574        let owner_recorder = configured_builder().expect("buckets").build_recorder();
575        let owner_handle = owner_recorder.handle();
576        let shadow_recorder = configured_builder().expect("buckets").build_recorder();
577        let shadow_handle = shadow_recorder.handle();
578
579        let shard =
580            |name: &str| SinkShardMetrics::new(&labels(name), 0, &["r0".into()], E2eBasis::Ingest);
581        let owner = metrics::with_local_recorder(&owner_recorder, || shard("cross_recorder"));
582        let shadow = metrics::with_local_recorder(&shadow_recorder, || shard("cross_recorder"));
583
584        owner.set_shard_healthy(false);
585        shadow.set_shard_healthy(true);
586
587        assert_eq!(
588            gauge_value(&owner_handle.render(), names::SINK_SHARD_HEALTHY),
589            0.0,
590            "the owner's reading stands"
591        );
592        assert_eq!(
593            gauge_value(&shadow_handle.render(), names::SINK_SHARD_HEALTHY),
594            0.0,
595            "the shadow registered its series but never published to it — the \
596             `1` it asked for must not appear even in its own recorder"
597        );
598    }
599
600    /// One gauge stands for a shard that writes up to `inflight.max_per_shard`
601    /// batches at once, each backing off on its own schedule, so it publishes
602    /// the longest live step. The middle assertion is the one that matters:
603    /// when the longest sleeper wakes, the gauge must fall back to the batch
604    /// still asleep, not to `0` — the failure mode of every implementation
605    /// where each write task simply sets and clears the gauge itself.
606    #[test]
607    fn retry_backoff_gauge_publishes_the_longest_live_step() {
608        let recorder = configured_builder()
609            .expect("bucket config must be valid")
610            .build_recorder();
611        let handle = recorder.handle();
612        metrics::with_local_recorder(&recorder, || {
613            let shard = SinkShardMetrics::new(
614                &labels("backoff_longest_step"),
615                0,
616                &["r0".into()],
617                E2eBasis::Ingest,
618            );
619            let backoff = || gauge_value(&handle.render(), names::SINK_RETRY_BACKOFF_SECONDS);
620
621            // Published from construction: a shard that has never retried is
622            // not backing off, so there is no measurement to wait for.
623            assert_eq!(backoff(), 0.0, "a fresh shard is not backing off");
624
625            let short = shard.backing_off(1, Duration::from_secs(4));
626            assert_eq!(backoff(), 4.0);
627            let long = shard.backing_off(2, Duration::from_secs(30));
628            assert_eq!(backoff(), 30.0, "the longer sleep wins");
629            drop(long);
630            assert_eq!(backoff(), 4.0, "batch 1 is still asleep");
631            drop(short);
632            assert_eq!(backoff(), 0.0, "nothing is backing off");
633        });
634    }
635
636    /// The same property under concurrent publishers, which is the case that
637    /// actually occurs: `inflight.max_per_shard` defaults to 2 and the write
638    /// tasks share one `SinkShardMetrics` across a multi-threaded I/O runtime.
639    ///
640    /// The regression is publishing the max *outside* the map lock: two
641    /// publishers' `set` calls then land in the opposite order from the
642    /// snapshots they computed, and the loser strands the gauge at a value no
643    /// batch is serving — until the next mutation, which under a patient retry
644    /// policy is `retry.max` away, and after the shard recovers is never. Both
645    /// directions are checked, because both are reachable and the stranded-high
646    /// one never self-clears: a sustained false reading on a healthy shard.
647    ///
648    /// Each round races the two mutations against each other and then asserts
649    /// at a *quiescent* point — every operation has returned and the live set
650    /// is known exactly, so there is one correct reading and no tolerance to
651    /// tune. Against the fixed code this holds by construction; the round and
652    /// sleeper counts are sized against the unfixed code, which diverged
653    /// within the first 50 rounds on every one of six calibration runs.
654    #[test]
655    fn retry_backoff_gauge_is_consistent_under_concurrent_publishers() {
656        const SLEEPERS: usize = 7;
657        const ROUNDS: usize = 1_000;
658
659        let recorder = configured_builder()
660            .expect("bucket config must be valid")
661            .build_recorder();
662        let handle = recorder.handle();
663        let divergence = metrics::with_local_recorder(&recorder, || {
664            // Handles bind to the recorder at construction, so the threads
665            // below publish through this one without inheriting the
666            // thread-local.
667            let shard = SinkShardMetrics::new(
668                &labels("backoff_concurrent"),
669                0,
670                &["r0".into()],
671                E2eBasis::Ingest,
672            );
673            let gate = std::sync::Barrier::new(SLEEPERS + 1);
674
675            std::thread::scope(|scope| {
676                for k in 1..=SLEEPERS {
677                    let (shard, gate) = (&shard, &gate);
678                    scope.spawn(move || {
679                        let step = Duration::from_secs(k as u64);
680                        let mut guard = Some(shard.backing_off(k as u64, step));
681                        for _ in 0..ROUNDS {
682                            gate.wait();
683                            drop(guard.take()); // races the long sleep starting
684                            gate.wait();
685                            gate.wait();
686                            guard = Some(shard.backing_off(k as u64, step)); // races it ending
687                            gate.wait();
688                            gate.wait();
689                        }
690                    });
691                }
692
693                // Divergences are recorded, not asserted in place: a panic
694                // here would leave the sleepers parked on the barrier and
695                // `scope` would join them forever, turning a failure into a
696                // 120s nextest TIMEOUT. Run every round out, report after.
697                let backoff = || gauge_value(&handle.render(), names::SINK_RETRY_BACKOFF_SECONDS);
698                let mut first_bad = None;
699                let mut record = |round, phase, want: f64, got: f64| {
700                    if got != want && first_bad.is_none() {
701                        first_bad = Some(format!(
702                            "round {round}, {phase}: gauge read {got}, expected {want}"
703                        ));
704                    }
705                };
706                for round in 0..ROUNDS {
707                    gate.wait();
708                    let long = shard.backing_off(0, Duration::from_secs(1000));
709                    gate.wait();
710                    // Only batch 0 is asleep: a short sleeper ending must not
711                    // strand the gauge below the sleep still running.
712                    record(round, "a short sleeper ended", 1000.0, backoff());
713                    gate.wait();
714                    drop(long);
715                    gate.wait();
716                    // Batch 0 has woken: the gauge must fall back to the
717                    // longest sleeper still asleep, not to 0 and not to 1000.
718                    record(round, "the long sleeper ended", SLEEPERS as f64, backoff());
719                    gate.wait();
720                }
721                first_bad
722            })
723        });
724        assert_eq!(divergence, None, "gauge stranded off the live backoff set");
725    }
726
727    /// The value of an unlabelled-or-single-series gauge in a rendered
728    /// exposition (the value is the line's last space-separated token).
729    fn gauge_value(rendered: &str, name: &str) -> f64 {
730        let line = rendered
731            .lines()
732            .find(|l| l.starts_with(name))
733            .unwrap_or_else(|| panic!("`{name}` not rendered:\n{rendered}"));
734        line.rsplit(' ').next().unwrap().parse().expect("value")
735    }
736
737    #[test]
738    fn custom_meter_inherits_standard_labels_and_namespace() {
739        let rendered = render_with_local_recorder(|| {
740            // A connector owns the `kafka` namespace: local names are
741            // auto-prefixed `spate_kafka_`.
742            let meter = Meter::with_namespace("kafka", "orders", "orders_kafka", "kafka");
743            meter
744                .counter("schema_fetches_total", &[("registry", "prod".into())])
745                .increment(3);
746            meter.gauge("cache_entries", &[]).set(17.0);
747            meter.histogram("fetch_duration_seconds", &[]).record(0.012);
748
749            // A pipeline author's default scope lands under `spate_custom_`.
750            Meter::new("orders", "enrich", "map")
751                .counter("orders_enriched_total", &[])
752                .increment(9);
753
754            // The same scope also builds a framework stage handle.
755            let deser = DeserMetrics::new(meter.labels());
756            deser.batch(510, 0, Duration::from_millis(1));
757        });
758
759        for needle in [
760            // Auto-prefixed name; standard labels first, then the extra label.
761            r#"spate_kafka_schema_fetches_total{pipeline="orders",component="orders_kafka",component_type="kafka",registry="prod"} 3"#,
762            r#"spate_kafka_cache_entries{pipeline="orders",component="orders_kafka",component_type="kafka"} 17"#,
763            r#"spate_kafka_fetch_duration_seconds_bucket{pipeline="orders",component="orders_kafka",component_type="kafka""#,
764            // The author's default scope uses the `spate_custom_` bucket.
765            r#"spate_custom_orders_enriched_total{pipeline="orders",component="enrich",component_type="map"} 9"#,
766            // The framework handle from the same Meter carries the same labels.
767            r#"spate_deser_records_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="ok"} 510"#,
768        ] {
769            assert!(
770                rendered.contains(needle),
771                "rendered output missing `{needle}`:\n{rendered}"
772            );
773        }
774    }
775
776    #[test]
777    #[should_panic(expected = "reserved framework root")]
778    fn custom_meter_rejects_reserved_namespace() {
779        Meter::with_namespace("sink", "p", "c", "t");
780    }
781
782    #[test]
783    #[should_panic(expected = "lowercase")]
784    fn custom_meter_rejects_invalid_namespace() {
785        Meter::with_namespace("Bad Name", "p", "c", "t");
786    }
787
788    #[test]
789    #[should_panic(expected = "without the `spate_` prefix")]
790    fn custom_meter_rejects_prefixed_local_name() {
791        let _ = Meter::new("p", "c", "t").counter("spate_custom_hits_total", &[]);
792    }
793
794    #[test]
795    #[should_panic(expected = "shadows a standard label")]
796    fn custom_meter_rejects_shadowed_standard_label() {
797        let _ = Meter::new("p", "c", "t").counter("hits_total", &[("component", "x".into())]);
798    }
799
800    #[test]
801    #[should_panic(expected = "role segment")]
802    fn custom_meter_rejects_role_prefixed_local_name() {
803        // `sink_`/`source_` are reserved to the runtime's role scoping, so a
804        // hand-written name starting with one can't alias a role-scoped family.
805        let _ = Meter::new("p", "c", "t").counter("sink_writes_total", &[]);
806    }
807
808    #[test]
809    fn for_component_scopes_by_role_and_gates_ineligible_types() {
810        // A reserved component_type (an undeclared source's default) yields no
811        // Meter rather than panicking.
812        assert!(Meter::for_component("source", MetricRole::Source, "p", "c").is_none());
813        assert!(Meter::for_component("sink", MetricRole::Sink, "p", "c").is_none());
814        // The `custom` author bucket is off-limits to component scoping (a
815        // sink's default `component_type`), so it too gets no Meter.
816        assert!(Meter::for_component("custom", MetricRole::Sink, "p", "c").is_none());
817        // A malformed component_type (a legal label, an illegal name segment)
818        // yields None (a warning is logged, not asserted here).
819        assert!(Meter::for_component("clickhouse-v2", MetricRole::Sink, "p", "c").is_none());
820        assert!(Meter::for_component("", MetricRole::Source, "p", "c").is_none());
821
822        let rendered = render_with_local_recorder(|| {
823            Meter::for_component("kafka", MetricRole::Source, "orders", "orders_in")
824                .expect("valid namespace")
825                .counter("bytes_total", &[])
826                .increment(10);
827            Meter::for_component("clickhouse", MetricRole::Sink, "orders", "orders_out")
828                .expect("valid namespace")
829                .counter("bytes_total", &[])
830                .increment(20);
831        });
832        // Role in the name; component_type is both namespace and label.
833        assert!(rendered.contains(
834            r#"spate_kafka_source_bytes_total{pipeline="orders",component="orders_in",component_type="kafka"} 10"#
835        ));
836        assert!(rendered.contains(
837            r#"spate_clickhouse_sink_bytes_total{pipeline="orders",component="orders_out",component_type="clickhouse"} 20"#
838        ));
839    }
840
841    #[test]
842    fn duration_histograms_use_configured_buckets() {
843        let rendered = render_with_local_recorder(|| {
844            let src = SourceMetrics::new(&labels("buckets"));
845            src.poll_duration(Duration::from_millis(3));
846        });
847        assert!(
848            rendered.contains(r#"le="0.005""#),
849            "expected a 5ms bucket boundary:\n{rendered}"
850        );
851        assert!(
852            rendered.contains("spate_source_poll_duration_seconds_bucket"),
853            "expected histogram exposition:\n{rendered}"
854        );
855    }
856
857    /// Consumer lag is the only golden signal with no aggregate series, so it
858    /// must publish whatever `per_partition_detail` is set to — a cardinality
859    /// knob that could delete it would silently restore the "backlogged
860    /// consumer reports nothing" failure this shape exists to prevent.
861    #[test]
862    fn source_lag_publishes_independently_of_partition_detail() {
863        let rendered = render_with_local_recorder(|| {
864            let src = SourceMetrics::new(&labels("lag_ungated"));
865            src.set_partition_lag(PartitionId(1), 5);
866        });
867        assert!(
868            rendered.contains(
869                r#"spate_source_lag_records{pipeline="orders",component="lag_ungated",component_type="kafka",partition="1"} 5"#
870            ),
871            "lag must publish without any detail flag:\n{rendered}"
872        );
873    }
874
875    /// Unmeasured lag must be absent, never `0`: a registered-but-unwritten
876    /// gauge renders a zero that is indistinguishable from "caught up", which
877    /// is exactly how this family read 0 on every Kafka pipeline for 14 days.
878    #[test]
879    fn unmeasured_source_lag_registers_no_series() {
880        let rendered = render_with_local_recorder(|| {
881            let src = SourceMetrics::new(&labels("lag_unmeasured"));
882            src.batch(10, 100);
883        });
884        assert!(
885            !rendered.contains("spate_source_lag_records"),
886            "lag must be absent until measured:\n{rendered}"
887        );
888    }
889
890    #[test]
891    fn per_partition_series_are_gated_and_retained() {
892        // Distinct component labels: both instances share a family name, so
893        // the gated one needs its own series to be provably absent. Its
894        // *unlabelled* aggregate is registered eagerly either way — only the
895        // `partition`-labelled series are gated.
896        let gated_labels = ComponentLabels::new("orders", "gated_checkpoint", "checkpoint");
897        let rendered = render_with_local_recorder(|| {
898            let gated = CheckpointMetrics::new(&gated_labels, false);
899            gated.set_partition_pending(PartitionId(1), 5);
900
901            let detailed = CheckpointMetrics::new(&labels("detailed_checkpoint"), true);
902            detailed.set_partition_pending(PartitionId(1), 5);
903            detailed.set_partition_pending(PartitionId(2), 9);
904            detailed.retain_partitions(&[PartitionId(2)]);
905            // Only partition 2 survives; partition 1 is zeroed on the way out
906            // (the exporter cannot delete a series, so the retention has to
907            // leave a truthful value behind rather than a stale 5).
908            detailed.set_partition_pending(PartitionId(2), 11);
909        });
910        let gated_series_leaked = rendered.lines().any(|l| {
911            l.starts_with("spate_checkpoint_pending_batches")
912                && l.contains("gated_checkpoint")
913                && l.contains("partition=")
914        });
915        assert!(
916            !gated_series_leaked,
917            "per-partition checkpoint detail must be gated off:\n{rendered}"
918        );
919        assert!(rendered.contains(
920            r#"spate_checkpoint_pending_batches{pipeline="orders",component="detailed_checkpoint",component_type="kafka",partition="2"} 11"#
921        ));
922        // The retained half, asserted on the partition that was dropped: it
923        // must read 0, not the 5 it last held. Without the zeroing this line
924        // still renders `5` — the assertion below is the only thing in this
925        // test that can tell the two apart.
926        assert!(
927            rendered.contains(
928                r#"spate_checkpoint_pending_batches{pipeline="orders",component="detailed_checkpoint",component_type="kafka",partition="1"} 0"#
929            ),
930            "a retained-out partition must be zeroed, not left stale:\n{rendered}"
931        );
932    }
933
934    #[test]
935    fn state_gauge_flips_exactly_one_state() {
936        let rendered = render_with_local_recorder(|| {
937            let pl = PipelineMetrics::new(&labels("state_gauge"), "0.1.0");
938            pl.set_state(PipelineState::Draining);
939        });
940        assert!(rendered.contains(r#"state="draining"} 1"#));
941        for other in ["starting", "running", "failed"] {
942            assert!(
943                rendered.contains(&format!(r#"state="{other}"}} 0"#)),
944                "state `{other}` should read 0:\n{rendered}"
945            );
946        }
947    }
948
949    #[test]
950    fn noop_exporter_renders_empty() {
951        let handle = install(&MetricsSettings {
952            exporter: Exporter::None,
953            ..MetricsSettings::default()
954        })
955        .expect("noop install");
956        assert_eq!(handle.render(), "");
957        handle.upkeep_tick(); // must not panic
958    }
959
960    /// The single test that installs the process-global recorder: install,
961    /// register, render, upkeep. Kept as ONE test because a global recorder
962    /// can only be installed once per test process; all other tests use
963    /// local recorders.
964    #[test]
965    fn install_prometheus_end_to_end() {
966        let handle = install(&MetricsSettings::default()).expect("first install succeeds");
967
968        let pl = PipelineMetrics::new(&labels("install_e2e"), "0.1.0");
969        pl.set_threads(4);
970
971        let rendered = handle.render();
972        assert!(rendered.contains("spate_pipeline_info"));
973        assert!(rendered.contains("spate_pipeline_threads"));
974        assert!(
975            rendered.contains("process_cpu_seconds_total"),
976            "process collector wired:\n{rendered}"
977        );
978
979        handle.upkeep_tick();
980        let render_fn = handle.render_fn();
981        assert!(render_fn().contains("spate_pipeline_threads"));
982
983        // Install is idempotent: a second call returns the SAME exporter,
984        // so handles registered between the two calls stay visible. This is
985        // the assembly-order guarantee: user code installs early, registers
986        // sink handles, and the runtime's own install() reuses the exporter
987        // (the flagship-example pattern).
988        let shard = SinkShardMetrics::new(
989            &labels("install_e2e"),
990            7,
991            &["reuse-7-0".into()],
992            E2eBasis::Ingest,
993        );
994        shard.flushed(FlushReason::Rows, 10, 1_000, Duration::from_millis(3));
995        shard.e2e_observed(Duration::from_millis(25), i64::MAX);
996        let second = install(&MetricsSettings::default()).expect("second install reuses");
997        let rendered = second.render();
998        assert!(
999            rendered.contains("spate_sink_records_total"),
1000            "handles registered before the second install render through it:\n{rendered}"
1001        );
1002        assert!(rendered.contains("spate_e2e_latency_seconds"));
1003
1004        // Exporter::None never claims the process slot.
1005        let noop = install(&MetricsSettings {
1006            exporter: Exporter::None,
1007            ..MetricsSettings::default()
1008        })
1009        .expect("noop install");
1010        assert!(noop.render().is_empty());
1011        assert!(
1012            install(&MetricsSettings::default())
1013                .expect("prometheus still reusable")
1014                .render()
1015                .contains("spate_pipeline_info")
1016        );
1017    }
1018}