Skip to main content

rusticity_core/
alarms.rs

1use crate::config::AwsConfig;
2use crate::metrics::{MetricQuery, MetricReader, MetricWindow};
3use anyhow::Result;
4
5pub struct AlarmsClient {
6    config: AwsConfig,
7}
8
9/// A single entry in an alarm's metric math definition.
10#[derive(Debug, Clone)]
11pub struct SubMetric {
12    pub id: String,
13    pub label: String,
14    pub expression: Option<String>,
15    pub namespace: Option<String>,
16    pub metric_name: Option<String>,
17    pub dimensions: Vec<(String, String)>,
18    pub period: i32,
19    pub stat: String,
20    pub return_data: bool,
21}
22
23/// All alarm fields returned by describe_alarms, ready to be used by the term layer.
24#[derive(Debug, Clone)]
25pub struct AlarmData {
26    pub name: String,
27    pub state: String,
28    pub state_updated: String,
29    pub description: String,
30    pub metric_name: String,
31    pub namespace: String,
32    pub statistic: String,
33    pub period: u32,
34    pub comparison: String,
35    pub threshold: f64,
36    pub actions_enabled: bool,
37    pub state_reason: String,
38    pub resource: String,
39    pub dimensions: String,
40    pub expression: String,
41    pub alarm_type: String,
42    pub cross_account: String,
43    pub alarm_arn: String,
44    pub datapoints_to_alarm: u32,
45    pub evaluation_periods: u32,
46    pub treat_missing_data: String,
47    pub evaluate_low_sample_percentile: String,
48    /// Non-empty for metric math alarms; contains the sub-metrics and expression.
49    pub sub_metrics: Vec<SubMetric>,
50}
51
52/// Parameters for a simple GetMetricStatistics call.
53pub struct MetricStatsRequest<'a> {
54    pub namespace: &'a str,
55    pub metric_name: &'a str,
56    pub statistic: &'a str,
57    pub period: i32,
58    pub dimensions: &'a [(String, String)],
59    pub start_time: chrono::DateTime<chrono::Utc>,
60    pub end_time: chrono::DateTime<chrono::Utc>,
61}
62
63impl AlarmsClient {
64    pub fn new(config: AwsConfig) -> Self {
65        Self { config }
66    }
67
68    pub async fn list_alarms(&self) -> Result<Vec<AlarmData>> {
69        let client = self.config.cloudwatch_client();
70        let resp = client.describe_alarms().send().await?;
71        let mut alarms = Vec::new();
72
73        for alarm in resp.metric_alarms() {
74            let name = alarm.alarm_name().unwrap_or("").to_string();
75            let state = alarm
76                .state_value()
77                .map(|s| s.as_str())
78                .unwrap_or("INSUFFICIENT_DATA")
79                .to_string();
80            let state_updated = alarm
81                .state_updated_timestamp()
82                .map(|t| {
83                    let dt = chrono::DateTime::parse_from_rfc3339(&t.to_string())
84                        .unwrap_or_else(|_| chrono::Utc::now().into());
85                    dt.format("%Y-%m-%d %H:%M:%S").to_string()
86                })
87                .unwrap_or_default();
88            let description = alarm.alarm_description().unwrap_or("").to_string();
89            let metric_name = alarm.metric_name().unwrap_or("").to_string();
90            let namespace = alarm.namespace().unwrap_or("").to_string();
91            let statistic = alarm
92                .statistic()
93                .map(|s| s.as_str())
94                .unwrap_or("")
95                .to_string();
96            let period = alarm.period().unwrap_or(0) as u32;
97            let comparison = alarm
98                .comparison_operator()
99                .map(|c| c.as_str())
100                .unwrap_or("")
101                .to_string();
102            let threshold = alarm.threshold().unwrap_or(0.0);
103            let actions_enabled = alarm.actions_enabled().unwrap_or(false);
104            let state_reason = alarm.state_reason().unwrap_or("").to_string();
105            let resource = alarm
106                .dimensions()
107                .iter()
108                .map(|d| format!("{}={}", d.name().unwrap_or(""), d.value().unwrap_or("")))
109                .collect::<Vec<_>>()
110                .join(", ");
111            let dimensions = resource.clone();
112            let expression = if !alarm.metrics().is_empty() {
113                "Expression".to_string()
114            } else {
115                String::new()
116            };
117            let alarm_type = if !alarm.metrics().is_empty() {
118                "Metric math"
119            } else {
120                "Metric"
121            }
122            .to_string();
123            let alarm_arn = alarm.alarm_arn().unwrap_or("").to_string();
124            let datapoints_to_alarm = alarm.datapoints_to_alarm().unwrap_or(0) as u32;
125            let evaluation_periods = alarm.evaluation_periods().unwrap_or(0) as u32;
126            let treat_missing_data = alarm
127                .treat_missing_data()
128                .map(|s| s.to_string())
129                .unwrap_or_default();
130            let evaluate_low_sample_percentile = alarm
131                .evaluate_low_sample_count_percentile()
132                .unwrap_or("")
133                .to_string();
134
135            // Extract sub-metrics for metric math alarms
136            let sub_metrics: Vec<SubMetric> = alarm
137                .metrics()
138                .iter()
139                .map(|mq| {
140                    let id = mq.id().unwrap_or("").to_string();
141                    let label = mq.label().unwrap_or("").to_string();
142                    let expr = mq.expression().map(|s| s.to_string());
143                    let (ns, mn, dims, period, stat) = if let Some(ms) = mq.metric_stat() {
144                        let ns = ms
145                            .metric()
146                            .and_then(|m| m.namespace())
147                            .unwrap_or("")
148                            .to_string();
149                        let mn = ms
150                            .metric()
151                            .and_then(|m| m.metric_name())
152                            .unwrap_or("")
153                            .to_string();
154                        let dims: Vec<(String, String)> = ms
155                            .metric()
156                            .map(|m| {
157                                m.dimensions()
158                                    .iter()
159                                    .map(|d| {
160                                        (
161                                            d.name().unwrap_or("").to_string(),
162                                            d.value().unwrap_or("").to_string(),
163                                        )
164                                    })
165                                    .collect()
166                            })
167                            .unwrap_or_default();
168                        let period = ms.period().unwrap_or(60);
169                        let stat = ms.stat().unwrap_or("Average").to_string();
170                        (Some(ns), Some(mn), dims, period, stat)
171                    } else {
172                        // Expression entry — read period directly from MetricDataQuery if set
173                        let period = mq.period().unwrap_or(60);
174                        (None, None, vec![], period, String::new())
175                    };
176                    SubMetric {
177                        id,
178                        label,
179                        expression: expr,
180                        namespace: ns,
181                        metric_name: mn,
182                        dimensions: dims,
183                        period,
184                        stat,
185                        return_data: mq.return_data().unwrap_or(false),
186                    }
187                })
188                .collect();
189
190            alarms.push(AlarmData {
191                name,
192                state,
193                state_updated,
194                description,
195                metric_name,
196                namespace,
197                statistic,
198                period,
199                comparison,
200                threshold,
201                actions_enabled,
202                state_reason,
203                resource,
204                dimensions,
205                expression,
206                alarm_type,
207                cross_account: String::new(),
208                alarm_arn,
209                datapoints_to_alarm,
210                evaluation_periods,
211                treat_missing_data,
212                evaluate_low_sample_percentile,
213                sub_metrics,
214            });
215        }
216
217        Ok(alarms)
218    }
219
220    /// Fetch the time series for a metric math alarm by calling GetMetricData with the
221    /// alarm's sub-metric queries. Returns (timestamps_ms, values) for the visible expression.
222    pub async fn get_metric_math_data(
223        &self,
224        sub_metrics: &[SubMetric],
225        start_time: chrono::DateTime<chrono::Utc>,
226        end_time: chrono::DateTime<chrono::Utc>,
227    ) -> Result<Vec<(i64, f64)>> {
228        use aws_sdk_cloudwatch::types::{Dimension, Metric, MetricDataQuery, MetricStat};
229
230        let client = self.config.cloudwatch_client();
231
232        // Find a representative period — prefer raw metric entries, fall back to expression entry period.
233        let default_period: Option<i32> = sub_metrics
234            .iter()
235            .filter(|sm| sm.period > 0)
236            .map(|sm| sm.period)
237            .next();
238
239        // Determine which entry should return data.
240        // If no entry has return_data=true (e.g. single Metrics Insights alarm),
241        // force return_data on the first expression entry.
242        let visible_id: Option<String> = sub_metrics
243            .iter()
244            .find(|sm| sm.return_data)
245            .map(|sm| sm.id.clone())
246            .or_else(|| {
247                sub_metrics
248                    .iter()
249                    .find(|sm| sm.expression.is_some())
250                    .map(|sm| sm.id.clone())
251            });
252
253        // Build MetricDataQuery list from stored sub_metrics
254        let queries: Vec<MetricDataQuery> = sub_metrics
255            .iter()
256            .filter_map(|sm| {
257                let force_return = visible_id.as_deref() == Some(sm.id.as_str());
258                let mut b = MetricDataQuery::builder().id(&sm.id);
259                b = b.return_data(sm.return_data || force_return);
260                if let Some(expr) = &sm.expression {
261                    b = b.expression(expr);
262                    // Both metric math and Metrics Insights expressions require period.
263                    // Use the period stored on this SubMetric (read from describe_alarms),
264                    // falling back to default_period from sibling entries, or sensible defaults.
265                    let is_insights_query = expr.trim_start().to_uppercase().starts_with("SELECT");
266                    let p = if sm.period > 0 {
267                        sm.period
268                    } else if is_insights_query {
269                        default_period.unwrap_or(300)
270                    } else {
271                        default_period.unwrap_or(60)
272                    };
273                    b = b.period(p);
274                } else if let (Some(ns), Some(mn)) = (&sm.namespace, &sm.metric_name) {
275                    if ns.is_empty() || mn.is_empty() {
276                        return None;
277                    }
278                    let mut metric_b = Metric::builder().namespace(ns).metric_name(mn);
279                    for (k, v) in &sm.dimensions {
280                        metric_b =
281                            metric_b.dimensions(Dimension::builder().name(k).value(v).build());
282                    }
283                    let metric = metric_b.build();
284                    let ms = MetricStat::builder()
285                        .metric(metric)
286                        .period(sm.period)
287                        .stat(&sm.stat)
288                        .build();
289                    b = b.metric_stat(ms);
290                } else {
291                    return None;
292                }
293                Some(b.build())
294            })
295            .collect();
296
297        if queries.is_empty() {
298            return Ok(vec![]);
299        }
300
301        let mut req = client
302            .get_metric_data()
303            .start_time(aws_sdk_cloudwatch::primitives::DateTime::from_millis(
304                start_time.timestamp_millis(),
305            ))
306            .end_time(aws_sdk_cloudwatch::primitives::DateTime::from_millis(
307                end_time.timestamp_millis(),
308            ));
309        for q in queries {
310            req = req.metric_data_queries(q);
311        }
312
313        let resp = req.send().await?;
314
315        // Find the result for the visible entry
316        let result = resp
317            .metric_data_results()
318            .iter()
319            .find(|r| visible_id.as_deref() == r.id())
320            .or_else(|| {
321                resp.metric_data_results().iter().find(|r| {
322                    sub_metrics
323                        .iter()
324                        .any(|sm| sm.return_data && sm.id == r.id().unwrap_or(""))
325                })
326            })
327            .or_else(|| resp.metric_data_results().first());
328
329        let mut data: Vec<(i64, f64)> = match result {
330            Some(r) => r
331                .timestamps()
332                .iter()
333                .zip(r.values().iter())
334                .map(|(ts, val)| (ts.as_secs_f64() as i64, *val))
335                .collect(),
336            None => vec![],
337        };
338        data.sort_by_key(|(ts, _)| *ts);
339        Ok(data)
340    }
341
342    pub async fn get_metric_statistics(
343        &self,
344        req: MetricStatsRequest<'_>,
345    ) -> Result<Vec<(i64, f64)>> {
346        let mut query = MetricQuery::new(
347            req.namespace,
348            req.metric_name,
349            aws_sdk_cloudwatch::types::Statistic::from(req.statistic),
350        );
351        for (name, value) in req.dimensions {
352            query = query.dimension(name, value);
353        }
354        MetricReader::new(self.config.cloudwatch_client())
355            .read(
356                query,
357                MetricWindow {
358                    start: aws_smithy_types::DateTime::from_millis(
359                        req.start_time.timestamp_millis(),
360                    ),
361                    end: aws_smithy_types::DateTime::from_millis(req.end_time.timestamp_millis()),
362                    period: req.period,
363                },
364            )
365            .await
366    }
367}