saluki_components/transforms/aggregate/
config.rs1use saluki_core::data_model::event::metric::HistogramSummary;
2use saluki_error::{generic_error, GenericError};
3use stringtheory::MetaString;
4
5#[derive(Clone)]
7#[cfg_attr(test, derive(Debug, PartialEq, serde::Serialize))]
8pub enum HistogramStatistic {
9 Count,
11
12 Sum,
14
15 Minimum,
17
18 Maximum,
20
21 Average,
23
24 Median,
26
27 Percentile {
29 q: f64,
31
32 suffix: MetaString,
36 },
37}
38
39impl HistogramStatistic {
40 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 pub fn is_rate_statistic(&self) -> bool {
55 matches!(self, HistogramStatistic::Count)
56 }
57
58 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#[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 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 #[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 pub fn statistics(&self) -> &[HistogramStatistic] {
149 &self.statistics
150 }
151
152 pub fn copy_to_distribution(&self) -> bool {
154 self.copy_to_distribution
155 }
156
157 pub fn copy_to_distribution_prefix(&self) -> &str {
159 &self.copy_to_distribution_prefix
160 }
161}
162
163#[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 #[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}