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