1mod backpressure;
45mod checkpoint;
46mod coordination;
47mod deser;
48mod labels;
49mod meter;
50pub mod names;
51mod operator;
52mod ownership;
53mod pipeline;
54mod queue;
55mod sink;
56mod source;
57
58pub use backpressure::BackpressureMetrics;
59pub use checkpoint::CheckpointMetrics;
60pub use coordination::{
61 AcquireReason, CoordinationMetrics, ReplanOutcome, RevocationOutcome, SplitLossReason, StoreOp,
62 WriteOutcome,
63};
64pub use deser::DeserMetrics;
65pub use labels::ComponentLabels;
66pub use meter::Meter;
67pub(crate) use meter::MetricRole;
70pub use operator::OperatorMetrics;
71pub use pipeline::{PipelineMetrics, PipelineState};
72pub use queue::QueueMetrics;
73pub use sink::{BackoffGuard, FlushReason, SinkShardMetrics};
74pub(crate) use sink::AttemptOutcome;
77pub use source::SourceMetrics;
78
79pub use metrics::{Counter, Gauge, Histogram, SharedString};
85
86use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder, PrometheusHandle};
87use std::net::{Ipv4Addr, SocketAddr};
88use std::sync::{Arc, Mutex, PoisonError};
89use std::time::Duration;
90
91pub const DURATION_SECONDS_BUCKETS: &[f64] = &[
94 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,
95];
96
97pub const BATCH_ROWS_BUCKETS: &[f64] = &[
99 64.0, 256.0, 1024.0, 4096.0, 16384.0, 65536.0, 262144.0, 1048576.0,
100];
101
102pub const BATCH_BYTES_BUCKETS: &[f64] = &[
104 4096.0,
105 16384.0,
106 65536.0,
107 262144.0,
108 1048576.0,
109 4194304.0,
110 16777216.0,
111 67108864.0,
112 268435456.0,
113];
114
115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum Exporter {
119 #[default]
121 Prometheus,
122 None,
124}
125
126#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
128#[non_exhaustive]
129pub enum E2eBasis {
130 #[default]
132 Ingest,
133 Event,
136}
137
138#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct MetricsSettings {
143 pub exporter: Exporter,
145 pub listen: SocketAddr,
147 pub per_partition_detail: bool,
149 pub e2e_basis: E2eBasis,
151}
152
153impl Default for MetricsSettings {
154 fn default() -> Self {
155 MetricsSettings {
156 exporter: Exporter::Prometheus,
157 listen: SocketAddr::from((Ipv4Addr::UNSPECIFIED, 9090)),
158 per_partition_detail: false,
159 e2e_basis: E2eBasis::Ingest,
160 }
161 }
162}
163
164#[derive(Debug, thiserror::Error)]
166#[non_exhaustive]
167pub enum MetricsError {
168 #[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(
179 "metric series {0} already has a live owner in this process; \
180 gauge series cannot be shared (rename the component or the pipeline)"
181 )]
182 DuplicateSeries(String),
183}
184
185#[derive(Clone, Debug)]
187pub struct MetricsHandle {
188 inner: Inner,
189 process: Option<Arc<metrics_process::Collector>>,
190}
191
192#[derive(Clone, Debug)]
193enum Inner {
194 Prometheus(PrometheusHandle),
195 Noop,
196}
197
198impl MetricsHandle {
199 #[must_use]
202 pub fn render(&self) -> String {
203 match &self.inner {
204 Inner::Prometheus(handle) => {
205 if let Some(process) = &self.process {
206 process.collect();
207 }
208 handle.render()
209 }
210 Inner::Noop => String::new(),
211 }
212 }
213
214 #[must_use]
217 pub fn render_fn(&self) -> Arc<dyn Fn() -> String + Send + Sync> {
218 let this = self.clone();
219 Arc::new(move || this.render())
220 }
221
222 pub fn upkeep_tick(&self) {
225 if let Inner::Prometheus(handle) = &self.inner {
226 handle.run_upkeep();
227 }
228 if let Some(process) = &self.process {
229 process.collect();
230 }
231 }
232
233 #[must_use]
235 pub fn spawn_upkeep(&self, period: Duration) -> tokio::task::JoinHandle<()> {
236 let this = self.clone();
237 tokio::spawn(async move {
238 let mut tick = tokio::time::interval(period);
239 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
240 loop {
241 tick.tick().await;
242 this.upkeep_tick();
243 }
244 })
245 }
246}
247
248fn configured_builder() -> Result<PrometheusBuilder, BuildError> {
251 PrometheusBuilder::new()
252 .set_buckets_for_metric(
253 Matcher::Suffix("_duration_seconds".into()),
254 DURATION_SECONDS_BUCKETS,
255 )?
256 .set_buckets_for_metric(
257 Matcher::Full(names::E2E_LATENCY_SECONDS.into()),
258 DURATION_SECONDS_BUCKETS,
259 )?
260 .set_buckets_for_metric(
265 Matcher::Full(names::COORDINATION_ASSIGNMENT_LATENCY_SECONDS.into()),
266 DURATION_SECONDS_BUCKETS,
267 )?
268 .set_buckets_for_metric(
269 Matcher::Full(names::SINK_BATCH_ROWS.into()),
270 BATCH_ROWS_BUCKETS,
271 )?
272 .set_buckets_for_metric(
273 Matcher::Full(names::SINK_BATCH_BYTES.into()),
274 BATCH_BYTES_BUCKETS,
275 )
276}
277
278static INSTALLED: std::sync::OnceLock<MetricsHandle> = std::sync::OnceLock::new();
284
285static INSTALLED_SETTINGS: std::sync::OnceLock<MetricsSettings> = std::sync::OnceLock::new();
288
289pub fn install(settings: &MetricsSettings) -> Result<MetricsHandle, MetricsError> {
304 static INSTALL: Mutex<()> = Mutex::new(());
310 let _serialized = INSTALL.lock().unwrap_or_else(PoisonError::into_inner);
311 if settings.exporter == Exporter::None {
315 return Ok(MetricsHandle {
316 inner: Inner::Noop,
317 process: None,
318 });
319 }
320 if let Some(existing) = INSTALLED.get() {
321 if INSTALLED_SETTINGS
322 .get()
323 .is_some_and(|first| first != settings)
324 {
325 tracing::warn!(
326 requested = ?settings,
327 active = ?INSTALLED_SETTINGS.get(),
328 "metrics exporter already installed with different settings; \
329 the first install's exporter stays in effect"
330 );
331 }
332 return Ok(existing.clone());
333 }
334 let builder = configured_builder().map_err(|e| MetricsError::Build(e.to_string()))?;
335 let handle = builder.install_recorder().map_err(|e| match e {
336 BuildError::FailedToSetGlobalRecorder(_) => MetricsError::AlreadyInstalled,
337 other => MetricsError::Build(other.to_string()),
338 })?;
339 let process = metrics_process::Collector::new("process_");
340 process.describe();
341 process.collect();
342 let handle = MetricsHandle {
343 inner: Inner::Prometheus(handle),
344 process: Some(Arc::new(process)),
345 };
346 let _ = INSTALLED_SETTINGS.set(settings.clone());
347 Ok(INSTALLED.get_or_init(|| handle).clone())
348}
349
350#[cfg(all(test, not(loom)))] mod tests {
352 use super::*;
353 use crate::error::ErrorClass;
354 use crate::record::PartitionId;
355
356 fn render_with_local_recorder(f: impl FnOnce()) -> String {
359 let recorder = configured_builder()
360 .expect("bucket config must be valid")
361 .build_recorder();
362 let handle = recorder.handle();
363 metrics::with_local_recorder(&recorder, f);
364 handle.run_upkeep();
365 handle.render()
366 }
367
368 fn labels(component: &str) -> ComponentLabels {
376 ComponentLabels::new("orders", component.to_owned(), "kafka")
377 }
378
379 #[test]
380 fn handle_structs_register_and_render_the_taxonomy() {
381 let rendered = render_with_local_recorder(|| {
382 let src = SourceMetrics::new(&labels("orders_kafka"));
383 src.batch(512, 131_072);
384 src.poll_duration(Duration::from_millis(3));
385 src.set_partition_lag(PartitionId(7), 40);
386 src.rebalance_assigned();
387 src.set_lanes_active(4);
388
389 let deser = DeserMetrics::new(&labels("orders_kafka"));
390 deser.batch(510, 2, Duration::from_millis(1));
391 deser.dropped(2);
392
393 let op = OperatorMetrics::new(&labels("orders_kafka"));
394 op.batch(510, 380, Duration::from_micros(600));
395 op.filtered(130);
396 op.errors(ErrorClass::RecordLevel, 1);
397
398 let q = QueueMetrics::new(&labels("orders_kafka"), "chain->sink/0", 4096);
399 q.set_depth(17);
400 q.full_events(1);
401
402 let bp = BackpressureMetrics::new(&labels("orders_kafka"));
403 bp.pause_started();
404 bp.pause_ended(Duration::from_millis(250));
405 bp.set_inflight_bytes(1 << 20);
406
407 let shard = SinkShardMetrics::new(
408 &labels("orders_kafka"),
409 3,
410 &["ch-3-0".into(), "ch-3-1".into()],
411 E2eBasis::Ingest,
412 );
413 shard.flushed(
414 FlushReason::Rows,
415 500_000,
416 64 << 20,
417 Duration::from_millis(90),
418 );
419 shard.retries(1);
420 shard.errors(ErrorClass::Retryable, 1);
421 shard.set_inflight(2);
422 shard.set_replica_healthy(1, false);
423 shard.breaker_opened(1);
424 shard.replica_error(1);
425 shard.set_shard_healthy(false);
426 shard.abandoned(0);
427 shard.drain_overrun();
428
429 let cp = CheckpointMetrics::new(&labels("orders_kafka"), false);
430 cp.set_pending_max(12);
431 cp.commit(true, Duration::from_millis(4));
432 cp.set_watermark_age(Duration::from_secs(1));
433
434 let coord = CoordinationMetrics::new(&labels("orders_kafka"));
435 coord.set_splits_owned(3);
436 coord.set_splits_completed(1);
437 coord.set_splits_quarantined(1);
438 coord.set_live_workers(2);
439 coord.set_leader(true);
440 coord.set_idle(false);
441 coord.acquired(AcquireReason::Expired);
442 coord.acquired(AcquireReason::Reassigned);
443 coord.lost(SplitLossReason::Fenced);
444 coord.released(1);
445 coord.revocation(RevocationOutcome::Requested);
446 coord.revocation(RevocationOutcome::Drained);
447 coord.revocation(RevocationOutcome::Forced);
448 coord.revocation(RevocationOutcome::Cancelled);
449 coord.assignment_latency(Duration::from_millis(900));
450 coord.drain_duration(Duration::from_millis(120));
451 coord.set_splits_draining(2);
452 coord.planned(8);
453 coord.replan(ReplanOutcome::Noop, Duration::from_millis(20));
454 coord.failed();
455 coord.quarantined();
456 coord.write(WriteOutcome::Conflict, Duration::from_millis(8));
457 coord.reconcile(Duration::from_millis(15));
458 coord.store_op(StoreOp::Put, Duration::from_micros(600));
459
460 let pl = PipelineMetrics::new(&labels("orders_kafka"), "0.1.0");
461 pl.set_state(PipelineState::Running);
462 pl.set_threads(4);
463 });
464
465 for needle in [
467 r#"spate_source_records_total{pipeline="orders",component="orders_kafka",component_type="kafka"} 512"#,
468 r#"partition="7""#,
469 r#"spate_deser_records_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="ok"} 510"#,
470 r#"spate_operator_records_dropped_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="filtered"} 130"#,
471 r#"spate_queue_capacity{pipeline="orders",component="orders_kafka",component_type="kafka",queue="chain->sink/0"} 4096"#,
472 r#"spate_backpressure_pause_events_total{pipeline="orders",component="orders_kafka",component_type="kafka"} 1"#,
473 r#"spate_sink_flushes_total{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3",reason="rows"} 1"#,
474 r#"spate_sink_replica_healthy{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3",replica="ch-3-1"} 0"#,
475 r#"spate_sink_replica_errors_total{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3",replica="ch-3-1"} 1"#,
476 r#"spate_sink_shard_healthy{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3"} 0"#,
477 r#"spate_checkpoint_commits_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="ok"} 1"#,
478 r#"spate_coordination_acquisitions_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="expired"} 1"#,
479 r#"spate_coordination_acquisitions_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="reassigned"} 1"#,
480 r#"spate_coordination_split_losses_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="fenced"} 1"#,
481 r#"spate_coordination_revocations_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="requested"} 1"#,
482 r#"spate_coordination_revocations_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="drained"} 1"#,
483 r#"spate_coordination_revocations_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="forced"} 1"#,
484 r#"spate_coordination_revocations_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="cancelled"} 1"#,
485 r#"spate_coordination_splits_draining{pipeline="orders",component="orders_kafka",component_type="kafka"} 2"#,
486 r#"spate_coordination_replans_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="noop"} 1"#,
487 r#"spate_coordination_writes_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="conflict"} 1"#,
488 r#"spate_coordination_leader{pipeline="orders",component="orders_kafka",component_type="kafka"} 1"#,
489 r#"spate_pipeline_state{pipeline="orders",component="orders_kafka",component_type="kafka",state="running"} 1"#,
490 r#"spate_pipeline_info{pipeline="orders",component="orders_kafka",component_type="kafka",version="0.1.0"} 1"#,
491 ] {
492 assert!(
493 rendered.contains(needle),
494 "rendered output missing `{needle}`:\n{rendered}"
495 );
496 }
497 }
498
499 #[test]
514 fn a_second_handle_set_cannot_reset_a_live_shards_gauges() {
515 let recorder = configured_builder()
516 .expect("bucket config must be valid")
517 .build_recorder();
518 let handle = recorder.handle();
519 metrics::with_local_recorder(&recorder, || {
520 let owner = SinkShardMetrics::new(
521 &labels("clobbered_shard"),
522 0,
523 &["r0".into()],
524 E2eBasis::Ingest,
525 );
526 let healthy = || gauge_value(&handle.render(), names::SINK_SHARD_HEALTHY);
527 let backoff = || gauge_value(&handle.render(), names::SINK_RETRY_BACKOFF_SECONDS);
528 let written = || gauge_value(&handle.render(), names::SINK_RECORDS_TOTAL);
529
530 owner.set_shard_healthy(false);
532 owner.set_replica_healthy(0, false);
533 let _sleeping = owner.backing_off(1, Duration::from_secs(600));
534 owner.flushed(FlushReason::Rows, 5, 50, Duration::from_millis(1));
535 assert_eq!(healthy(), 0.0);
536 assert_eq!(backoff(), 600.0);
537
538 let shadow = SinkShardMetrics::new(
541 &labels("clobbered_shard"),
542 0,
543 &["r0".into()],
544 E2eBasis::Ingest,
545 );
546 assert_eq!(healthy(), 0.0, "a second handle set reset shard health");
547 assert_eq!(backoff(), 600.0, "a second handle set reset the backoff");
548
549 shadow.set_shard_healthy(true);
552 shadow.set_replica_healthy(0, true);
553 let _shadow_sleep = shadow.backing_off(9, Duration::from_secs(1));
554 assert_eq!(healthy(), 0.0, "the shadow published a gauge");
555 assert_eq!(backoff(), 600.0, "the shadow published a gauge");
556
557 shadow.flushed(FlushReason::Rows, 7, 70, Duration::from_millis(1));
559 assert_eq!(
560 written(),
561 12.0,
562 "counters must aggregate across handle sets, not be suppressed"
563 );
564 });
565 }
566
567 #[test]
573 fn ownership_is_process_wide_not_per_recorder() {
574 let owner_recorder = configured_builder().expect("buckets").build_recorder();
575 let owner_handle = owner_recorder.handle();
576 let shadow_recorder = configured_builder().expect("buckets").build_recorder();
577 let shadow_handle = shadow_recorder.handle();
578
579 let shard =
580 |name: &str| SinkShardMetrics::new(&labels(name), 0, &["r0".into()], E2eBasis::Ingest);
581 let owner = metrics::with_local_recorder(&owner_recorder, || shard("cross_recorder"));
582 let shadow = metrics::with_local_recorder(&shadow_recorder, || shard("cross_recorder"));
583
584 owner.set_shard_healthy(false);
585 shadow.set_shard_healthy(true);
586
587 assert_eq!(
588 gauge_value(&owner_handle.render(), names::SINK_SHARD_HEALTHY),
589 0.0,
590 "the owner's reading stands"
591 );
592 assert_eq!(
593 gauge_value(&shadow_handle.render(), names::SINK_SHARD_HEALTHY),
594 0.0,
595 "the shadow registered its series but never published to it — the \
596 `1` it asked for must not appear even in its own recorder"
597 );
598 }
599
600 #[test]
607 fn retry_backoff_gauge_publishes_the_longest_live_step() {
608 let recorder = configured_builder()
609 .expect("bucket config must be valid")
610 .build_recorder();
611 let handle = recorder.handle();
612 metrics::with_local_recorder(&recorder, || {
613 let shard = SinkShardMetrics::new(
614 &labels("backoff_longest_step"),
615 0,
616 &["r0".into()],
617 E2eBasis::Ingest,
618 );
619 let backoff = || gauge_value(&handle.render(), names::SINK_RETRY_BACKOFF_SECONDS);
620
621 assert_eq!(backoff(), 0.0, "a fresh shard is not backing off");
624
625 let short = shard.backing_off(1, Duration::from_secs(4));
626 assert_eq!(backoff(), 4.0);
627 let long = shard.backing_off(2, Duration::from_secs(30));
628 assert_eq!(backoff(), 30.0, "the longer sleep wins");
629 drop(long);
630 assert_eq!(backoff(), 4.0, "batch 1 is still asleep");
631 drop(short);
632 assert_eq!(backoff(), 0.0, "nothing is backing off");
633 });
634 }
635
636 #[test]
655 fn retry_backoff_gauge_is_consistent_under_concurrent_publishers() {
656 const SLEEPERS: usize = 7;
657 const ROUNDS: usize = 1_000;
658
659 let recorder = configured_builder()
660 .expect("bucket config must be valid")
661 .build_recorder();
662 let handle = recorder.handle();
663 let divergence = metrics::with_local_recorder(&recorder, || {
664 let shard = SinkShardMetrics::new(
668 &labels("backoff_concurrent"),
669 0,
670 &["r0".into()],
671 E2eBasis::Ingest,
672 );
673 let gate = std::sync::Barrier::new(SLEEPERS + 1);
674
675 std::thread::scope(|scope| {
676 for k in 1..=SLEEPERS {
677 let (shard, gate) = (&shard, &gate);
678 scope.spawn(move || {
679 let step = Duration::from_secs(k as u64);
680 let mut guard = Some(shard.backing_off(k as u64, step));
681 for _ in 0..ROUNDS {
682 gate.wait();
683 drop(guard.take()); gate.wait();
685 gate.wait();
686 guard = Some(shard.backing_off(k as u64, step)); gate.wait();
688 gate.wait();
689 }
690 });
691 }
692
693 let backoff = || gauge_value(&handle.render(), names::SINK_RETRY_BACKOFF_SECONDS);
698 let mut first_bad = None;
699 let mut record = |round, phase, want: f64, got: f64| {
700 if got != want && first_bad.is_none() {
701 first_bad = Some(format!(
702 "round {round}, {phase}: gauge read {got}, expected {want}"
703 ));
704 }
705 };
706 for round in 0..ROUNDS {
707 gate.wait();
708 let long = shard.backing_off(0, Duration::from_secs(1000));
709 gate.wait();
710 record(round, "a short sleeper ended", 1000.0, backoff());
713 gate.wait();
714 drop(long);
715 gate.wait();
716 record(round, "the long sleeper ended", SLEEPERS as f64, backoff());
719 gate.wait();
720 }
721 first_bad
722 })
723 });
724 assert_eq!(divergence, None, "gauge stranded off the live backoff set");
725 }
726
727 fn gauge_value(rendered: &str, name: &str) -> f64 {
730 let line = rendered
731 .lines()
732 .find(|l| l.starts_with(name))
733 .unwrap_or_else(|| panic!("`{name}` not rendered:\n{rendered}"));
734 line.rsplit(' ').next().unwrap().parse().expect("value")
735 }
736
737 #[test]
738 fn custom_meter_inherits_standard_labels_and_namespace() {
739 let rendered = render_with_local_recorder(|| {
740 let meter = Meter::with_namespace("kafka", "orders", "orders_kafka", "kafka");
743 meter
744 .counter("schema_fetches_total", &[("registry", "prod".into())])
745 .increment(3);
746 meter.gauge("cache_entries", &[]).set(17.0);
747 meter.histogram("fetch_duration_seconds", &[]).record(0.012);
748
749 Meter::new("orders", "enrich", "map")
751 .counter("orders_enriched_total", &[])
752 .increment(9);
753
754 let deser = DeserMetrics::new(meter.labels());
756 deser.batch(510, 0, Duration::from_millis(1));
757 });
758
759 for needle in [
760 r#"spate_kafka_schema_fetches_total{pipeline="orders",component="orders_kafka",component_type="kafka",registry="prod"} 3"#,
762 r#"spate_kafka_cache_entries{pipeline="orders",component="orders_kafka",component_type="kafka"} 17"#,
763 r#"spate_kafka_fetch_duration_seconds_bucket{pipeline="orders",component="orders_kafka",component_type="kafka""#,
764 r#"spate_custom_orders_enriched_total{pipeline="orders",component="enrich",component_type="map"} 9"#,
766 r#"spate_deser_records_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="ok"} 510"#,
768 ] {
769 assert!(
770 rendered.contains(needle),
771 "rendered output missing `{needle}`:\n{rendered}"
772 );
773 }
774 }
775
776 #[test]
777 #[should_panic(expected = "reserved framework root")]
778 fn custom_meter_rejects_reserved_namespace() {
779 Meter::with_namespace("sink", "p", "c", "t");
780 }
781
782 #[test]
783 #[should_panic(expected = "lowercase")]
784 fn custom_meter_rejects_invalid_namespace() {
785 Meter::with_namespace("Bad Name", "p", "c", "t");
786 }
787
788 #[test]
789 #[should_panic(expected = "without the `spate_` prefix")]
790 fn custom_meter_rejects_prefixed_local_name() {
791 let _ = Meter::new("p", "c", "t").counter("spate_custom_hits_total", &[]);
792 }
793
794 #[test]
795 #[should_panic(expected = "shadows a standard label")]
796 fn custom_meter_rejects_shadowed_standard_label() {
797 let _ = Meter::new("p", "c", "t").counter("hits_total", &[("component", "x".into())]);
798 }
799
800 #[test]
801 #[should_panic(expected = "role segment")]
802 fn custom_meter_rejects_role_prefixed_local_name() {
803 let _ = Meter::new("p", "c", "t").counter("sink_writes_total", &[]);
806 }
807
808 #[test]
809 fn for_component_scopes_by_role_and_gates_ineligible_types() {
810 assert!(Meter::for_component("source", MetricRole::Source, "p", "c").is_none());
813 assert!(Meter::for_component("sink", MetricRole::Sink, "p", "c").is_none());
814 assert!(Meter::for_component("custom", MetricRole::Sink, "p", "c").is_none());
817 assert!(Meter::for_component("clickhouse-v2", MetricRole::Sink, "p", "c").is_none());
820 assert!(Meter::for_component("", MetricRole::Source, "p", "c").is_none());
821
822 let rendered = render_with_local_recorder(|| {
823 Meter::for_component("kafka", MetricRole::Source, "orders", "orders_in")
824 .expect("valid namespace")
825 .counter("bytes_total", &[])
826 .increment(10);
827 Meter::for_component("clickhouse", MetricRole::Sink, "orders", "orders_out")
828 .expect("valid namespace")
829 .counter("bytes_total", &[])
830 .increment(20);
831 });
832 assert!(rendered.contains(
834 r#"spate_kafka_source_bytes_total{pipeline="orders",component="orders_in",component_type="kafka"} 10"#
835 ));
836 assert!(rendered.contains(
837 r#"spate_clickhouse_sink_bytes_total{pipeline="orders",component="orders_out",component_type="clickhouse"} 20"#
838 ));
839 }
840
841 #[test]
842 fn duration_histograms_use_configured_buckets() {
843 let rendered = render_with_local_recorder(|| {
844 let src = SourceMetrics::new(&labels("buckets"));
845 src.poll_duration(Duration::from_millis(3));
846 });
847 assert!(
848 rendered.contains(r#"le="0.005""#),
849 "expected a 5ms bucket boundary:\n{rendered}"
850 );
851 assert!(
852 rendered.contains("spate_source_poll_duration_seconds_bucket"),
853 "expected histogram exposition:\n{rendered}"
854 );
855 }
856
857 #[test]
862 fn source_lag_publishes_independently_of_partition_detail() {
863 let rendered = render_with_local_recorder(|| {
864 let src = SourceMetrics::new(&labels("lag_ungated"));
865 src.set_partition_lag(PartitionId(1), 5);
866 });
867 assert!(
868 rendered.contains(
869 r#"spate_source_lag_records{pipeline="orders",component="lag_ungated",component_type="kafka",partition="1"} 5"#
870 ),
871 "lag must publish without any detail flag:\n{rendered}"
872 );
873 }
874
875 #[test]
879 fn unmeasured_source_lag_registers_no_series() {
880 let rendered = render_with_local_recorder(|| {
881 let src = SourceMetrics::new(&labels("lag_unmeasured"));
882 src.batch(10, 100);
883 });
884 assert!(
885 !rendered.contains("spate_source_lag_records"),
886 "lag must be absent until measured:\n{rendered}"
887 );
888 }
889
890 #[test]
891 fn per_partition_series_are_gated_and_retained() {
892 let gated_labels = ComponentLabels::new("orders", "gated_checkpoint", "checkpoint");
897 let rendered = render_with_local_recorder(|| {
898 let gated = CheckpointMetrics::new(&gated_labels, false);
899 gated.set_partition_pending(PartitionId(1), 5);
900
901 let detailed = CheckpointMetrics::new(&labels("detailed_checkpoint"), true);
902 detailed.set_partition_pending(PartitionId(1), 5);
903 detailed.set_partition_pending(PartitionId(2), 9);
904 detailed.retain_partitions(&[PartitionId(2)]);
905 detailed.set_partition_pending(PartitionId(2), 11);
909 });
910 let gated_series_leaked = rendered.lines().any(|l| {
911 l.starts_with("spate_checkpoint_pending_batches")
912 && l.contains("gated_checkpoint")
913 && l.contains("partition=")
914 });
915 assert!(
916 !gated_series_leaked,
917 "per-partition checkpoint detail must be gated off:\n{rendered}"
918 );
919 assert!(rendered.contains(
920 r#"spate_checkpoint_pending_batches{pipeline="orders",component="detailed_checkpoint",component_type="kafka",partition="2"} 11"#
921 ));
922 assert!(
927 rendered.contains(
928 r#"spate_checkpoint_pending_batches{pipeline="orders",component="detailed_checkpoint",component_type="kafka",partition="1"} 0"#
929 ),
930 "a retained-out partition must be zeroed, not left stale:\n{rendered}"
931 );
932 }
933
934 #[test]
935 fn state_gauge_flips_exactly_one_state() {
936 let rendered = render_with_local_recorder(|| {
937 let pl = PipelineMetrics::new(&labels("state_gauge"), "0.1.0");
938 pl.set_state(PipelineState::Draining);
939 });
940 assert!(rendered.contains(r#"state="draining"} 1"#));
941 for other in ["starting", "running", "failed"] {
942 assert!(
943 rendered.contains(&format!(r#"state="{other}"}} 0"#)),
944 "state `{other}` should read 0:\n{rendered}"
945 );
946 }
947 }
948
949 #[test]
950 fn noop_exporter_renders_empty() {
951 let handle = install(&MetricsSettings {
952 exporter: Exporter::None,
953 ..MetricsSettings::default()
954 })
955 .expect("noop install");
956 assert_eq!(handle.render(), "");
957 handle.upkeep_tick(); }
959
960 #[test]
965 fn install_prometheus_end_to_end() {
966 let handle = install(&MetricsSettings::default()).expect("first install succeeds");
967
968 let pl = PipelineMetrics::new(&labels("install_e2e"), "0.1.0");
969 pl.set_threads(4);
970
971 let rendered = handle.render();
972 assert!(rendered.contains("spate_pipeline_info"));
973 assert!(rendered.contains("spate_pipeline_threads"));
974 assert!(
975 rendered.contains("process_cpu_seconds_total"),
976 "process collector wired:\n{rendered}"
977 );
978
979 handle.upkeep_tick();
980 let render_fn = handle.render_fn();
981 assert!(render_fn().contains("spate_pipeline_threads"));
982
983 let shard = SinkShardMetrics::new(
989 &labels("install_e2e"),
990 7,
991 &["reuse-7-0".into()],
992 E2eBasis::Ingest,
993 );
994 shard.flushed(FlushReason::Rows, 10, 1_000, Duration::from_millis(3));
995 shard.e2e_observed(Duration::from_millis(25), i64::MAX);
996 let second = install(&MetricsSettings::default()).expect("second install reuses");
997 let rendered = second.render();
998 assert!(
999 rendered.contains("spate_sink_records_total"),
1000 "handles registered before the second install render through it:\n{rendered}"
1001 );
1002 assert!(rendered.contains("spate_e2e_latency_seconds"));
1003
1004 let noop = install(&MetricsSettings {
1006 exporter: Exporter::None,
1007 ..MetricsSettings::default()
1008 })
1009 .expect("noop install");
1010 assert!(noop.render().is_empty());
1011 assert!(
1012 install(&MetricsSettings::default())
1013 .expect("prometheus still reusable")
1014 .render()
1015 .contains("spate_pipeline_info")
1016 );
1017 }
1018}