1#[cfg(doctest)]
6use doc_comment::doctest;
7#[cfg(doctest)]
8doctest!("../README.md");
9
10use std::fmt::Debug;
11use std::time::Duration;
12
13use batch_aint_one::{BatchStats, MetricsRecorder, MetricsRecorderFactory};
14use prometheus::{HistogramOpts, HistogramVec, IntCounterVec, IntGaugeVec, Opts, Registry};
15
16const DEFAULT_PREFIX: &str = "batch";
17
18const CHANNEL_DURATION_BUCKETS: &[f64] = &[0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1];
19const BATCH_SIZE_BUCKETS: &[f64] = &[1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0];
20
21#[derive(Debug, Clone)]
29pub struct BatchMetrics {
30 items_received_total: IntCounterVec,
31 channel_duration_seconds: HistogramVec,
32 items_rejected_total: IntCounterVec,
33 batches_completed_total: IntCounterVec,
34 batch_size: HistogramVec,
35 batch_processing_duration_seconds: HistogramVec,
36 item_latency_seconds: HistogramVec,
37 queue_duration_seconds: HistogramVec,
38 resource_acquisition_duration_seconds: HistogramVec,
39 active_keys: IntGaugeVec,
40 processing_concurrency: IntGaugeVec,
41 processing_concurrency_max_per_key: IntGaugeVec,
42 queue_depth: IntGaugeVec,
43 queue_depth_max_per_key: IntGaugeVec,
44}
45
46impl BatchMetrics {
47 pub fn new(registry: &Registry) -> prometheus::Result<Self> {
49 Self::with_prefix(registry, DEFAULT_PREFIX)
50 }
51
52 pub fn with_prefix(registry: &Registry, prefix: &str) -> prometheus::Result<Self> {
56 let batcher_labels = &["batcher"];
57 let batcher_success_labels = &["batcher", "success"];
58
59 let items_received_total = IntCounterVec::new(
60 Opts::new("items_received_total", "Items received by the batcher").namespace(prefix),
61 batcher_labels,
62 )?;
63 let channel_duration_seconds = HistogramVec::new(
64 HistogramOpts::new(
65 "channel_duration_seconds",
66 "Time items spent in the channel before the worker picked them up",
67 )
68 .namespace(prefix)
69 .buckets(CHANNEL_DURATION_BUCKETS.to_vec()),
70 batcher_labels,
71 )?;
72 let items_rejected_total = IntCounterVec::new(
73 Opts::new(
74 "items_rejected_total",
75 "Items rejected due to a full batch queue",
76 )
77 .namespace(prefix),
78 batcher_labels,
79 )?;
80 let batches_completed_total = IntCounterVec::new(
81 Opts::new(
82 "batches_completed_total",
83 "Batches that finished processing",
84 )
85 .namespace(prefix),
86 batcher_success_labels,
87 )?;
88 let batch_size = HistogramVec::new(
89 HistogramOpts::new("batch_size", "Number of items per batch")
90 .namespace(prefix)
91 .buckets(BATCH_SIZE_BUCKETS.to_vec()),
92 batcher_labels,
93 )?;
94 let batch_processing_duration_seconds = HistogramVec::new(
95 HistogramOpts::new(
96 "batch_processing_duration_seconds",
97 "Time taken to process a batch",
98 )
99 .namespace(prefix),
100 batcher_success_labels,
101 )?;
102 let item_latency_seconds = HistogramVec::new(
103 HistogramOpts::new(
104 "item_latency_seconds",
105 "End-to-end latency per item, from submission to result delivery",
106 )
107 .namespace(prefix),
108 batcher_labels,
109 )?;
110 let queue_duration_seconds = HistogramVec::new(
111 HistogramOpts::new(
112 "queue_duration_seconds",
113 "Time items spent in the batch queue before processing started",
114 )
115 .namespace(prefix),
116 batcher_labels,
117 )?;
118 let resource_acquisition_duration_seconds = HistogramVec::new(
119 HistogramOpts::new(
120 "resource_acquisition_duration_seconds",
121 "Time taken to acquire resources for a batch",
122 )
123 .namespace(prefix),
124 batcher_success_labels,
125 )?;
126 let active_keys = IntGaugeVec::new(
127 Opts::new("active_keys", "Number of keys with active batch queues").namespace(prefix),
128 batcher_labels,
129 )?;
130 let processing_concurrency = IntGaugeVec::new(
131 Opts::new(
132 "processing_concurrency",
133 "Total number of batches currently processing",
134 )
135 .namespace(prefix),
136 batcher_labels,
137 )?;
138 let processing_concurrency_max_per_key = IntGaugeVec::new(
139 Opts::new(
140 "processing_concurrency_max_per_key",
141 "Highest batch processing concurrency across any single key",
142 )
143 .namespace(prefix),
144 batcher_labels,
145 )?;
146 let queue_depth = IntGaugeVec::new(
147 Opts::new(
148 "queue_depth",
149 "Total number of batches queued for processing",
150 )
151 .namespace(prefix),
152 batcher_labels,
153 )?;
154 let queue_depth_max_per_key = IntGaugeVec::new(
155 Opts::new(
156 "queue_depth_max_per_key",
157 "Deepest batch queue across any single key",
158 )
159 .namespace(prefix),
160 batcher_labels,
161 )?;
162
163 registry.register(Box::new(items_received_total.clone()))?;
164 registry.register(Box::new(channel_duration_seconds.clone()))?;
165 registry.register(Box::new(items_rejected_total.clone()))?;
166 registry.register(Box::new(batches_completed_total.clone()))?;
167 registry.register(Box::new(batch_size.clone()))?;
168 registry.register(Box::new(batch_processing_duration_seconds.clone()))?;
169 registry.register(Box::new(item_latency_seconds.clone()))?;
170 registry.register(Box::new(queue_duration_seconds.clone()))?;
171 registry.register(Box::new(resource_acquisition_duration_seconds.clone()))?;
172 registry.register(Box::new(active_keys.clone()))?;
173 registry.register(Box::new(processing_concurrency.clone()))?;
174 registry.register(Box::new(processing_concurrency_max_per_key.clone()))?;
175 registry.register(Box::new(queue_depth.clone()))?;
176 registry.register(Box::new(queue_depth_max_per_key.clone()))?;
177
178 Ok(Self {
179 items_received_total,
180 channel_duration_seconds,
181 items_rejected_total,
182 batches_completed_total,
183 batch_size,
184 batch_processing_duration_seconds,
185 item_latency_seconds,
186 queue_duration_seconds,
187 resource_acquisition_duration_seconds,
188 active_keys,
189 processing_concurrency,
190 processing_concurrency_max_per_key,
191 queue_depth,
192 queue_depth_max_per_key,
193 })
194 }
195
196 pub fn recorder(&self, batcher_name: &str) -> BatchMetricsRecorder {
200 BatchMetricsRecorder {
201 metrics: self.clone(),
202 batcher_name: batcher_name.to_string(),
203 }
204 }
205}
206
207impl MetricsRecorderFactory for BatchMetrics {
208 fn create_recorder(&self, batcher_name: &str) -> Box<dyn MetricsRecorder> {
209 Box::new(self.recorder(batcher_name))
210 }
211}
212
213#[derive(Debug, Clone)]
218pub struct BatchMetricsRecorder {
219 metrics: BatchMetrics,
220 batcher_name: String,
221}
222
223impl MetricsRecorder for BatchMetricsRecorder {
224 fn item_received(&self, channel_duration: Duration) {
225 let name = &self.batcher_name;
226 self.metrics
227 .items_received_total
228 .with_label_values(&[name])
229 .inc();
230 self.metrics
231 .channel_duration_seconds
232 .with_label_values(&[name])
233 .observe(channel_duration.as_secs_f64());
234 }
235
236 fn item_rejected(&self) {
237 self.metrics
238 .items_rejected_total
239 .with_label_values(&[&self.batcher_name])
240 .inc();
241 }
242
243 fn batch_completed(&self, metrics: &BatchStats) {
244 let name = self.batcher_name.as_str();
245 let success = if metrics.success { "true" } else { "false" };
246
247 self.metrics
248 .batches_completed_total
249 .with_label_values(&[name, success])
250 .inc();
251 self.metrics
252 .batch_size
253 .with_label_values(&[name])
254 .observe(metrics.size as f64);
255 self.metrics
256 .batch_processing_duration_seconds
257 .with_label_values(&[name, success])
258 .observe(metrics.processing_duration.as_secs_f64());
259
260 for latency in &metrics.item_latencies {
261 self.metrics
262 .item_latency_seconds
263 .with_label_values(&[name])
264 .observe(latency.as_secs_f64());
265 }
266 for queue_duration in &metrics.queue_durations {
267 self.metrics
268 .queue_duration_seconds
269 .with_label_values(&[name])
270 .observe(queue_duration.as_secs_f64());
271 }
272 }
273
274 fn resource_acquisition_completed(&self, duration: Duration, success: bool) {
275 let success = if success { "true" } else { "false" };
276 self.metrics
277 .resource_acquisition_duration_seconds
278 .with_label_values(&[self.batcher_name.as_str(), success])
279 .observe(duration.as_secs_f64());
280 }
281
282 fn active_keys_changed(&self, count: usize) {
283 self.metrics
284 .active_keys
285 .with_label_values(&[&self.batcher_name])
286 .set(count as i64);
287 }
288
289 fn processing_concurrency_changed(&self, total: usize, max_per_key: usize) {
290 let name = &self.batcher_name;
291 self.metrics
292 .processing_concurrency
293 .with_label_values(&[name])
294 .set(total as i64);
295 self.metrics
296 .processing_concurrency_max_per_key
297 .with_label_values(&[name])
298 .set(max_per_key as i64);
299 }
300
301 fn queue_depth_changed(&self, total: usize, max_per_key: usize) {
302 let name = &self.batcher_name;
303 self.metrics
304 .queue_depth
305 .with_label_values(&[name])
306 .set(total as i64);
307 self.metrics
308 .queue_depth_max_per_key
309 .with_label_values(&[name])
310 .set(max_per_key as i64);
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use prometheus::proto::MetricFamily;
317
318 use super::*;
319
320 fn get_metric<'a>(families: &'a [MetricFamily], name: &str) -> Option<&'a MetricFamily> {
321 families.iter().find(|f| f.name() == name)
322 }
323
324 fn get_counter_value(families: &[MetricFamily], name: &str, labels: &[(&str, &str)]) -> f64 {
325 let family =
326 get_metric(families, name).unwrap_or_else(|| panic!("metric {name} not found"));
327 for metric in family.get_metric() {
328 if labels_match(metric, labels) {
329 return metric.get_counter().value();
330 }
331 }
332 panic!("no metric {name} with labels {labels:?}");
333 }
334
335 fn get_gauge_value(families: &[MetricFamily], name: &str, labels: &[(&str, &str)]) -> f64 {
336 let family =
337 get_metric(families, name).unwrap_or_else(|| panic!("metric {name} not found"));
338 for metric in family.get_metric() {
339 if labels_match(metric, labels) {
340 return metric.get_gauge().value();
341 }
342 }
343 panic!("no metric {name} with labels {labels:?}");
344 }
345
346 fn get_histogram_count(families: &[MetricFamily], name: &str, labels: &[(&str, &str)]) -> u64 {
347 let family =
348 get_metric(families, name).unwrap_or_else(|| panic!("metric {name} not found"));
349 for metric in family.get_metric() {
350 if labels_match(metric, labels) {
351 return metric.get_histogram().get_sample_count();
352 }
353 }
354 panic!("no metric {name} with labels {labels:?}");
355 }
356
357 fn get_histogram_sum(families: &[MetricFamily], name: &str, labels: &[(&str, &str)]) -> f64 {
358 let family =
359 get_metric(families, name).unwrap_or_else(|| panic!("metric {name} not found"));
360 for metric in family.get_metric() {
361 if labels_match(metric, labels) {
362 return metric.get_histogram().get_sample_sum();
363 }
364 }
365 panic!("no metric {name} with labels {labels:?}");
366 }
367
368 fn labels_match(metric: &prometheus::proto::Metric, expected: &[(&str, &str)]) -> bool {
369 expected.iter().all(|(k, v)| {
370 metric
371 .get_label()
372 .iter()
373 .any(|l| l.name() == *k && l.value() == *v)
374 })
375 }
376
377 #[test]
378 fn records_item_received() {
379 let registry = Registry::new();
380 let metrics = BatchMetrics::new(®istry).unwrap();
381 let recorder = metrics.recorder("test");
382
383 recorder.item_received(Duration::from_millis(5));
384 recorder.item_received(Duration::from_millis(10));
385
386 let families = registry.gather();
387 let labels = &[("batcher", "test")];
388
389 assert_eq!(
390 get_counter_value(&families, "batch_items_received_total", labels),
391 2.0,
392 );
393 assert_eq!(
394 get_histogram_count(&families, "batch_channel_duration_seconds", labels),
395 2,
396 );
397 assert!(get_histogram_sum(&families, "batch_channel_duration_seconds", labels) > 0.0,);
398 }
399
400 #[test]
401 fn records_item_rejected() {
402 let registry = Registry::new();
403 let metrics = BatchMetrics::new(®istry).unwrap();
404 let recorder = metrics.recorder("test");
405
406 recorder.item_rejected();
407
408 let families = registry.gather();
409 assert_eq!(
410 get_counter_value(
411 &families,
412 "batch_items_rejected_total",
413 &[("batcher", "test")]
414 ),
415 1.0,
416 );
417 }
418
419 #[test]
420 fn records_batch_completed() {
421 let registry = Registry::new();
422 let metrics = BatchMetrics::new(®istry).unwrap();
423 let recorder = metrics.recorder("test");
424
425 let batch_metrics = BatchStats::new(
426 3,
427 Duration::from_millis(50),
428 true,
429 vec![
430 Duration::from_millis(100),
431 Duration::from_millis(110),
432 Duration::from_millis(120),
433 ],
434 vec![
435 Duration::from_millis(40),
436 Duration::from_millis(30),
437 Duration::from_millis(20),
438 ],
439 );
440 recorder.batch_completed(&batch_metrics);
441
442 let families = registry.gather();
443 let success_labels = &[("batcher", "test"), ("success", "true")];
444 let batcher_labels = &[("batcher", "test")];
445
446 assert_eq!(
447 get_counter_value(&families, "batch_batches_completed_total", success_labels),
448 1.0,
449 );
450 assert_eq!(
451 get_histogram_count(&families, "batch_batch_size", batcher_labels),
452 1,
453 );
454 assert_eq!(
455 get_histogram_sum(&families, "batch_batch_size", batcher_labels),
456 3.0,
457 );
458 assert_eq!(
459 get_histogram_count(
460 &families,
461 "batch_batch_processing_duration_seconds",
462 success_labels,
463 ),
464 1,
465 );
466 assert_eq!(
467 get_histogram_count(&families, "batch_item_latency_seconds", batcher_labels),
468 3,
469 );
470 assert_eq!(
471 get_histogram_count(&families, "batch_queue_duration_seconds", batcher_labels),
472 3,
473 );
474 }
475
476 #[test]
477 fn records_resource_acquisition() {
478 let registry = Registry::new();
479 let metrics = BatchMetrics::new(®istry).unwrap();
480 let recorder = metrics.recorder("test");
481
482 recorder.resource_acquisition_completed(Duration::from_millis(25), true);
483 recorder.resource_acquisition_completed(Duration::from_millis(100), false);
484
485 let families = registry.gather();
486
487 assert_eq!(
488 get_histogram_count(
489 &families,
490 "batch_resource_acquisition_duration_seconds",
491 &[("batcher", "test"), ("success", "true")],
492 ),
493 1,
494 );
495 assert_eq!(
496 get_histogram_count(
497 &families,
498 "batch_resource_acquisition_duration_seconds",
499 &[("batcher", "test"), ("success", "false")],
500 ),
501 1,
502 );
503 }
504
505 #[test]
506 fn records_gauge_metrics() {
507 let registry = Registry::new();
508 let metrics = BatchMetrics::new(®istry).unwrap();
509 let recorder = metrics.recorder("test");
510
511 recorder.active_keys_changed(5);
512 recorder.processing_concurrency_changed(3, 2);
513 recorder.queue_depth_changed(10, 4);
514
515 let families = registry.gather();
516 let labels = &[("batcher", "test")];
517
518 assert_eq!(get_gauge_value(&families, "batch_active_keys", labels), 5.0);
519 assert_eq!(
520 get_gauge_value(&families, "batch_processing_concurrency", labels),
521 3.0,
522 );
523 assert_eq!(
524 get_gauge_value(
525 &families,
526 "batch_processing_concurrency_max_per_key",
527 labels
528 ),
529 2.0,
530 );
531 assert_eq!(
532 get_gauge_value(&families, "batch_queue_depth", labels),
533 10.0
534 );
535 assert_eq!(
536 get_gauge_value(&families, "batch_queue_depth_max_per_key", labels),
537 4.0,
538 );
539 }
540
541 #[test]
542 fn multiple_batchers_have_separate_labels() {
543 let registry = Registry::new();
544 let metrics = BatchMetrics::new(®istry).unwrap();
545 let recorder_a = metrics.recorder("batcher-a");
546 let recorder_b = metrics.recorder("batcher-b");
547
548 recorder_a.item_received(Duration::from_millis(1));
549 recorder_a.item_received(Duration::from_millis(1));
550 recorder_b.item_received(Duration::from_millis(1));
551
552 let families = registry.gather();
553
554 assert_eq!(
555 get_counter_value(
556 &families,
557 "batch_items_received_total",
558 &[("batcher", "batcher-a")],
559 ),
560 2.0,
561 );
562 assert_eq!(
563 get_counter_value(
564 &families,
565 "batch_items_received_total",
566 &[("batcher", "batcher-b")],
567 ),
568 1.0,
569 );
570 }
571
572 #[test]
573 fn custom_prefix() {
574 let registry = Registry::new();
575 let metrics = BatchMetrics::with_prefix(®istry, "myapp").unwrap();
576 let recorder = metrics.recorder("test");
577
578 recorder.item_received(Duration::from_millis(1));
579
580 let families = registry.gather();
581 assert!(get_metric(&families, "myapp_items_received_total").is_some());
582 assert!(get_metric(&families, "batch_items_received_total").is_none());
583 }
584}