Browse Source

feat(discord): Update message builder for metric alerts (#57079)

Split from https://github.com/getsentry/sentry/pull/55928

The overall feature has been tested and approved.

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Julia Hoge 1 year ago
parent
commit
5bbc056c4c

+ 0 - 1
pyproject.toml

@@ -483,7 +483,6 @@ module = [
     "sentry.integrations.slack.client",
     "sentry.integrations.slack.integration",
     "sentry.integrations.slack.message_builder.issues",
-    "sentry.integrations.slack.message_builder.metric_alerts",
     "sentry.integrations.slack.message_builder.notifications.digest",
     "sentry.integrations.slack.message_builder.notifications.issues",
     "sentry.integrations.slack.notifications",

+ 1 - 0
src/sentry/integrations/discord/message_builder/base/embed/__init__.py

@@ -1,3 +1,4 @@
 from .base import *  # noqa: F401,F403
 from .field import *  # noqa: F401,F403
 from .footer import *  # noqa: F401,F403
+from .image import *  # noqa: F401,F403

+ 6 - 0
src/sentry/integrations/discord/message_builder/base/embed/base.py

@@ -5,6 +5,7 @@ from datetime import datetime
 
 from sentry.integrations.discord.message_builder.base.embed.field import DiscordMessageEmbedField
 from sentry.integrations.discord.message_builder.base.embed.footer import DiscordMessageEmbedFooter
+from sentry.integrations.discord.message_builder.base.embed.image import DiscordMessageEmbedImage
 
 
 class DiscordMessageEmbed:
@@ -25,6 +26,7 @@ class DiscordMessageEmbed:
         footer: DiscordMessageEmbedFooter | None = None,
         fields: Iterable[DiscordMessageEmbedField] | None = None,
         timestamp: datetime | None = None,
+        image: DiscordMessageEmbedImage | None = None,
     ) -> None:
         self.title = title
         self.description = description
@@ -33,6 +35,7 @@ class DiscordMessageEmbed:
         self.footer = footer
         self.fields = fields
         self.timestamp = timestamp
+        self.image = image
 
     def build(self) -> dict[str, object]:
         attributes = vars(self).items()
@@ -47,4 +50,7 @@ class DiscordMessageEmbed:
         if self.timestamp is not None:
             embed["timestamp"] = self.timestamp.isoformat()
 
+        if self.image is not None:
+            embed["image"] = self.image.build()
+
         return embed

+ 19 - 0
src/sentry/integrations/discord/message_builder/base/embed/image.py

@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+
+class DiscordMessageEmbedImage:
+    def __init__(
+        self,
+        url: str,
+        proxy_url: str | None = None,
+        height: int | None = None,
+        width: int | None = None,
+    ) -> None:
+        self.url = url
+        self.proxy_url = proxy_url
+        self.height = height
+        self.width = width
+
+    def build(self) -> dict[str, str]:
+        attributes = vars(self).items()
+        return {k: v for k, v in attributes if v}

+ 53 - 0
src/sentry/integrations/discord/message_builder/metric_alerts.py

@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import time
+from datetime import datetime
+from typing import Optional
+
+from sentry.incidents.models import AlertRule, Incident, IncidentStatus
+from sentry.integrations.discord.message_builder import INCIDENT_COLOR_MAPPING, LEVEL_TO_COLOR
+from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
+from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed
+from sentry.integrations.discord.message_builder.base.embed.image import DiscordMessageEmbedImage
+from sentry.integrations.metric_alerts import metric_alert_attachment_info
+from sentry.integrations.slack.utils.escape import escape_slack_text
+
+
+class DiscordMetricAlertMessageBuilder(DiscordMessageBuilder):
+    def __init__(
+        self,
+        alert_rule: AlertRule,
+        incident: Optional[Incident] = None,
+        new_status: Optional[IncidentStatus] = None,
+        metric_value: Optional[int] = None,
+        chart_url: Optional[str] = None,
+        notification_uuid: str | None = None,
+    ) -> None:
+        self.alert_rule = alert_rule
+        self.incident = incident
+        self.metric_value = metric_value
+        self.new_status = new_status
+        self.chart_url = chart_url
+        self.notification_uuid = notification_uuid
+
+    def build(self, notification_uuid: str | None = None) -> dict[str, object]:
+        data = metric_alert_attachment_info(
+            self.alert_rule, self.incident, self.new_status, self.metric_value
+        )
+
+        embeds = [
+            DiscordMessageEmbed(
+                title=data["title"],
+                url=f"{data['title_link']}&referrer=discord",
+                description=f"<{data['title_link']}|*{escape_slack_text(data['title'])}*>  \n{data['text']}",
+                color=LEVEL_TO_COLOR[INCIDENT_COLOR_MAPPING.get(data["status"], "")],
+                image=DiscordMessageEmbedImage(self.chart_url) if self.chart_url else None,
+            )
+        ]
+
+        return self._build(embeds=embeds)
+
+
+def get_started_at(timestamp: datetime) -> str:
+    unix_timestamp = int(time.mktime(timestamp.timetuple()))
+    return f"Started <t:{unix_timestamp}:R>"

+ 1 - 1
src/sentry/integrations/metric_alerts.py

@@ -149,7 +149,7 @@ def metric_alert_attachment_info(
     alert_rule: AlertRule,
     selected_incident: Optional[Incident] = None,
     new_status: Optional[IncidentStatus] = None,
-    metric_value: Optional[str] = None,
+    metric_value: Optional[int] = None,
 ):
     latest_incident = None
     if selected_incident is None:

+ 178 - 0
tests/sentry/integrations/discord/test_message_builder.py

@@ -0,0 +1,178 @@
+from __future__ import annotations
+
+from django.urls import reverse
+
+from sentry.incidents.logic import CRITICAL_TRIGGER_LABEL
+from sentry.incidents.models import IncidentStatus
+from sentry.integrations.discord.message_builder import LEVEL_TO_COLOR
+from sentry.integrations.discord.message_builder.metric_alerts import (
+    DiscordMetricAlertMessageBuilder,
+)
+from sentry.testutils.cases import TestCase
+from sentry.testutils.silo import region_silo_test
+from sentry.utils.http import absolute_uri
+
+
+@region_silo_test(stable=True)
+class BuildMetricAlertAttachmentTest(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.alert_rule = self.create_alert_rule()
+
+    def test_metric_alert_without_incidents(self):
+        title = f"Resolved: {self.alert_rule.name}"
+        link = absolute_uri(
+            reverse(
+                "sentry-metric-alert-details",
+                kwargs={
+                    "organization_slug": self.alert_rule.organization.slug,
+                    "alert_rule_id": self.alert_rule.id,
+                },
+            )
+        )
+        assert DiscordMetricAlertMessageBuilder(self.alert_rule).build() == {
+            "content": "",
+            "embeds": [
+                {
+                    "title": title,
+                    "description": f"<{link}|*{title}*>  \n",
+                    "url": f"{link}&referrer=discord",
+                    "color": LEVEL_TO_COLOR["_incident_resolved"],
+                }
+            ],
+            "components": [],
+        }
+
+    def test_metric_alert_with_selected_incident(self):
+        new_status = IncidentStatus.CLOSED.value
+        incident = self.create_incident(alert_rule=self.alert_rule, status=new_status)
+        trigger = self.create_alert_rule_trigger(self.alert_rule, CRITICAL_TRIGGER_LABEL, 100)
+        self.create_alert_rule_trigger_action(
+            alert_rule_trigger=trigger, triggered_for_incident=incident
+        )
+        title = f"Resolved: {self.alert_rule.name}"
+        link = (
+            absolute_uri(
+                reverse(
+                    "sentry-metric-alert-details",
+                    kwargs={
+                        "organization_slug": self.alert_rule.organization.slug,
+                        "alert_rule_id": self.alert_rule.id,
+                    },
+                )
+            )
+            + f"?alert={incident.identifier}"
+        )
+
+        assert DiscordMetricAlertMessageBuilder(self.alert_rule, incident).build() == {
+            "content": "",
+            "embeds": [
+                {
+                    "title": title,
+                    "description": f"<{link}|*{title}*>  \n",
+                    "url": f"{link}&referrer=discord",
+                    "color": LEVEL_TO_COLOR["_incident_resolved"],
+                }
+            ],
+            "components": [],
+        }
+
+    def test_metric_alert_with_active_incident(self):
+        incident = self.create_incident(
+            alert_rule=self.alert_rule, status=IncidentStatus.CRITICAL.value
+        )
+        trigger = self.create_alert_rule_trigger(self.alert_rule, CRITICAL_TRIGGER_LABEL, 100)
+        self.create_alert_rule_trigger_action(
+            alert_rule_trigger=trigger, triggered_for_incident=incident
+        )
+        title = f"Critical: {self.alert_rule.name}"
+        link = absolute_uri(
+            reverse(
+                "sentry-metric-alert-details",
+                kwargs={
+                    "organization_slug": self.alert_rule.organization.slug,
+                    "alert_rule_id": self.alert_rule.id,
+                },
+            )
+        )
+
+        assert DiscordMetricAlertMessageBuilder(self.alert_rule).build() == {
+            "content": "",
+            "embeds": [
+                {
+                    "color": LEVEL_TO_COLOR["fatal"],
+                    "title": title,
+                    "description": f"<{link}|*{title}*>  \n0 events in the last 10 minutes",
+                    "url": f"{link}&referrer=discord",
+                }
+            ],
+            "components": [],
+        }
+
+    def test_metric_value(self):
+        incident = self.create_incident(
+            alert_rule=self.alert_rule, status=IncidentStatus.CLOSED.value
+        )
+
+        # This test will use the action/method and not the incident to build status
+        title = f"Critical: {self.alert_rule.name}"
+        metric_value = 5000
+        trigger = self.create_alert_rule_trigger(self.alert_rule, CRITICAL_TRIGGER_LABEL, 100)
+        self.create_alert_rule_trigger_action(
+            alert_rule_trigger=trigger, triggered_for_incident=incident
+        )
+        link = absolute_uri(
+            reverse(
+                "sentry-metric-alert-details",
+                kwargs={
+                    "organization_slug": self.alert_rule.organization.slug,
+                    "alert_rule_id": self.alert_rule.id,
+                },
+            )
+        )
+        assert DiscordMetricAlertMessageBuilder(
+            self.alert_rule, incident, IncidentStatus.CRITICAL, metric_value=metric_value
+        ).build() == {
+            "content": "",
+            "embeds": [
+                {
+                    "title": title,
+                    "color": LEVEL_TO_COLOR["fatal"],
+                    "description": f"<{link}?alert={incident.identifier}|*{title}*>  \n"
+                    f"{metric_value} events in the last 10 minutes",
+                    "url": f"{link}?alert={incident.identifier}&referrer=discord",
+                }
+            ],
+            "components": [],
+        }
+
+    def test_metric_alert_chart(self):
+        title = f"Resolved: {self.alert_rule.name}"
+        link = absolute_uri(
+            reverse(
+                "sentry-metric-alert-details",
+                kwargs={
+                    "organization_slug": self.alert_rule.organization.slug,
+                    "alert_rule_id": self.alert_rule.id,
+                },
+            )
+        )
+        incident = self.create_incident(
+            alert_rule=self.alert_rule, status=IncidentStatus.OPEN.value
+        )
+        new_status = IncidentStatus.CLOSED
+        assert DiscordMetricAlertMessageBuilder(
+            self.alert_rule, incident, new_status, chart_url="chart_url"
+        ).build() == {
+            "content": "",
+            "embeds": [
+                {
+                    "title": title,
+                    "description": f"<{link}?alert={incident.identifier}|*{title}*>  \n",
+                    "url": f"{link}?alert={incident.identifier}&referrer=discord",
+                    "color": 5097329,
+                    "image": {"url": "chart_url"},
+                }
+            ],
+            "components": [],
+        }