Skip to main content

rusticity_core/
alarms.rs

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