Skip to main content

rusticity_core/
metrics.rs

1//! Shared CloudWatch GetMetricStatistics request/response plumbing.
2//! GetMetricData (batched queries/expressions) deliberately remains separate.
3use anyhow::Result;
4use aws_sdk_cloudwatch::{
5    operation::get_metric_statistics::builders::GetMetricStatisticsFluentBuilder,
6    types::{Datapoint, Dimension, Statistic},
7    Client,
8};
9use aws_smithy_types::DateTime;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12pub struct MetricQuery<'a> {
13    pub namespace: &'a str,
14    pub name: &'a str,
15    pub statistic: Statistic,
16    pub dimensions: Vec<Dimension>,
17}
18impl<'a> MetricQuery<'a> {
19    pub fn new(namespace: &'a str, name: &'a str, statistic: Statistic) -> Self {
20        Self {
21            namespace,
22            name,
23            statistic,
24            dimensions: Vec::new(),
25        }
26    }
27    pub fn dimension(mut self, name: &str, value: &str) -> Self {
28        self.dimensions
29            .push(Dimension::builder().name(name).value(value).build());
30        self
31    }
32}
33
34#[derive(Debug, Clone)]
35pub struct MetricWindow {
36    pub start: DateTime,
37    pub end: DateTime,
38    pub period: i32,
39}
40impl MetricWindow {
41    pub fn last_three_hours(period: i32) -> Result<Self> {
42        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
43        Ok(Self {
44            start: DateTime::from_secs(now - 3 * 3600),
45            end: DateTime::from_secs(now),
46            period,
47        })
48    }
49}
50
51pub struct MetricReader {
52    client: Client,
53}
54impl MetricReader {
55    pub fn new(client: Client) -> Self {
56        Self { client }
57    }
58    fn request(
59        &self,
60        query: &MetricQuery<'_>,
61        window: MetricWindow,
62    ) -> GetMetricStatisticsFluentBuilder {
63        self.client
64            .get_metric_statistics()
65            .namespace(query.namespace)
66            .metric_name(query.name)
67            .statistics(query.statistic.clone())
68            .set_dimensions(Some(query.dimensions.clone()))
69            .start_time(window.start)
70            .end_time(window.end)
71            .period(window.period)
72    }
73    pub async fn read(
74        &self,
75        query: MetricQuery<'_>,
76        window: MetricWindow,
77    ) -> Result<Vec<(i64, f64)>> {
78        let response = self.request(&query, window).send().await?;
79        Ok(series(response.datapoints(), &query.statistic))
80    }
81    pub async fn recent(&self, query: MetricQuery<'_>, period: i32) -> Result<Vec<(i64, f64)>> {
82        self.read(query, MetricWindow::last_three_hours(period)?)
83            .await
84    }
85}
86
87fn series(datapoints: &[Datapoint], statistic: &Statistic) -> Vec<(i64, f64)> {
88    let mut points: Vec<_> = datapoints
89        .iter()
90        .filter_map(|point| {
91            let timestamp = point.timestamp()?.secs();
92            let value = match statistic {
93                Statistic::Sum => point.sum(),
94                Statistic::Minimum => point.minimum(),
95                Statistic::Maximum => point.maximum(),
96                Statistic::SampleCount => point.sample_count(),
97                _ => point.average(),
98            }?;
99            Some((timestamp, value))
100        })
101        .collect();
102    points.sort_by_key(|(time, _)| *time);
103    points
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::config::AwsConfig;
110
111    #[test]
112    fn request_preserves_namespace_metric_dimensions_period_and_explicit_window() {
113        let reader =
114            MetricReader::new(AwsConfig::dummy(Some("us-east-2".into())).cloudwatch_client());
115        let query = MetricQuery::new("AWS/Lambda", "Invocations", Statistic::Sum)
116            .dimension("FunctionName", "function")
117            .dimension("Resource", "function:alias");
118        let request = reader.request(
119            &query,
120            MetricWindow {
121                start: DateTime::from_secs(100),
122                end: DateTime::from_secs(200),
123                period: 60,
124            },
125        );
126        let input = request.as_input();
127        assert_eq!(input.get_namespace().as_deref(), Some("AWS/Lambda"));
128        assert_eq!(input.get_metric_name().as_deref(), Some("Invocations"));
129        assert_eq!(input.get_statistics().as_ref().unwrap(), &[Statistic::Sum]);
130        assert_eq!(input.get_dimensions().as_ref().unwrap().len(), 2);
131        assert_eq!(
132            input.get_dimensions().as_ref().unwrap()[1].value(),
133            Some("function:alias")
134        );
135        assert_eq!(input.get_period(), &Some(60));
136        assert_eq!(input.get_start_time(), &Some(DateTime::from_secs(100)));
137        assert_eq!(input.get_end_time(), &Some(DateTime::from_secs(200)));
138    }
139
140    #[test]
141    fn recent_windows_use_one_clock_sample_and_preserve_period() {
142        for period in [60, 300] {
143            let window = MetricWindow::last_three_hours(period).unwrap();
144            assert_eq!(window.end.secs() - window.start.secs(), 10800);
145            assert_eq!(window.period, period);
146        }
147    }
148
149    #[test]
150    fn series_selects_each_statistic_sorts_and_does_not_invent_missing_values() {
151        let complete = |time| {
152            Datapoint::builder()
153                .timestamp(DateTime::from_secs(time))
154                .average(1.0)
155                .sum(2.0)
156                .minimum(0.0)
157                .maximum(3.0)
158                .sample_count(4.0)
159                .build()
160        };
161        let points = [
162            complete(20),
163            Datapoint::builder().average(99.0).build(),
164            Datapoint::builder()
165                .timestamp(DateTime::from_secs(15))
166                .build(),
167            complete(10),
168        ];
169        for (stat, expected) in [
170            (Statistic::Average, 1.0),
171            (Statistic::Sum, 2.0),
172            (Statistic::Minimum, 0.0),
173            (Statistic::Maximum, 3.0),
174            (Statistic::SampleCount, 4.0),
175        ] {
176            assert_eq!(series(&points, &stat), [(10, expected), (20, expected)]);
177            assert!(series(&[], &stat).is_empty());
178        }
179    }
180}