diff --git a/pyproject.toml b/pyproject.toml index e28cd62..e671824 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,12 @@ mastodon-discord = [ "mastodon-py>=2.2.2", "requests>=2.34.2", ] +sns-discord = [ + "hgtk>=0.2.1", +] [tool.uv] -default-groups = ["dev", "grafana-github", "mastodon-discord"] +default-groups = ["dev", "grafana-github", "mastodon-discord", "sns-discord"] [tool.ruff.lint] extend-select = [ diff --git a/sns-discord/lambda_function.py b/sns-discord/lambda_function.py index fd26d8b..672d447 100644 --- a/sns-discord/lambda_function.py +++ b/sns-discord/lambda_function.py @@ -1,12 +1,15 @@ -"""Forward SNS CloudWatch alarm notifications to a Discord webhook.""" +"""Post CloudWatch alarm and AWS Budgets notifications from SNS to Discord in Korean.""" import json import os +import re import urllib.error import urllib.request +from datetime import datetime, timedelta, timezone from typing import Any import boto3 +import hgtk RED = 0xE83535 GREEN = 0x2DAF32 @@ -21,6 +24,22 @@ "MINIMUM": "Minimum", "MAXIMUM": "Maximum", } +STAT_KOREAN = { + "SAMPLECOUNT": "표본 수", + "AVERAGE": "평균", + "SUM": "합계", + "MINIMUM": "최솟값", + "MAXIMUM": "최댓값", +} +COMPARISON_KOREAN = { + "GreaterThanThreshold": "초과", + "GreaterThanOrEqualToThreshold": "이상", + "LessThanThreshold": "미만", + "LessThanOrEqualToThreshold": "이하", +} +KST = timezone(timedelta(hours=9), "KST") +# A datapoint in NewStateReason, e.g. 3184.0 (03/10/26 01:37:00), in UTC +DATAPOINT = re.compile(r"(-?[\d.]+(?:E-?\d+)?) \((\d\d/\d\d/\d\d \d\d:\d\d:\d\d)\)") CHART_WINDOW_MULTIPLIER = 12 CHART_MIN_MINUTES = 30 CHART_MAX_MINUTES = 360 @@ -76,12 +95,7 @@ def lambda_handler(event: Any, context: Any) -> None: def build_payload(post_data: dict[str, Any], chart: bytes | None) -> dict[str, Any]: embed = post_data["embed"] if chart is not None: - embed = { - **embed, - "description": "", - "fields": [], - "image": {"url": "attachment://chart.png"}, - } + embed = {**embed, "image": {"url": "attachment://chart.png"}} return { "content": post_data["content"], "embeds": [embed], @@ -91,39 +105,33 @@ def build_payload(post_data: dict[str, Any], chart: bytes | None) -> dict[str, A def parse_message(event: Any) -> dict[str, Any]: message = sns_message(event) - fields: list[dict[str, Any]] = [] trigger = None region = None if message is None: notify = True color = RED summary = "알지 못하는 유형의 이벤트가 발생했습니다." - dump = json.dumps(event, indent=2, ensure_ascii=False) + description = code_block(json.dumps(event, indent=2, ensure_ascii=False)) else: try: parsed = json.loads(message) except ValueError: notify = True color = RED - summary = "" - dump = message + budget = budget_summary(message) + summary = budget or "" + description = "" if budget else code_block(message) else: alarm = parsed if isinstance(parsed, dict) else {} test = alarm.get("AlarmName") == "_Test" notify = alarm.get("NewStateValue") != "OK" and not test color = GRAY if test else RED if notify else GREEN - alarm_name = alarm.get("AlarmName") - reason = alarm.get("NewStateReason") - summary = "[{}] {}".format( - alarm_name - if isinstance(alarm_name, str) - else "(메시지에 AlarmName이 없습니다)", - reason - if isinstance(reason, str) - else "(메시지에 NewStateReason이 없습니다)", + summary = alarm_summary(alarm) + # Written by whoever made the alarm, so already in their words + alarm_description = alarm.get("AlarmDescription") + description = ( + alarm_description if isinstance(alarm_description, str) else "" ) - fields = message_to_fields(alarm) - dump = "" if isinstance(alarm.get("Trigger"), dict): trigger = alarm["Trigger"] region = alarm_region(alarm) @@ -131,16 +139,33 @@ def parse_message(event: Any) -> dict[str, Any]: mention = f"<@&{MENTION_ROLE}> " if notify else "🟢 " return { "content": mention + summary, - "embed": { - "color": color, - "description": f"```json\n{dump}\n```" if dump else "", - "fields": fields, - }, + "embed": {"color": color, "description": description}, "trigger": trigger, "region": region, } +def budget_summary(message: str) -> str | None: + # AWS Budgets sends plain text with lines such as "Budget Name: lambda" + lines = dict(line.split(": ", 1) for line in message.splitlines() if ": " in line) + name = lines.get("Budget Name") + kind = lines.get("Alert Type") + threshold = lines.get("Alert Threshold") + if name is None or kind is None or threshold is None: + return None + noun = {"ACTUAL": "실제", "FORECASTED": "예상"}.get(kind, kind) + summary = f"[예산 {name}] {noun} 비용이 알림 기준({threshold})을 넘었습니다." + figures = [ + f"{label} {amount}" + for label, amount in ( + (noun, lines.get(f"{kind} Amount")), + ("예산", lines.get("Budgeted Amount")), + ) + if amount is not None + ] + return f"{summary} {', '.join(figures)}." if figures else summary + + def alarm_region(alarm: dict[str, Any]) -> str | None: # arn:aws:cloudwatch:::alarm: arn = alarm.get("AlarmArn") @@ -148,6 +173,105 @@ def alarm_region(alarm: dict[str, Any]) -> str | None: return parts[3] if len(parts) > 3 and parts[3] else None +def alarm_summary(alarm: dict[str, Any]) -> str: + name = alarm.get("AlarmName") + state = {"ALARM": "경보", "OK": "해제", "INSUFFICIENT_DATA": "데이터 부족"}.get( + alarm.get("NewStateValue"), "상태 알 수 없음" + ) + when = parse_time(alarm.get("StateChangeTime"), "%Y-%m-%dT%H:%M:%S.%f%z") + head = "[{}] {}".format( + name if isinstance(name, str) else "(메시지에 AlarmName이 없습니다)", + state if when is None else f"{state}, {when:%-m월 %-d일 %H:%M} KST", + ) + trigger = alarm.get("Trigger") + condition = ( + alarm_condition(trigger, alarm.get("NewStateValue")) + if isinstance(trigger, dict) + else None + ) + reason = alarm.get("NewStateReason") + datapoints = recent_datapoints(reason) if isinstance(reason, str) else None + return " ".join( + part + for part in (head + ".", condition, datapoints and f"최근 값 {datapoints}.") + if part + ) + + +def alarm_condition(trigger: dict[str, Any], state: Any) -> str | None: + period = trigger.get("Period") + threshold = trigger.get("Threshold") + comparison = COMPARISON_KOREAN.get(trigger.get("ComparisonOperator")) + raw_stat = trigger.get("ExtendedStatistic") or trigger.get("Statistic") + stat = ( + STAT_KOREAN.get(raw_stat.upper(), f"{raw_stat} 값") + if isinstance(raw_stat, str) + else None + ) + if ( + not isinstance(period, int) + or not isinstance(threshold, (int, float)) + or comparison is None + or stat is None + ): + return None + + rule = f"기준({format_number(threshold)} {comparison})" + subject = f"{format_period(period)} {hgtk.josa.attach(stat, hgtk.josa.I_GA)}" + if state == "ALARM": + evaluated = trigger.get("EvaluationPeriods") + breached = trigger.get("DatapointsToAlarm") or evaluated + if isinstance(evaluated, int) and isinstance(breached, int): + return f"{subject} 최근 {evaluated}번 중 {breached}번 {rule}에 걸렸습니다." + return f"{subject} {rule}에 걸렸습니다." + if state == "OK": + return f"{subject} {rule}에 걸리지 않습니다." + return f"{rule}을 판단할 데이터가 모자랍니다." + + +def recent_datapoints(reason: str) -> str | None: + points = [] + for value, stamp in DATAPOINT.findall(reason): + when = parse_time(stamp + "+0000", "%d/%m/%y %H:%M:%S%z") + try: + number = float(value) + except ValueError: + continue + points.append( + format_number(number) + if when is None + else f"{format_number(number)}({when:%H:%M})" + ) + return ", ".join(points) or None + + +def parse_time(value: Any, pattern: str) -> datetime | None: + if not isinstance(value, str): + return None + try: + return datetime.strptime(value, pattern).astimezone(KST) + except ValueError: + return None + + +def format_period(seconds: int) -> str: + if seconds % 86400 == 0: + return f"{seconds // 86400}일" + if seconds % 3600 == 0: + return f"{seconds // 3600}시간" + if seconds % 60 == 0: + return f"{seconds // 60}분" + return f"{seconds}초" + + +def format_number(value: float) -> str: + if value == int(value): + return f"{int(value):,}" + if abs(value) >= 1: + return f"{value:,.2f}".rstrip("0").rstrip(".") + return f"{value:.3g}" + + def sns_message(event: Any) -> str | None: try: message = event["Records"][0]["Sns"]["Message"] @@ -156,26 +280,8 @@ def sns_message(event: Any) -> str | None: return message if isinstance(message, str) else None -def message_to_fields(message: dict[str, Any]) -> list[dict[str, Any]]: - fields = [] - for key, value in message.items(): - if key in ("NewStateReason", "AlarmName"): - continue - text, inline = value_to_string(value) - fields.append({"name": key, "value": text, "inline": inline}) - return fields - - -def value_to_string(value: Any) -> tuple[str, bool]: - if isinstance(value, str): - text = value - else: - stringified = json.dumps(value, indent=2, ensure_ascii=False) - if "\n" in stringified: - text = f"```json\n{stringified}\n```" - else: - text = f"`{stringified}`" - return text, "\n" not in text +def code_block(text: str) -> str: + return f"```json\n{text}\n```" def build_chart_widget( @@ -231,9 +337,7 @@ def build_chart_widget( widget["region"] = region threshold = trigger.get("Threshold") if isinstance(threshold, (int, float)): - widget["annotations"] = { - "horizontal": [{"value": threshold, "label": "Threshold"}] - } + widget["annotations"] = {"horizontal": [{"value": threshold, "label": "기준"}]} return widget diff --git a/sns-discord/test_lambda_function.py b/sns-discord/test_lambda_function.py index e4a4e52..0b9ae87 100644 --- a/sns-discord/test_lambda_function.py +++ b/sns-discord/test_lambda_function.py @@ -6,13 +6,12 @@ GRAY, GREEN, RED, + alarm_summary, build_chart_widget, build_payload, encode_multipart, fetch_chart_image, - message_to_fields, parse_message, - value_to_string, ) FULL_TRIGGER = { @@ -58,7 +57,7 @@ def test_alarm(self): ) self.assertEqual( post_data["content"], - "<@&678974055365476392> [Femiwiki CPU credit balance] Threshold Crossed: 1 out of the last 1 datapoints was less than the threshold.", + "<@&678974055365476392> [Femiwiki CPU credit balance] 경보.", "An alarm should be parsed as an alarm.", ) self.assertEqual(post_data["embed"]["color"], RED) @@ -82,7 +81,7 @@ def test_ok(self): ) self.assertEqual( post_data["content"], - "🟢 [Test] (메시지에 NewStateReason이 없습니다)", + "🟢 [Test] 해제.", "An OK should be parsed as an OK.", ) self.assertEqual(post_data["embed"]["color"], GREEN) @@ -139,7 +138,7 @@ def test_full_alarm(self): ) self.assertEqual( post_data["content"], - "<@&678974055365476392> [Femiwiki CPU credit balance] Threshold Crossed: 1 out of the last 1 datapoints [71.58514626666667 (09/04/22 21:01:00)] was less than the threshold (72.0) (minimum 1 datapoint for OK -> ALARM transition).", + "<@&678974055365476392> [Femiwiki CPU credit balance] 경보, 4월 10일 06:06 KST. 5분 최솟값이 최근 1번 중 1번 기준(72 미만)에 걸렸습니다. 최근 값 71.59(06:01).", ) self.assertEqual(post_data["embed"]["color"], RED) self.assertEqual(post_data["trigger"]["MetricName"], "CPUCreditBalance") @@ -166,46 +165,160 @@ def test_test_alarm(self): ) self.assertEqual( post_data["content"], - "🟢 [_Test] 테스트", + "🟢 [_Test] 해제.", "A test alarm should be parsed as a test alarm.", ) self.assertEqual(post_data["embed"]["color"], GRAY) self.assertIsNone(post_data["trigger"]) - -class MessageToFieldsTest(unittest.TestCase): - def test_message_to_fields(self): - fields = message_to_fields( + def test_description_is_the_alarm_description(self): + post_data = parse_message( { - "NewStateValue": "ALARM", - "OldStateValue": "OK", - "NewStateReason": "Threshold Crossed: 1 out of the last 1 datapoints [71.58514626666667 (09/04/22 21:01:00)] was less than the threshold (72.0) (minimum 1 datapoint for OK -> ALARM transition).", + "Records": [ + { + "Sns": { + "Message": json.dumps( + { + "AlarmName": "_Test", + "AlarmDescription": "시험입니다.", + "NewStateValue": "ALARM", + "AWSAccountId": "302617221463", + }, + ensure_ascii=False, + ) + } + } + ] } ) - self.assertEqual(len(fields), 2, "NewStateReason should be removed") - self.assertEqual(fields[1]["value"], "OK") + self.assertEqual( + post_data["embed"], {"color": GRAY, "description": "시험입니다."} + ) - fields = message_to_fields( - { - "InsufficientDataActions": [], - "OKActions": [], - } + def test_plain_text_is_dumped(self): + post_data = parse_message({"Records": [{"Sns": {"Message": "hello"}}]}) + self.assertEqual(post_data["embed"]["description"], "```json\nhello\n```") + + def test_budget(self): + message = ( + "AWS Budget Notification October 03, 2026\n" + "AWS Account 302617221463\n\n" + "Dear AWS Customer,\n\n" + "You requested that we alert you when the ACTUAL Cost associated with" + " your lambda budget is greater than $0.01 for the current month." + " The ACTUAL Cost associated with this budget is $0.02.\n\n" + "Budget Name: lambda\n" + "Budget Type: Cost\n" + "Budgeted Amount: $1.00\n" + "Alert Type: ACTUAL\n" + "Alert Threshold: > $0.01\n" + "ACTUAL Amount: $0.02\n\n" + "[1] https://console.aws.amazon.com/billing/home#/budgets\n" + ) + post_data = parse_message({"Records": [{"Sns": {"Message": message}}]}) + self.assertEqual( + post_data["content"], + "<@&678974055365476392> [예산 lambda] 실제 비용이 알림 기준(> $0.01)을" + " 넘었습니다. 실제 $0.02, 예산 $1.00.", + ) + self.assertEqual(post_data["embed"], {"color": RED, "description": ""}) + + def test_forecasted_budget(self): + message = ( + "Budget Name: lambda\n" + "Alert Type: FORECASTED\n" + "Alert Threshold: > $0.01\n" + "FORECASTED Amount: $0.05\n" + ) + post_data = parse_message({"Records": [{"Sns": {"Message": message}}]}) + self.assertEqual( + post_data["content"], + "<@&678974055365476392> [예산 lambda] 예상 비용이 알림 기준(> $0.01)을" + " 넘었습니다. 예상 $0.05.", ) - self.assertEqual(len(fields), 2) - self.assertEqual(fields[1]["value"], "`[]`") -class ValueToStringTest(unittest.TestCase): - def test_value_to_string(self): - self.assertEqual(value_to_string(None), ("`null`", True)) - self.assertEqual(value_to_string("Foo"), ("Foo", True)) +class AlarmSummaryTest(unittest.TestCase): + def test_ok_after_insufficient_data(self): self.assertEqual( - value_to_string(["Foo", "Bar"]), - ('```json\n[\n "Foo",\n "Bar"\n]\n```', False), + alarm_summary( + { + "AlarmName": "CloudFront Requests", + "NewStateValue": "OK", + "NewStateReason": "Threshold Crossed: 3 out of the last 3 datapoints [3184.0 (03/10/26 01:37:00), 2856.0 (03/10/26 01:32:00), 3223.0 (03/10/26 01:27:00)] were not greater than the threshold (60000.0) (minimum 1 datapoint for ALARM -> OK transition).", + "StateChangeTime": "2026-10-03T01:46:52.498+0000", + "OldStateValue": "INSUFFICIENT_DATA", + "Trigger": { + "Statistic": "SUM", + "Period": 300, + "EvaluationPeriods": 3, + "DatapointsToAlarm": 3, + "ComparisonOperator": "GreaterThanThreshold", + "Threshold": 60000.0, + }, + } + ), + "[CloudFront Requests] 해제, 10월 3일 10:46 KST. 5분 합계가 기준(60,000 초과)에 걸리지 않습니다. 최근 값 3,184(10:37), 2,856(10:32), 3,223(10:27).", ) + + def test_insufficient_data(self): self.assertEqual( - value_to_string({"Foo": "bar"}), - ('```json\n{\n "Foo": "bar"\n}\n```', False), + alarm_summary( + { + "AlarmName": "Bounce Rate", + "NewStateValue": "INSUFFICIENT_DATA", + "NewStateReason": "Insufficient Data: 1 datapoint was unknown.", + "StateChangeTime": "2026-10-02T17:33:39.107+0000", + "Trigger": { + "Statistic": "AVERAGE", + "Period": 300, + "EvaluationPeriods": 1, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "Threshold": 0.05, + }, + } + ), + "[Bounce Rate] 데이터 부족, 10월 3일 02:33 KST. 기준(0.05 이상)을 판단할 데이터가 모자랍니다.", + ) + + def test_extended_statistic_and_hours(self): + self.assertEqual( + alarm_summary( + { + "AlarmName": "Latency", + "NewStateValue": "ALARM", + "Trigger": { + "ExtendedStatistic": "p90", + "Period": 3600, + "EvaluationPeriods": 2, + "ComparisonOperator": "GreaterThanThreshold", + "Threshold": 1.5, + }, + } + ), + "[Latency] 경보. 1시간 p90 값이 최근 2번 중 2번 기준(1.5 초과)에 걸렸습니다.", + ) + + def test_unknown_comparison_skips_the_condition(self): + self.assertEqual( + alarm_summary( + { + "AlarmName": "Anomaly", + "NewStateValue": "ALARM", + "Trigger": { + "Statistic": "SUM", + "Period": 60, + "ComparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + }, + } + ), + "[Anomaly] 경보.", + ) + + def test_missing_name(self): + self.assertEqual( + alarm_summary({"NewStateValue": "OK"}), + "[(메시지에 AlarmName이 없습니다)] 해제.", ) @@ -226,7 +339,7 @@ def test_full_trigger(self): ], ) self.assertEqual( - widget["annotations"]["horizontal"], [{"value": 72.0, "label": "Threshold"}] + widget["annotations"]["horizontal"], [{"value": 72.0, "label": "기준"}] ) self.assertEqual(widget["end"], "PT0H") @@ -299,36 +412,24 @@ def test_success_returns_image_bytes(self): class BuildPayloadTest(unittest.TestCase): - def test_no_chart_keeps_fields_and_description(self): - post_data = { - "content": "hi", - "embed": { - "color": RED, - "description": "```json\n{}\n```", - "fields": [{"name": "a", "value": "b", "inline": True}], - }, - } + def test_no_chart(self): + post_data = {"content": "hi", "embed": {"color": RED, "description": "설명"}} payload = build_payload(post_data, None) - embed = payload["embeds"][0] - self.assertEqual(embed["description"], "```json\n{}\n```") - self.assertEqual(embed["fields"], [{"name": "a", "value": "b", "inline": True}]) - self.assertNotIn("image", embed) - - def test_chart_strips_fields_and_description(self): - post_data = { - "content": "hi", - "embed": { - "color": RED, - "description": "```json\n{}\n```", - "fields": [{"name": "a", "value": "b", "inline": True}], - }, - } + self.assertEqual(payload["embeds"], [{"color": RED, "description": "설명"}]) + + def test_chart_keeps_the_description(self): + post_data = {"content": "hi", "embed": {"color": RED, "description": "설명"}} payload = build_payload(post_data, b"\x89PNG...") - embed = payload["embeds"][0] - self.assertEqual(embed["description"], "") - self.assertEqual(embed["fields"], []) - self.assertEqual(embed["image"], {"url": "attachment://chart.png"}) - self.assertEqual(embed["color"], RED) + self.assertEqual( + payload["embeds"], + [ + { + "color": RED, + "description": "설명", + "image": {"url": "attachment://chart.png"}, + } + ], + ) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index 554dc16..2d08234 100644 --- a/uv.lock +++ b/uv.lock @@ -299,6 +299,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] +[[package]] +name = "hgtk" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/d0/d62a73954ab95a8d3967a063c371c16feddfb8bd2957fd11691adf7834e8/hgtk-0.2.1-py2.py3-none-any.whl", hash = "sha256:f3e33dacf6ab2564f6257418b718e2c7a4ae9fffa32e18d6c4f6278b72ba73ee", size = 12011, upload-time = "2023-09-17T10:36:01.773Z" }, +] + [[package]] name = "html2text" version = "2025.4.15" @@ -508,6 +516,9 @@ mastodon-discord = [ { name = "mastodon-py" }, { name = "requests" }, ] +sns-discord = [ + { name = "hgtk" }, +] [package.metadata] @@ -523,6 +534,7 @@ mastodon-discord = [ { name = "mastodon-py", specifier = ">=2.2.2" }, { name = "requests", specifier = ">=2.34.2" }, ] +sns-discord = [{ name = "hgtk", specifier = ">=0.2.1" }] [[package]] name = "ty"