Skip to main content

batch_aint_one_prometheus/
lib.rs

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