1mod 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;
63pub(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;
75pub(crate) use meter::MetricRole;
78pub use operator::OperatorMetrics;
79pub use pipeline::{PipelineMetrics, PipelineState};
80pub use queue::QueueMetrics;
81pub use sink::{BackoffGuard, FlushReason, SinkShardMetrics};
82pub(crate) use sink::AttemptOutcome;
85pub use source::SourceMetrics;
86
87pub 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
99pub 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
104pub const BATCH_ROWS_BUCKETS: &[f64] = &[
106 64.0, 256.0, 1024.0, 4096.0, 16384.0, 65536.0, 262144.0, 1048576.0,
107];
108
109pub 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum Exporter {
126 #[default]
128 Prometheus,
129 None,
132}
133
134#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum E2eBasis {
138 #[default]
140 Ingest,
141 Event,
144}
145
146#[derive(Clone, Debug, Default, PartialEq, Eq)]
149pub struct MetricsSettings {
150 pub exporter: Exporter,
152 pub per_partition_detail: bool,
154 pub e2e_basis: E2eBasis,
156}
157
158#[derive(Debug, thiserror::Error)]
160#[non_exhaustive]
161pub enum MetricsError {
162 #[error("a metrics recorder is already installed in this process")]
164 AlreadyInstalled,
165 #[error("failed to build the metrics exporter: {0}")]
167 Build(String),
168 #[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#[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 #[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 #[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 #[must_use]
219 pub fn exports(&self) -> bool {
220 matches!(self.inner, Inner::Prometheus(_))
221 }
222
223 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 #[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
249fn 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 .set_buckets_for_metric(
271 Matcher::Full(names::E2E_LATENCY_SECONDS.into()),
272 DURATION_SECONDS_BUCKETS,
273 )?
274 .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#[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
309static INSTALLED: std::sync::OnceLock<MetricsHandle> = std::sync::OnceLock::new();
312
313static INSTALLED_SETTINGS: std::sync::OnceLock<MetricsSettings> = std::sync::OnceLock::new();
316
317pub fn install(settings: &MetricsSettings) -> Result<MetricsHandle, MetricsError> {
333 static INSTALL: Mutex<()> = Mutex::new(());
338 let _serialized = INSTALL.lock().unwrap_or_else(PoisonError::into_inner);
339 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)))] mod tests {
380 use super::*;
381 use crate::error::ErrorClass;
382 use crate::record::PartitionId;
383
384 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 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 #[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 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 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 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 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 #[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 #[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 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 #[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 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()); gate.wait();
705 gate.wait();
706 guard = Some(shard.backing_off(k as u64, step)); gate.wait();
708 gate.wait();
709 }
710 });
711 }
712
713 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 record(round, "a short sleeper ended", 1000.0, backoff());
732 gate.wait();
733 drop(long);
734 gate.wait();
735 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 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 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 Meter::new("orders", "enrich", "map")
770 .counter("orders_enriched_total", &[])
771 .increment(9);
772
773 let deser = DeserMetrics::new(meter.labels());
775 deser.batch(510, 0, Duration::from_millis(1));
776 });
777
778 for needle in [
779 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 r#"spate_custom_orders_enriched_total{pipeline="orders",component="enrich",component_type="map"} 9"#,
785 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 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 assert!(Meter::for_component("source", MetricRole::Source, "p", "c").is_none());
832 assert!(Meter::for_component("sink", MetricRole::Sink, "p", "c").is_none());
833 assert!(Meter::for_component("custom", MetricRole::Sink, "p", "c").is_none());
836 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 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 #[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 #[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 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 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 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 #[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(); }
1010
1011 #[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 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 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}