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