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://github.com/ThomWright/batch-aint-one) 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}
45
46impl BatchMetrics {
47    /// Register metrics on `registry` with the default prefix (`batch`).
48    pub fn new(registry: &Registry) -> prometheus::Result<Self> {
49        Self::with_prefix(registry, DEFAULT_PREFIX)
50    }
51
52    /// Register metrics on `registry` with a custom prefix.
53    ///
54    /// Metric names follow the pattern `{prefix}_{metric_name}`.
55    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    /// Create a [`BatchMetricsRecorder`] for a specific batcher instance.
197    ///
198    /// The `batcher_name` is used as the value for the `batcher` label on all metrics.
199    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/// Records metrics for a single batcher instance.
214///
215/// Created via [`BatchMetrics::recorder`]. Implements [`MetricsRecorder`] so it can be
216/// passed to `Batcher::builder().metrics(...)`.
217#[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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry, "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}