saluki_components/transforms/aggregate/
config.rs

1use saluki_core::data_model::event::metric::HistogramSummary;
2use saluki_error::{generic_error, GenericError};
3use stringtheory::MetaString;
4
5/// A histogram statistic to calculate.
6#[derive(Clone)]
7#[cfg_attr(test, derive(Debug, PartialEq, serde::Serialize))]
8pub enum HistogramStatistic {
9    /// Total count of values in the histogram.
10    Count,
11
12    /// Sum of all values in the histogram.
13    Sum,
14
15    /// Minimum value in the histogram.
16    Minimum,
17
18    /// Maximum value in the histogram.
19    Maximum,
20
21    /// Average value in the histogram.
22    Average,
23
24    /// Median value in the histogram.
25    Median,
26
27    /// Calculate the given percentile from the histogram.
28    Percentile {
29        /// Quantile value to calculate.
30        q: f64,
31
32        /// Suffix to append to the metric name, representing the percentile.
33        ///
34        /// For example, a percentile of 95% (quantile value of 0.95) would have a suffix of `95percentile`.
35        suffix: MetaString,
36    },
37}
38
39impl HistogramStatistic {
40    /// Returns the suffix to be used for metrics representing this statistic.
41    pub fn suffix(&self) -> &str {
42        match self {
43            HistogramStatistic::Count => "count",
44            HistogramStatistic::Sum => "sum",
45            HistogramStatistic::Minimum => "min",
46            HistogramStatistic::Maximum => "max",
47            HistogramStatistic::Average => "avg",
48            HistogramStatistic::Median => "median",
49            HistogramStatistic::Percentile { suffix, .. } => suffix,
50        }
51    }
52
53    /// Returns `true` if this statistics should be represented as a rate.
54    pub fn is_rate_statistic(&self) -> bool {
55        matches!(self, HistogramStatistic::Count)
56    }
57
58    /// Returns the value of this statistic from the given histogram summary.
59    pub fn value_from_histogram(&self, summary: &HistogramSummary<'_>) -> f64 {
60        match self {
61            HistogramStatistic::Count => summary.count() as f64,
62            HistogramStatistic::Sum => summary.sum(),
63            HistogramStatistic::Minimum => summary.min().unwrap_or(0.0),
64            HistogramStatistic::Maximum => summary.max().unwrap_or(0.0),
65            HistogramStatistic::Average => summary.avg(),
66            HistogramStatistic::Median => summary.median().unwrap_or(0.0),
67            HistogramStatistic::Percentile { q, .. } => {
68                saluki_antithesis::always_ge!(*q, 0.0, "histogram percentile quantile at or above zero");
69                saluki_antithesis::always_le!(*q, 1.0, "histogram percentile quantile at or below one");
70                summary.quantile(*q).unwrap_or(0.0)
71            }
72        }
73    }
74}
75
76/// Statistics to calculate over histograms, and how to copy them to distributions.
77#[derive(Clone)]
78#[cfg_attr(test, derive(Debug, PartialEq))]
79pub struct HistogramConfiguration {
80    statistics: Vec<HistogramStatistic>,
81    copy_to_distribution: bool,
82    copy_to_distribution_prefix: String,
83}
84
85impl HistogramConfiguration {
86    /// Creates a new `HistogramConfiguration` from the aggregates and percentiles to calculate.
87    ///
88    /// Aggregates name a statistic to compute over each histogram: `count`, `sum`, `min`, `max`, `avg`, or `median`.
89    /// Percentiles are expressed in quantile form, between `0.0` and `1.0` inclusive: 95% becomes `0.95`. Quantiles
90    /// that extend beyond two decimal places are rounded to the nearest whole percentile.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if an aggregate names an unsupported statistic, or if a percentile is not a number between
95    /// `0.0` and `1.0`.
96    pub fn try_new(
97        aggregates: &[String], percentiles: &[String], copy_to_distribution: bool, copy_to_distribution_prefix: String,
98    ) -> Result<Self, GenericError> {
99        let mut statistics = Vec::new();
100
101        for aggregate in aggregates {
102            match aggregate.as_str() {
103                "count" => statistics.push(HistogramStatistic::Count),
104                "sum" => statistics.push(HistogramStatistic::Sum),
105                "min" => statistics.push(HistogramStatistic::Minimum),
106                "max" => statistics.push(HistogramStatistic::Maximum),
107                "avg" => statistics.push(HistogramStatistic::Average),
108                "median" => statistics.push(HistogramStatistic::Median),
109                _ => return Err(generic_error!("Unknown histogram aggregate: {}", aggregate)),
110            }
111        }
112
113        for faux_percentile in percentiles {
114            let quantile = faux_percentile
115                .parse::<f64>()
116                .map_err(|_| generic_error!("Invalid percentile: {}", faux_percentile))?;
117            if !(0.0..=1.0).contains(&quantile) {
118                return Err(generic_error!("Percentile out of range: {}", faux_percentile));
119            }
120
121            let percentile = (quantile * 100.0 + 0.5) as u32;
122            let quantile = f64::from(percentile) / 100.0;
123            let suffix = format!("{}percentile", percentile).into();
124            statistics.push(HistogramStatistic::Percentile { q: quantile, suffix });
125        }
126
127        Ok(Self {
128            statistics,
129            copy_to_distribution,
130            copy_to_distribution_prefix,
131        })
132    }
133
134    /// Creates a configuration from already-parsed statistics, for tests that exercise histogram aggregation rather
135    /// than configuration.
136    #[cfg(test)]
137    pub fn from_statistics(
138        statistics: &[HistogramStatistic], copy_to_distribution: bool, copy_to_distribution_prefix: String,
139    ) -> Self {
140        Self {
141            statistics: statistics.to_vec(),
142            copy_to_distribution,
143            copy_to_distribution_prefix,
144        }
145    }
146
147    /// Returns the configured aggregate statistics to calculate.
148    pub fn statistics(&self) -> &[HistogramStatistic] {
149        &self.statistics
150    }
151
152    /// Returns `true` if histograms should be copied to distributions.
153    pub fn copy_to_distribution(&self) -> bool {
154        self.copy_to_distribution
155    }
156
157    /// Returns the prefix to append to the distributions copied from histograms.
158    pub fn copy_to_distribution_prefix(&self) -> &str {
159        &self.copy_to_distribution_prefix
160    }
161}
162
163/// Fixture values for tests that exercise histogram aggregation rather than configuration.
164#[cfg(test)]
165impl Default for HistogramConfiguration {
166    fn default() -> Self {
167        Self {
168            statistics: vec![
169                HistogramStatistic::Maximum,
170                HistogramStatistic::Median,
171                HistogramStatistic::Average,
172                HistogramStatistic::Count,
173                HistogramStatistic::Percentile {
174                    q: 0.95,
175                    suffix: "95percentile".into(),
176                },
177            ],
178            copy_to_distribution: false,
179            copy_to_distribution_prefix: "".into(),
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// `try_new` documents two rejection branches: an aggregate that names no supported statistic, and a percentile
189    /// that is not a quantile between `0.0` and `1.0`.
190    #[test]
191    fn unsupported_aggregates_and_percentiles_are_rejected() {
192        let unknown_aggregate = HistogramConfiguration::try_new(&["p99".to_string()], &[], false, String::new());
193        assert!(unknown_aggregate.is_err());
194
195        let non_numeric = HistogramConfiguration::try_new(&[], &["abc".to_string()], false, String::new());
196        assert!(non_numeric.is_err());
197
198        let above_range = HistogramConfiguration::try_new(&[], &["1.1".to_string()], false, String::new());
199        assert!(above_range.is_err());
200
201        let below_range = HistogramConfiguration::try_new(&[], &["-0.1".to_string()], false, String::new());
202        assert!(below_range.is_err());
203    }
204
205    #[test]
206    fn percentile_suffixes_match_agent_rounding() {
207        let config =
208            HistogramConfiguration::try_new(&[], &["0.299".to_string(), "0.73".to_string()], false, String::new())
209                .unwrap();
210
211        assert_eq!(
212            config.statistics(),
213            &[
214                HistogramStatistic::Percentile {
215                    q: 0.30,
216                    suffix: "30percentile".into(),
217                },
218                HistogramStatistic::Percentile {
219                    q: 0.73,
220                    suffix: "73percentile".into(),
221                },
222            ]
223        );
224    }
225}