From bcc4d2c677871a71d8882d45d3ad5fb62752bfc7 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Wed, 28 Feb 2024 22:30:40 +0100 Subject: [PATCH 01/20] feat(data_classes): initialise cloud_watch_alarm_event data class --- .../data_classes/cloud_watch_alarm_event.py | 164 ++++++++++++++++++ tests/events/cloudWatchAlarmEvent.json | 40 +++++ .../test_cloud_watch_alarm_event.py | 42 +++++ 3 files changed, 246 insertions(+) create mode 100644 aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py create mode 100644 tests/events/cloudWatchAlarmEvent.json create mode 100644 tests/unit/data_classes/test_cloud_watch_alarm_event.py diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py new file mode 100644 index 00000000000..359d591856c --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -0,0 +1,164 @@ +import json +from enum import Enum, auto +from typing import List, Optional + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class AlarmStateValue(Enum): + OK = auto() + ALARM = auto() + INSUFFICIENT_DATA = auto() + + +class CloudWatchAlarmState(DictWrapper): + @property + def value(self) -> AlarmStateValue: + """ + Overall state of the alarm. + """ + return AlarmStateValue[self["value"]] + + @property + def reason(self) -> Optional[str]: + """ + Reason why alarm was changed to this state. + """ + return self.get("reason") + + @property + def reason_data(self) -> Optional[dict]: + """ + Additional data to back up the reason, usually contains the evaluated data points, + the calculated threshold and timestamps. + """ + return json.loads(self.get("reasonData")) if self.get("reasonData") is not None else None + + @property + def timestamp(self) -> str: + """ + Timestamp of this state change in ISO-8601 format. + """ + return self["timestamp"] + + +class CloudWatchAlarmMetric(DictWrapper): + @property + def metric_id(self) -> str: + """ + Unique ID of the alarm metric. + """ + return self["id"] + + @property + def namespace(self) -> Optional[str]: + """ + Namespace of the correspondent CloudWatch Metric. + """ + return self.get("metricStat", {}).get("metric", {}).get("namespace", None) + + @property + def name(self) -> Optional[str]: + """ + Name of the correspondent CloudWatch Metric. + """ + return self.get("metricStat", {}).get("metric", {}).get("name", None) + + @property + def dimensions(self) -> Optional[dict]: + """ + Additional dimensions of the correspondent CloudWatch Metric, if available. + """ + return self.get("metricStat", {}).get("metric", {}).get("dimensions", None) + + @property + def period(self) -> Optional[int]: + """ + Metric evaluation period, in seconds. + """ + return self.get("metricStat", {}).get("period", None) + + @property + def stat(self) -> Optional[str]: + """ + Statistical aggregation of metric points, e.g. Average, SampleCount, etc. + """ + return self.get("metricStat", {}).get("stat", None) + + @property + def return_data(self) -> bool: + return self["returnData"] + + +class CloudWatchAlarmEvent(DictWrapper): + @property + def source(self) -> str: + """ + Source of the triggered event, usually it is "aws.cloudwatch". + """ + return self["source"] + + @property + def alarm_arn(self) -> str: + """ + The ARN of the CloudWatch Alarm. + """ + return self["alarmArn"] + + @property + def region(self) -> str: + """ + The AWS region in which the Alarm is active. + """ + return self["region"] + + @property + def source_account_id(self) -> str: + """ + The AWS Account ID that the Alarm is deployed to. + """ + return self["accountId"] + + @property + def timestamp(self) -> str: + """ + Alarm state change event timestamp in ISO-8601 format. + """ + return self["time"] + + @property + def alarm_name(self) -> str: + """ + Alarm name. + """ + return self.get("alarmData").get("alarmName") + + @property + def alarm_description(self) -> Optional[str]: + """ + Optional description for the Alarm. + """ + return self.get("alarmData").get("configuration", {}).get("description", None) + + @property + def state(self): + """ + The current state of the Alarm. + """ + return CloudWatchAlarmState(self.get("alarmData").get("state")) + + @property + def previous_state(self): + """ + The previous state of the Alarm. + """ + return CloudWatchAlarmState(self.get("alarmData").get("previousState")) + + @property + def alarm_metrics(self) -> Optional[List[CloudWatchAlarmMetric]]: + maybe_metrics = self.get("alarmData", {}).get("configuration", {}).get("metrics", None) + + if maybe_metrics is not None: + return [CloudWatchAlarmMetric(i) for i in maybe_metrics] + + return None diff --git a/tests/events/cloudWatchAlarmEvent.json b/tests/events/cloudWatchAlarmEvent.json new file mode 100644 index 00000000000..5ed6a053a52 --- /dev/null +++ b/tests/events/cloudWatchAlarmEvent.json @@ -0,0 +1,40 @@ +{ + "source": "aws.cloudwatch", + "alarmArn": "arn:aws:cloudwatch:eu-west-1:912397435824:alarm:test_alarm", + "accountId": "000000000000", + "time": "2024-02-17T11:53:08.431+0000", + "region": "eu-west-1", + "alarmData": { + "alarmName": "Test alert", + "state": { + "value": "ALARM", + "reason": "Threshold Crossed: 1 out of the last 1 datapoints [1.0 (17/02/24 11:51:00)] was less than the threshold (10.0) (minimum 1 datapoint for OK -> ALARM transition).", + "reasonData": "{\"version\":\"1.0\",\"queryDate\":\"2024-02-17T11:53:08.423+0000\",\"startDate\":\"2024-02-17T11:51:00.000+0000\",\"statistic\":\"SampleCount\",\"period\":60,\"recentDatapoints\":[1.0],\"threshold\":10.0,\"evaluatedDatapoints\":[{\"timestamp\":\"2024-02-17T11:51:00.000+0000\",\"sampleCount\":1.0,\"value\":1.0}]}", + "timestamp": "2024-02-17T11:53:08.431+0000" + }, + "previousState": { + "value": "OK", + "reason": "Threshold Crossed: 1 out of the last 1 datapoints [1.0 (17/02/24 11:50:00)] was not greater than the threshold (10.0) (minimum 1 datapoint for ALARM -> OK transition).", + "reasonData": "{\"version\":\"1.0\",\"queryDate\":\"2024-02-17T11:51:31.460+0000\",\"startDate\":\"2024-02-17T11:50:00.000+0000\",\"statistic\":\"SampleCount\",\"period\":60,\"recentDatapoints\":[1.0],\"threshold\":10.0,\"evaluatedDatapoints\":[{\"timestamp\":\"2024-02-17T11:50:00.000+0000\",\"sampleCount\":1.0,\"value\":1.0}]}", + "timestamp": "2024-02-17T11:51:31.462+0000" + }, + "configuration": { + "description": "This is description **here**", + "metrics": [ + { + "id": "5ac6db74-a82d-24b1-6f62-b2ac23cc8ea4", + "metricStat": { + "metric": { + "namespace": "AWS/Lambda", + "name": "Invocations", + "dimensions": {} + }, + "period": 60, + "stat": "SampleCount" + }, + "returnData": true + } + ] + } + } +} \ No newline at end of file diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py new file mode 100644 index 00000000000..17a31591588 --- /dev/null +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -0,0 +1,42 @@ +import json + +from aws_lambda_powertools.utilities.data_classes.cloud_watch_alarm_event import AlarmStateValue, CloudWatchAlarmEvent +from tests.functional.utils import load_event + + +def test_cloud_watch_alarm_event(): + raw_event = load_event("cloudWatchAlarmEvent.json") + parsed_event = CloudWatchAlarmEvent(raw_event) + + assert parsed_event.source == raw_event["source"] + assert parsed_event.region == raw_event["region"] + assert parsed_event.alarm_arn == raw_event["alarmArn"] + assert parsed_event.alarm_description == raw_event["alarmData"]["configuration"]["description"] + assert parsed_event.alarm_name == raw_event["alarmData"]["alarmName"] + + assert parsed_event.state.value == AlarmStateValue[raw_event["alarmData"]["state"]["value"]] + assert parsed_event.state.reason == raw_event["alarmData"]["state"]["reason"] + assert parsed_event.state.reason_data == json.loads(raw_event["alarmData"]["state"]["reasonData"]) + assert parsed_event.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] + + assert parsed_event.previous_state.value == AlarmStateValue[raw_event["alarmData"]["previousState"]["value"]] + assert parsed_event.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] + assert parsed_event.previous_state.reason_data == json.loads(raw_event["alarmData"]["previousState"]["reasonData"]) + assert parsed_event.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] + + assert parsed_event.alarm_metrics[0].metric_id == raw_event["alarmData"]["configuration"]["metrics"][0]["id"] + assert ( + parsed_event.alarm_metrics[0].name + == raw_event["alarmData"]["configuration"]["metrics"][0]["metricStat"]["metric"]["name"] + ) + assert ( + parsed_event.alarm_metrics[0].namespace + == raw_event["alarmData"]["configuration"]["metrics"][0]["metricStat"]["metric"]["namespace"] + ) + assert ( + parsed_event.alarm_metrics[0].dimensions + == raw_event["alarmData"]["configuration"]["metrics"][0]["metricStat"]["metric"]["dimensions"] + ) + assert ( + parsed_event.alarm_metrics[0].return_data == raw_event["alarmData"]["configuration"]["metrics"][0]["returnData"] + ) From 889d5b5c100472c6f745855b36c46fa9d643fce2 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Wed, 28 Feb 2024 22:44:41 +0100 Subject: [PATCH 02/20] fix(data_classes): add CloudWatchAlarmEvent to dataclasses index --- aws_lambda_powertools/utilities/data_classes/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py index 38274f0bab4..6f701dc18b1 100644 --- a/aws_lambda_powertools/utilities/data_classes/__init__.py +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -7,6 +7,7 @@ from .appsync_resolver_event import AppSyncResolverEvent from .aws_config_rule_event import AWSConfigRuleEvent from .bedrock_agent_event import BedrockAgentEvent +from .cloud_watch_alarm_event import CloudWatchAlarmEvent from .cloud_watch_custom_widget_event import CloudWatchDashboardCustomWidgetEvent from .cloud_watch_logs_event import CloudWatchLogsEvent from .code_pipeline_job_event import CodePipelineJobEvent @@ -42,6 +43,7 @@ "AppSyncResolverEvent", "ALBEvent", "BedrockAgentEvent", + "CloudWatchAlarmEvent", "CloudWatchDashboardCustomWidgetEvent", "CloudWatchLogsEvent", "CodePipelineJobEvent", From 8b227f4e442d95bf2aa05c9844efe092cfd42f6f Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Wed, 28 Feb 2024 22:49:53 +0100 Subject: [PATCH 03/20] fix(data_classes): add the satellite classes to index --- .../utilities/data_classes/__init__.py | 10 +++++++++- .../utilities/data_classes/cloud_watch_alarm_event.py | 6 +++--- .../unit/data_classes/test_cloud_watch_alarm_event.py | 9 ++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py index 6f701dc18b1..ecbf49fa08f 100644 --- a/aws_lambda_powertools/utilities/data_classes/__init__.py +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -7,7 +7,12 @@ from .appsync_resolver_event import AppSyncResolverEvent from .aws_config_rule_event import AWSConfigRuleEvent from .bedrock_agent_event import BedrockAgentEvent -from .cloud_watch_alarm_event import CloudWatchAlarmEvent +from .cloud_watch_alarm_event import ( + CloudWatchAlarmEvent, + CloudWatchAlarmStateValue, + CloudWatchAlarmState, + CloudWatchAlarmMetric, +) from .cloud_watch_custom_widget_event import CloudWatchDashboardCustomWidgetEvent from .cloud_watch_logs_event import CloudWatchLogsEvent from .code_pipeline_job_event import CodePipelineJobEvent @@ -44,6 +49,9 @@ "ALBEvent", "BedrockAgentEvent", "CloudWatchAlarmEvent", + "CloudWatchAlarmMetric", + "CloudWatchAlarmState", + "CloudWatchAlarmStateValue", "CloudWatchDashboardCustomWidgetEvent", "CloudWatchLogsEvent", "CodePipelineJobEvent", diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index 359d591856c..26f075a7b8f 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -5,7 +5,7 @@ from aws_lambda_powertools.utilities.data_classes.common import DictWrapper -class AlarmStateValue(Enum): +class CloudWatchAlarmStateValue(Enum): OK = auto() ALARM = auto() INSUFFICIENT_DATA = auto() @@ -13,11 +13,11 @@ class AlarmStateValue(Enum): class CloudWatchAlarmState(DictWrapper): @property - def value(self) -> AlarmStateValue: + def value(self) -> CloudWatchAlarmStateValue: """ Overall state of the alarm. """ - return AlarmStateValue[self["value"]] + return CloudWatchAlarmStateValue[self["value"]] @property def reason(self) -> Optional[str]: diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py index 17a31591588..a69ecfb50e6 100644 --- a/tests/unit/data_classes/test_cloud_watch_alarm_event.py +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -1,6 +1,9 @@ import json -from aws_lambda_powertools.utilities.data_classes.cloud_watch_alarm_event import AlarmStateValue, CloudWatchAlarmEvent +from aws_lambda_powertools.utilities.data_classes.cloud_watch_alarm_event import ( + CloudWatchAlarmStateValue, + CloudWatchAlarmEvent, +) from tests.functional.utils import load_event @@ -14,12 +17,12 @@ def test_cloud_watch_alarm_event(): assert parsed_event.alarm_description == raw_event["alarmData"]["configuration"]["description"] assert parsed_event.alarm_name == raw_event["alarmData"]["alarmName"] - assert parsed_event.state.value == AlarmStateValue[raw_event["alarmData"]["state"]["value"]] + assert parsed_event.state.value == CloudWatchAlarmStateValue[raw_event["alarmData"]["state"]["value"]] assert parsed_event.state.reason == raw_event["alarmData"]["state"]["reason"] assert parsed_event.state.reason_data == json.loads(raw_event["alarmData"]["state"]["reasonData"]) assert parsed_event.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] - assert parsed_event.previous_state.value == AlarmStateValue[raw_event["alarmData"]["previousState"]["value"]] + assert parsed_event.previous_state.value == CloudWatchAlarmStateValue[raw_event["alarmData"]["previousState"]["value"]] assert parsed_event.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] assert parsed_event.previous_state.reason_data == json.loads(raw_event["alarmData"]["previousState"]["reasonData"]) assert parsed_event.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] From bc7cb2eb5424eb58fa888b0f0962b947f5fe52d8 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 11:30:26 +0100 Subject: [PATCH 04/20] fix(data_classes): address mypy issues --- .../utilities/data_classes/__init__.py | 4 +- .../data_classes/cloud_watch_alarm_event.py | 43 +++++++++++++++---- .../test_cloud_watch_alarm_event.py | 6 ++- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py index ecbf49fa08f..47ea0b9f448 100644 --- a/aws_lambda_powertools/utilities/data_classes/__init__.py +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -9,9 +9,9 @@ from .bedrock_agent_event import BedrockAgentEvent from .cloud_watch_alarm_event import ( CloudWatchAlarmEvent, - CloudWatchAlarmStateValue, - CloudWatchAlarmState, CloudWatchAlarmMetric, + CloudWatchAlarmState, + CloudWatchAlarmStateValue, ) from .cloud_watch_custom_widget_event import CloudWatchDashboardCustomWidgetEvent from .cloud_watch_logs_event import CloudWatchLogsEvent diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index 26f075a7b8f..f56f6301211 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from enum import Enum, auto from typing import List, Optional @@ -32,7 +34,10 @@ def reason_data(self) -> Optional[dict]: Additional data to back up the reason, usually contains the evaluated data points, the calculated threshold and timestamps. """ - return json.loads(self.get("reasonData")) if self.get("reasonData") is not None else None + if self.get("reasonData") is None: + return None + + return json.loads(str(self.get("reasonData"))) @property def timestamp(self) -> str: @@ -43,6 +48,11 @@ def timestamp(self) -> str: class CloudWatchAlarmMetric(DictWrapper): + def __init__(self, data: dict): + super().__init__(data) + + self._metric_stat: dict | None = self.get("metricStat") + @property def metric_id(self) -> str: """ @@ -55,35 +65,50 @@ def namespace(self) -> Optional[str]: """ Namespace of the correspondent CloudWatch Metric. """ - return self.get("metricStat", {}).get("metric", {}).get("namespace", None) + if self._metric_stat is not None: + return self._metric_stat.get("metric", {}).get("namespace", None) + + return None @property def name(self) -> Optional[str]: """ Name of the correspondent CloudWatch Metric. """ - return self.get("metricStat", {}).get("metric", {}).get("name", None) + if self._metric_stat is not None: + return self._metric_stat.get("metric", {}).get("name", None) + + return None @property def dimensions(self) -> Optional[dict]: """ Additional dimensions of the correspondent CloudWatch Metric, if available. """ - return self.get("metricStat", {}).get("metric", {}).get("dimensions", None) + if self._metric_stat is not None: + return self._metric_stat.get("metric", {}).get("dimensions", None) + + return None @property def period(self) -> Optional[int]: """ Metric evaluation period, in seconds. """ - return self.get("metricStat", {}).get("period", None) + if self._metric_stat is not None: + return self._metric_stat.get("period", None) + + return None @property def stat(self) -> Optional[str]: """ Statistical aggregation of metric points, e.g. Average, SampleCount, etc. """ - return self.get("metricStat", {}).get("stat", None) + if self._metric_stat is not None: + return self._metric_stat.get("stat", None) + + return None @property def return_data(self) -> bool: @@ -131,14 +156,14 @@ def alarm_name(self) -> str: """ Alarm name. """ - return self.get("alarmData").get("alarmName") + return self["alarmData"]["alarmName"] @property def alarm_description(self) -> Optional[str]: """ Optional description for the Alarm. """ - return self.get("alarmData").get("configuration", {}).get("description", None) + return self["alarmData"].get("configuration", {}).get("description", None) @property def state(self): @@ -156,7 +181,7 @@ def previous_state(self): @property def alarm_metrics(self) -> Optional[List[CloudWatchAlarmMetric]]: - maybe_metrics = self.get("alarmData", {}).get("configuration", {}).get("metrics", None) + maybe_metrics = self["alarmData"].get("configuration", {}).get("metrics", None) if maybe_metrics is not None: return [CloudWatchAlarmMetric(i) for i in maybe_metrics] diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py index a69ecfb50e6..d95341ebb13 100644 --- a/tests/unit/data_classes/test_cloud_watch_alarm_event.py +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -1,8 +1,8 @@ import json from aws_lambda_powertools.utilities.data_classes.cloud_watch_alarm_event import ( - CloudWatchAlarmStateValue, CloudWatchAlarmEvent, + CloudWatchAlarmStateValue, ) from tests.functional.utils import load_event @@ -22,7 +22,9 @@ def test_cloud_watch_alarm_event(): assert parsed_event.state.reason_data == json.loads(raw_event["alarmData"]["state"]["reasonData"]) assert parsed_event.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] - assert parsed_event.previous_state.value == CloudWatchAlarmStateValue[raw_event["alarmData"]["previousState"]["value"]] + assert ( + parsed_event.previous_state.value == CloudWatchAlarmStateValue[raw_event["alarmData"]["previousState"]["value"]] + ) assert parsed_event.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] assert parsed_event.previous_state.reason_data == json.loads(raw_event["alarmData"]["previousState"]["reasonData"]) assert parsed_event.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] From 270a46448ecf731db4af9576bd1e623052e2bf08 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 11:31:11 +0100 Subject: [PATCH 05/20] docs(data_classes): add documentation on CloudWatchAlarmEvent --- docs/utilities/data_classes.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 97b7a5dfda2..ddca0b8456a 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -86,6 +86,7 @@ Log Data Event for Troubleshooting | [AppSync Resolver](#appsync-resolver) | `AppSyncResolverEvent` | | [AWS Config Rule](#aws-config-rule) | `AWSConfigRuleEvent` | | [Bedrock Agent](#bedrock-agent) | `BedrockAgent` | +| [CloudWatch Alarm State Change Action](#cloudwatch-alarm-state-change-action) | `CloudWatchAlarmEvent` | | [CloudWatch Dashboard Custom Widget](#cloudwatch-dashboard-custom-widget) | `CloudWatchDashboardCustomWidgetEvent` | | [CloudWatch Logs](#cloudwatch-logs) | `CloudWatchLogsEvent` | | [CodePipeline Job Event](#codepipeline-job) | `CodePipelineJobEvent` | @@ -528,6 +529,23 @@ In this example, we also use the new Logger `correlation_id` and built-in `corre return { "markdown": f"# {echo}" } ``` +### CloudWatch Alarm State Change Action + +[CloudWatch supports Lambda as an alarm state change action](https://aws.amazon.com/about-aws/whats-new/2023/12/amazon-cloudwatch-alarms-lambda-change-action/){target="_blank"}. +You can use the `CloudWathAlarmEvent` data class to access the fields containing such data as alarm information, current state, and previous state. + +=== "app.py" + + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, CloudWatchAlarmEvent + + @event_source(data_class=CloudWatchAlarmEvent) + def lambda_handler(event: CloudWatchAlarmEvent, context): + if event.state.value.name == "ALARM": + print(f"{event.alarm_name} is on alarm because {event.state.reason}...") + do_something_with(event.alarm_arn) + ``` + ### CloudWatch Logs CloudWatch Logs events by default are compressed and base64 encoded. You can use the helper function provided to decode, From 6c99b9d89dfe2580fd33bdd97d5c95c83c0142f5 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 11:52:16 +0100 Subject: [PATCH 06/20] fix(data_classes): add 'expression' and 'label' fields to CloudWatchAlarmMetric --- .../data_classes/cloud_watch_alarm_event.py | 17 +++++++++++++ tests/events/cloudWatchAlarmEvent.json | 23 +++++++++++++++-- .../test_cloud_watch_alarm_event.py | 25 +++++++++++++------ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index f56f6301211..ccb644ff733 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -60,6 +60,20 @@ def metric_id(self) -> str: """ return self["id"] + @property + def expression(self) -> Optional[str]: + """ + The mathematical expression for calculating the metric, if applicable. + """ + return self.get("expression", None) + + @property + def label(self) -> Optional[str]: + """ + Optional label of the metric. + """ + return self.get("label", None) + @property def namespace(self) -> Optional[str]: """ @@ -112,6 +126,9 @@ def stat(self) -> Optional[str]: @property def return_data(self) -> bool: + """ + Whether this metric data is used to determine the state of the alarm or not. + """ return self["returnData"] diff --git a/tests/events/cloudWatchAlarmEvent.json b/tests/events/cloudWatchAlarmEvent.json index 5ed6a053a52..a4597b24ed0 100644 --- a/tests/events/cloudWatchAlarmEvent.json +++ b/tests/events/cloudWatchAlarmEvent.json @@ -22,7 +22,13 @@ "description": "This is description **here**", "metrics": [ { - "id": "5ac6db74-a82d-24b1-6f62-b2ac23cc8ea4", + "id": "e1", + "expression": "m1/m2", + "label": "Expression1", + "returnData": true + }, + { + "id": "m1", "metricStat": { "metric": { "namespace": "AWS/Lambda", @@ -32,7 +38,20 @@ "period": 60, "stat": "SampleCount" }, - "returnData": true + "returnData": false + }, + { + "id": "m2", + "metricStat": { + "metric": { + "namespace": "AWS/Lambda", + "name": "Duration", + "dimensions": {} + }, + "period": 60, + "stat": "SampleCount" + }, + "returnData": false } ] } diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py index d95341ebb13..8073214de85 100644 --- a/tests/unit/data_classes/test_cloud_watch_alarm_event.py +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -29,19 +29,30 @@ def test_cloud_watch_alarm_event(): assert parsed_event.previous_state.reason_data == json.loads(raw_event["alarmData"]["previousState"]["reasonData"]) assert parsed_event.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] + # test the 'expression' metric assert parsed_event.alarm_metrics[0].metric_id == raw_event["alarmData"]["configuration"]["metrics"][0]["id"] assert ( - parsed_event.alarm_metrics[0].name - == raw_event["alarmData"]["configuration"]["metrics"][0]["metricStat"]["metric"]["name"] + parsed_event.alarm_metrics[0].expression == raw_event["alarmData"]["configuration"]["metrics"][0]["expression"] ) + assert parsed_event.alarm_metrics[0].label == raw_event["alarmData"]["configuration"]["metrics"][0]["label"] assert ( - parsed_event.alarm_metrics[0].namespace - == raw_event["alarmData"]["configuration"]["metrics"][0]["metricStat"]["metric"]["namespace"] + parsed_event.alarm_metrics[0].return_data == raw_event["alarmData"]["configuration"]["metrics"][0]["returnData"] ) + + # test the 'metric' metric + assert parsed_event.alarm_metrics[1].metric_id == raw_event["alarmData"]["configuration"]["metrics"][1]["id"] assert ( - parsed_event.alarm_metrics[0].dimensions - == raw_event["alarmData"]["configuration"]["metrics"][0]["metricStat"]["metric"]["dimensions"] + parsed_event.alarm_metrics[1].name + == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["name"] ) assert ( - parsed_event.alarm_metrics[0].return_data == raw_event["alarmData"]["configuration"]["metrics"][0]["returnData"] + parsed_event.alarm_metrics[1].namespace + == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["namespace"] + ) + assert ( + parsed_event.alarm_metrics[1].dimensions + == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["dimensions"] + ) + assert ( + parsed_event.alarm_metrics[1].return_data == raw_event["alarmData"]["configuration"]["metrics"][1]["returnData"] ) From 1fa7703264453a2685c9c35778373729ba757cda Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 20:01:38 +0100 Subject: [PATCH 07/20] fix(data_classes): change accountId to `123456789012` in cloudWatchAlarmEvent sample --- tests/events/cloudWatchAlarmEvent.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/events/cloudWatchAlarmEvent.json b/tests/events/cloudWatchAlarmEvent.json index a4597b24ed0..fca5a968c72 100644 --- a/tests/events/cloudWatchAlarmEvent.json +++ b/tests/events/cloudWatchAlarmEvent.json @@ -1,7 +1,7 @@ { "source": "aws.cloudwatch", "alarmArn": "arn:aws:cloudwatch:eu-west-1:912397435824:alarm:test_alarm", - "accountId": "000000000000", + "accountId": "123456789012", "time": "2024-02-17T11:53:08.431+0000", "region": "eu-west-1", "alarmData": { From a26a0ea4fb16b43479f602a87e3c8dee58d2150a Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 20:13:11 +0100 Subject: [PATCH 08/20] fix(data_classes): add a new `reason_data_decoded` property to CloudWatchAlarmState This will allow the customers to easily access the raw 'reasonData' field, as well as accessing parsed data using the new property. --- .../data_classes/cloud_watch_alarm_event.py | 13 ++++++++----- .../data_classes/test_cloud_watch_alarm_event.py | 8 ++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index ccb644ff733..5e9fc67bde0 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -1,8 +1,7 @@ from __future__ import annotations -import json from enum import Enum, auto -from typing import List, Optional +from typing import Any, List, Optional from aws_lambda_powertools.utilities.data_classes.common import DictWrapper @@ -29,15 +28,19 @@ def reason(self) -> Optional[str]: return self.get("reason") @property - def reason_data(self) -> Optional[dict]: + def reason_data(self) -> Optional[str]: """ Additional data to back up the reason, usually contains the evaluated data points, the calculated threshold and timestamps. """ - if self.get("reasonData") is None: + return self.get("reasonData", None) + + @property + def reason_data_decoded(self) -> Optional[Any]: + if self.reason_data is None: return None - return json.loads(str(self.get("reasonData"))) + return self._json_deserializer(self.reason_data) @property def timestamp(self) -> str: diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py index 8073214de85..26caffe2f26 100644 --- a/tests/unit/data_classes/test_cloud_watch_alarm_event.py +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -1,5 +1,3 @@ -import json - from aws_lambda_powertools.utilities.data_classes.cloud_watch_alarm_event import ( CloudWatchAlarmEvent, CloudWatchAlarmStateValue, @@ -19,14 +17,16 @@ def test_cloud_watch_alarm_event(): assert parsed_event.state.value == CloudWatchAlarmStateValue[raw_event["alarmData"]["state"]["value"]] assert parsed_event.state.reason == raw_event["alarmData"]["state"]["reason"] - assert parsed_event.state.reason_data == json.loads(raw_event["alarmData"]["state"]["reasonData"]) + assert parsed_event.state.reason_data == raw_event["alarmData"]["state"]["reasonData"] + assert parsed_event.state.reason_data_decoded["queryDate"] == "2024-02-17T11:53:08.423+0000" assert parsed_event.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] assert ( parsed_event.previous_state.value == CloudWatchAlarmStateValue[raw_event["alarmData"]["previousState"]["value"]] ) assert parsed_event.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] - assert parsed_event.previous_state.reason_data == json.loads(raw_event["alarmData"]["previousState"]["reasonData"]) + assert parsed_event.previous_state.reason_data == raw_event["alarmData"]["previousState"]["reasonData"] + assert parsed_event.previous_state.reason_data_decoded["queryDate"] == "2024-02-17T11:51:31.460+0000" assert parsed_event.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] # test the 'expression' metric From 5e36ba2cab349901537e270c63442edcd4ae01bd Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 20:22:37 +0100 Subject: [PATCH 09/20] fix(data_classes): use Literal instead of Enum for the property `value` in CloudWatchAlarmState --- .../utilities/data_classes/__init__.py | 2 -- .../utilities/data_classes/cloud_watch_alarm_event.py | 11 +++-------- docs/utilities/data_classes.md | 2 +- .../unit/data_classes/test_cloud_watch_alarm_event.py | 11 +++-------- 4 files changed, 7 insertions(+), 19 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py index 47ea0b9f448..36665f86087 100644 --- a/aws_lambda_powertools/utilities/data_classes/__init__.py +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -11,7 +11,6 @@ CloudWatchAlarmEvent, CloudWatchAlarmMetric, CloudWatchAlarmState, - CloudWatchAlarmStateValue, ) from .cloud_watch_custom_widget_event import CloudWatchDashboardCustomWidgetEvent from .cloud_watch_logs_event import CloudWatchLogsEvent @@ -51,7 +50,6 @@ "CloudWatchAlarmEvent", "CloudWatchAlarmMetric", "CloudWatchAlarmState", - "CloudWatchAlarmStateValue", "CloudWatchDashboardCustomWidgetEvent", "CloudWatchLogsEvent", "CodePipelineJobEvent", diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index 5e9fc67bde0..9b2a398bf2b 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -1,15 +1,10 @@ from __future__ import annotations -from enum import Enum, auto -from typing import Any, List, Optional +from typing import Any, List, Literal, Optional from aws_lambda_powertools.utilities.data_classes.common import DictWrapper - -class CloudWatchAlarmStateValue(Enum): - OK = auto() - ALARM = auto() - INSUFFICIENT_DATA = auto() +CloudWatchAlarmStateValue = Literal["OK", "ALARM", "INSUFFICIENT_DATA"] class CloudWatchAlarmState(DictWrapper): @@ -18,7 +13,7 @@ def value(self) -> CloudWatchAlarmStateValue: """ Overall state of the alarm. """ - return CloudWatchAlarmStateValue[self["value"]] + return self["value"] @property def reason(self) -> Optional[str]: diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index ddca0b8456a..23962caa854 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -541,7 +541,7 @@ You can use the `CloudWathAlarmEvent` data class to access the fields containing @event_source(data_class=CloudWatchAlarmEvent) def lambda_handler(event: CloudWatchAlarmEvent, context): - if event.state.value.name == "ALARM": + if event.state.value == "ALARM": print(f"{event.alarm_name} is on alarm because {event.state.reason}...") do_something_with(event.alarm_arn) ``` diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py index 26caffe2f26..5be1a42d9a9 100644 --- a/tests/unit/data_classes/test_cloud_watch_alarm_event.py +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -1,7 +1,4 @@ -from aws_lambda_powertools.utilities.data_classes.cloud_watch_alarm_event import ( - CloudWatchAlarmEvent, - CloudWatchAlarmStateValue, -) +from aws_lambda_powertools.utilities.data_classes import CloudWatchAlarmEvent from tests.functional.utils import load_event @@ -15,15 +12,13 @@ def test_cloud_watch_alarm_event(): assert parsed_event.alarm_description == raw_event["alarmData"]["configuration"]["description"] assert parsed_event.alarm_name == raw_event["alarmData"]["alarmName"] - assert parsed_event.state.value == CloudWatchAlarmStateValue[raw_event["alarmData"]["state"]["value"]] + assert parsed_event.state.value == "ALARM" assert parsed_event.state.reason == raw_event["alarmData"]["state"]["reason"] assert parsed_event.state.reason_data == raw_event["alarmData"]["state"]["reasonData"] assert parsed_event.state.reason_data_decoded["queryDate"] == "2024-02-17T11:53:08.423+0000" assert parsed_event.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] - assert ( - parsed_event.previous_state.value == CloudWatchAlarmStateValue[raw_event["alarmData"]["previousState"]["value"]] - ) + assert parsed_event.previous_state.value == "OK" assert parsed_event.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] assert parsed_event.previous_state.reason_data == raw_event["alarmData"]["previousState"]["reasonData"] assert parsed_event.previous_state.reason_data_decoded["queryDate"] == "2024-02-17T11:51:31.460+0000" From 2ac09221116907bcace82669d56df453f82846f7 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 20:37:34 +0100 Subject: [PATCH 10/20] improv(data_classes): introduce CloudWatchAlarmData data class that contains the alarm data --- .../utilities/data_classes/__init__.py | 2 + .../data_classes/cloud_watch_alarm_event.py | 89 ++++++++++++------- .../test_cloud_watch_alarm_event.py | 45 +++++----- 3 files changed, 82 insertions(+), 54 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py index 36665f86087..8d8b689bd42 100644 --- a/aws_lambda_powertools/utilities/data_classes/__init__.py +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -8,6 +8,7 @@ from .aws_config_rule_event import AWSConfigRuleEvent from .bedrock_agent_event import BedrockAgentEvent from .cloud_watch_alarm_event import ( + CloudWatchAlarmData, CloudWatchAlarmEvent, CloudWatchAlarmMetric, CloudWatchAlarmState, @@ -47,6 +48,7 @@ "AppSyncResolverEvent", "ALBEvent", "BedrockAgentEvent", + "CloudWatchAlarmData", "CloudWatchAlarmEvent", "CloudWatchAlarmMetric", "CloudWatchAlarmState", diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index 9b2a398bf2b..730bb6b587d 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -130,6 +130,59 @@ def return_data(self) -> bool: return self["returnData"] +class CloudWatchAlarmData(DictWrapper): + def __init__(self, data: dict): + super().__init__(data) + + self._configuration = self.get("configuration", None) + + @property + def name(self) -> str: + """ + Alarm name. + """ + return self["alarmName"] + + @property + def description(self) -> Optional[str]: + """ + Optional description for the Alarm. + """ + if self._configuration is not None: + return self._configuration.get("description", None) + + return None + + @property + def state(self) -> CloudWatchAlarmState: + """ + The current state of the Alarm. + """ + return CloudWatchAlarmState(self["state"]) + + @property + def previous_state(self) -> CloudWatchAlarmState: + """ + The previous state of the Alarm. + """ + return CloudWatchAlarmState(self["previousState"]) + + @property + def metrics(self) -> Optional[List[CloudWatchAlarmMetric]]: + """ + The metrics evaluated for the Alarm. + """ + if self._configuration is None: + return None + + maybe_metrics = self._configuration.get("metrics", None) + + if maybe_metrics is not None: + return [CloudWatchAlarmMetric(i) for i in maybe_metrics] + + return None + + class CloudWatchAlarmEvent(DictWrapper): @property def source(self) -> str: @@ -167,38 +220,8 @@ def timestamp(self) -> str: return self["time"] @property - def alarm_name(self) -> str: - """ - Alarm name. - """ - return self["alarmData"]["alarmName"] - - @property - def alarm_description(self) -> Optional[str]: - """ - Optional description for the Alarm. - """ - return self["alarmData"].get("configuration", {}).get("description", None) - - @property - def state(self): - """ - The current state of the Alarm. - """ - return CloudWatchAlarmState(self.get("alarmData").get("state")) - - @property - def previous_state(self): + def alarm_data(self) -> CloudWatchAlarmData: """ - The previous state of the Alarm. + Contains basic data about the Alarm and its current and previous states. """ - return CloudWatchAlarmState(self.get("alarmData").get("previousState")) - - @property - def alarm_metrics(self) -> Optional[List[CloudWatchAlarmMetric]]: - maybe_metrics = self["alarmData"].get("configuration", {}).get("metrics", None) - - if maybe_metrics is not None: - return [CloudWatchAlarmMetric(i) for i in maybe_metrics] - - return None + return CloudWatchAlarmData(self["alarmData"]) diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py index 5be1a42d9a9..09713efa0a4 100644 --- a/tests/unit/data_classes/test_cloud_watch_alarm_event.py +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -9,45 +9,48 @@ def test_cloud_watch_alarm_event(): assert parsed_event.source == raw_event["source"] assert parsed_event.region == raw_event["region"] assert parsed_event.alarm_arn == raw_event["alarmArn"] - assert parsed_event.alarm_description == raw_event["alarmData"]["configuration"]["description"] - assert parsed_event.alarm_name == raw_event["alarmData"]["alarmName"] + assert parsed_event.alarm_data.description == raw_event["alarmData"]["configuration"]["description"] + assert parsed_event.alarm_data.name == raw_event["alarmData"]["alarmName"] - assert parsed_event.state.value == "ALARM" - assert parsed_event.state.reason == raw_event["alarmData"]["state"]["reason"] - assert parsed_event.state.reason_data == raw_event["alarmData"]["state"]["reasonData"] - assert parsed_event.state.reason_data_decoded["queryDate"] == "2024-02-17T11:53:08.423+0000" - assert parsed_event.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] + assert parsed_event.alarm_data.state.value == "ALARM" + assert parsed_event.alarm_data.state.reason == raw_event["alarmData"]["state"]["reason"] + assert parsed_event.alarm_data.state.reason_data == raw_event["alarmData"]["state"]["reasonData"] + assert parsed_event.alarm_data.state.reason_data_decoded["queryDate"] == "2024-02-17T11:53:08.423+0000" + assert parsed_event.alarm_data.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] - assert parsed_event.previous_state.value == "OK" - assert parsed_event.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] - assert parsed_event.previous_state.reason_data == raw_event["alarmData"]["previousState"]["reasonData"] - assert parsed_event.previous_state.reason_data_decoded["queryDate"] == "2024-02-17T11:51:31.460+0000" - assert parsed_event.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] + assert parsed_event.alarm_data.previous_state.value == "OK" + assert parsed_event.alarm_data.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] + assert parsed_event.alarm_data.previous_state.reason_data == raw_event["alarmData"]["previousState"]["reasonData"] + assert parsed_event.alarm_data.previous_state.reason_data_decoded["queryDate"] == "2024-02-17T11:51:31.460+0000" + assert parsed_event.alarm_data.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] # test the 'expression' metric - assert parsed_event.alarm_metrics[0].metric_id == raw_event["alarmData"]["configuration"]["metrics"][0]["id"] + assert parsed_event.alarm_data.metrics[0].metric_id == raw_event["alarmData"]["configuration"]["metrics"][0]["id"] assert ( - parsed_event.alarm_metrics[0].expression == raw_event["alarmData"]["configuration"]["metrics"][0]["expression"] + parsed_event.alarm_data.metrics[0].expression + == raw_event["alarmData"]["configuration"]["metrics"][0]["expression"] ) - assert parsed_event.alarm_metrics[0].label == raw_event["alarmData"]["configuration"]["metrics"][0]["label"] + assert parsed_event.alarm_data.metrics[0].label == raw_event["alarmData"]["configuration"]["metrics"][0]["label"] assert ( - parsed_event.alarm_metrics[0].return_data == raw_event["alarmData"]["configuration"]["metrics"][0]["returnData"] + parsed_event.alarm_data.metrics[0].return_data + == raw_event["alarmData"]["configuration"]["metrics"][0]["returnData"] ) # test the 'metric' metric - assert parsed_event.alarm_metrics[1].metric_id == raw_event["alarmData"]["configuration"]["metrics"][1]["id"] + assert parsed_event.alarm_data.metrics[1].metric_id == raw_event["alarmData"]["configuration"]["metrics"][1]["id"] assert ( - parsed_event.alarm_metrics[1].name + parsed_event.alarm_data.metrics[1].name == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["name"] ) assert ( - parsed_event.alarm_metrics[1].namespace + parsed_event.alarm_data.metrics[1].namespace == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["namespace"] ) assert ( - parsed_event.alarm_metrics[1].dimensions + parsed_event.alarm_data.metrics[1].dimensions == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["dimensions"] ) assert ( - parsed_event.alarm_metrics[1].return_data == raw_event["alarmData"]["configuration"]["metrics"][1]["returnData"] + parsed_event.alarm_data.metrics[1].return_data + == raw_event["alarmData"]["configuration"]["metrics"][1]["returnData"] ) From 19b6e86942dd1c785df2142c84a2a1893964433e Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 20:39:32 +0100 Subject: [PATCH 11/20] improv(data_classes): change source property return type to Literal --- .../utilities/data_classes/cloud_watch_alarm_event.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index 730bb6b587d..0a6ff0edce0 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -32,6 +32,9 @@ def reason_data(self) -> Optional[str]: @property def reason_data_decoded(self) -> Optional[Any]: + """ + Deserialized version of reason_data. + """ if self.reason_data is None: return None @@ -185,9 +188,9 @@ def metrics(self) -> Optional[List[CloudWatchAlarmMetric]]: class CloudWatchAlarmEvent(DictWrapper): @property - def source(self) -> str: + def source(self) -> Literal["aws.cloudwatch"]: """ - Source of the triggered event, usually it is "aws.cloudwatch". + Source of the triggered event. """ return self["source"] From 2255d98a12a8b08bf7faaddf1f15396a10ebb86e Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 20:46:26 +0100 Subject: [PATCH 12/20] docs(data_classes): update the example for CloudWatch Alarm State Change Action --- docs/utilities/data_classes.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 23962caa854..8e316b73293 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -540,10 +540,17 @@ You can use the `CloudWathAlarmEvent` data class to access the fields containing from aws_lambda_powertools.utilities.data_classes import event_source, CloudWatchAlarmEvent @event_source(data_class=CloudWatchAlarmEvent) - def lambda_handler(event: CloudWatchAlarmEvent, context): - if event.state.value == "ALARM": - print(f"{event.alarm_name} is on alarm because {event.state.reason}...") - do_something_with(event.alarm_arn) + def lambda_handler(event: CloudWatchAlarmEvent, context: LambdaContext) -> dict: + logger.info(f"Alarm {event.alarm_data.name} state is {event.alarm_data.state.value}") + + # You can now work with event. For example, you can enrich the received data, and + # decide on how you want to route the alarm. + + return { + "name": event.alarm_data.name, + "arn": event.alarm_arn, + "urgent": "Priority: P1" in event.alarm_data.description, + } ``` ### CloudWatch Logs From e25add994b82d4513160ac1f262c28ce0aae3620 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 20:46:38 +0100 Subject: [PATCH 13/20] docs(data_classes): add a working example under `examples/event_sources/` --- .../src/cloudwatch_alarm_event.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 examples/event_sources/src/cloudwatch_alarm_event.py diff --git a/examples/event_sources/src/cloudwatch_alarm_event.py b/examples/event_sources/src/cloudwatch_alarm_event.py new file mode 100644 index 00000000000..d039ec5570e --- /dev/null +++ b/examples/event_sources/src/cloudwatch_alarm_event.py @@ -0,0 +1,19 @@ +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.data_classes import CloudWatchAlarmEvent, event_source +from aws_lambda_powertools.utilities.typing import LambdaContext + +logger = Logger() + + +@event_source(data_class=CloudWatchAlarmEvent) +def lambda_handler(event: CloudWatchAlarmEvent, context: LambdaContext) -> dict: + logger.info(f"Alarm {event.alarm_data.name} state is {event.alarm_data.state.value}") + + # You can now work with event. For example, you can enrich the received data, and + # decide on how you want to route the alarm. + + return { + "name": event.alarm_data.name, + "arn": event.alarm_arn, + "urgent": "Priority: P1" in event.alarm_data.description, + } From f6c3c1d480d3d7adec1631f676d5700035275935 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 21:01:23 +0100 Subject: [PATCH 14/20] improv(data_classes): use `cached_property` decorator for `reason_data_decoded` --- .../utilities/data_classes/cloud_watch_alarm_event.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index 0a6ff0edce0..d05d92c231b 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import cached_property from typing import Any, List, Literal, Optional from aws_lambda_powertools.utilities.data_classes.common import DictWrapper @@ -30,7 +31,7 @@ def reason_data(self) -> Optional[str]: """ return self.get("reasonData", None) - @property + @cached_property def reason_data_decoded(self) -> Optional[Any]: """ Deserialized version of reason_data. From 71e63a856cd833602a017db42bb5854494ed6619 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Thu, 29 Feb 2024 21:03:28 +0100 Subject: [PATCH 15/20] docs(data_classes): reformat table in data_classes --- docs/utilities/data_classes.md | 68 +++++++++++++++++----------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 8e316b73293..216d8535fcf 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -74,40 +74,40 @@ Log Data Event for Troubleshooting ## Supported event sources -| Event Source | Data_class | -|---------------------------------------------------------------------------|----------------------------------------------------| -| [Active MQ](#active-mq) | `ActiveMQEvent` | -| [API Gateway Authorizer](#api-gateway-authorizer) | `APIGatewayAuthorizerRequestEvent` | -| [API Gateway Authorizer V2](#api-gateway-authorizer-v2) | `APIGatewayAuthorizerEventV2` | -| [API Gateway Proxy](#api-gateway-proxy) | `APIGatewayProxyEvent` | -| [API Gateway Proxy V2](#api-gateway-proxy-v2) | `APIGatewayProxyEventV2` | -| [Application Load Balancer](#application-load-balancer) | `ALBEvent` | -| [AppSync Authorizer](#appsync-authorizer) | `AppSyncAuthorizerEvent` | -| [AppSync Resolver](#appsync-resolver) | `AppSyncResolverEvent` | -| [AWS Config Rule](#aws-config-rule) | `AWSConfigRuleEvent` | -| [Bedrock Agent](#bedrock-agent) | `BedrockAgent` | -| [CloudWatch Alarm State Change Action](#cloudwatch-alarm-state-change-action) | `CloudWatchAlarmEvent` | -| [CloudWatch Dashboard Custom Widget](#cloudwatch-dashboard-custom-widget) | `CloudWatchDashboardCustomWidgetEvent` | -| [CloudWatch Logs](#cloudwatch-logs) | `CloudWatchLogsEvent` | -| [CodePipeline Job Event](#codepipeline-job) | `CodePipelineJobEvent` | -| [Cognito User Pool](#cognito-user-pool) | Multiple available under `cognito_user_pool_event` | -| [Connect Contact Flow](#connect-contact-flow) | `ConnectContactFlowEvent` | -| [DynamoDB streams](#dynamodb-streams) | `DynamoDBStreamEvent`, `DynamoDBRecordEventName` | -| [EventBridge](#eventbridge) | `EventBridgeEvent` | -| [Kafka](#kafka) | `KafkaEvent` | -| [Kinesis Data Stream](#kinesis-streams) | `KinesisStreamEvent` | -| [Kinesis Firehose Delivery Stream](#kinesis-firehose-delivery-stream) | `KinesisFirehoseEvent` | -| [Lambda Function URL](#lambda-function-url) | `LambdaFunctionUrlEvent` | -| [Rabbit MQ](#rabbit-mq) | `RabbitMQEvent` | -| [S3](#s3) | `S3Event` | -| [S3 Batch Operations](#s3-batch-operations) | `S3BatchOperationEvent` | -| [S3 Object Lambda](#s3-object-lambda) | `S3ObjectLambdaEvent` | -| [S3 EventBridge Notification](#s3-eventbridge-notification) | `S3EventBridgeNotificationEvent` | -| [SES](#ses) | `SESEvent` | -| [SNS](#sns) | `SNSEvent` | -| [SQS](#sqs) | `SQSEvent` | -| [VPC Lattice V2](#vpc-lattice-v2) | `VPCLatticeV2Event` | -| [VPC Lattice V1](#vpc-lattice-v1) | `VPCLatticeEvent` | +| Event Source | Data_class | +|-------------------------------------------------------------------------------|----------------------------------------------------| +| [Active MQ](#active-mq) | `ActiveMQEvent` | +| [API Gateway Authorizer](#api-gateway-authorizer) | `APIGatewayAuthorizerRequestEvent` | +| [API Gateway Authorizer V2](#api-gateway-authorizer-v2) | `APIGatewayAuthorizerEventV2` | +| [API Gateway Proxy](#api-gateway-proxy) | `APIGatewayProxyEvent` | +| [API Gateway Proxy V2](#api-gateway-proxy-v2) | `APIGatewayProxyEventV2` | +| [Application Load Balancer](#application-load-balancer) | `ALBEvent` | +| [AppSync Authorizer](#appsync-authorizer) | `AppSyncAuthorizerEvent` | +| [AppSync Resolver](#appsync-resolver) | `AppSyncResolverEvent` | +| [AWS Config Rule](#aws-config-rule) | `AWSConfigRuleEvent` | +| [Bedrock Agent](#bedrock-agent) | `BedrockAgent` | +| [CloudWatch Alarm State Change Action](#cloudwatch-alarm-state-change-action) | `CloudWatchAlarmEvent` | +| [CloudWatch Dashboard Custom Widget](#cloudwatch-dashboard-custom-widget) | `CloudWatchDashboardCustomWidgetEvent` | +| [CloudWatch Logs](#cloudwatch-logs) | `CloudWatchLogsEvent` | +| [CodePipeline Job Event](#codepipeline-job) | `CodePipelineJobEvent` | +| [Cognito User Pool](#cognito-user-pool) | Multiple available under `cognito_user_pool_event` | +| [Connect Contact Flow](#connect-contact-flow) | `ConnectContactFlowEvent` | +| [DynamoDB streams](#dynamodb-streams) | `DynamoDBStreamEvent`, `DynamoDBRecordEventName` | +| [EventBridge](#eventbridge) | `EventBridgeEvent` | +| [Kafka](#kafka) | `KafkaEvent` | +| [Kinesis Data Stream](#kinesis-streams) | `KinesisStreamEvent` | +| [Kinesis Firehose Delivery Stream](#kinesis-firehose-delivery-stream) | `KinesisFirehoseEvent` | +| [Lambda Function URL](#lambda-function-url) | `LambdaFunctionUrlEvent` | +| [Rabbit MQ](#rabbit-mq) | `RabbitMQEvent` | +| [S3](#s3) | `S3Event` | +| [S3 Batch Operations](#s3-batch-operations) | `S3BatchOperationEvent` | +| [S3 Object Lambda](#s3-object-lambda) | `S3ObjectLambdaEvent` | +| [S3 EventBridge Notification](#s3-eventbridge-notification) | `S3EventBridgeNotificationEvent` | +| [SES](#ses) | `SESEvent` | +| [SNS](#sns) | `SNSEvent` | +| [SQS](#sqs) | `SQSEvent` | +| [VPC Lattice V2](#vpc-lattice-v2) | `VPCLatticeV2Event` | +| [VPC Lattice V1](#vpc-lattice-v1) | `VPCLatticeEvent` | ???+ info The examples provided below are far from exhaustive - the data classes themselves are designed to provide a form of From 1b8803c736a4bdd8c0049d26afe1854b55b611d5 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Fri, 1 Mar 2024 09:06:02 +0100 Subject: [PATCH 16/20] docs(data_classes): fix cloudwatch_alarm_event example typing issue --- examples/event_sources/src/cloudwatch_alarm_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/event_sources/src/cloudwatch_alarm_event.py b/examples/event_sources/src/cloudwatch_alarm_event.py index d039ec5570e..690742f0f72 100644 --- a/examples/event_sources/src/cloudwatch_alarm_event.py +++ b/examples/event_sources/src/cloudwatch_alarm_event.py @@ -15,5 +15,5 @@ def lambda_handler(event: CloudWatchAlarmEvent, context: LambdaContext) -> dict: return { "name": event.alarm_data.name, "arn": event.alarm_arn, - "urgent": "Priority: P1" in event.alarm_data.description, + "urgent": "Priority: P1" in (event.alarm_data.description or ""), } From c9c7754869c6c8bfd9c95b91816daee04b8fff49 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Fri, 1 Mar 2024 09:06:26 +0100 Subject: [PATCH 17/20] docs(data_classes): replace example code with reference to the example file for CW alarm state change action --- docs/utilities/data_classes.md | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 216d8535fcf..8a5e8f8aa47 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -536,21 +536,8 @@ You can use the `CloudWathAlarmEvent` data class to access the fields containing === "app.py" - ```python - from aws_lambda_powertools.utilities.data_classes import event_source, CloudWatchAlarmEvent - - @event_source(data_class=CloudWatchAlarmEvent) - def lambda_handler(event: CloudWatchAlarmEvent, context: LambdaContext) -> dict: - logger.info(f"Alarm {event.alarm_data.name} state is {event.alarm_data.state.value}") - - # You can now work with event. For example, you can enrich the received data, and - # decide on how you want to route the alarm. - - return { - "name": event.alarm_data.name, - "arn": event.alarm_arn, - "urgent": "Priority: P1" in event.alarm_data.description, - } + ```python hl_lines="2 8" + --8<-- "examples/event_sources/src/cloudwatch_alarm_event.py" ``` ### CloudWatch Logs From 71c6c34413569b2c7f9571e1baa5e4aa0396f471 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Fri, 1 Mar 2024 09:15:40 +0100 Subject: [PATCH 18/20] feat(data_classes): add `actions_suppressed_by` and `actions_suppressed_reason` properties to CloudWatchAlarmState --- .../data_classes/cloud_watch_alarm_event.py | 16 ++++++++++++++++ tests/events/cloudWatchAlarmEvent.json | 4 +++- .../data_classes/test_cloud_watch_alarm_event.py | 8 ++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index d05d92c231b..238734d6db9 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -6,6 +6,7 @@ from aws_lambda_powertools.utilities.data_classes.common import DictWrapper CloudWatchAlarmStateValue = Literal["OK", "ALARM", "INSUFFICIENT_DATA"] +CloudWatchAlarmActionSuppressor = Literal["Alarm", "ExtensionPeriod", "WaitPeriod"] class CloudWatchAlarmState(DictWrapper): @@ -41,6 +42,21 @@ def reason_data_decoded(self) -> Optional[Any]: return self._json_deserializer(self.reason_data) + @property + def actions_suppressed_by(self) -> Optional[CloudWatchAlarmActionSuppressor]: + """ + Describes why the actions when the value is `ALARM` are suppressed in a composite + alarm. + """ + return self.get("actionsSuppressedBy", None) + + @property + def actions_suppressed_reason(self) -> Optional[str]: + """ + Captures the reason for action suppression. + """ + return self.get("actionsSuppressedReason", None) + @property def timestamp(self) -> str: """ diff --git a/tests/events/cloudWatchAlarmEvent.json b/tests/events/cloudWatchAlarmEvent.json index fca5a968c72..883772232af 100644 --- a/tests/events/cloudWatchAlarmEvent.json +++ b/tests/events/cloudWatchAlarmEvent.json @@ -16,7 +16,9 @@ "value": "OK", "reason": "Threshold Crossed: 1 out of the last 1 datapoints [1.0 (17/02/24 11:50:00)] was not greater than the threshold (10.0) (minimum 1 datapoint for ALARM -> OK transition).", "reasonData": "{\"version\":\"1.0\",\"queryDate\":\"2024-02-17T11:51:31.460+0000\",\"startDate\":\"2024-02-17T11:50:00.000+0000\",\"statistic\":\"SampleCount\",\"period\":60,\"recentDatapoints\":[1.0],\"threshold\":10.0,\"evaluatedDatapoints\":[{\"timestamp\":\"2024-02-17T11:50:00.000+0000\",\"sampleCount\":1.0,\"value\":1.0}]}", - "timestamp": "2024-02-17T11:51:31.462+0000" + "timestamp": "2024-02-17T11:51:31.462+0000", + "actionsSuppressedBy": "WaitPeriod", + "actionsSuppressedReason": "Actions suppressed by WaitPeriod" }, "configuration": { "description": "This is description **here**", diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py index 09713efa0a4..25de9c01c5d 100644 --- a/tests/unit/data_classes/test_cloud_watch_alarm_event.py +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -23,6 +23,14 @@ def test_cloud_watch_alarm_event(): assert parsed_event.alarm_data.previous_state.reason_data == raw_event["alarmData"]["previousState"]["reasonData"] assert parsed_event.alarm_data.previous_state.reason_data_decoded["queryDate"] == "2024-02-17T11:51:31.460+0000" assert parsed_event.alarm_data.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] + assert ( + parsed_event.alarm_data.previous_state.actions_suppressed_by + == raw_event["alarmData"]["previousState"]["actionsSuppressedBy"] + ) + assert ( + parsed_event.alarm_data.previous_state.actions_suppressed_reason + == raw_event["alarmData"]["previousState"]["actionsSuppressedReason"] + ) # test the 'expression' metric assert parsed_event.alarm_data.metrics[0].metric_id == raw_event["alarmData"]["configuration"]["metrics"][0]["id"] From 529191250dd20a1c7fd64c7ca19ab7eaeaeae652 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Mon, 11 Mar 2024 15:41:05 +0000 Subject: [PATCH 19/20] Refactoring code --- .../utilities/data_classes/__init__.py | 4 + .../data_classes/cloud_watch_alarm_event.py | 148 +++++++++--------- .../cloudWatchAlarmEventCompositeMetric.json | 30 ++++ ... => cloudWatchAlarmEventSingleMetric.json} | 6 +- .../test_cloud_watch_alarm_event.py | 104 ++++++++---- 5 files changed, 180 insertions(+), 112 deletions(-) create mode 100644 tests/events/cloudWatchAlarmEventCompositeMetric.json rename tests/events/{cloudWatchAlarmEvent.json => cloudWatchAlarmEventSingleMetric.json} (93%) diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py index 8d8b689bd42..64416e3cdd9 100644 --- a/aws_lambda_powertools/utilities/data_classes/__init__.py +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -8,9 +8,11 @@ from .aws_config_rule_event import AWSConfigRuleEvent from .bedrock_agent_event import BedrockAgentEvent from .cloud_watch_alarm_event import ( + CloudWatchAlarmConfiguration, CloudWatchAlarmData, CloudWatchAlarmEvent, CloudWatchAlarmMetric, + CloudWatchAlarmMetricStat, CloudWatchAlarmState, ) from .cloud_watch_custom_widget_event import CloudWatchDashboardCustomWidgetEvent @@ -52,6 +54,8 @@ "CloudWatchAlarmEvent", "CloudWatchAlarmMetric", "CloudWatchAlarmState", + "CloudWatchAlarmConfiguration", + "CloudWatchAlarmMetricStat", "CloudWatchDashboardCustomWidgetEvent", "CloudWatchLogsEvent", "CodePipelineJobEvent", diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index 238734d6db9..8cec9a258a7 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -1,31 +1,28 @@ from __future__ import annotations from functools import cached_property -from typing import Any, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional from aws_lambda_powertools.utilities.data_classes.common import DictWrapper -CloudWatchAlarmStateValue = Literal["OK", "ALARM", "INSUFFICIENT_DATA"] -CloudWatchAlarmActionSuppressor = Literal["Alarm", "ExtensionPeriod", "WaitPeriod"] - class CloudWatchAlarmState(DictWrapper): @property - def value(self) -> CloudWatchAlarmStateValue: + def value(self) -> Literal["OK", "ALARM", "INSUFFICIENT_DATA"]: """ Overall state of the alarm. """ return self["value"] @property - def reason(self) -> Optional[str]: + def reason(self) -> str: """ Reason why alarm was changed to this state. """ return self.get("reason") @property - def reason_data(self) -> Optional[str]: + def reason_data(self) -> str: """ Additional data to back up the reason, usually contains the evaluated data points, the calculated threshold and timestamps. @@ -37,13 +34,11 @@ def reason_data_decoded(self) -> Optional[Any]: """ Deserialized version of reason_data. """ - if self.reason_data is None: - return None - return self._json_deserializer(self.reason_data) + return self._json_deserializer(self.reason_data) if self.reason_data else None @property - def actions_suppressed_by(self) -> Optional[CloudWatchAlarmActionSuppressor]: + def actions_suppressed_by(self) -> Optional[Literal["Alarm", "ExtensionPeriod", "WaitPeriod"]]: """ Describes why the actions when the value is `ALARM` are suppressed in a composite alarm. @@ -66,11 +61,6 @@ def timestamp(self) -> str: class CloudWatchAlarmMetric(DictWrapper): - def __init__(self, data: dict): - super().__init__(data) - - self._metric_stat: dict | None = self.get("metricStat") - @property def metric_id(self) -> str: """ @@ -81,98 +71,67 @@ def metric_id(self) -> str: @property def expression(self) -> Optional[str]: """ - The mathematical expression for calculating the metric, if applicable. + Optional expression of the alarm metric. """ return self.get("expression", None) @property - def label(self) -> Optional[str]: + def label(self) -> str: """ - Optional label of the metric. + Optional label of the alarm metric. """ return self.get("label", None) @property - def namespace(self) -> Optional[str]: - """ - Namespace of the correspondent CloudWatch Metric. - """ - if self._metric_stat is not None: - return self._metric_stat.get("metric", {}).get("namespace", None) - - return None - - @property - def name(self) -> Optional[str]: + def return_data(self) -> bool: """ - Name of the correspondent CloudWatch Metric. + Whether this metric data is used to determine the state of the alarm or not. """ - if self._metric_stat is not None: - return self._metric_stat.get("metric", {}).get("name", None) - - return None + return self["returnData"] @property - def dimensions(self) -> Optional[dict]: - """ - Additional dimensions of the correspondent CloudWatch Metric, if available. - """ - if self._metric_stat is not None: - return self._metric_stat.get("metric", {}).get("dimensions", None) + def metric_stat(self) -> CloudWatchAlarmMetricStat: + return CloudWatchAlarmMetricStat(self["metricStat"]) - return None +class CloudWatchAlarmMetricStat(DictWrapper): @property def period(self) -> Optional[int]: """ Metric evaluation period, in seconds. """ - if self._metric_stat is not None: - return self._metric_stat.get("period", None) - - return None + return self.get("period", None) @property def stat(self) -> Optional[str]: """ Statistical aggregation of metric points, e.g. Average, SampleCount, etc. """ - if self._metric_stat is not None: - return self._metric_stat.get("stat", None) + return self.get("stat", None) - return None + @property + def unit(self) -> Optional[str]: + """ + Unit for metric. + """ + return self.get("unit", None) @property - def return_data(self) -> bool: + def metric(self) -> Dict: """ - Whether this metric data is used to determine the state of the alarm or not. + Metric details """ - return self["returnData"] + return self.get("metric", {}) class CloudWatchAlarmData(DictWrapper): - def __init__(self, data: dict): - super().__init__(data) - - self._configuration = self.get("configuration", None) - @property - def name(self) -> str: + def alarm_name(self) -> str: """ Alarm name. """ return self["alarmName"] - @property - def description(self) -> Optional[str]: - """ - Optional description for the Alarm. - """ - if self._configuration is not None: - return self._configuration.get("description", None) - - return None - @property def state(self) -> CloudWatchAlarmState: """ @@ -188,19 +147,56 @@ def previous_state(self) -> CloudWatchAlarmState: return CloudWatchAlarmState(self["previousState"]) @property - def metrics(self) -> Optional[List[CloudWatchAlarmMetric]]: + def configuration(self) -> CloudWatchAlarmConfiguration: """ - The metrics evaluated for the Alarm. + The configuration of the Alarm. """ - if self._configuration is None: - return None + return CloudWatchAlarmConfiguration(self["configuration"]) - maybe_metrics = self._configuration.get("metrics", None) - if maybe_metrics is not None: - return [CloudWatchAlarmMetric(i) for i in maybe_metrics] +class CloudWatchAlarmConfiguration(DictWrapper): + @property + def description(self) -> Optional[str]: + """ + Optional description for the Alarm. + """ + return self.get("description", None) - return None + @property + def alarm_rule(self) -> Optional[str]: + """ + Optional description for the Alarm rule in case of composite alarm. + """ + return self.get("alarmRule", None) + + @property + def alarm_actions_suppressor(self) -> Optional[str]: + """ + Optional action suppression for the Alarm rule in case of composite alarm. + """ + return self.get("actionsSuppressor", None) + + @property + def alarm_actions_suppressor_wait_period(self) -> Optional[str]: + """ + Optional action suppression wait period for the Alarm rule in case of composite alarm. + """ + return self.get("actionsSuppressorWaitPeriod", None) + + @property + def alarm_actions_suppressor_extension_period(self) -> Optional[str]: + """ + Optional action suppression extension period for the Alarm rule in case of composite alarm. + """ + return self.get("actionsSuppressorExtensionPeriod", None) + + @property + def metrics(self) -> Optional[List[CloudWatchAlarmMetric]]: + """ + The metrics evaluated for the Alarm. + """ + metrics = self.get("metrics") + return [CloudWatchAlarmMetric(i) for i in metrics] if metrics else None class CloudWatchAlarmEvent(DictWrapper): diff --git a/tests/events/cloudWatchAlarmEventCompositeMetric.json b/tests/events/cloudWatchAlarmEventCompositeMetric.json new file mode 100644 index 00000000000..67200c10edb --- /dev/null +++ b/tests/events/cloudWatchAlarmEventCompositeMetric.json @@ -0,0 +1,30 @@ +{ + "source":"aws.cloudwatch", + "alarmArn":"arn:aws:cloudwatch:us-east-1:111122223333:alarm:SuppressionDemo.Main", + "accountId":"111122223333", + "time":"2023-08-04T12:56:46.138+0000", + "region":"us-east-1", + "alarmData":{ + "alarmName":"CompositeDemo.Main", + "state":{ + "value":"ALARM", + "reason":"arn:aws:cloudwatch:us-east-1:111122223333:alarm:CompositeDemo.FirstChild transitioned to ALARM at Friday 04 August, 2023 12:54:46 UTC", + "reasonData":"{\"triggeringAlarms\":[{\"arn\":\"arn:aws:cloudwatch:us-east-1:111122223333:alarm:CompositeDemo.FirstChild\",\"state\":{\"value\":\"ALARM\",\"timestamp\":\"2023-08-04T12:54:46.138+0000\"}}]}", + "timestamp":"2023-08-04T12:56:46.138+0000" + }, + "previousState":{ + "value":"ALARM", + "reason":"arn:aws:cloudwatch:us-east-1:111122223333:alarm:CompositeDemo.FirstChild transitioned to ALARM at Friday 04 August, 2023 12:54:46 UTC", + "reasonData":"{\"triggeringAlarms\":[{\"arn\":\"arn:aws:cloudwatch:us-east-1:111122223333:alarm:CompositeDemo.FirstChild\",\"state\":{\"value\":\"ALARM\",\"timestamp\":\"2023-08-04T12:54:46.138+0000\"}}]}", + "timestamp":"2023-08-04T12:54:46.138+0000", + "actionsSuppressedBy":"WaitPeriod", + "actionsSuppressedReason":"Actions suppressed by WaitPeriod" + }, + "configuration":{ + "alarmRule":"ALARM(CompositeDemo.FirstChild) OR ALARM(CompositeDemo.SecondChild)", + "actionsSuppressor":"CompositeDemo.ActionsSuppressor", + "actionsSuppressorWaitPeriod":120, + "actionsSuppressorExtensionPeriod":180 + } + } +} diff --git a/tests/events/cloudWatchAlarmEvent.json b/tests/events/cloudWatchAlarmEventSingleMetric.json similarity index 93% rename from tests/events/cloudWatchAlarmEvent.json rename to tests/events/cloudWatchAlarmEventSingleMetric.json index 883772232af..fa5089cd6b5 100644 --- a/tests/events/cloudWatchAlarmEvent.json +++ b/tests/events/cloudWatchAlarmEventSingleMetric.json @@ -16,9 +16,7 @@ "value": "OK", "reason": "Threshold Crossed: 1 out of the last 1 datapoints [1.0 (17/02/24 11:50:00)] was not greater than the threshold (10.0) (minimum 1 datapoint for ALARM -> OK transition).", "reasonData": "{\"version\":\"1.0\",\"queryDate\":\"2024-02-17T11:51:31.460+0000\",\"startDate\":\"2024-02-17T11:50:00.000+0000\",\"statistic\":\"SampleCount\",\"period\":60,\"recentDatapoints\":[1.0],\"threshold\":10.0,\"evaluatedDatapoints\":[{\"timestamp\":\"2024-02-17T11:50:00.000+0000\",\"sampleCount\":1.0,\"value\":1.0}]}", - "timestamp": "2024-02-17T11:51:31.462+0000", - "actionsSuppressedBy": "WaitPeriod", - "actionsSuppressedReason": "Actions suppressed by WaitPeriod" + "timestamp": "2024-02-17T11:51:31.462+0000" }, "configuration": { "description": "This is description **here**", @@ -58,4 +56,4 @@ ] } } -} \ No newline at end of file +} diff --git a/tests/unit/data_classes/test_cloud_watch_alarm_event.py b/tests/unit/data_classes/test_cloud_watch_alarm_event.py index 25de9c01c5d..56933a1505d 100644 --- a/tests/unit/data_classes/test_cloud_watch_alarm_event.py +++ b/tests/unit/data_classes/test_cloud_watch_alarm_event.py @@ -1,27 +1,84 @@ +import json +from typing import Dict, List + from aws_lambda_powertools.utilities.data_classes import CloudWatchAlarmEvent from tests.functional.utils import load_event -def test_cloud_watch_alarm_event(): - raw_event = load_event("cloudWatchAlarmEvent.json") +def test_cloud_watch_alarm_event_single_metric(): + raw_event = load_event("cloudWatchAlarmEventSingleMetric.json") + parsed_event = CloudWatchAlarmEvent(raw_event) + + assert parsed_event.source == raw_event["source"] + assert parsed_event.region == raw_event["region"] + assert parsed_event.alarm_arn == raw_event["alarmArn"] + assert parsed_event.alarm_data.alarm_name == raw_event["alarmData"]["alarmName"] + + assert parsed_event.alarm_data.state.value == raw_event["alarmData"]["state"]["value"] + assert parsed_event.alarm_data.state.reason == raw_event["alarmData"]["state"]["reason"] + assert parsed_event.alarm_data.state.reason_data == raw_event["alarmData"]["state"]["reasonData"] + assert parsed_event.alarm_data.state.reason_data_decoded == json.loads( + raw_event["alarmData"]["state"]["reasonData"], + ) + assert parsed_event.alarm_data.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] + + assert parsed_event.alarm_data.previous_state.value == raw_event["alarmData"]["previousState"]["value"] + assert parsed_event.alarm_data.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] + assert parsed_event.alarm_data.previous_state.reason_data == raw_event["alarmData"]["previousState"]["reasonData"] + assert parsed_event.alarm_data.previous_state.reason_data_decoded == json.loads( + raw_event["alarmData"]["previousState"]["reasonData"], + ) + assert parsed_event.alarm_data.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] + + assert parsed_event.alarm_data.configuration.description == raw_event["alarmData"]["configuration"]["description"] + assert parsed_event.alarm_data.configuration.alarm_rule is None + assert parsed_event.alarm_data.configuration.alarm_actions_suppressor is None + assert parsed_event.alarm_data.configuration.alarm_actions_suppressor_extension_period is None + assert parsed_event.alarm_data.configuration.alarm_actions_suppressor_wait_period is None + + assert isinstance(parsed_event.alarm_data.configuration.metrics, List) + # metric position 0 + metric_0 = parsed_event.alarm_data.configuration.metrics[0] + raw_metric_0 = raw_event["alarmData"]["configuration"]["metrics"][0] + assert metric_0.metric_id == raw_metric_0["id"] + assert metric_0.expression == raw_metric_0["expression"] + assert metric_0.label == raw_metric_0["label"] + assert metric_0.return_data == raw_metric_0["returnData"] + + # metric position 1 + metric_1 = parsed_event.alarm_data.configuration.metrics[1] + raw_metric_1 = raw_event["alarmData"]["configuration"]["metrics"][1] + assert metric_1.metric_id == raw_metric_1["id"] + assert metric_1.return_data == raw_metric_1["returnData"] + assert metric_1.metric_stat.stat == raw_metric_1["metricStat"]["stat"] + assert metric_1.metric_stat.period == raw_metric_1["metricStat"]["period"] + assert metric_1.metric_stat.unit is None + assert isinstance(metric_1.metric_stat.metric, Dict) + + +def test_cloud_watch_alarm_event_composite_metric(): + raw_event = load_event("cloudWatchAlarmEventCompositeMetric.json") parsed_event = CloudWatchAlarmEvent(raw_event) assert parsed_event.source == raw_event["source"] assert parsed_event.region == raw_event["region"] assert parsed_event.alarm_arn == raw_event["alarmArn"] - assert parsed_event.alarm_data.description == raw_event["alarmData"]["configuration"]["description"] - assert parsed_event.alarm_data.name == raw_event["alarmData"]["alarmName"] + assert parsed_event.alarm_data.alarm_name == raw_event["alarmData"]["alarmName"] - assert parsed_event.alarm_data.state.value == "ALARM" + assert parsed_event.alarm_data.state.value == raw_event["alarmData"]["state"]["value"] assert parsed_event.alarm_data.state.reason == raw_event["alarmData"]["state"]["reason"] assert parsed_event.alarm_data.state.reason_data == raw_event["alarmData"]["state"]["reasonData"] - assert parsed_event.alarm_data.state.reason_data_decoded["queryDate"] == "2024-02-17T11:53:08.423+0000" + assert parsed_event.alarm_data.state.reason_data_decoded == json.loads( + raw_event["alarmData"]["state"]["reasonData"], + ) assert parsed_event.alarm_data.state.timestamp == raw_event["alarmData"]["state"]["timestamp"] - assert parsed_event.alarm_data.previous_state.value == "OK" + assert parsed_event.alarm_data.previous_state.value == raw_event["alarmData"]["previousState"]["value"] assert parsed_event.alarm_data.previous_state.reason == raw_event["alarmData"]["previousState"]["reason"] assert parsed_event.alarm_data.previous_state.reason_data == raw_event["alarmData"]["previousState"]["reasonData"] - assert parsed_event.alarm_data.previous_state.reason_data_decoded["queryDate"] == "2024-02-17T11:51:31.460+0000" + assert parsed_event.alarm_data.previous_state.reason_data_decoded == json.loads( + raw_event["alarmData"]["previousState"]["reasonData"], + ) assert parsed_event.alarm_data.previous_state.timestamp == raw_event["alarmData"]["previousState"]["timestamp"] assert ( parsed_event.alarm_data.previous_state.actions_suppressed_by @@ -32,33 +89,16 @@ def test_cloud_watch_alarm_event(): == raw_event["alarmData"]["previousState"]["actionsSuppressedReason"] ) - # test the 'expression' metric - assert parsed_event.alarm_data.metrics[0].metric_id == raw_event["alarmData"]["configuration"]["metrics"][0]["id"] - assert ( - parsed_event.alarm_data.metrics[0].expression - == raw_event["alarmData"]["configuration"]["metrics"][0]["expression"] - ) - assert parsed_event.alarm_data.metrics[0].label == raw_event["alarmData"]["configuration"]["metrics"][0]["label"] - assert ( - parsed_event.alarm_data.metrics[0].return_data - == raw_event["alarmData"]["configuration"]["metrics"][0]["returnData"] - ) - - # test the 'metric' metric - assert parsed_event.alarm_data.metrics[1].metric_id == raw_event["alarmData"]["configuration"]["metrics"][1]["id"] - assert ( - parsed_event.alarm_data.metrics[1].name - == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["name"] - ) + assert parsed_event.alarm_data.configuration.alarm_rule == raw_event["alarmData"]["configuration"]["alarmRule"] assert ( - parsed_event.alarm_data.metrics[1].namespace - == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["namespace"] + parsed_event.alarm_data.configuration.alarm_actions_suppressor_wait_period + == raw_event["alarmData"]["configuration"]["actionsSuppressorWaitPeriod"] ) assert ( - parsed_event.alarm_data.metrics[1].dimensions - == raw_event["alarmData"]["configuration"]["metrics"][1]["metricStat"]["metric"]["dimensions"] + parsed_event.alarm_data.configuration.alarm_actions_suppressor_extension_period + == raw_event["alarmData"]["configuration"]["actionsSuppressorExtensionPeriod"] ) assert ( - parsed_event.alarm_data.metrics[1].return_data - == raw_event["alarmData"]["configuration"]["metrics"][1]["returnData"] + parsed_event.alarm_data.configuration.alarm_actions_suppressor + == raw_event["alarmData"]["configuration"]["actionsSuppressor"] ) From 629703d9002137f3fca6d9e7ae0f9f292dbe149c Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Mon, 11 Mar 2024 15:47:55 +0000 Subject: [PATCH 20/20] Refactoring code --- .../utilities/data_classes/cloud_watch_alarm_event.py | 8 ++++---- docs/utilities/data_classes.md | 2 +- examples/event_sources/src/cloudwatch_alarm_event.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py index 8cec9a258a7..d085228cb37 100644 --- a/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_alarm_event.py @@ -19,7 +19,7 @@ def reason(self) -> str: """ Reason why alarm was changed to this state. """ - return self.get("reason") + return self["reason"] @property def reason_data(self) -> str: @@ -27,7 +27,7 @@ def reason_data(self) -> str: Additional data to back up the reason, usually contains the evaluated data points, the calculated threshold and timestamps. """ - return self.get("reasonData", None) + return self["reasonData"] @cached_property def reason_data_decoded(self) -> Optional[Any]: @@ -76,7 +76,7 @@ def expression(self) -> Optional[str]: return self.get("expression", None) @property - def label(self) -> str: + def label(self) -> Optional[str]: """ Optional label of the alarm metric. """ @@ -117,7 +117,7 @@ def unit(self) -> Optional[str]: return self.get("unit", None) @property - def metric(self) -> Dict: + def metric(self) -> Optional[Dict]: """ Metric details """ diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 8a5e8f8aa47..45c9ccd9869 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -531,7 +531,7 @@ In this example, we also use the new Logger `correlation_id` and built-in `corre ### CloudWatch Alarm State Change Action -[CloudWatch supports Lambda as an alarm state change action](https://aws.amazon.com/about-aws/whats-new/2023/12/amazon-cloudwatch-alarms-lambda-change-action/){target="_blank"}. +[CloudWatch supports Lambda as an alarm state change action](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html#alarms-and-actions){target="_blank"}. You can use the `CloudWathAlarmEvent` data class to access the fields containing such data as alarm information, current state, and previous state. === "app.py" diff --git a/examples/event_sources/src/cloudwatch_alarm_event.py b/examples/event_sources/src/cloudwatch_alarm_event.py index 690742f0f72..503c25ef0b0 100644 --- a/examples/event_sources/src/cloudwatch_alarm_event.py +++ b/examples/event_sources/src/cloudwatch_alarm_event.py @@ -7,13 +7,13 @@ @event_source(data_class=CloudWatchAlarmEvent) def lambda_handler(event: CloudWatchAlarmEvent, context: LambdaContext) -> dict: - logger.info(f"Alarm {event.alarm_data.name} state is {event.alarm_data.state.value}") + logger.info(f"Alarm {event.alarm_data.alarm_name} state is {event.alarm_data.state.value}") # You can now work with event. For example, you can enrich the received data, and # decide on how you want to route the alarm. return { - "name": event.alarm_data.name, + "name": event.alarm_data.alarm_name, "arn": event.alarm_arn, - "urgent": "Priority: P1" in (event.alarm_data.description or ""), + "urgent": "Priority: P1" in (event.alarm_data.configuration.description or ""), }