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