1use std::collections::{BTreeMap, HashMap};
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use http::StatusCode;
6
7use fakecloud_core::query::{
8 optional_query_param, query_metadata_only_xml, query_response_xml, required_query_param,
9};
10use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
11
12use std::sync::Arc;
13
14use fakecloud_persistence::SnapshotStore;
15use tokio::sync::Mutex;
16
17use crate::state::{
18 AlarmHistoryItem, AlarmMetricQuery, AlarmMetricStat, AlarmState, CloudWatchSnapshot, Dashboard,
19 MetricAlarm, MetricDatum, SharedCloudWatchState, StatisticSet,
20 CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
21};
22
23pub(crate) const NS: &str = "http://monitoring.amazonaws.com/doc/2010-08-01/";
24
25pub(crate) const STANDARD_UNITS: &[&str] = &[
27 "Seconds",
28 "Microseconds",
29 "Milliseconds",
30 "Bytes",
31 "Kilobytes",
32 "Megabytes",
33 "Gigabytes",
34 "Terabytes",
35 "Bits",
36 "Kilobits",
37 "Megabits",
38 "Gigabits",
39 "Terabits",
40 "Percent",
41 "Count",
42 "Bytes/Second",
43 "Kilobytes/Second",
44 "Megabytes/Second",
45 "Gigabytes/Second",
46 "Terabytes/Second",
47 "Bits/Second",
48 "Kilobits/Second",
49 "Megabits/Second",
50 "Gigabits/Second",
51 "Terabits/Second",
52 "Count/Second",
53 "None",
54];
55
56const SUPPORTED_ACTIONS: &[&str] = &[
57 "PutMetricData",
59 "GetMetricStatistics",
60 "GetMetricData",
61 "ListMetrics",
62 "PutMetricAlarm",
63 "DescribeAlarms",
64 "DescribeAlarmsForMetric",
65 "DeleteAlarms",
66 "EnableAlarmActions",
67 "DisableAlarmActions",
68 "SetAlarmState",
69 "DescribeAlarmHistory",
70 "PutDashboard",
72 "GetDashboard",
73 "DeleteDashboards",
74 "ListDashboards",
75 "PutAnomalyDetector",
77 "DescribeAnomalyDetectors",
78 "DeleteAnomalyDetector",
79 "PutInsightRule",
81 "DescribeInsightRules",
82 "DeleteInsightRules",
83 "EnableInsightRules",
84 "DisableInsightRules",
85 "GetInsightRuleReport",
86 "PutManagedInsightRules",
87 "ListManagedInsightRules",
88 "PutMetricStream",
90 "GetMetricStream",
91 "ListMetricStreams",
92 "DeleteMetricStream",
93 "StartMetricStreams",
94 "StopMetricStreams",
95 "PutCompositeAlarm",
97 "PutAlarmMuteRule",
99 "GetAlarmMuteRule",
100 "ListAlarmMuteRules",
101 "DeleteAlarmMuteRule",
102 "GetOTelEnrichment",
104 "StartOTelEnrichment",
105 "StopOTelEnrichment",
106 "AssociateDatasetKmsKey",
108 "DisassociateDatasetKmsKey",
109 "GetDataset",
110 "DescribeAlarmContributors",
112 "GetMetricWidgetImage",
113 "TagResource",
115 "UntagResource",
116 "ListTagsForResource",
117];
118
119pub struct CloudWatchService {
120 pub(crate) state: SharedCloudWatchState,
121 snapshot_store: Option<Arc<dyn SnapshotStore>>,
122 snapshot_lock: Arc<Mutex<()>>,
123}
124
125impl CloudWatchService {
126 pub fn new(state: SharedCloudWatchState) -> Self {
127 Self {
128 state,
129 snapshot_store: None,
130 snapshot_lock: Arc::new(Mutex::new(())),
131 }
132 }
133
134 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
138 self.snapshot_store = Some(store);
139 self
140 }
141
142 pub(crate) async fn save_snapshot(&self) {
146 save_cloudwatch_snapshot(
147 &self.state,
148 self.snapshot_store.clone(),
149 &self.snapshot_lock,
150 )
151 .await;
152 }
153
154 pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
159 let store = self.snapshot_store.clone()?;
160 let state = self.state.clone();
161 let lock = self.snapshot_lock.clone();
162 Some(Arc::new(move || {
163 let state = state.clone();
164 let store = store.clone();
165 let lock = lock.clone();
166 Box::pin(async move {
167 save_cloudwatch_snapshot(&state, Some(store), &lock).await;
168 })
169 }))
170 }
171}
172
173pub async fn save_cloudwatch_snapshot(
180 state: &SharedCloudWatchState,
181 store: Option<Arc<dyn SnapshotStore>>,
182 lock: &Mutex<()>,
183) {
184 let Some(store) = store else {
185 return;
186 };
187 let _guard = lock.lock().await;
188 let snapshot = CloudWatchSnapshot {
189 schema_version: CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
190 accounts: state.read().clone_for_snapshot(),
191 };
192 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
193 let bytes = serde_json::to_vec(&snapshot)
194 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
195 store.save(&bytes)
196 })
197 .await;
198 match join {
199 Ok(Ok(())) => {}
200 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudwatch snapshot"),
201 Err(err) => tracing::error!(%err, "cloudwatch snapshot task panicked"),
202 }
203}
204
205#[async_trait]
206impl AwsService for CloudWatchService {
207 fn service_name(&self) -> &str {
208 "monitoring"
209 }
210
211 fn supported_actions(&self) -> &[&str] {
212 SUPPORTED_ACTIONS
213 }
214
215 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
216 let mutates = matches!(
217 req.action.as_str(),
218 "PutMetricData"
219 | "PutMetricAlarm"
220 | "DeleteAlarms"
221 | "EnableAlarmActions"
222 | "DisableAlarmActions"
223 | "SetAlarmState"
224 | "PutDashboard"
225 | "DeleteDashboards"
226 | "PutAnomalyDetector"
227 | "DeleteAnomalyDetector"
228 | "PutInsightRule"
229 | "DeleteInsightRules"
230 | "EnableInsightRules"
231 | "DisableInsightRules"
232 | "PutManagedInsightRules"
233 | "PutMetricStream"
234 | "DeleteMetricStream"
235 | "StartMetricStreams"
236 | "StopMetricStreams"
237 | "PutCompositeAlarm"
238 | "PutAlarmMuteRule"
239 | "DeleteAlarmMuteRule"
240 | "StartOTelEnrichment"
241 | "StopOTelEnrichment"
242 | "AssociateDatasetKmsKey"
243 | "DisassociateDatasetKmsKey"
244 | "TagResource"
245 | "UntagResource"
246 );
247 let result = match req.action.as_str() {
248 "PutMetricData" => self.put_metric_data(&req),
249 "GetMetricStatistics" => self.get_metric_statistics(&req),
250 "GetMetricData" => self.get_metric_data(&req),
251 "ListMetrics" => self.list_metrics(&req),
252 "PutMetricAlarm" => self.put_metric_alarm(&req),
253 "DescribeAlarms" => self.describe_alarms(&req),
254 "DescribeAlarmsForMetric" => self.describe_alarms_for_metric(&req),
255 "DeleteAlarms" => self.delete_alarms(&req),
256 "EnableAlarmActions" => self.enable_alarm_actions(&req),
257 "DisableAlarmActions" => self.disable_alarm_actions(&req),
258 "SetAlarmState" => self.set_alarm_state(&req),
259 "DescribeAlarmHistory" => self.describe_alarm_history(&req),
260 "PutDashboard" => self.put_dashboard(&req),
261 "GetDashboard" => self.get_dashboard(&req),
262 "DeleteDashboards" => self.delete_dashboards(&req),
263 "ListDashboards" => self.list_dashboards(&req),
264 "PutAnomalyDetector" => self.put_anomaly_detector(&req),
266 "DescribeAnomalyDetectors" => self.describe_anomaly_detectors(&req),
267 "DeleteAnomalyDetector" => self.delete_anomaly_detector(&req),
268 "PutInsightRule" => self.put_insight_rule(&req),
270 "DescribeInsightRules" => self.describe_insight_rules(&req),
271 "DeleteInsightRules" => self.delete_insight_rules(&req),
272 "EnableInsightRules" => self.enable_insight_rules(&req),
273 "DisableInsightRules" => self.disable_insight_rules(&req),
274 "GetInsightRuleReport" => self.get_insight_rule_report(&req),
275 "PutManagedInsightRules" => self.put_managed_insight_rules(&req),
276 "ListManagedInsightRules" => self.list_managed_insight_rules(&req),
277 "PutMetricStream" => self.put_metric_stream(&req),
279 "GetMetricStream" => self.get_metric_stream(&req),
280 "ListMetricStreams" => self.list_metric_streams(&req),
281 "DeleteMetricStream" => self.delete_metric_stream(&req),
282 "StartMetricStreams" => self.start_metric_streams(&req),
283 "StopMetricStreams" => self.stop_metric_streams(&req),
284 "PutCompositeAlarm" => self.put_composite_alarm(&req),
286 "PutAlarmMuteRule" => self.put_alarm_mute_rule(&req),
288 "GetAlarmMuteRule" => self.get_alarm_mute_rule(&req),
289 "ListAlarmMuteRules" => self.list_alarm_mute_rules(&req),
290 "DeleteAlarmMuteRule" => self.delete_alarm_mute_rule(&req),
291 "GetOTelEnrichment" => self.get_otel_enrichment(&req),
293 "StartOTelEnrichment" => self.start_otel_enrichment(&req),
294 "StopOTelEnrichment" => self.stop_otel_enrichment(&req),
295 "AssociateDatasetKmsKey" => self.associate_dataset_kms_key(&req),
297 "DisassociateDatasetKmsKey" => self.disassociate_dataset_kms_key(&req),
298 "GetDataset" => self.get_dataset(&req),
299 "DescribeAlarmContributors" => self.describe_alarm_contributors(&req),
301 "GetMetricWidgetImage" => self.get_metric_widget_image(&req),
302 "TagResource" => self.tag_resource(&req),
304 "UntagResource" => self.untag_resource(&req),
305 "ListTagsForResource" => self.list_tags_for_resource(&req),
306 _ => Err(AwsServiceError::action_not_implemented(
307 "monitoring",
308 &req.action,
309 )),
310 };
311 if mutates && result.is_ok() {
312 self.save_snapshot().await;
313 }
314 if request_is_json(&req) {
318 return result.map(crate::json_protocol::xml_response_to_json);
319 }
320 result
321 }
322}
323
324fn request_is_json(req: &AwsRequest) -> bool {
327 req.headers.contains_key("x-amz-target")
328}
329
330pub(crate) fn xml_response(action: &str, inner: &str, request_id: &str) -> AwsResponse {
331 AwsResponse::xml(
332 StatusCode::OK,
333 query_response_xml(action, NS, inner, request_id),
334 )
335}
336
337pub(crate) fn empty_metadata_response(action: &str, request_id: &str) -> AwsResponse {
338 AwsResponse::xml(
339 StatusCode::OK,
340 query_metadata_only_xml(action, NS, request_id),
341 )
342}
343
344pub(crate) fn invalid_param(message: impl Into<String>) -> AwsServiceError {
345 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidParameterValue", message)
346}
347
348pub(crate) fn not_found(message: impl Into<String>) -> AwsServiceError {
350 AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", message)
351}
352
353pub(crate) fn missing_param(name: &str) -> AwsServiceError {
356 AwsServiceError::aws_error(
357 StatusCode::BAD_REQUEST,
358 "MissingParameter",
359 format!("The request must contain the parameter {name}."),
360 )
361}
362
363pub(crate) fn collect_indexed(req: &AwsRequest, prefix: &str) -> Vec<HashMap<String, String>> {
364 let mut by_index: BTreeMap<u32, HashMap<String, String>> = BTreeMap::new();
365 let needle = format!("{prefix}.member.");
366 for (k, v) in req.query_params.iter() {
367 let Some(rest) = k.strip_prefix(&needle) else {
368 continue;
369 };
370 let mut parts = rest.splitn(2, '.');
371 let Some(idx_str) = parts.next() else {
372 continue;
373 };
374 let Ok(idx) = idx_str.parse::<u32>() else {
375 continue;
376 };
377 let field = parts.next().unwrap_or("").to_string();
378 by_index.entry(idx).or_default().insert(field, v.clone());
379 }
380 by_index.into_values().collect()
381}
382
383fn collect_member_numbers(
386 member: &HashMap<String, String>,
387 prefix: &str,
388) -> Result<Vec<f64>, AwsServiceError> {
389 let needle = format!("{prefix}.member.");
390 let mut by_index: BTreeMap<u32, f64> = BTreeMap::new();
391 for (k, v) in member.iter() {
392 let Some(idx_str) = k.strip_prefix(&needle) else {
393 continue;
394 };
395 let Ok(idx) = idx_str.parse::<u32>() else {
396 continue;
397 };
398 let n = v
399 .parse::<f64>()
400 .map_err(|_| invalid_param(format!("{prefix} entries must be numbers")))?;
401 by_index.insert(idx, n);
402 }
403 Ok(by_index.into_values().collect())
404}
405
406fn values_counts_statistic(
409 member: &HashMap<String, String>,
410) -> Result<Option<StatisticSet>, AwsServiceError> {
411 let values = collect_member_numbers(member, "Values")?;
412 if values.is_empty() {
413 return Ok(None);
414 }
415 let counts = collect_member_numbers(member, "Counts")?;
416 let mut sample_count = 0.0;
417 let mut sum = 0.0;
418 let mut minimum = f64::INFINITY;
419 let mut maximum = f64::NEG_INFINITY;
420 for (i, v) in values.iter().enumerate() {
421 let c = counts.get(i).copied().unwrap_or(1.0);
422 sample_count += c;
423 sum += v * c;
424 minimum = minimum.min(*v);
425 maximum = maximum.max(*v);
426 }
427 Ok(Some(StatisticSet {
428 sample_count,
429 sum,
430 minimum,
431 maximum,
432 }))
433}
434
435fn parse_dimensions(member: &HashMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
436 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
437 let needle = format!("{prefix}.member.");
438 for (k, v) in member.iter() {
439 let Some(rest) = k.strip_prefix(&needle) else {
440 continue;
441 };
442 let mut parts = rest.splitn(2, '.');
443 let Some(idx_str) = parts.next() else {
444 continue;
445 };
446 let Ok(idx) = idx_str.parse::<u32>() else {
447 continue;
448 };
449 let field = parts.next().unwrap_or("");
450 let entry = dims.entry(idx).or_default();
451 match field {
452 "Name" => entry.0 = Some(v.clone()),
453 "Value" => entry.1 = Some(v.clone()),
454 _ => {}
455 }
456 }
457 let mut out = BTreeMap::new();
458 for (_, (name, value)) in dims {
459 if let (Some(n), Some(v)) = (name, value) {
460 out.insert(n, v);
461 }
462 }
463 out
464}
465
466fn parse_alarm_metrics(req: &AwsRequest) -> Vec<AlarmMetricQuery> {
469 let mut out = Vec::new();
470 for member in collect_indexed(req, "Metrics") {
471 let Some(id) = member.get("Id").cloned() else {
472 continue;
473 };
474 let metric_stat = if member.keys().any(|k| k.starts_with("MetricStat.")) {
475 let dimensions = parse_dimensions(&member, "MetricStat.Metric.Dimensions");
476 Some(AlarmMetricStat {
477 namespace: member.get("MetricStat.Metric.Namespace").cloned(),
478 metric_name: member.get("MetricStat.Metric.MetricName").cloned(),
479 dimensions,
480 period: member
481 .get("MetricStat.Period")
482 .and_then(|s| s.parse::<i64>().ok()),
483 stat: member.get("MetricStat.Stat").cloned(),
484 unit: member.get("MetricStat.Unit").cloned(),
485 })
486 } else {
487 None
488 };
489 out.push(AlarmMetricQuery {
490 id,
491 metric_stat,
492 expression: member.get("Expression").cloned(),
493 label: member.get("Label").cloned(),
494 return_data: member
495 .get("ReturnData")
496 .map(|s| s.eq_ignore_ascii_case("true")),
497 account_id: member.get("AccountId").cloned(),
498 period: member.get("Period").and_then(|s| s.parse::<i64>().ok()),
499 });
500 }
501 out
502}
503
504pub(crate) fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
505 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
506 let needle = format!("{prefix}.member.");
507 for (k, v) in req.query_params.iter() {
508 let Some(rest) = k.strip_prefix(&needle) else {
509 continue;
510 };
511 let mut parts = rest.splitn(2, '.');
512 let Some(idx_str) = parts.next() else {
513 continue;
514 };
515 let Ok(idx) = idx_str.parse::<u32>() else {
516 continue;
517 };
518 let field = parts.next().unwrap_or("");
519 let entry = dims.entry(idx).or_default();
520 match field {
521 "Name" => entry.0 = Some(v.clone()),
522 "Value" => entry.1 = Some(v.clone()),
523 _ => {}
524 }
525 }
526 let mut out = BTreeMap::new();
527 for (_, (name, value)) in dims {
528 if let (Some(n), Some(v)) = (name, value) {
529 out.insert(n, v);
530 }
531 }
532 out
533}
534
535pub(crate) fn validate_len(
539 req: &AwsRequest,
540 param: &str,
541 min: usize,
542 max: usize,
543) -> Result<(), AwsServiceError> {
544 if let Some(v) = req.query_params.get(param) {
545 let len = v.chars().count();
546 if len < min || len > max {
547 return Err(invalid_param(format!(
548 "{param} length {len} is outside [{min}, {max}]"
549 )));
550 }
551 }
552 Ok(())
553}
554
555pub(crate) fn validate_range_i64(
557 req: &AwsRequest,
558 param: &str,
559 min: i64,
560 max: i64,
561) -> Result<(), AwsServiceError> {
562 if let Some(v) = req.query_params.get(param) {
563 if v.is_empty() {
564 return Ok(());
565 }
566 let n = v
567 .parse::<i64>()
568 .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
569 if n < min || n > max {
570 return Err(invalid_param(format!(
571 "{param} value {n} is outside [{min}, {max}]"
572 )));
573 }
574 }
575 Ok(())
576}
577
578pub(crate) fn validate_enum(
580 req: &AwsRequest,
581 param: &str,
582 allowed: &[&str],
583) -> Result<(), AwsServiceError> {
584 if let Some(v) = req.query_params.get(param) {
585 if !v.is_empty() && !allowed.contains(&v.as_str()) {
586 return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
587 }
588 }
589 Ok(())
590}
591
592pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
594 let needle = format!("{prefix}.member.");
595 let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
596 for (k, v) in req.query_params.iter() {
597 let Some(rest) = k.strip_prefix(&needle) else {
598 continue;
599 };
600 if let Ok(idx) = rest.parse::<u32>() {
601 by_index.insert(idx, v.clone());
602 }
603 }
604 by_index.into_values().collect()
605}
606
607pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
609 let members = collect_indexed(req, prefix);
610 let mut out = BTreeMap::new();
611 for m in members {
612 if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
613 out.insert(k.clone(), v.clone());
614 }
615 }
616 out
617}
618
619pub(crate) fn xml_escape(s: &str) -> String {
620 s.replace('&', "&")
621 .replace('<', "<")
622 .replace('>', ">")
623 .replace('"', """)
624 .replace('\'', "'")
625}
626
627pub(crate) fn encode_offset_token(offset: usize) -> String {
629 use base64::Engine;
630 base64::engine::general_purpose::STANDARD.encode(format!("offset:{offset}"))
631}
632
633pub(crate) fn decode_offset_token(token: Option<&String>) -> usize {
637 use base64::Engine;
638 let Some(token) = token else {
639 return 0;
640 };
641 base64::engine::general_purpose::STANDARD
642 .decode(token)
643 .ok()
644 .and_then(|b| String::from_utf8(b).ok())
645 .and_then(|s| s.strip_prefix("offset:").map(|n| n.to_string()))
646 .and_then(|n| n.parse::<usize>().ok())
647 .unwrap_or(0)
648}
649
650#[derive(Clone, Copy)]
654struct DatumStats {
655 sum: f64,
656 min: f64,
657 max: f64,
658 count: f64,
659}
660
661fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
662 if let Some(v) = d.value {
663 return Some(DatumStats {
664 sum: v,
665 min: v,
666 max: v,
667 count: 1.0,
668 });
669 }
670 if let Some(s) = &d.statistic_values {
671 return Some(DatumStats {
672 sum: s.sum,
673 min: s.minimum,
674 max: s.maximum,
675 count: s.sample_count,
676 });
677 }
678 None
679}
680
681fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
682 acc.sum += other.sum;
683 acc.count += other.count;
684 if other.min < acc.min {
685 acc.min = other.min;
686 }
687 if other.max > acc.max {
688 acc.max = other.max;
689 }
690}
691
692fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
693 match stat {
694 "Sum" => Some(agg.sum),
695 "Average" => {
696 if agg.count > 0.0 {
697 Some(agg.sum / agg.count)
698 } else {
699 None
700 }
701 }
702 "Minimum" => Some(agg.min),
703 "Maximum" => Some(agg.max),
704 "SampleCount" => Some(agg.count),
705 _ => None,
706 }
707}
708
709pub(crate) fn parse_percentile(stat: &str) -> Option<f64> {
713 let rest = stat.strip_prefix('p').or_else(|| stat.strip_prefix('P'))?;
714 let p = rest.parse::<f64>().ok()?;
715 if (0.0..=100.0).contains(&p) {
716 Some(p)
717 } else {
718 None
719 }
720}
721
722pub(crate) fn percentile(sorted: &[f64], p: f64) -> Option<f64> {
726 if sorted.is_empty() {
727 return None;
728 }
729 if sorted.len() == 1 {
730 return Some(sorted[0]);
731 }
732 let rank = (p / 100.0) * (sorted.len() as f64 - 1.0);
733 let lo = rank.floor() as usize;
734 let hi = rank.ceil() as usize;
735 if lo == hi {
736 return Some(sorted[lo]);
737 }
738 let frac = rank - lo as f64;
739 Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
740}
741
742struct MetricBucket {
747 agg: DatumStats,
748 samples: Vec<f64>,
749 unit: Option<String>,
750}
751
752fn resolve_stat(stat: &str, bucket: &MetricBucket, samples_sorted: &[f64]) -> Option<f64> {
755 if let Some(p) = parse_percentile(stat) {
756 return percentile(samples_sorted, p);
757 }
758 stat_value(stat, bucket.agg)
759}
760
761#[allow(clippy::too_many_arguments)]
766fn collect_metric_buckets(
767 data: &[MetricDatum],
768 metric_name: &str,
769 dim_filter: &BTreeMap<String, String>,
770 unit_filter: Option<&str>,
771 period: i64,
772 start_ts: DateTime<Utc>,
773 end_ts: DateTime<Utc>,
774) -> BTreeMap<DateTime<Utc>, MetricBucket> {
775 let mut buckets: BTreeMap<DateTime<Utc>, MetricBucket> = BTreeMap::new();
776 for d in data.iter() {
777 if d.metric_name != metric_name {
778 continue;
779 }
780 if let Some(uf) = unit_filter {
781 if d.unit.as_deref().unwrap_or("None") != uf {
782 continue;
783 }
784 }
785 if &d.dimensions != dim_filter {
789 continue;
790 }
791 if d.timestamp < start_ts || d.timestamp >= end_ts {
792 continue;
793 }
794 let Some(stats) = datum_stats(d) else {
795 continue;
796 };
797 let secs = d.timestamp.timestamp();
798 let bucket_secs = secs - secs.rem_euclid(period);
799 let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
800 match buckets.get_mut(&bucket_ts) {
801 Some(bucket) => {
802 merge_stats(&mut bucket.agg, stats);
803 if bucket.unit != d.unit {
804 bucket.unit = None;
805 }
806 if let Some(v) = d.value {
807 bucket.samples.push(v);
808 }
809 }
810 None => {
811 buckets.insert(
812 bucket_ts,
813 MetricBucket {
814 agg: stats,
815 samples: d.value.map(|v| vec![v]).unwrap_or_default(),
816 unit: d.unit.clone(),
817 },
818 );
819 }
820 }
821 }
822 buckets
823}
824
825pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
826 let mut s = String::from("<Dimensions>");
827 for (name, value) in dims.iter() {
828 s.push_str(&format!(
829 "<member><Name>{}</Name><Value>{}</Value></member>",
830 xml_escape(name),
831 xml_escape(value),
832 ));
833 }
834 s.push_str("</Dimensions>");
835 s
836}
837
838impl CloudWatchService {
839 fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
840 let namespace = required_query_param(req, "Namespace")?;
841 let members = collect_indexed(req, "MetricData");
842 if members.is_empty() {
843 return Err(invalid_param(
844 "PutMetricData requires at least one MetricData entry",
845 ));
846 }
847
848 let now = Utc::now();
849 let mut state = self.state.write();
850 let acct = state.get_or_create(&req.account_id);
851 let metrics_map = acct.metrics_in_mut(&req.region);
852 let bucket = metrics_map.entry(namespace.clone()).or_default();
853
854 for member in members {
855 let metric_name = member
856 .get("MetricName")
857 .cloned()
858 .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
859 let value = member
860 .get("Value")
861 .map(|s| s.parse::<f64>())
862 .transpose()
863 .map_err(|_| invalid_param("Value must be a valid number"))?;
864 let timestamp = member
865 .get("Timestamp")
866 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
867 .map(|d| d.with_timezone(&Utc))
868 .unwrap_or(now);
869 let unit = member.get("Unit").cloned();
870 let storage_resolution = member
871 .get("StorageResolution")
872 .and_then(|s| s.parse::<i64>().ok());
873 let dimensions = parse_dimensions(&member, "Dimensions");
874
875 let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
876 member.get("StatisticValues.SampleCount"),
877 member.get("StatisticValues.Sum"),
878 member.get("StatisticValues.Minimum"),
879 member.get("StatisticValues.Maximum"),
880 ) {
881 Some(StatisticSet {
882 sample_count: sc.parse::<f64>().map_err(|_| {
883 invalid_param("StatisticValues.SampleCount must be a number")
884 })?,
885 sum: sum
886 .parse::<f64>()
887 .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
888 minimum: min
889 .parse::<f64>()
890 .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
891 maximum: max
892 .parse::<f64>()
893 .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
894 })
895 } else {
896 None
897 };
898
899 let statistic_values = match statistic_values {
903 Some(s) => Some(s),
904 None => values_counts_statistic(&member)?,
905 };
906
907 if value.is_none() && statistic_values.is_none() {
908 return Err(invalid_param(
909 "MetricData entry must supply either Value, StatisticValues, or Values",
910 ));
911 }
912
913 bucket.push(MetricDatum {
914 metric_name,
915 dimensions,
916 timestamp,
917 value,
918 statistic_values,
919 unit,
920 storage_resolution,
921 });
922 }
923
924 Ok(empty_metadata_response("PutMetricData", &req.request_id))
925 }
926
927 fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
928 validate_len(req, "Namespace", 1, 255)?;
929 validate_len(req, "MetricName", 1, 255)?;
930 validate_len(req, "OwningAccount", 1, 255)?;
931 validate_enum(req, "RecentlyActive", &["PT3H"])?;
932 let namespace = optional_query_param(req, "Namespace");
933 let metric_name = optional_query_param(req, "MetricName");
934 let dim_filter = parse_dimensions_query(req, "Dimensions");
935 const LIST_METRICS_PAGE: usize = 500;
938 let offset = decode_offset_token(req.query_params.get("NextToken"));
939
940 let state = self.state.read();
941 let mut all: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
944 if let Some(acct) = state.get(&req.account_id) {
945 if let Some(map) = acct.metrics_in(&req.region) {
946 for (ns, data) in map.iter() {
947 if let Some(filter_ns) = namespace.as_ref() {
948 if ns != filter_ns {
949 continue;
950 }
951 }
952 let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
953 BTreeMap::new();
954 for d in data.iter() {
955 if let Some(filter_name) = metric_name.as_ref() {
956 if &d.metric_name != filter_name {
957 continue;
958 }
959 }
960 if !dim_filter.is_empty()
965 && !dim_filter
966 .iter()
967 .all(|(k, v)| d.dimensions.get(k) == Some(v))
968 {
969 continue;
970 }
971 seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
972 }
973 for ((name, dims), _) in seen {
974 all.push((ns.clone(), name, dims));
975 }
976 }
977 }
978 }
979
980 let page = all.iter().skip(offset).take(LIST_METRICS_PAGE);
981 let mut out = String::from("<Metrics>");
982 {
983 for (ns, name, dims) in page {
984 out.push_str("<member>");
985 out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
986 out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(name)));
987 out.push_str(&render_dimensions(dims));
988 out.push_str("</member>");
989 }
990 }
991 out.push_str("</Metrics>");
992 if offset + LIST_METRICS_PAGE < all.len() {
993 out.push_str(&format!(
994 "<NextToken>{}</NextToken>",
995 encode_offset_token(offset + LIST_METRICS_PAGE)
996 ));
997 }
998
999 Ok(xml_response("ListMetrics", &out, &req.request_id))
1000 }
1001
1002 fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1003 let namespace = required_query_param(req, "Namespace")?;
1004 let metric_name = required_query_param(req, "MetricName")?;
1005 let start = required_query_param(req, "StartTime")?;
1006 let end = required_query_param(req, "EndTime")?;
1007 let period = required_query_param(req, "Period")?
1008 .parse::<i64>()
1009 .map_err(|_| invalid_param("Period must be an integer"))?;
1010 if period <= 0 {
1011 return Err(invalid_param("Period must be positive"));
1012 }
1013 let start_ts = DateTime::parse_from_rfc3339(&start)
1014 .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
1015 .with_timezone(&Utc);
1016 let end_ts = DateTime::parse_from_rfc3339(&end)
1017 .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
1018 .with_timezone(&Utc);
1019
1020 let mut statistics: Vec<String> = Vec::new();
1021 let mut extended_statistics: Vec<String> = Vec::new();
1022 for (k, v) in req.query_params.iter() {
1023 if k.starts_with("Statistics.member.") {
1024 statistics.push(v.clone());
1025 } else if k.starts_with("ExtendedStatistics.member.") {
1026 extended_statistics.push(v.clone());
1027 }
1028 }
1029 if statistics.is_empty() && extended_statistics.is_empty() {
1030 return Err(invalid_param(
1031 "At least one of Statistics or ExtendedStatistics is required",
1032 ));
1033 }
1034
1035 let dim_filter = parse_dimensions_query(req, "Dimensions");
1036 let unit_filter = req.query_params.get("Unit").cloned();
1040
1041 let state = self.state.read();
1042 type StatPoint = (
1044 DateTime<Utc>,
1045 BTreeMap<String, f64>,
1046 Vec<(String, f64)>,
1047 Option<String>,
1048 );
1049 let mut datapoints: Vec<StatPoint> = Vec::new();
1050 if let Some(acct) = state.get(&req.account_id) {
1051 if let Some(map) = acct.metrics_in(&req.region) {
1052 if let Some(data) = map.get(&namespace) {
1053 let buckets = collect_metric_buckets(
1054 data,
1055 &metric_name,
1056 &dim_filter,
1057 unit_filter.as_deref(),
1058 period,
1059 start_ts,
1060 end_ts,
1061 );
1062 for (ts, bucket) in buckets {
1063 let mut sorted = bucket.samples.clone();
1064 sorted
1065 .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1066 let mut simple = BTreeMap::new();
1067 for stat in statistics.iter() {
1068 if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1069 simple.insert(stat.clone(), v);
1070 }
1071 }
1072 let mut extended = Vec::new();
1073 for stat in extended_statistics.iter() {
1074 if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1075 extended.push((stat.clone(), v));
1076 }
1077 }
1078 let unit = unit_filter.clone().or(bucket.unit);
1079 datapoints.push((ts, simple, extended, unit));
1080 }
1081 }
1082 }
1083 }
1084
1085 let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
1086 inner.push_str("<Datapoints>");
1087 for (ts, simple, extended, unit) in datapoints {
1088 inner.push_str("<member>");
1089 inner.push_str(&format!(
1090 "<Timestamp>{}</Timestamp>",
1091 ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1092 ));
1093 for (name, value) in simple {
1094 inner.push_str(&format!("<{name}>{value}</{name}>"));
1095 }
1096 if !extended.is_empty() {
1097 inner.push_str("<ExtendedStatistics>");
1098 for (name, value) in extended {
1099 inner.push_str(&format!(
1100 "<entry><key>{}</key><value>{}</value></entry>",
1101 xml_escape(&name),
1102 value
1103 ));
1104 }
1105 inner.push_str("</ExtendedStatistics>");
1106 }
1107 if let Some(u) = unit {
1108 inner.push_str(&format!("<Unit>{}</Unit>", xml_escape(&u)));
1109 }
1110 inner.push_str("</member>");
1111 }
1112 inner.push_str("</Datapoints>");
1113
1114 Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
1115 }
1116
1117 fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1118 validate_enum(
1119 req,
1120 "ScanBy",
1121 &["TimestampDescending", "TimestampAscending"],
1122 )?;
1123 let start = required_query_param(req, "StartTime")?;
1124 let end = required_query_param(req, "EndTime")?;
1125 let start_ts = DateTime::parse_from_rfc3339(&start)
1126 .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
1127 .with_timezone(&Utc);
1128 let end_ts = DateTime::parse_from_rfc3339(&end)
1129 .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
1130 .with_timezone(&Utc);
1131
1132 let descending = req
1136 .query_params
1137 .get("ScanBy")
1138 .map(|s| s != "TimestampAscending")
1139 .unwrap_or(true);
1140
1141 let queries = collect_indexed(req, "MetricDataQueries");
1144
1145 let state = self.state.read();
1146
1147 let mut series_by_id: BTreeMap<String, crate::metric_math::Series> = BTreeMap::new();
1150 for q in &queries {
1151 let id = q.get("Id").cloned().unwrap_or_default();
1152 let Some(metric_name) = q.get("MetricStat.Metric.MetricName") else {
1153 continue;
1154 };
1155 let Some(namespace) = q.get("MetricStat.Metric.Namespace") else {
1156 continue;
1157 };
1158 let stat = q
1159 .get("MetricStat.Stat")
1160 .cloned()
1161 .unwrap_or_else(|| "Sum".to_string());
1162 let period: i64 = q
1163 .get("MetricStat.Period")
1164 .and_then(|s| s.parse::<i64>().ok())
1165 .filter(|p| *p > 0)
1166 .unwrap_or(60);
1167 let unit_filter = q.get("MetricStat.Unit").cloned();
1168 let dim_filter = parse_dimensions(q, "MetricStat.Metric.Dimensions");
1169
1170 let mut series = crate::metric_math::Series::new();
1171 if let Some(acct) = state.get(&req.account_id) {
1172 if let Some(map) = acct.metrics_in(&req.region) {
1173 if let Some(data) = map.get(namespace) {
1174 let buckets = collect_metric_buckets(
1175 data,
1176 metric_name,
1177 &dim_filter,
1178 unit_filter.as_deref(),
1179 period,
1180 start_ts,
1181 end_ts,
1182 );
1183 for (ts, bucket) in buckets {
1184 let mut sorted = bucket.samples.clone();
1185 sorted.sort_by(|a, b| {
1186 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
1187 });
1188 if let Some(v) = resolve_stat(&stat, &bucket, &sorted) {
1189 series.insert(ts, v);
1190 }
1191 }
1192 }
1193 }
1194 }
1195 series_by_id.insert(id, series);
1196 }
1197
1198 let mut inner = String::from("<MetricDataResults>");
1201 for q in &queries {
1202 let id = q.get("Id").cloned().unwrap_or_default();
1203 let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
1204 let return_data = q
1205 .get("ReturnData")
1206 .map(|s| !s.eq_ignore_ascii_case("false"))
1207 .unwrap_or(true);
1208 if !return_data {
1209 continue;
1210 }
1211
1212 let mut error_message: Option<String> = None;
1213 let series: crate::metric_math::Series = if let Some(expr) = q.get("Expression") {
1214 match crate::metric_math::evaluate(expr, &series_by_id) {
1215 Ok(s) => s,
1216 Err(e) => {
1217 error_message = Some(e);
1218 crate::metric_math::Series::new()
1219 }
1220 }
1221 } else {
1222 series_by_id.get(&id).cloned().unwrap_or_default()
1223 };
1224
1225 let mut timestamps: Vec<String> = Vec::new();
1226 let mut values: Vec<f64> = Vec::new();
1227 for (ts, v) in series.iter() {
1228 timestamps.push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
1229 values.push(*v);
1230 }
1231 if descending {
1232 timestamps.reverse();
1233 values.reverse();
1234 }
1235
1236 inner.push_str("<member>");
1237 inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
1238 inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
1239 inner.push_str("<Timestamps>");
1240 for ts in ×tamps {
1241 inner.push_str(&format!("<member>{ts}</member>"));
1242 }
1243 inner.push_str("</Timestamps>");
1244 inner.push_str("<Values>");
1245 for v in &values {
1246 inner.push_str(&format!("<member>{v}</member>"));
1247 }
1248 inner.push_str("</Values>");
1249 if let Some(msg) = error_message {
1250 inner.push_str("<StatusCode>InternalError</StatusCode>");
1251 inner.push_str("<Messages><member>");
1252 inner.push_str("<Code>Error</Code>");
1253 inner.push_str(&format!("<Value>{}</Value>", xml_escape(&msg)));
1254 inner.push_str("</member></Messages>");
1255 } else {
1256 inner.push_str("<StatusCode>Complete</StatusCode>");
1257 }
1258 inner.push_str("</member>");
1259 }
1260 inner.push_str("</MetricDataResults>");
1261 inner.push_str("<Messages></Messages>");
1262
1263 Ok(xml_response("GetMetricData", &inner, &req.request_id))
1264 }
1265
1266 fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1267 validate_len(req, "AlarmName", 1, 255)?;
1273 validate_len(req, "AlarmDescription", 0, 1024)?;
1274 validate_len(req, "MetricName", 1, 255)?;
1275 validate_len(req, "Namespace", 1, 255)?;
1276 validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1277 validate_len(req, "TreatMissingData", 1, 255)?;
1278 validate_len(req, "ThresholdMetricId", 1, 255)?;
1279 validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1280 validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1281 validate_range_i64(req, "Period", 1, i64::MAX)?;
1282 validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1283 validate_enum(
1284 req,
1285 "ComparisonOperator",
1286 &[
1287 "GreaterThanOrEqualToThreshold",
1288 "GreaterThanThreshold",
1289 "GreaterThanUpperThreshold",
1290 "LessThanLowerOrGreaterThanUpperThreshold",
1291 "LessThanLowerThreshold",
1292 "LessThanOrEqualToThreshold",
1293 "LessThanThreshold",
1294 ],
1295 )?;
1296 validate_enum(
1297 req,
1298 "Statistic",
1299 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1300 )?;
1301 validate_enum(req, "Unit", STANDARD_UNITS)?;
1302 let alarm_name = required_query_param(req, "AlarmName")?;
1303 let comparison = optional_query_param(req, "ComparisonOperator")
1304 .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1305 let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1306 .and_then(|s| s.parse::<i64>().ok())
1307 .unwrap_or(1);
1308
1309 let alarm_description = optional_query_param(req, "AlarmDescription");
1310 let actions_enabled = optional_query_param(req, "ActionsEnabled")
1311 .map(|s| s.eq_ignore_ascii_case("true"))
1312 .unwrap_or(true);
1313
1314 let metric_name = optional_query_param(req, "MetricName");
1315 let namespace = optional_query_param(req, "Namespace");
1316 let statistic = optional_query_param(req, "Statistic");
1317 let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1318 let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1319 let unit = optional_query_param(req, "Unit");
1320 let datapoints_to_alarm =
1321 optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1322 let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1323 let treat_missing_data = optional_query_param(req, "TreatMissingData");
1324 let evaluate_low_sample_count_percentile =
1325 optional_query_param(req, "EvaluateLowSampleCountPercentile");
1326 let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1329 let dimensions = parse_dimensions_query(req, "Dimensions");
1330 let metrics = parse_alarm_metrics(req);
1334 let inline_tags = parse_tags(req, "Tags");
1337
1338 let mut ok_actions = Vec::new();
1339 let mut alarm_actions = Vec::new();
1340 let mut insufficient_data_actions = Vec::new();
1341 for (k, v) in req.query_params.iter() {
1342 if k.starts_with("OKActions.member.") {
1343 ok_actions.push(v.clone());
1344 } else if k.starts_with("AlarmActions.member.") {
1345 alarm_actions.push(v.clone());
1346 } else if k.starts_with("InsufficientDataActions.member.") {
1347 insufficient_data_actions.push(v.clone());
1348 }
1349 }
1350
1351 let arn = format!(
1352 "arn:aws:cloudwatch:{}:{}:alarm:{}",
1353 req.region, req.account_id, alarm_name
1354 );
1355 let now = Utc::now();
1356
1357 let mut state = self.state.write();
1358 let acct = state.get_or_create(&req.account_id);
1359 let alarms = acct.alarms_in_mut(&req.region);
1360 let existing = alarms.get(&alarm_name).cloned();
1361 let alarm = MetricAlarm {
1362 alarm_name: alarm_name.clone(),
1363 alarm_arn: arn,
1364 alarm_description,
1365 actions_enabled,
1366 ok_actions,
1367 alarm_actions,
1368 insufficient_data_actions,
1369 state_value: existing
1370 .as_ref()
1371 .map(|a| a.state_value)
1372 .unwrap_or(AlarmState::InsufficientData),
1373 state_reason: existing
1374 .as_ref()
1375 .map(|a| a.state_reason.clone())
1376 .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1377 state_updated_timestamp: existing
1378 .as_ref()
1379 .map(|a| a.state_updated_timestamp)
1380 .unwrap_or(now),
1381 metric_name,
1382 namespace,
1383 statistic,
1384 extended_statistic,
1385 dimensions,
1386 period,
1387 unit,
1388 evaluation_periods,
1389 datapoints_to_alarm,
1390 threshold,
1391 comparison_operator: comparison,
1392 treat_missing_data,
1393 evaluate_low_sample_count_percentile,
1394 threshold_metric_id,
1395 configuration_updated_timestamp: existing
1396 .as_ref()
1397 .map(|a| a.configuration_updated_timestamp)
1398 .unwrap_or(now),
1399 alarm_configuration_updated_timestamp: now,
1400 metrics,
1401 };
1402 let alarm_arn = alarm.alarm_arn.clone();
1403 let history_name = alarm_name.clone();
1404 let created = existing.is_none();
1405 alarms.insert(alarm_name, alarm);
1406
1407 if created && !inline_tags.is_empty() {
1412 let bucket = acct.tags.entry(alarm_arn).or_default();
1413 for (k, v) in inline_tags {
1414 bucket.insert(k, v);
1415 }
1416 }
1417
1418 let summary = if created {
1419 format!("Alarm \"{history_name}\" created")
1420 } else {
1421 format!("Alarm \"{history_name}\" updated")
1422 };
1423 let history_data = "{\"type\":\"Update\",\"version\":\"1.0\"}".to_string();
1424 push_alarm_history(
1425 acct,
1426 &req.region,
1427 &history_name,
1428 "MetricAlarm",
1429 "ConfigurationUpdate",
1430 summary,
1431 history_data,
1432 );
1433
1434 Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1435 }
1436
1437 fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1438 let mut filter_names: Vec<String> = Vec::new();
1439 for (k, v) in req.query_params.iter() {
1440 if k.starts_with("AlarmNames.member.") {
1441 filter_names.push(v.clone());
1442 }
1443 }
1444 validate_len(req, "AlarmNamePrefix", 1, 255)?;
1445 validate_len(req, "ActionPrefix", 1, 1024)?;
1446 validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1447 validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1448 validate_range_i64(req, "MaxRecords", 1, 100)?;
1449 validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1450 let prefix = optional_query_param(req, "AlarmNamePrefix");
1451 let state_filter = optional_query_param(req, "StateValue");
1452 let action_prefix = optional_query_param(req, "ActionPrefix");
1453 let max_records = optional_query_param(req, "MaxRecords")
1457 .and_then(|s| s.parse::<usize>().ok())
1458 .filter(|n| *n > 0)
1459 .unwrap_or(100);
1460 let offset = decode_offset_token(req.query_params.get("NextToken"));
1461
1462 let matches = |name: &str, sv: &str, actions: [&[String]; 3]| -> bool {
1465 if !filter_names.is_empty() && !filter_names.contains(&name.to_string()) {
1466 return false;
1467 }
1468 if let Some(p) = prefix.as_ref() {
1469 if !name.starts_with(p) {
1470 return false;
1471 }
1472 }
1473 if let Some(want) = state_filter.as_ref() {
1474 if sv != want {
1475 return false;
1476 }
1477 }
1478 if let Some(ap) = action_prefix.as_ref() {
1479 let any = actions
1480 .iter()
1481 .flat_map(|a| a.iter())
1482 .any(|a| a.starts_with(ap));
1483 if !any {
1484 return false;
1485 }
1486 }
1487 true
1488 };
1489
1490 let state = self.state.read();
1491 let mut combined: Vec<(bool, String)> = Vec::new();
1492 if let Some(acct) = state.get(&req.account_id) {
1493 if let Some(alarms) = acct.alarms_in(&req.region) {
1494 for alarm in alarms.values() {
1495 if matches(
1496 &alarm.alarm_name,
1497 alarm.state_value.as_str(),
1498 [
1499 &alarm.alarm_actions,
1500 &alarm.ok_actions,
1501 &alarm.insufficient_data_actions,
1502 ],
1503 ) {
1504 combined.push((false, render_alarm(alarm)));
1505 }
1506 }
1507 }
1508 if let Some(composites) = acct.composite_alarms_in(&req.region) {
1509 for alarm in composites.values() {
1510 if matches(
1511 &alarm.alarm_name,
1512 alarm.state_value.as_str(),
1513 [
1514 &alarm.alarm_actions,
1515 &alarm.ok_actions,
1516 &alarm.insufficient_data_actions,
1517 ],
1518 ) {
1519 combined
1520 .push((true, crate::composite_alarms::render_composite_alarm(alarm)));
1521 }
1522 }
1523 }
1524 }
1525
1526 let page: Vec<&(bool, String)> = combined.iter().skip(offset).take(max_records).collect();
1527 let mut inner = String::from("<MetricAlarms>");
1528 for (is_composite, body) in &page {
1529 if !*is_composite {
1530 inner.push_str(body);
1531 }
1532 }
1533 inner.push_str("</MetricAlarms>");
1534 inner.push_str("<CompositeAlarms>");
1535 for (is_composite, body) in &page {
1536 if *is_composite {
1537 inner.push_str(body);
1538 }
1539 }
1540 inner.push_str("</CompositeAlarms>");
1541 if offset + max_records < combined.len() {
1542 inner.push_str(&format!(
1543 "<NextToken>{}</NextToken>",
1544 encode_offset_token(offset + max_records)
1545 ));
1546 }
1547
1548 Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1549 }
1550
1551 fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1552 validate_len(req, "MetricName", 1, 255)?;
1553 validate_len(req, "Namespace", 1, 255)?;
1554 validate_range_i64(req, "Period", 1, i64::MAX)?;
1555 validate_enum(
1556 req,
1557 "Statistic",
1558 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1559 )?;
1560 validate_enum(req, "Unit", STANDARD_UNITS)?;
1561 let metric_name = required_query_param(req, "MetricName")?;
1562 let namespace = required_query_param(req, "Namespace")?;
1563 let dim_filter = parse_dimensions_query(req, "Dimensions");
1564
1565 let state = self.state.read();
1566 let mut inner = String::from("<MetricAlarms>");
1567 if let Some(acct) = state.get(&req.account_id) {
1568 if let Some(alarms) = acct.alarms_in(&req.region) {
1569 for alarm in alarms.values() {
1570 if alarm.metric_name.as_deref() != Some(&metric_name) {
1571 continue;
1572 }
1573 if alarm.namespace.as_deref() != Some(&namespace) {
1574 continue;
1575 }
1576 if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1577 continue;
1578 }
1579 inner.push_str(&render_alarm(alarm));
1580 }
1581 }
1582 }
1583 inner.push_str("</MetricAlarms>");
1584
1585 Ok(xml_response(
1586 "DescribeAlarmsForMetric",
1587 &inner,
1588 &req.request_id,
1589 ))
1590 }
1591
1592 fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1593 let mut names: Vec<String> = Vec::new();
1597 for (k, v) in req.query_params.iter() {
1598 if k.starts_with("AlarmNames.member.") {
1599 names.push(v.clone());
1600 }
1601 }
1602
1603 let mut state = self.state.write();
1604 let acct = state.get_or_create(&req.account_id);
1605 for name in &names {
1606 acct.alarms_in_mut(&req.region).remove(name);
1607 acct.composite_alarms_in_mut(&req.region).remove(name);
1608 acct.alarm_history_in_mut(&req.region).remove(name);
1611 }
1612
1613 Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1614 }
1615
1616 fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1617 self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1618 }
1619
1620 fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1621 self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1622 }
1623
1624 fn toggle_alarm_actions(
1625 &self,
1626 req: &AwsRequest,
1627 enabled: bool,
1628 action_name: &str,
1629 ) -> Result<AwsResponse, AwsServiceError> {
1630 let mut names: Vec<String> = Vec::new();
1631 for (k, v) in req.query_params.iter() {
1632 if k.starts_with("AlarmNames.member.") {
1633 names.push(v.clone());
1634 }
1635 }
1636 let mut state = self.state.write();
1637 let acct = state.get_or_create(&req.account_id);
1638 let alarms = acct.alarms_in_mut(&req.region);
1639 for name in names {
1640 if let Some(alarm) = alarms.get_mut(&name) {
1641 alarm.actions_enabled = enabled;
1642 alarm.alarm_configuration_updated_timestamp = Utc::now();
1643 }
1644 }
1645 Ok(empty_metadata_response(action_name, &req.request_id))
1646 }
1647
1648 fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1649 validate_len(req, "AlarmName", 1, 255)?;
1650 validate_len(req, "StateReason", 0, 1023)?;
1651 validate_len(req, "StateReasonData", 0, 4000)?;
1652 let alarm_name = required_query_param(req, "AlarmName")?;
1653 let state_value = required_query_param(req, "StateValue")?;
1654 let state_reason = req
1658 .query_params
1659 .get("StateReason")
1660 .cloned()
1661 .ok_or_else(|| {
1662 AwsServiceError::aws_error(
1663 StatusCode::BAD_REQUEST,
1664 "MissingParameter",
1665 "The request must contain the parameter StateReason.",
1666 )
1667 })?;
1668 let new_state = AlarmState::parse(&state_value)
1669 .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1670
1671 let mut state = self.state.write();
1672 let acct = state.get_or_create(&req.account_id);
1673 let alarms = acct.alarms_in_mut(&req.region);
1674 let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1675 AwsServiceError::aws_error(
1676 StatusCode::NOT_FOUND,
1677 "ResourceNotFound",
1678 format!("Alarm {alarm_name} not found"),
1679 )
1680 })?;
1681 let old_state = alarm.state_value.as_str().to_string();
1682 alarm.state_value = new_state;
1683 alarm.state_reason = state_reason.clone();
1684 alarm.state_updated_timestamp = Utc::now();
1685
1686 let new_state_str = new_state.as_str().to_string();
1687 let summary = format!("Alarm updated from {old_state} to {new_state_str}");
1688 let history_data = format!(
1689 "{{\"oldState\":{{\"stateValue\":\"{old_state}\"}},\"newState\":{{\"stateValue\":\"{new_state_str}\",\"stateReason\":\"{}\"}}}}",
1690 state_reason.replace('"', "\\\"")
1691 );
1692 push_alarm_history(
1693 acct,
1694 &req.region,
1695 &alarm_name,
1696 "MetricAlarm",
1697 "StateUpdate",
1698 summary,
1699 history_data,
1700 );
1701
1702 Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1703 }
1704
1705 fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1706 validate_len(req, "AlarmName", 1, 255)?;
1707 validate_len(req, "AlarmContributorId", 1, 16)?;
1708 validate_range_i64(req, "MaxRecords", 1, 100)?;
1709 validate_enum(
1710 req,
1711 "HistoryItemType",
1712 &[
1713 "ConfigurationUpdate",
1714 "StateUpdate",
1715 "Action",
1716 "AlarmContributorStateUpdate",
1717 "AlarmContributorAction",
1718 ],
1719 )?;
1720 validate_enum(
1721 req,
1722 "ScanBy",
1723 &["TimestampDescending", "TimestampAscending"],
1724 )?;
1725 let alarm_filter = optional_query_param(req, "AlarmName");
1726 let type_filter = optional_query_param(req, "HistoryItemType");
1727 let start_date = optional_query_param(req, "StartDate")
1728 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1729 .map(|d| d.with_timezone(&Utc));
1730 let end_date = optional_query_param(req, "EndDate")
1731 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1732 .map(|d| d.with_timezone(&Utc));
1733 let descending = req
1735 .query_params
1736 .get("ScanBy")
1737 .map(|s| s != "TimestampAscending")
1738 .unwrap_or(true);
1739 let max_records = optional_query_param(req, "MaxRecords")
1740 .and_then(|s| s.parse::<usize>().ok())
1741 .filter(|n| *n > 0)
1742 .unwrap_or(100);
1743 let offset = decode_offset_token(req.query_params.get("NextToken"));
1744
1745 let state = self.state.read();
1746 let mut items: Vec<&AlarmHistoryItem> = Vec::new();
1747 if let Some(acct) = state.get(&req.account_id) {
1748 if let Some(history) = acct.alarm_history_in(&req.region) {
1749 for (name, list) in history.iter() {
1750 if let Some(f) = alarm_filter.as_ref() {
1751 if name != f {
1752 continue;
1753 }
1754 }
1755 for item in list.iter() {
1756 if let Some(t) = type_filter.as_ref() {
1757 if &item.history_item_type != t {
1758 continue;
1759 }
1760 }
1761 if let Some(sd) = start_date {
1762 if item.timestamp < sd {
1763 continue;
1764 }
1765 }
1766 if let Some(ed) = end_date {
1767 if item.timestamp > ed {
1768 continue;
1769 }
1770 }
1771 items.push(item);
1772 }
1773 }
1774 }
1775 }
1776 items.sort_by_key(|i| i.timestamp);
1777 if descending {
1778 items.reverse();
1779 }
1780 let total = items.len();
1781 let page: Vec<&AlarmHistoryItem> =
1782 items.into_iter().skip(offset).take(max_records).collect();
1783
1784 let mut inner = String::from("<AlarmHistoryItems>");
1785 for item in page {
1786 inner.push_str("<member>");
1787 inner.push_str(&format!(
1788 "<AlarmName>{}</AlarmName>",
1789 xml_escape(&item.alarm_name)
1790 ));
1791 inner.push_str(&format!(
1792 "<AlarmType>{}</AlarmType>",
1793 xml_escape(&item.alarm_type)
1794 ));
1795 inner.push_str(&format!(
1796 "<Timestamp>{}</Timestamp>",
1797 item.timestamp
1798 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1799 ));
1800 inner.push_str(&format!(
1801 "<HistoryItemType>{}</HistoryItemType>",
1802 xml_escape(&item.history_item_type)
1803 ));
1804 inner.push_str(&format!(
1805 "<HistorySummary>{}</HistorySummary>",
1806 xml_escape(&item.history_summary)
1807 ));
1808 inner.push_str(&format!(
1809 "<HistoryData>{}</HistoryData>",
1810 xml_escape(&item.history_data)
1811 ));
1812 inner.push_str("</member>");
1813 }
1814 inner.push_str("</AlarmHistoryItems>");
1815 if offset + max_records < total {
1816 inner.push_str(&format!(
1817 "<NextToken>{}</NextToken>",
1818 encode_offset_token(offset + max_records)
1819 ));
1820 }
1821 Ok(xml_response(
1822 "DescribeAlarmHistory",
1823 &inner,
1824 &req.request_id,
1825 ))
1826 }
1827
1828 fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1829 let dashboard_name = req
1830 .query_params
1831 .get("DashboardName")
1832 .ok_or_else(|| invalid_param("DashboardName is required"))?
1833 .clone();
1834 let body = req
1835 .query_params
1836 .get("DashboardBody")
1837 .ok_or_else(|| invalid_param("DashboardBody is required"))?
1838 .clone();
1839 if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1842 return Err(AwsServiceError::aws_error(
1843 StatusCode::BAD_REQUEST,
1844 "InvalidParameterInput",
1845 "DashboardBody must be a valid JSON object",
1846 ));
1847 }
1848 let arn = format!(
1849 "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1850 req.account_id
1851 );
1852 let dashboard = Dashboard {
1853 name: dashboard_name.clone(),
1854 arn,
1855 size_bytes: body.len() as i64,
1856 body,
1857 last_modified: Utc::now(),
1858 };
1859 let mut state = self.state.write();
1860 let acct = state.get_or_create(&req.account_id);
1861 acct.dashboards.insert(dashboard_name, dashboard);
1862 let inner = String::from("<DashboardValidationMessages/>");
1865 Ok(xml_response("PutDashboard", &inner, &req.request_id))
1866 }
1867
1868 fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1869 let name = req
1870 .query_params
1871 .get("DashboardName")
1872 .ok_or_else(|| invalid_param("DashboardName is required"))?
1873 .clone();
1874 let state = self.state.read();
1875 let dashboard = state
1876 .get(&req.account_id)
1877 .and_then(|a| a.dashboards.get(&name))
1878 .cloned()
1879 .ok_or_else(|| {
1880 AwsServiceError::aws_error(
1881 StatusCode::NOT_FOUND,
1882 "ResourceNotFound",
1883 format!("Dashboard {name} does not exist"),
1884 )
1885 })?;
1886 let inner = format!(
1887 "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1888 xml_escape(&dashboard.arn),
1889 xml_escape(&dashboard.body),
1890 xml_escape(&dashboard.name),
1891 );
1892 Ok(xml_response("GetDashboard", &inner, &req.request_id))
1893 }
1894
1895 fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1896 let mut names: Vec<String> = Vec::new();
1897 for (k, v) in req.query_params.iter() {
1898 if k.starts_with("DashboardNames.member.") {
1899 names.push(v.clone());
1900 }
1901 }
1902 if names.is_empty() {
1903 return Err(invalid_param(
1904 "DashboardNames must contain at least one name",
1905 ));
1906 }
1907 let mut state = self.state.write();
1908 let acct = state.get_or_create(&req.account_id);
1909 for n in names {
1910 acct.dashboards.remove(&n);
1911 }
1912 let body = format!(
1916 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1917 <DeleteDashboardsResponse xmlns=\"{NS}\">\
1918 <DeleteDashboardsResult/>\
1919 <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1920 </DeleteDashboardsResponse>",
1921 req.request_id
1922 );
1923 Ok(AwsResponse::xml(StatusCode::OK, body))
1924 }
1925
1926 fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1927 let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1928 let state = self.state.read();
1929 let dashboards: Vec<Dashboard> = state
1930 .get(&req.account_id)
1931 .map(|a| {
1932 a.dashboards
1933 .values()
1934 .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1935 .cloned()
1936 .collect()
1937 })
1938 .unwrap_or_default();
1939 let mut entries = String::new();
1940 for d in &dashboards {
1941 entries.push_str("<member>");
1942 entries.push_str(&format!(
1943 "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1944 xml_escape(&d.arn),
1945 xml_escape(&d.name),
1946 d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1947 d.size_bytes,
1948 ));
1949 entries.push_str("</member>");
1950 }
1951 let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1952 Ok(xml_response("ListDashboards", &inner, &req.request_id))
1953 }
1954}
1955
1956fn push_alarm_history(
1960 acct: &mut crate::state::CloudWatchState,
1961 region: &str,
1962 alarm_name: &str,
1963 alarm_type: &str,
1964 history_item_type: &str,
1965 history_summary: String,
1966 history_data: String,
1967) {
1968 acct.alarm_history_in_mut(region)
1969 .entry(alarm_name.to_string())
1970 .or_default()
1971 .push(AlarmHistoryItem {
1972 alarm_name: alarm_name.to_string(),
1973 alarm_type: alarm_type.to_string(),
1974 timestamp: Utc::now(),
1975 history_item_type: history_item_type.to_string(),
1976 history_summary,
1977 history_data,
1978 });
1979}
1980
1981fn render_alarm(alarm: &MetricAlarm) -> String {
1982 let mut s = String::from("<member>");
1983 s.push_str(&format!(
1984 "<AlarmName>{}</AlarmName>",
1985 xml_escape(&alarm.alarm_name)
1986 ));
1987 s.push_str(&format!(
1988 "<AlarmArn>{}</AlarmArn>",
1989 xml_escape(&alarm.alarm_arn)
1990 ));
1991 if let Some(d) = &alarm.alarm_description {
1992 s.push_str(&format!(
1993 "<AlarmDescription>{}</AlarmDescription>",
1994 xml_escape(d)
1995 ));
1996 }
1997 s.push_str(&format!(
1998 "<ActionsEnabled>{}</ActionsEnabled>",
1999 alarm.actions_enabled
2000 ));
2001 push_action_list(&mut s, "OKActions", &alarm.ok_actions);
2002 push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
2003 push_action_list(
2004 &mut s,
2005 "InsufficientDataActions",
2006 &alarm.insufficient_data_actions,
2007 );
2008 s.push_str(&format!(
2009 "<StateValue>{}</StateValue>",
2010 alarm.state_value.as_str()
2011 ));
2012 s.push_str(&format!(
2013 "<StateReason>{}</StateReason>",
2014 xml_escape(&alarm.state_reason)
2015 ));
2016 s.push_str(&format!(
2017 "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
2018 alarm
2019 .state_updated_timestamp
2020 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2021 ));
2022 if let Some(m) = &alarm.metric_name {
2023 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
2024 }
2025 if let Some(n) = &alarm.namespace {
2026 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
2027 }
2028 if let Some(stat) = &alarm.statistic {
2029 s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
2030 }
2031 if let Some(ext) = &alarm.extended_statistic {
2032 s.push_str(&format!(
2033 "<ExtendedStatistic>{}</ExtendedStatistic>",
2034 xml_escape(ext)
2035 ));
2036 }
2037 s.push_str(&render_dimensions(&alarm.dimensions));
2038 if let Some(p) = alarm.period {
2039 s.push_str(&format!("<Period>{p}</Period>"));
2040 }
2041 if let Some(u) = &alarm.unit {
2042 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2043 }
2044 s.push_str(&format!(
2045 "<EvaluationPeriods>{}</EvaluationPeriods>",
2046 alarm.evaluation_periods
2047 ));
2048 if let Some(d) = alarm.datapoints_to_alarm {
2049 s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
2050 }
2051 if let Some(t) = alarm.threshold {
2052 s.push_str(&format!("<Threshold>{t}</Threshold>"));
2053 }
2054 if let Some(tid) = &alarm.threshold_metric_id {
2055 s.push_str(&format!(
2056 "<ThresholdMetricId>{}</ThresholdMetricId>",
2057 xml_escape(tid)
2058 ));
2059 }
2060 s.push_str(&format!(
2061 "<ComparisonOperator>{}</ComparisonOperator>",
2062 xml_escape(&alarm.comparison_operator)
2063 ));
2064 if let Some(t) = &alarm.treat_missing_data {
2065 s.push_str(&format!(
2066 "<TreatMissingData>{}</TreatMissingData>",
2067 xml_escape(t)
2068 ));
2069 }
2070 if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
2071 s.push_str(&format!(
2072 "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
2073 xml_escape(e)
2074 ));
2075 }
2076 s.push_str(&format!(
2077 "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
2078 alarm
2079 .alarm_configuration_updated_timestamp
2080 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2081 ));
2082 render_alarm_metrics(&mut s, &alarm.metrics);
2083 s.push_str("</member>");
2084 s
2085}
2086
2087fn render_alarm_metrics(s: &mut String, metrics: &[AlarmMetricQuery]) {
2089 if metrics.is_empty() {
2090 return;
2091 }
2092 s.push_str("<Metrics>");
2093 for q in metrics {
2094 s.push_str("<member>");
2095 s.push_str(&format!("<Id>{}</Id>", xml_escape(&q.id)));
2096 if let Some(stat) = &q.metric_stat {
2097 s.push_str("<MetricStat>");
2098 s.push_str("<Metric>");
2099 if let Some(ns) = &stat.namespace {
2100 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
2101 }
2102 if let Some(mn) = &stat.metric_name {
2103 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(mn)));
2104 }
2105 s.push_str(&render_dimensions(&stat.dimensions));
2106 s.push_str("</Metric>");
2107 if let Some(p) = stat.period {
2108 s.push_str(&format!("<Period>{p}</Period>"));
2109 }
2110 if let Some(st) = &stat.stat {
2111 s.push_str(&format!("<Stat>{}</Stat>", xml_escape(st)));
2112 }
2113 if let Some(u) = &stat.unit {
2114 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2115 }
2116 s.push_str("</MetricStat>");
2117 }
2118 if let Some(e) = &q.expression {
2119 s.push_str(&format!("<Expression>{}</Expression>", xml_escape(e)));
2120 }
2121 if let Some(l) = &q.label {
2122 s.push_str(&format!("<Label>{}</Label>", xml_escape(l)));
2123 }
2124 if let Some(rd) = q.return_data {
2125 s.push_str(&format!("<ReturnData>{rd}</ReturnData>"));
2126 }
2127 if let Some(p) = q.period {
2128 s.push_str(&format!("<Period>{p}</Period>"));
2129 }
2130 if let Some(acct) = &q.account_id {
2131 s.push_str(&format!("<AccountId>{}</AccountId>", xml_escape(acct)));
2132 }
2133 s.push_str("</member>");
2134 }
2135 s.push_str("</Metrics>");
2136}
2137
2138fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
2139 s.push_str(&format!("<{name}>"));
2140 for action in actions {
2141 s.push_str(&format!("<member>{}</member>", xml_escape(action)));
2142 }
2143 s.push_str(&format!("</{name}>"));
2144}