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