1mod handles;
26pub mod names;
27
28pub use handles::{
29 BackpressureMetrics, CheckpointMetrics, ComponentLabels, DeserMetrics, FlushReason,
30 OperatorMetrics, PipelineMetrics, PipelineState, QueueMetrics, SinkShardMetrics, SourceMetrics,
31};
32
33use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder, PrometheusHandle};
34use std::net::{Ipv4Addr, SocketAddr};
35use std::sync::Arc;
36use std::time::Duration;
37
38pub const DURATION_SECONDS_BUCKETS: &[f64] = &[
41 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,
42];
43
44pub const BATCH_ROWS_BUCKETS: &[f64] = &[
46 64.0, 256.0, 1024.0, 4096.0, 16384.0, 65536.0, 262144.0, 1048576.0,
47];
48
49pub const BATCH_BYTES_BUCKETS: &[f64] = &[
51 4096.0,
52 16384.0,
53 65536.0,
54 262144.0,
55 1048576.0,
56 4194304.0,
57 16777216.0,
58 67108864.0,
59 268435456.0,
60];
61
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum Exporter {
66 #[default]
68 Prometheus,
69 None,
71}
72
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum E2eBasis {
77 #[default]
79 Ingest,
80 Event,
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct MetricsSettings {
90 pub exporter: Exporter,
92 pub listen: SocketAddr,
94 pub per_partition_detail: bool,
96 pub e2e_basis: E2eBasis,
98}
99
100impl Default for MetricsSettings {
101 fn default() -> Self {
102 MetricsSettings {
103 exporter: Exporter::Prometheus,
104 listen: SocketAddr::from((Ipv4Addr::UNSPECIFIED, 9090)),
105 per_partition_detail: false,
106 e2e_basis: E2eBasis::Ingest,
107 }
108 }
109}
110
111#[derive(Debug, thiserror::Error)]
113#[non_exhaustive]
114pub enum MetricsError {
115 #[error("a metrics recorder is already installed in this process")]
118 AlreadyInstalled,
119 #[error("failed to build the metrics exporter: {0}")]
121 Build(String),
122}
123
124#[derive(Clone, Debug)]
126pub struct MetricsHandle {
127 inner: Inner,
128 process: Option<Arc<metrics_process::Collector>>,
129}
130
131#[derive(Clone, Debug)]
132enum Inner {
133 Prometheus(PrometheusHandle),
134 Noop,
135}
136
137impl MetricsHandle {
138 #[must_use]
141 pub fn render(&self) -> String {
142 match &self.inner {
143 Inner::Prometheus(handle) => {
144 if let Some(process) = &self.process {
145 process.collect();
146 }
147 handle.render()
148 }
149 Inner::Noop => String::new(),
150 }
151 }
152
153 #[must_use]
156 pub fn render_fn(&self) -> Arc<dyn Fn() -> String + Send + Sync> {
157 let this = self.clone();
158 Arc::new(move || this.render())
159 }
160
161 pub fn upkeep_tick(&self) {
164 if let Inner::Prometheus(handle) = &self.inner {
165 handle.run_upkeep();
166 }
167 if let Some(process) = &self.process {
168 process.collect();
169 }
170 }
171
172 #[must_use]
174 pub fn spawn_upkeep(&self, period: Duration) -> tokio::task::JoinHandle<()> {
175 let this = self.clone();
176 tokio::spawn(async move {
177 let mut tick = tokio::time::interval(period);
178 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
179 loop {
180 tick.tick().await;
181 this.upkeep_tick();
182 }
183 })
184 }
185}
186
187fn configured_builder() -> Result<PrometheusBuilder, BuildError> {
190 PrometheusBuilder::new()
191 .set_buckets_for_metric(
192 Matcher::Suffix("_duration_seconds".into()),
193 DURATION_SECONDS_BUCKETS,
194 )?
195 .set_buckets_for_metric(
196 Matcher::Full(names::E2E_LATENCY_SECONDS.into()),
197 DURATION_SECONDS_BUCKETS,
198 )?
199 .set_buckets_for_metric(
200 Matcher::Full(names::SINK_BATCH_ROWS.into()),
201 BATCH_ROWS_BUCKETS,
202 )?
203 .set_buckets_for_metric(
204 Matcher::Full(names::SINK_BATCH_BYTES.into()),
205 BATCH_BYTES_BUCKETS,
206 )
207}
208
209static INSTALLED: std::sync::OnceLock<MetricsHandle> = std::sync::OnceLock::new();
215
216static INSTALLED_SETTINGS: std::sync::OnceLock<MetricsSettings> = std::sync::OnceLock::new();
219
220pub fn install(settings: &MetricsSettings) -> Result<MetricsHandle, MetricsError> {
235 if settings.exporter == Exporter::None {
239 return Ok(MetricsHandle {
240 inner: Inner::Noop,
241 process: None,
242 });
243 }
244 if let Some(existing) = INSTALLED.get() {
245 if INSTALLED_SETTINGS
246 .get()
247 .is_some_and(|first| first != settings)
248 {
249 tracing::warn!(
250 requested = ?settings,
251 active = ?INSTALLED_SETTINGS.get(),
252 "metrics exporter already installed with different settings; \
253 the first install's exporter stays in effect"
254 );
255 }
256 return Ok(existing.clone());
257 }
258 let builder = configured_builder().map_err(|e| MetricsError::Build(e.to_string()))?;
259 let handle = builder.install_recorder().map_err(|e| match e {
260 BuildError::FailedToSetGlobalRecorder(_) => MetricsError::AlreadyInstalled,
261 other => MetricsError::Build(other.to_string()),
262 })?;
263 let process = metrics_process::Collector::new("process_");
264 process.describe();
265 process.collect();
266 let handle = MetricsHandle {
267 inner: Inner::Prometheus(handle),
268 process: Some(Arc::new(process)),
269 };
270 let _ = INSTALLED_SETTINGS.set(settings.clone());
271 Ok(INSTALLED.get_or_init(|| handle).clone())
272}
273
274#[cfg(all(test, not(loom)))] mod tests {
276 use super::*;
277 use crate::error::ErrorClass;
278 use crate::record::PartitionId;
279
280 fn render_with_local_recorder(f: impl FnOnce()) -> String {
283 let recorder = configured_builder()
284 .expect("bucket config must be valid")
285 .build_recorder();
286 let handle = recorder.handle();
287 metrics::with_local_recorder(&recorder, f);
288 handle.run_upkeep();
289 handle.render()
290 }
291
292 fn labels() -> ComponentLabels {
293 ComponentLabels::new("orders", "orders_kafka", "kafka")
294 }
295
296 #[test]
297 fn handle_structs_register_and_render_the_taxonomy() {
298 let rendered = render_with_local_recorder(|| {
299 let src = SourceMetrics::new(&labels(), true);
300 src.batch(512, 131_072);
301 src.poll_duration(Duration::from_millis(3));
302 src.set_lag_max(42);
303 src.set_partition_lag(PartitionId(7), 40);
304 src.rebalance_assigned();
305 src.set_lanes_active(4);
306
307 let deser = DeserMetrics::new(&labels());
308 deser.batch(510, 2, Duration::from_millis(1));
309 deser.dropped(2);
310
311 let op = OperatorMetrics::new(&labels());
312 op.batch(510, 380, Duration::from_micros(600));
313 op.filtered(130);
314 op.errors(ErrorClass::RecordLevel, 1);
315
316 let q = QueueMetrics::new(&labels(), "chain->sink/0", 4096);
317 q.set_depth(17);
318 q.full_events(1);
319
320 let bp = BackpressureMetrics::new(&labels());
321 bp.pause_started();
322 bp.pause_ended(Duration::from_millis(250));
323 bp.set_inflight_bytes(1 << 20);
324
325 let shard = SinkShardMetrics::new(
326 &labels(),
327 3,
328 &["ch-3-0".into(), "ch-3-1".into()],
329 E2eBasis::Ingest,
330 );
331 shard.flushed(
332 FlushReason::Rows,
333 500_000,
334 64 << 20,
335 Duration::from_millis(90),
336 );
337 shard.retries(1);
338 shard.errors(ErrorClass::Retryable, 1);
339 shard.set_inflight(2);
340 shard.set_replica_healthy(1, false);
341 shard.breaker_opened(1);
342 shard.abandoned(0);
343
344 let cp = CheckpointMetrics::new(&labels(), false);
345 cp.set_pending_max(12);
346 cp.commit(true, Duration::from_millis(4));
347 cp.set_watermark_age(Duration::from_secs(1));
348
349 let pl = PipelineMetrics::new(&labels(), "0.1.0");
350 pl.set_state(PipelineState::Running);
351 pl.set_threads(4);
352 });
353
354 for needle in [
356 r#"etl_source_records_total{pipeline="orders",component="orders_kafka",component_type="kafka"} 512"#,
357 r#"partition="7""#,
358 r#"etl_deser_records_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="ok"} 510"#,
359 r#"etl_operator_records_dropped_total{pipeline="orders",component="orders_kafka",component_type="kafka",reason="filtered"} 130"#,
360 r#"etl_queue_capacity{pipeline="orders",component="orders_kafka",component_type="kafka",queue="chain->sink/0"} 4096"#,
361 r#"etl_backpressure_pause_events_total{pipeline="orders",component="orders_kafka",component_type="kafka"} 1"#,
362 r#"etl_sink_flushes_total{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3",reason="rows"} 1"#,
363 r#"etl_sink_replica_healthy{pipeline="orders",component="orders_kafka",component_type="kafka",shard="3",replica="ch-3-1"} 0"#,
364 r#"etl_checkpoint_commits_total{pipeline="orders",component="orders_kafka",component_type="kafka",outcome="ok"} 1"#,
365 r#"etl_pipeline_state{pipeline="orders",component="orders_kafka",component_type="kafka",state="running"} 1"#,
366 r#"etl_pipeline_info{pipeline="orders",component="orders_kafka",component_type="kafka",version="0.1.0"} 1"#,
367 ] {
368 assert!(
369 rendered.contains(needle),
370 "rendered output missing `{needle}`:\n{rendered}"
371 );
372 }
373 }
374
375 #[test]
376 fn duration_histograms_use_configured_buckets() {
377 let rendered = render_with_local_recorder(|| {
378 let src = SourceMetrics::new(&labels(), false);
379 src.poll_duration(Duration::from_millis(3));
380 });
381 assert!(
382 rendered.contains(r#"le="0.005""#),
383 "expected a 5ms bucket boundary:\n{rendered}"
384 );
385 assert!(
386 rendered.contains("etl_source_poll_duration_seconds_bucket"),
387 "expected histogram exposition:\n{rendered}"
388 );
389 }
390
391 #[test]
392 fn per_partition_series_are_gated_and_retained() {
393 let rendered = render_with_local_recorder(|| {
394 let gated = SourceMetrics::new(&labels(), false);
395 gated.set_partition_lag(PartitionId(1), 5);
396
397 let detailed = CheckpointMetrics::new(&labels(), true);
398 detailed.set_partition_pending(PartitionId(1), 5);
399 detailed.set_partition_pending(PartitionId(2), 9);
400 detailed.retain_partitions(&[PartitionId(2)]);
401 detailed.set_partition_pending(PartitionId(2), 11);
403 });
404 let gated_series_leaked = rendered
405 .lines()
406 .any(|l| l.starts_with("etl_source_lag_records") && l.contains("partition="));
407 assert!(
408 !gated_series_leaked,
409 "per-partition lag must be gated off:\n{rendered}"
410 );
411 assert!(rendered.contains(
412 r#"etl_checkpoint_pending_batches{pipeline="orders",component="orders_kafka",component_type="kafka",partition="2"} 11"#
413 ));
414 }
415
416 #[test]
417 fn state_gauge_flips_exactly_one_state() {
418 let rendered = render_with_local_recorder(|| {
419 let pl = PipelineMetrics::new(&labels(), "0.1.0");
420 pl.set_state(PipelineState::Draining);
421 });
422 assert!(rendered.contains(r#"state="draining"} 1"#));
423 for other in ["starting", "running", "failed"] {
424 assert!(
425 rendered.contains(&format!(r#"state="{other}"}} 0"#)),
426 "state `{other}` should read 0:\n{rendered}"
427 );
428 }
429 }
430
431 #[test]
432 fn noop_exporter_renders_empty() {
433 let handle = install(&MetricsSettings {
434 exporter: Exporter::None,
435 ..MetricsSettings::default()
436 })
437 .expect("noop install");
438 assert_eq!(handle.render(), "");
439 handle.upkeep_tick(); }
441
442 #[test]
447 fn install_prometheus_end_to_end() {
448 let handle = install(&MetricsSettings::default()).expect("first install succeeds");
449
450 let pl = PipelineMetrics::new(&labels(), "0.1.0");
451 pl.set_threads(4);
452
453 let rendered = handle.render();
454 assert!(rendered.contains("etl_pipeline_info"));
455 assert!(rendered.contains("etl_pipeline_threads"));
456 assert!(
457 rendered.contains("process_cpu_seconds_total"),
458 "process collector wired:\n{rendered}"
459 );
460
461 handle.upkeep_tick();
462 let render_fn = handle.render_fn();
463 assert!(render_fn().contains("etl_pipeline_threads"));
464
465 let shard = SinkShardMetrics::new(&labels(), 7, &["reuse-7-0".into()], E2eBasis::Ingest);
471 shard.flushed(FlushReason::Rows, 10, 1_000, Duration::from_millis(3));
472 shard.e2e_observed(Duration::from_millis(25), i64::MAX);
473 let second = install(&MetricsSettings::default()).expect("second install reuses");
474 let rendered = second.render();
475 assert!(
476 rendered.contains("etl_sink_records_total"),
477 "handles registered before the second install render through it:\n{rendered}"
478 );
479 assert!(rendered.contains("etl_e2e_latency_seconds"));
480
481 let noop = install(&MetricsSettings {
483 exporter: Exporter::None,
484 ..MetricsSettings::default()
485 })
486 .expect("noop install");
487 assert!(noop.render().is_empty());
488 assert!(
489 install(&MetricsSettings::default())
490 .expect("prometheus still reusable")
491 .render()
492 .contains("etl_pipeline_info")
493 );
494 }
495}