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