saluki_core/observability/metrics/
histogram.rs1use std::sync::LazyLock;
4
5use crate::data_model::event::metric::Histogram;
6
7const HISTOGRAM_BUCKET_COUNT: usize = 30;
8static TIME_HISTOGRAM_BUCKETS: LazyLock<[(f64, &'static str); HISTOGRAM_BUCKET_COUNT]> =
9 LazyLock::new(|| histogram_buckets::<HISTOGRAM_BUCKET_COUNT>(0.000000128, 4.0));
10static NON_TIME_HISTOGRAM_BUCKETS: LazyLock<[(f64, &'static str); HISTOGRAM_BUCKET_COUNT]> =
11 LazyLock::new(|| histogram_buckets::<HISTOGRAM_BUCKET_COUNT>(1.0, 2.0));
12
13#[derive(Clone, Debug)]
18pub struct AggregatedHistogram {
19 sum: f64,
20 count: u64,
21 buckets: Vec<(f64, &'static str, u64)>,
22}
23
24impl AggregatedHistogram {
25 pub fn new(metric_name: &str) -> Self {
30 let base_buckets = if metric_name.ends_with("_seconds") {
31 &TIME_HISTOGRAM_BUCKETS[..]
32 } else {
33 &NON_TIME_HISTOGRAM_BUCKETS[..]
34 };
35
36 let buckets = base_buckets
37 .iter()
38 .map(|(upper_bound, upper_bound_str)| (*upper_bound, *upper_bound_str, 0))
39 .collect();
40
41 Self {
42 sum: 0.0,
43 count: 0,
44 buckets,
45 }
46 }
47
48 pub fn merge(&mut self, other: &AggregatedHistogram) {
53 self.sum += other.sum;
54 self.count += other.count;
55 for (dst, src) in self.buckets.iter_mut().zip(other.buckets.iter()) {
56 dst.2 += src.2;
57 }
58 }
59
60 pub fn merge_histogram(&mut self, histogram: &Histogram) {
62 for sample in histogram.samples() {
63 self.add_sample(sample.value.into_inner(), sample.weight.0 as u64);
64 }
65 }
66
67 fn add_sample(&mut self, value: f64, weight: u64) {
68 self.sum += value * weight as f64;
69 self.count += weight;
70
71 for (upper_bound, _, count) in &mut self.buckets {
72 if value <= *upper_bound {
73 *count += weight;
74 }
75 }
76 }
77
78 pub fn sum(&self) -> f64 {
80 self.sum
81 }
82
83 pub fn count(&self) -> u64 {
85 self.count
86 }
87
88 pub fn buckets(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
91 self.buckets
92 .iter()
93 .map(|(_, upper_bound_str, count)| (*upper_bound_str, *count))
94 }
95}
96
97fn histogram_buckets<const N: usize>(base: f64, scale: f64) -> [(f64, &'static str); N] {
103 let mut buckets = [(0.0, ""); N];
104
105 let log_linear_buckets = std::iter::repeat(base).enumerate().flat_map(|(i, base)| {
106 let pow = scale.powf(i as f64);
107 let value = base * pow;
108
109 let next_pow = scale.powf((i + 1) as f64);
110 let next_value = base * next_pow;
111 let midpoint = (value + next_value) / 2.0;
112
113 [value, midpoint]
114 });
115
116 for (i, current_le) in log_linear_buckets.enumerate().take(N) {
117 let (bucket_le, bucket_le_str) = &mut buckets[i];
118 let current_le_str = format!("{}", current_le);
119
120 *bucket_le = current_le;
121 *bucket_le_str = current_le_str.leak();
122 }
123
124 buckets
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn generates_log_linear_bucket_schedule() {
133 let buckets = histogram_buckets::<9>(2.0, 4.0);
137 let bounds: Vec<f64> = buckets.iter().map(|(bound, _)| *bound).collect();
138 assert_eq!(bounds, vec![2.0, 5.0, 8.0, 20.0, 32.0, 80.0, 128.0, 320.0, 512.0]);
139
140 let labels: Vec<&str> = buckets.iter().map(|(_, label)| *label).collect();
142 assert_eq!(labels, vec!["2", "5", "8", "20", "32", "80", "128", "320", "512"]);
143
144 let non_time = histogram_buckets::<6>(1.0, 2.0);
146 let non_time_bounds: Vec<f64> = non_time.iter().map(|(bound, _)| *bound).collect();
147 assert_eq!(non_time_bounds, vec![1.0, 1.5, 2.0, 3.0, 4.0, 6.0]);
148 }
149
150 #[test]
151 fn metric_name_suffix_selects_the_bucket_schedule() {
152 let time = AggregatedHistogram::new("request_latency_seconds");
155 let non_time = AggregatedHistogram::new("request_count");
156
157 let time_first = time.buckets().next().expect("time schedule has buckets").0;
158 let non_time_first = non_time.buckets().next().expect("non-time schedule has buckets").0;
159
160 assert_eq!(
161 non_time_first, "1",
162 "the non-time schedule starts at an upper bound of 1.0"
163 );
164 assert_ne!(
165 time_first, non_time_first,
166 "the `_seconds` suffix must select a different, time-oriented bucket schedule"
167 );
168 }
169
170 #[test]
171 fn adds_weighted_samples_into_cumulative_buckets() {
172 let mut histogram = AggregatedHistogram::new("queue_depth");
176 histogram.add_sample(2.0, 3);
177
178 assert_eq!(histogram.count(), 3);
179 assert_eq!(histogram.sum(), 6.0);
180
181 let buckets: Vec<(&str, u64)> = histogram.buckets().collect();
182 assert_eq!(buckets[0], ("1", 0));
183 assert_eq!(buckets[1], ("1.5", 0));
184 assert_eq!(buckets[2], ("2", 3));
185 assert_eq!(buckets[3], ("3", 3));
186 }
187
188 #[test]
189 fn merge_sums_counts_sum_and_per_bucket_counts() {
190 let mut left = AggregatedHistogram::new("queue_depth");
191 left.add_sample(2.0, 1);
192
193 let mut right = AggregatedHistogram::new("queue_depth");
194 right.add_sample(4.0, 2);
195
196 left.merge(&right);
197
198 assert_eq!(left.count(), 3);
200 assert_eq!(left.sum(), 10.0);
201
202 let buckets: Vec<(&str, u64)> = left.buckets().collect();
203 assert_eq!(buckets[2], ("2", 1));
205 assert_eq!(buckets[4], ("4", 3));
206 }
207}