saluki_core/observability/metrics/
aggregated.rs

1//! Aggregated metrics state.
2//!
3//! Builds on the [`reflector`][super::reflector] machinery to maintain a long-lived view of every
4//! internal metric, keyed by metric context. Counters are summed, gauges keep the latest value by
5//! timestamp, and histograms accumulate into fixed-bucket [`AggregatedHistogram`]s.
6
7use std::{
8    iter::Once,
9    pin::Pin,
10    sync::{Arc, OnceLock},
11};
12
13use futures::{stream::StreamExt as _, Stream};
14use papaya::HashMap;
15use saluki_error::{generic_error, GenericError};
16
17use super::histogram::AggregatedHistogram;
18use super::reflector::{Processor, Reflector, ReflectorWorker};
19use super::{MetricsSnapshot, MetricsStream};
20use crate::data_model::event::metric::{context::Context, MetricValues};
21
22/// Aggregated metric value.
23#[derive(Clone, Debug)]
24pub enum AggregatedMetricValue {
25    /// A counter.
26    Counter(f64),
27
28    /// A gauge.
29    Gauge(f64),
30
31    /// A histogram with fixed buckets.
32    Histogram(AggregatedHistogram),
33}
34
35impl AggregatedMetricValue {
36    /// Returns the scalar value of the metric, if this is a counter or gauge.
37    ///
38    /// Returns `0.0` for histograms.
39    pub fn value(&self) -> f64 {
40        match self {
41            AggregatedMetricValue::Counter(value) => *value,
42            AggregatedMetricValue::Gauge(value) => *value,
43            AggregatedMetricValue::Histogram(_) => 0.0,
44        }
45    }
46
47    /// Merges two aggregated metric values.
48    ///
49    /// Counters are summed, gauges use last-write-wins (the incoming value is kept), histograms are merged per-bucket and
50    /// by sum/count.
51    pub fn merge(&mut self, incoming: &AggregatedMetricValue) {
52        match (self, incoming) {
53            (Self::Counter(a), Self::Counter(b)) => {
54                *a += *b;
55            }
56            (Self::Histogram(a), Self::Histogram(b)) => {
57                a.merge(b);
58            }
59            (Self::Gauge(a), Self::Gauge(b)) => *a = *b,
60            // When the existing and incoming type _don't_ match, take the incoming value.
61            (existing, incoming) => *existing = incoming.clone(),
62        }
63    }
64}
65
66#[derive(Clone)]
67pub(crate) struct AggregatedMetric {
68    pub(crate) timestamp: Option<u64>,
69    pub(crate) value: AggregatedMetricValue,
70}
71
72impl AggregatedMetric {
73    fn counter(value: f64) -> Self {
74        Self {
75            timestamp: None,
76            value: AggregatedMetricValue::Counter(value),
77        }
78    }
79
80    fn gauge(timestamp: u64, value: f64) -> Self {
81        Self {
82            timestamp: Some(timestamp),
83            value: AggregatedMetricValue::Gauge(value),
84        }
85    }
86
87    fn histogram(histogram: AggregatedHistogram) -> Self {
88        Self {
89            timestamp: None,
90            value: AggregatedMetricValue::Histogram(histogram),
91        }
92    }
93
94    fn merge(&self, other: Self) -> Self {
95        match (&self.value, other.value) {
96            (AggregatedMetricValue::Counter(a), AggregatedMetricValue::Counter(b)) => Self {
97                timestamp: None,
98                value: AggregatedMetricValue::Counter(a + b),
99            },
100            (AggregatedMetricValue::Gauge(a), AggregatedMetricValue::Gauge(b)) => {
101                let ts_a = self.timestamp.unwrap_or(0);
102                let ts_b = other.timestamp.unwrap_or(0);
103                let (new_ts, new_value) = if ts_a > ts_b { (ts_a, *a) } else { (ts_b, b) };
104
105                Self {
106                    timestamp: Some(new_ts),
107                    value: AggregatedMetricValue::Gauge(new_value),
108                }
109            }
110            (AggregatedMetricValue::Histogram(a), AggregatedMetricValue::Histogram(b)) => {
111                let mut merged = a.clone();
112                merged.merge(&b);
113                Self {
114                    timestamp: None,
115                    value: AggregatedMetricValue::Histogram(merged),
116                }
117            }
118            (_, other_value) => Self {
119                timestamp: other.timestamp,
120                value: other_value,
121            },
122        }
123    }
124}
125
126struct Inner {
127    metrics: HashMap<Context, AggregatedMetric>,
128}
129
130/// Aggregated metrics state.
131pub struct AggregatedMetricsState {
132    inner: Arc<Inner>,
133}
134
135impl AggregatedMetricsState {
136    /// Visits all metrics.
137    pub fn visit_metrics<F>(&self, mut visitor: F)
138    where
139        F: FnMut(&Context, &AggregatedMetricValue),
140    {
141        self.inner
142            .metrics
143            .pin()
144            .iter()
145            .for_each(|(context, value)| visitor(context, &value.value));
146    }
147
148    /// Searches the state for a single metric with a matching name and tags.
149    ///
150    /// The latest value for the metric is returned. Histograms are skipped.
151    ///
152    /// If no metric is found with a matching name and tags, or if multiple metrics are found with a matching name and
153    /// tags, `None` is returned. Tags are matched on a partial basis: a metric must at least have the tags provided,
154    /// but may have additional tags as well.
155    pub fn find_single_with_tags(&self, name: &str, tags: &[&str]) -> Option<f64> {
156        let mut had_existing = false;
157        let mut maybe_metric = None;
158
159        self.visit_metrics(|context, value| {
160            if context.name() == name {
161                for tag in tags {
162                    if !context.tags().has_tag(tag) {
163                        return;
164                    }
165                }
166
167                match value {
168                    AggregatedMetricValue::Counter(v) | AggregatedMetricValue::Gauge(v) => {
169                        had_existing = maybe_metric.is_some();
170                        maybe_metric = Some(*v);
171                    }
172                    AggregatedMetricValue::Histogram(_) => {}
173                }
174            }
175        });
176
177        if had_existing {
178            None
179        } else {
180            maybe_metric
181        }
182    }
183
184    /// Searches the state for all counter metrics with a matching name and returns their aggregated value.
185    ///
186    /// This method allows rolling up counter metrics that share a common name. For example, a metric may be emitted
187    /// with the same name, but N different values for a specific tag, potentially as a way to break down the metric by
188    /// a certain facet. This method can allow re-aggregating the value of each different facet value into a single
189    /// value.
190    ///
191    /// If multiple metrics are found with a matching name, but they're not all counter metrics, or if no metrics are
192    /// found with a matching name, `0.0` is returned.
193    pub fn get_aggregated_with_tags(&self, name: &str, tags: &[&str]) -> f64 {
194        let mut total = 0.0;
195
196        self.visit_metrics(|context, value| {
197            if context.name() == name {
198                for tag in tags {
199                    if !context.tags().has_tag(tag) {
200                        return;
201                    }
202                }
203
204                if let AggregatedMetricValue::Counter(value) = value {
205                    total += *value;
206                }
207            }
208        });
209
210        total
211    }
212}
213
214/// Aggregated metrics processor.
215///
216/// This processor maintains a map of aggregated metrics, where each metric is represented by its context (name and
217/// tags), and the aggregated value of the metric. Counters, gauges, and histograms are supported; all other metric
218/// types are ignored.
219///
220/// Aggregation follows the following rules:
221///
222/// - Counters are summed together.
223/// - Gauges are represented by the latest value (by timestamp).
224/// - Histograms are merged: per-bucket counts, sum, and total count are summed.
225///
226/// Aggregated values have no concept of "points" -- specific values at a specific timestamp -- or multiple data
227/// points: each metric simply has a single value that represents the aggregated amount since the processor was created.
228#[derive(Clone)]
229pub struct AggregatedMetricsProcessor;
230
231impl Processor for AggregatedMetricsProcessor {
232    type Input = MetricsSnapshot;
233    type State = AggregatedMetricsState;
234
235    fn build_initial_state(&self) -> Self::State {
236        AggregatedMetricsState {
237            inner: Arc::new(Inner {
238                metrics: HashMap::new(),
239            }),
240        }
241    }
242
243    fn process(&self, input: Self::Input, state: &Self::State) {
244        let metrics = state.inner.metrics.pin();
245
246        // Record (or merge) the metric values produced this flush.
247        for event in input.upserts {
248            if let Some(metric) = event.try_into_metric() {
249                let (context, values, _) = metric.into_parts();
250                if let Some(agg_metric) = metric_values_to_aggregated(context.name(), values) {
251                    metrics.update_or_insert_with(
252                        context,
253                        |existing| existing.merge(agg_metric.clone()),
254                        || agg_metric.clone(),
255                    );
256                }
257            }
258        }
259
260        // Remove any "evicted" metrics: they were idle long enough without anyone holding a handle to them,
261        // so they're considered dead and should be be removed to avoid unbounded growth.
262        for context in input.evictions {
263            metrics.remove(&context);
264        }
265    }
266}
267
268fn metric_values_to_aggregated(metric_name: &str, values: MetricValues) -> Option<AggregatedMetric> {
269    match values {
270        MetricValues::Counter(points) => {
271            // Extract all points and sum their values together.
272            let value = points.into_iter().map(|(_, value)| value).sum();
273            Some(AggregatedMetric::counter(value))
274        }
275        MetricValues::Gauge(points) => {
276            // Take the last point value, which will be the latest timestamped point, to represent the "current" value.
277            points
278                .into_iter()
279                .last()
280                .map(|(ts, value)| AggregatedMetric::gauge(ts.map(|ts| ts.get()).unwrap_or(0), value))
281        }
282        MetricValues::Histogram(points) => {
283            let mut aggregated = AggregatedHistogram::new(metric_name);
284            for (_, histogram) in points {
285                aggregated.merge_histogram(&histogram);
286            }
287            if aggregated.count() == 0 {
288                None
289            } else {
290                Some(AggregatedMetric::histogram(aggregated))
291            }
292        }
293        _ => None,
294    }
295}
296
297/// The source feeding the shared metrics reflector.
298///
299/// Boxed so that the worker driving it has a nameable type: the adapters applied to [`MetricsStream`] are otherwise
300/// unnameable.
301type SharedMetricsSource = Pin<Box<dyn Stream<Item = Once<MetricsSnapshot>> + Send>>;
302
303/// The worker that drives the shared metrics reflector. See [`initialize_shared_metrics_state`].
304pub type SharedMetricsWorker = ReflectorWorker<AggregatedMetricsProcessor, SharedMetricsSource>;
305
306static SHARED_METRICS_STATE: OnceLock<Reflector<AggregatedMetricsProcessor>> = OnceLock::new();
307
308/// Initializes the shared metrics state, returning the worker that drives it.
309///
310/// Must be called after [`initialize_metrics`][super::initialize_metrics] has installed the global recorder, and
311/// before any caller reaches for [`get_shared_metrics_state`]. The returned worker must be added to a
312/// [`Supervisor`][crate::runtime::Supervisor] -- as a transient child, per [`ReflectorWorker`] -- and until it runs,
313/// the shared state stays empty.
314///
315/// The subscription to the internal metrics registry is taken here rather than when the worker starts, so flushes
316/// that land between initialization and the supervisor starting are still observed.
317///
318/// # Errors
319///
320/// If the shared metrics state was already initialized, an error is returned.
321pub fn initialize_shared_metrics_state() -> Result<SharedMetricsWorker, GenericError> {
322    let source: SharedMetricsSource =
323        Box::pin(MetricsStream::register().map(Arc::unwrap_or_clone).map(std::iter::once));
324    let (reflector, worker) = Reflector::new(source, AggregatedMetricsProcessor);
325
326    SHARED_METRICS_STATE
327        .set(reflector)
328        .map_err(|_| generic_error!("Shared metrics state was already initialized."))?;
329
330    Ok(worker)
331}
332
333/// Gets the shared metrics state, which provides unified access to internal metrics in a simplified interface.
334///
335/// # Panics
336///
337/// Panics if [`initialize_shared_metrics_state`] has not been called.
338pub fn get_shared_metrics_state() -> Reflector<AggregatedMetricsProcessor> {
339    SHARED_METRICS_STATE
340        .get()
341        .expect("shared metrics state should be initialized before it is accessed")
342        .clone()
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::data_model::event::{
349        metric::{context::Context, Metric},
350        Event,
351    };
352
353    fn process_metrics(metrics: Vec<Event>) -> Vec<(String, AggregatedMetricValue)> {
354        let state = super::super::aggregate_upserts(metrics);
355
356        let mut result = Vec::new();
357        state.visit_metrics(|context, value| {
358            result.push((context.name().to_string(), value.clone()));
359        });
360
361        result.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
362
363        result
364    }
365
366    fn assert_counter(value: &AggregatedMetricValue, expected: f64) {
367        match value {
368            AggregatedMetricValue::Counter(v) => assert_eq!(*v, expected),
369            other => panic!("expected counter, got {other:?}"),
370        }
371    }
372
373    fn assert_gauge(value: &AggregatedMetricValue, expected: f64) {
374        match value {
375            AggregatedMetricValue::Gauge(v) => assert_eq!(*v, expected),
376            other => panic!("expected gauge, got {other:?}"),
377        }
378    }
379
380    fn assert_histogram<F: FnOnce(&AggregatedHistogram)>(value: &AggregatedMetricValue, check: F) {
381        match value {
382            AggregatedMetricValue::Histogram(h) => check(h),
383            other => panic!("expected histogram, got {other:?}"),
384        }
385    }
386
387    #[test]
388    fn aggregate_multiple() {
389        let input_metrics = vec![
390            Event::Metric(Metric::counter("counter", 14.0)),
391            Event::Metric(Metric::gauge("gauge", 28.0)),
392        ];
393
394        let aggregated_metrics = process_metrics(input_metrics);
395        assert_eq!(aggregated_metrics.len(), 2);
396        assert_eq!(aggregated_metrics[0].0, "counter");
397        assert_counter(&aggregated_metrics[0].1, 14.0);
398        assert_eq!(aggregated_metrics[1].0, "gauge");
399        assert_gauge(&aggregated_metrics[1].1, 28.0);
400    }
401
402    #[test]
403    fn aggregate_counters() {
404        let input_metrics = vec![
405            Event::Metric(Metric::counter("counter", 14.0)),
406            Event::Metric(Metric::counter("counter", [(123456, 22.0)])),
407            Event::Metric(Metric::counter("counter", [(123456, 67.0), (123457, 44.0)])),
408        ];
409
410        let aggregated_metrics = process_metrics(input_metrics);
411        assert_eq!(aggregated_metrics.len(), 1);
412        assert_eq!(aggregated_metrics[0].0, "counter");
413        assert_counter(&aggregated_metrics[0].1, 147.0);
414    }
415
416    #[test]
417    fn aggregate_gauges() {
418        let input_metrics = vec![
419            Event::Metric(Metric::gauge("gauge", 14.0)),
420            Event::Metric(Metric::gauge("gauge", [(123458, 44.0)])),
421            Event::Metric(Metric::gauge("gauge", [(123455, 67.0), (123457, 88.0)])),
422        ];
423
424        let aggregated_metrics = process_metrics(input_metrics);
425        assert_eq!(aggregated_metrics.len(), 1);
426        assert_gauge(&aggregated_metrics[0].1, 44.0);
427    }
428
429    #[test]
430    fn aggregate_gauges_bias_incoming() {
431        let input_metrics = vec![
432            Event::Metric(Metric::gauge("gauge", [(123456, 33.0)])),
433            Event::Metric(Metric::gauge("gauge", [(123456, 66.0)])),
434        ];
435
436        let aggregated_metrics = process_metrics(input_metrics);
437        assert_eq!(aggregated_metrics.len(), 1);
438        assert_gauge(&aggregated_metrics[0].1, 66.0);
439    }
440
441    #[test]
442    fn aggregate_type_change() {
443        // When aggregating metrics with identical contexts but different types (i.e. counter vs gauge), the aggregated
444        // metric should be the last type seen.
445        let input_metrics = vec![
446            Event::Metric(Metric::gauge("my_metric", 33.0)),
447            Event::Metric(Metric::counter("my_metric", 42.0)),
448        ];
449
450        let aggregated_metrics = process_metrics(input_metrics);
451        assert_eq!(aggregated_metrics.len(), 1);
452        assert_counter(&aggregated_metrics[0].1, 42.0);
453    }
454
455    #[test]
456    fn aggregate_histograms() {
457        // Histograms with the same context should merge: counts, sums, and per-bucket counts accumulate.
458        let input_metrics = vec![
459            Event::Metric(Metric::histogram("h", [1.0, 2.0, 3.0])),
460            Event::Metric(Metric::histogram("h", [4.0, 5.0])),
461        ];
462
463        let aggregated_metrics = process_metrics(input_metrics);
464        assert_eq!(aggregated_metrics.len(), 1);
465        assert_histogram(&aggregated_metrics[0].1, |hist| {
466            assert_eq!(hist.count(), 5);
467            assert_eq!(hist.sum(), 15.0);
468        });
469    }
470
471    #[test]
472    fn evict_removes_metric() {
473        let processor = AggregatedMetricsProcessor;
474        let state = processor.build_initial_state();
475
476        let context = Context::from_static_parts("counter", &[]);
477        processor.process(
478            MetricsSnapshot {
479                upserts: vec![Event::Metric(Metric::counter(context.clone(), 14.0))],
480                evictions: Vec::new(),
481            },
482            &state,
483        );
484
485        let count_named = |name: &str| {
486            let mut count = 0;
487            state.visit_metrics(|ctx, _| {
488                if ctx.name() == name {
489                    count += 1;
490                }
491            });
492            count
493        };
494
495        // The metric is present after the upsert.
496        assert_eq!(count_named("counter"), 1);
497
498        // Evicting its context removes it from the aggregated state.
499        processor.process(
500            MetricsSnapshot {
501                upserts: Vec::new(),
502                evictions: vec![context],
503            },
504            &state,
505        );
506        assert_eq!(count_named("counter"), 0);
507    }
508}