Skip to main content

rylv_metrics/dogstats/
histogram_config.rs

1use std::collections::HashMap;
2use std::hash::BuildHasher;
3use std::iter::FromIterator;
4use std::sync::Arc;
5
6use crate::dogstats::aggregator::SigFig;
7use crate::DefaultMetricHasher;
8use crate::MetricResult;
9
10/// Base histogram metrics that can be emitted alongside configured percentiles.
11#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
12pub enum HistogramBaseMetric {
13    /// Emit the `.count` metric.
14    Count,
15    /// Emit the `.min` metric.
16    Min,
17    /// Emit the `.avg` metric.
18    Avg,
19    /// Emit the `.max` metric.
20    Max,
21}
22
23impl HistogramBaseMetric {
24    const fn mask(self) -> u8 {
25        match self {
26            Self::Count => 1 << 0,
27            Self::Min => 1 << 1,
28            Self::Avg => 1 << 2,
29            Self::Max => 1 << 3,
30        }
31    }
32}
33
34#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
35#[repr(transparent)]
36pub struct HistogramBaseMetrics(u8);
37
38impl HistogramBaseMetrics {
39    pub(crate) const NONE: Self = Self(0);
40    pub(crate) const ALL: Self = Self(
41        HistogramBaseMetric::Count.mask()
42            | HistogramBaseMetric::Min.mask()
43            | HistogramBaseMetric::Avg.mask()
44            | HistogramBaseMetric::Max.mask(),
45    );
46
47    pub(crate) const fn only(metric: HistogramBaseMetric) -> Self {
48        Self(metric.mask())
49    }
50
51    pub(crate) const fn contains(self, metric: HistogramBaseMetric) -> bool {
52        self.0 & metric.mask() != 0
53    }
54
55    pub(crate) const fn with(self, metric: HistogramBaseMetric) -> Self {
56        Self(self.0 | metric.mask())
57    }
58
59    pub(crate) const fn without(self, metric: HistogramBaseMetric) -> Self {
60        Self(self.0 & !metric.mask())
61    }
62}
63
64impl From<HistogramBaseMetric> for HistogramBaseMetrics {
65    fn from(metric: HistogramBaseMetric) -> Self {
66        Self::only(metric)
67    }
68}
69
70impl<const N: usize> From<[HistogramBaseMetric; N]> for HistogramBaseMetrics {
71    fn from(metrics: [HistogramBaseMetric; N]) -> Self {
72        Self::from_iter(metrics)
73    }
74}
75
76impl FromIterator<HistogramBaseMetric> for HistogramBaseMetrics {
77    fn from_iter<T: IntoIterator<Item = HistogramBaseMetric>>(iter: T) -> Self {
78        iter.into_iter().fold(Self::NONE, Self::with)
79    }
80}
81
82/// Configuration for histogram precision.
83///
84/// Controls the number of significant figures used when recording
85/// histogram values, affecting both precision and memory usage.
86///
87/// # Example
88///
89/// ```ignore
90/// use rylv_metrics::{HistogramConfig, SigFig};
91/// let config = HistogramConfig::new(SigFig::TWO, vec![0.95, 0.99]).unwrap();
92/// ```
93#[derive(Debug, Clone)]
94pub struct HistogramConfig {
95    sig_fig: SigFig,
96    bounds: Bounds,
97    percentiles: Arc<[f64]>,
98    emit_base_metrics: HistogramBaseMetrics,
99}
100
101/// Inclusive lower and upper bounds for recorded histogram values.
102#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
103pub struct Bounds {
104    min: u64,
105    max: u64,
106}
107
108impl Bounds {
109    /// Creates histogram bounds.
110    ///
111    /// # Errors
112    /// Returns an error if `min < 1` or `max < min`.
113    pub fn new(min: u64, max: u64) -> MetricResult<Self> {
114        if min < 1 {
115            return Err("Invalid histogram bounds: min must be >= 1".into());
116        }
117        if max < min {
118            return Err("Invalid histogram bounds: max must be >= min".into());
119        }
120
121        Ok(Self { min, max })
122    }
123
124    /// Returns the inclusive lower bound.
125    #[must_use]
126    pub const fn min(self) -> u64 {
127        self.min
128    }
129
130    /// Returns the inclusive upper bound.
131    #[must_use]
132    pub const fn max(self) -> u64 {
133        self.max
134    }
135}
136
137impl Default for Bounds {
138    fn default() -> Self {
139        Self {
140            min: 1,
141            max: u64::MAX,
142        }
143    }
144}
145
146impl HistogramConfig {
147    const fn set_emit_base_metric(&mut self, metric: HistogramBaseMetric, emit: bool) {
148        self.emit_base_metrics = if emit {
149            self.emit_base_metrics.with(metric)
150        } else {
151            self.emit_base_metrics.without(metric)
152        };
153    }
154
155    /// Creates a new histogram configuration with the given significant figures and percentiles.
156    ///
157    /// # Errors
158    /// Returns an error if any percentile is NaN/inf or outside `[0.0, 1.0)`.
159    pub fn new(sig_fig: SigFig, percentiles: Vec<f64>) -> MetricResult<Self> {
160        for percentile in &percentiles {
161            if !percentile.is_finite() || *percentile < 0.0 || *percentile >= 1.0 {
162                return Err("Invalid percentile: must be finite and in range [0.0, 1.0)".into());
163            }
164        }
165
166        Ok(Self {
167            sig_fig,
168            bounds: Bounds::default(),
169            percentiles: percentiles.into(),
170            emit_base_metrics: HistogramBaseMetrics::ALL,
171        })
172    }
173
174    /// Replaces the set of emitted base histogram metrics.
175    #[must_use]
176    pub fn with_base_metrics(
177        mut self,
178        emit_base_metrics: impl IntoIterator<Item = HistogramBaseMetric>,
179    ) -> Self {
180        self.emit_base_metrics = HistogramBaseMetrics::from_iter(emit_base_metrics);
181        self
182    }
183
184    /// Enables or disables the `.count` histogram metric.
185    #[must_use]
186    pub const fn with_count(mut self, emit: bool) -> Self {
187        self.set_emit_base_metric(HistogramBaseMetric::Count, emit);
188        self
189    }
190
191    /// Enables or disables the `.min` histogram metric.
192    #[must_use]
193    pub const fn with_min(mut self, emit: bool) -> Self {
194        self.set_emit_base_metric(HistogramBaseMetric::Min, emit);
195        self
196    }
197
198    /// Enables or disables the `.avg` histogram metric.
199    ///
200    /// Note: `.avg` currently reflects p50 behavior for compatibility.
201    #[must_use]
202    pub const fn with_avg(mut self, emit: bool) -> Self {
203        self.set_emit_base_metric(HistogramBaseMetric::Avg, emit);
204        self
205    }
206
207    /// Enables or disables the `.max` histogram metric.
208    #[must_use]
209    pub const fn with_max(mut self, emit: bool) -> Self {
210        self.set_emit_base_metric(HistogramBaseMetric::Max, emit);
211        self
212    }
213
214    pub(crate) const fn sig_fig(&self) -> SigFig {
215        self.sig_fig
216    }
217
218    pub(crate) const fn percentiles(&self) -> &Arc<[f64]> {
219        &self.percentiles
220    }
221
222    pub(crate) const fn bounds(&self) -> Bounds {
223        self.bounds
224    }
225
226    pub(crate) const fn emit_base_metrics(&self) -> HistogramBaseMetrics {
227        self.emit_base_metrics
228    }
229
230    const fn with_bounds_checked(mut self, bounds: Bounds) -> Self {
231        self.bounds = bounds;
232        self
233    }
234
235    /// Sets histogram recording bounds.
236    ///
237    /// These bounds determine the compatible pool and the histogram allocation shape.
238    ///
239    /// # Errors
240    /// Returns an error if `min < 1` or `max < min`.
241    pub fn with_bounds(self, min: u64, max: u64) -> MetricResult<Self> {
242        Ok(self.with_bounds_checked(Bounds::new(min, max)?))
243    }
244}
245
246impl Default for HistogramConfig {
247    fn default() -> Self {
248        Self {
249            sig_fig: SigFig::default(),
250            bounds: Bounds::default(),
251            percentiles: vec![0.95, 0.99].into(),
252            emit_base_metrics: HistogramBaseMetrics::ALL,
253        }
254    }
255}
256
257#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
258pub struct HistogramPoolSpec {
259    pub sig_fig: SigFig,
260    pub bounds: Bounds,
261}
262
263impl HistogramPoolSpec {
264    pub(crate) const fn from_config(config: &HistogramConfig) -> Self {
265        Self {
266            sig_fig: config.sig_fig,
267            bounds: config.bounds,
268        }
269    }
270}
271
272#[derive(Debug, Clone)]
273pub struct ResolvedHistogramConfig {
274    config: HistogramConfig,
275    pool_id: usize,
276}
277
278impl ResolvedHistogramConfig {
279    pub const fn from_config(config: HistogramConfig, pool_id: usize) -> Self {
280        Self { config, pool_id }
281    }
282
283    pub(crate) const fn pool_id(&self) -> usize {
284        self.pool_id
285    }
286
287    pub(crate) const fn sig_fig(&self) -> SigFig {
288        self.config.sig_fig()
289    }
290
291    pub(crate) const fn bounds(&self) -> Bounds {
292        self.config.bounds()
293    }
294
295    pub(crate) const fn percentiles(&self) -> &Arc<[f64]> {
296        self.config.percentiles()
297    }
298
299    pub(crate) const fn emit_base_metrics(&self) -> HistogramBaseMetrics {
300        self.config.emit_base_metrics()
301    }
302}
303
304pub struct ResolvedHistogramConfigs<S = DefaultMetricHasher>
305where
306    S: BuildHasher + Clone,
307{
308    pub default_histogram_config: ResolvedHistogramConfig,
309    pub histogram_configs: HashMap<String, ResolvedHistogramConfig, S>,
310    pub pool_specs: Arc<[HistogramPoolSpec]>,
311    pub pool_count: usize,
312}
313
314pub fn resolve_histogram_configs<S>(
315    default_histogram_config: HistogramConfig,
316    histogram_configs: HashMap<String, HistogramConfig, S>,
317    hasher_builder: &S,
318) -> ResolvedHistogramConfigs<S>
319where
320    S: BuildHasher + Clone,
321{
322    let register_pool = |config: &HistogramConfig,
323                         pool_ids: &mut HashMap<HistogramPoolSpec, usize, S>,
324                         next_pool_id: &mut usize| {
325        let pool_spec = HistogramPoolSpec::from_config(config);
326        *pool_ids.entry(pool_spec).or_insert_with(|| {
327            let id = *next_pool_id;
328            *next_pool_id += 1;
329            id
330        })
331    };
332
333    let mut pool_ids = HashMap::with_hasher(hasher_builder.clone());
334    let mut next_pool_id = 0;
335    let default_pool_id =
336        register_pool(&default_histogram_config, &mut pool_ids, &mut next_pool_id);
337
338    let mut resolved_histogram_configs =
339        HashMap::with_capacity_and_hasher(histogram_configs.len(), hasher_builder.clone());
340    for (metric, config) in histogram_configs {
341        let pool_id = register_pool(&config, &mut pool_ids, &mut next_pool_id);
342        resolved_histogram_configs.insert(
343            metric,
344            ResolvedHistogramConfig::from_config(config, pool_id),
345        );
346    }
347
348    let mut pool_specs =
349        vec![HistogramPoolSpec::from_config(&default_histogram_config); next_pool_id];
350    for (pool_spec, pool_id) in pool_ids {
351        pool_specs[pool_id] = pool_spec;
352    }
353
354    ResolvedHistogramConfigs {
355        default_histogram_config: ResolvedHistogramConfig::from_config(
356            default_histogram_config,
357            default_pool_id,
358        ),
359        histogram_configs: resolved_histogram_configs,
360        pool_specs: pool_specs.into(),
361        pool_count: next_pool_id,
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::{resolve_histogram_configs, Bounds, HistogramBaseMetric, HistogramConfig};
368    use crate::dogstats::aggregator::SigFig;
369    use std::collections::HashMap;
370
371    #[test]
372    fn histogram_config_allows_empty_percentiles() {
373        let config = HistogramConfig::new(SigFig::default(), vec![]);
374        assert!(config.is_ok());
375    }
376
377    #[test]
378    fn histogram_config_rejects_invalid_percentiles() {
379        let invalid = [f64::NAN, f64::INFINITY, -0.1, 1.0];
380        for percentile in invalid {
381            let config = HistogramConfig::new(SigFig::default(), vec![percentile]);
382            assert!(config.is_err());
383        }
384    }
385
386    #[test]
387    fn bounds_reject_invalid_values() {
388        assert!(Bounds::new(0, 10).is_err());
389        assert!(Bounds::new(10, 9).is_err());
390    }
391
392    #[test]
393    fn histogram_base_metrics_builds_typed_sets() {
394        let metrics = super::HistogramBaseMetrics::from([
395            HistogramBaseMetric::Count,
396            HistogramBaseMetric::Max,
397        ]);
398
399        assert!(metrics.contains(HistogramBaseMetric::Count));
400        assert!(!metrics.contains(HistogramBaseMetric::Min));
401        assert!(metrics.contains(HistogramBaseMetric::Max));
402    }
403
404    #[test]
405    fn histogram_config_with_base_metrics_replaces_selection() {
406        let config = HistogramConfig::new(SigFig::default(), Vec::new())
407            .unwrap()
408            .with_base_metrics([HistogramBaseMetric::Count, HistogramBaseMetric::Max]);
409
410        let metrics = config.emit_base_metrics();
411        assert!(metrics.contains(HistogramBaseMetric::Count));
412        assert!(!metrics.contains(HistogramBaseMetric::Min));
413        assert!(!metrics.contains(HistogramBaseMetric::Avg));
414        assert!(metrics.contains(HistogramBaseMetric::Max));
415    }
416
417    #[test]
418    fn resolve_histogram_configs_reuses_pool_ids_for_matching_specs() {
419        let default_config = HistogramConfig::default();
420        let shared = HistogramConfig::new(SigFig::default(), vec![0.95]).unwrap();
421        let distinct = HistogramConfig::new(SigFig::TWO, vec![0.95]).unwrap();
422        let mut configs = HashMap::new();
423        configs.insert("metric.a".to_string(), shared.clone());
424        configs.insert("metric.b".to_string(), shared);
425        configs.insert("metric.c".to_string(), distinct);
426
427        let resolved =
428            resolve_histogram_configs(default_config, configs, &std::hash::RandomState::new());
429
430        assert_eq!(resolved.pool_count, 2);
431        assert_eq!(
432            resolved.histogram_configs["metric.a"].pool_id(),
433            resolved.histogram_configs["metric.b"].pool_id()
434        );
435        assert_ne!(
436            resolved.histogram_configs["metric.a"].pool_id(),
437            resolved.histogram_configs["metric.c"].pool_id()
438        );
439        assert_eq!(resolved.pool_specs.len(), resolved.pool_count);
440    }
441}