1mod backpressure;
49mod checkpoint;
50mod deser;
51#[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;
65pub(crate) use backpressure::InflightBudgetMetrics;
69pub use checkpoint::CheckpointMetrics;
70pub use deser::DeserMetrics;
71#[cfg(not(loom))]
74pub(crate) use feed::FeedMetrics;
75#[cfg(not(loom))]
76pub use feed::FeedOp;
77pub use labels::ComponentLabels;
78pub use meter::Meter;
79pub(crate) use meter::MetricRole;
82pub use operator::OperatorMetrics;
83pub use pipeline::{PipelineMetrics, PipelineState};
84pub(crate) use pipeline::{IoBusySampler, IoRuntimeMetrics, busy_reading as io_busy_reading};
86pub use queue::QueueMetrics;
87pub use sink::{BackoffGuard, FlushReason, SinkShardMetrics};
88pub(crate) use sink::AttemptOutcome;
91pub use source::SourceMetrics;
92
93pub 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
106pub 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
111pub const BATCH_ROWS_BUCKETS: &[f64] = &[
113 64.0, 256.0, 1024.0, 4096.0, 16384.0, 65536.0, 262144.0, 1048576.0,
114];
115
116pub 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum Exporter {
133 #[default]
135 Prometheus,
136 None,
139}
140
141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
143#[non_exhaustive]
144pub enum E2eBasis {
145 #[default]
147 Ingest,
148 Event,
151}
152
153#[derive(Clone, Debug, Default, PartialEq, Eq)]
156pub struct MetricsSettings {
157 pub exporter: Exporter,
159 pub per_partition_detail: bool,
161 pub e2e_basis: E2eBasis,
163}
164
165#[derive(Debug, thiserror::Error)]
167#[non_exhaustive]
168pub enum MetricsError {
169 #[error("a metrics recorder is already installed in this process")]
171 AlreadyInstalled,
172 #[error("failed to build the metrics exporter: {0}")]
174 Build(String),
175 #[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#[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 #[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 #[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 #[must_use]
226 pub fn exports(&self) -> bool {
227 matches!(self.inner, Inner::Prometheus(_))
228 }
229
230 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 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
262fn 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 .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#[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
311static INSTALLED: std::sync::OnceLock<MetricsHandle> = std::sync::OnceLock::new();
314
315static INSTALLED_SETTINGS: std::sync::OnceLock<MetricsSettings> = std::sync::OnceLock::new();
318
319pub fn install(settings: &MetricsSettings) -> Result<MetricsHandle, MetricsError> {
335 static INSTALL: Mutex<()> = Mutex::new(());
340 let _serialized = INSTALL.lock().unwrap_or_else(PoisonError::into_inner);
341 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)))] mod tests {
382 use super::*;
383 use crate::error::ErrorClass;
384 use crate::record::PartitionId;
385
386 fn labels(component: &str) -> ComponentLabels {
394 ComponentLabels::new("orders", component.to_owned(), "kafka")
395 }
396
397 #[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 #[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 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 #[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 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 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 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 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 #[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 #[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 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 #[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 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()); gate.wait();
725 gate.wait();
726 guard = Some(shard.backing_off(k as u64, step)); gate.wait();
728 gate.wait();
729 }
730 });
731 }
732
733 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 record(round, "a short sleeper ended", 1000.0, backoff());
752 gate.wait();
753 drop(long);
754 gate.wait();
755 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 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 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 Meter::new("orders", "enrich", "map")
790 .counter("orders_enriched_total", &[])
791 .increment(9);
792
793 let deser = DeserMetrics::new(meter.labels());
795 deser.batch(510, 0, Duration::from_millis(1));
796 });
797
798 for needle in [
799 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 r#"spate_custom_orders_enriched_total{pipeline="orders",component="enrich",component_type="map"} 9"#,
805 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 #[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 #[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 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 assert!(Meter::for_component("source", MetricRole::Source, "p", "c").is_none());
867 assert!(Meter::for_component("sink", MetricRole::Sink, "p", "c").is_none());
868 assert!(Meter::for_component("custom", MetricRole::Sink, "p", "c").is_none());
871 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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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(); }
1099
1100 #[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 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 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}