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