Browse Source

feat(discord): Add serializers and trigger actions for Discord metric… (#57092)

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

The overall feature has been tested and approved.
Julia Hoge 1 year ago
parent
commit
49dd5c721d

+ 2 - 0
src/sentry/api/serializers/models/alert_rule_trigger_action.py

@@ -22,6 +22,8 @@ class AlertRuleTriggerActionSerializer(Serializer):
             return "Send a notification via " + action.target_display
         elif action.type == action.Type.OPSGENIE.value:
             return "Send an Opsgenie notification to " + action.target_display
+        elif action.type == action.Type.DISCORD.value:
+            return "Send a Discord notification to " + action.target_display
 
     def get_identifier_from_action(self, action):
         if action.type in [

+ 38 - 0
src/sentry/api/serializers/rest_framework/notification_action.py

@@ -205,6 +205,43 @@ class NotificationActionSerializer(CamelSnakeModelSerializer):
         data["target_identifier"] = channel_id
         return data
 
+    def validate_discord_channel(
+        self, data: NotificationActionInputData
+    ) -> NotificationActionInputData:
+        """
+        Validates that SPECIFIC targets for DISCORD service have the following target data:
+            target_display: Discord channel id
+            target_identifier: Discord channel id
+        NOTE: Reaches out to via discord integration to verify channel
+        """
+        from sentry.integrations.discord.utils.channel import validate_channel_id
+
+        if (
+            data["service_type"] != ActionService.DISCORD.value
+            or data["target_type"] != ActionTarget.SPECIFIC.value
+        ):
+            return data
+
+        channel_name = data.get("target_display")
+        channel_id = data.get("target_identifier")
+
+        if not channel_id and channel_name:
+            raise serializers.ValidationError(
+                {"target_identifier": "Did not receive a discord channel id."}
+            )
+
+        try:
+            validate_channel_id(
+                channel_id=channel_id,
+                guild_id=self.integration.external_id,
+                integration_id=self.integration.id,
+            )
+        except Exception as e:
+            raise serializers.ValidationError({"target_identifier": str(e)})
+
+        data["target_identifier"] = channel_id
+        return data
+
     def validate_pagerduty_service(
         self, data: NotificationActionInputData
     ) -> NotificationActionInputData:
@@ -262,6 +299,7 @@ class NotificationActionSerializer(CamelSnakeModelSerializer):
 
         data = self.validate_slack_channel(data)
         data = self.validate_pagerduty_service(data)
+        data = self.validate_discord_channel(data)
 
         return data
 

+ 26 - 0
src/sentry/incidents/action_handlers.py

@@ -258,6 +258,32 @@ class MsTeamsActionHandler(DefaultActionHandler):
             self.record_alert_sent_analytics(self.action.target_identifier, notification_uuid)
 
 
+@AlertRuleTriggerAction.register_type(
+    "discord",
+    AlertRuleTriggerAction.Type.DISCORD,
+    [AlertRuleTriggerAction.TargetType.SPECIFIC],
+    integration_provider="discord",
+)
+class DiscordActionHandler(DefaultActionHandler):
+    provider = "discord"
+
+    def send_alert(
+        self,
+        metric_value: int | float,
+        new_status: IncidentStatus,
+        notification_uuid: str | None = None,
+    ):
+        from sentry.integrations.discord.actions.metric_alert import (
+            send_incident_alert_notification,
+        )
+
+        success = send_incident_alert_notification(
+            self.action, self.incident, metric_value, new_status, notification_uuid
+        )
+        if success:
+            self.record_alert_sent_analytics(self.action.target_identifier, notification_uuid)
+
+
 @AlertRuleTriggerAction.register_type(
     "pagerduty",
     AlertRuleTriggerAction.Type.PAGERDUTY,

+ 5 - 0
src/sentry/incidents/serializers/alert_rule_trigger_action.py

@@ -116,6 +116,11 @@ class AlertRuleTriggerActionSerializer(CamelSnakeModelSerializer):
                 raise serializers.ValidationError(
                     {"integration": "Integration must be provided for slack"}
                 )
+        elif attrs.get("type") == AlertRuleTriggerAction.Type.DISCORD:
+            if not attrs.get("integration_id"):
+                raise serializers.ValidationError(
+                    {"integration": "Integration must be provided for discord"}
+                )
 
         elif attrs.get("type") == AlertRuleTriggerAction.Type.SENTRY_APP:
             sentry_app_installation_uuid = attrs.get("sentry_app_installation_uuid")

+ 56 - 0
src/sentry/integrations/discord/actions/metric_alert.py

@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import sentry_sdk
+
+from sentry import features
+from sentry.incidents.charts import build_metric_alert_chart
+from sentry.incidents.models import AlertRuleTriggerAction, Incident, IncidentStatus
+from sentry.integrations.discord.client import DiscordClient
+from sentry.integrations.discord.message_builder.metric_alerts import (
+    DiscordMetricAlertMessageBuilder,
+)
+from sentry.shared_integrations.exceptions.base import ApiError
+
+from ..utils import logger
+
+
+def send_incident_alert_notification(
+    action: AlertRuleTriggerAction,
+    incident: Incident,
+    metric_value: int,
+    new_status: IncidentStatus,
+    notification_uuid: str | None = None,
+) -> None:
+    chart_url = None
+    if features.has("organizations:metric-alert-chartcuterie", incident.organization):
+        try:
+            chart_url = build_metric_alert_chart(
+                organization=incident.organization,
+                alert_rule=incident.alert_rule,
+                selected_incident=incident,
+            )
+        except Exception as e:
+            sentry_sdk.capture_exception(e)
+
+    channel = action.target_identifier
+
+    if not channel:
+        # We can't send a message if we don't know the channel
+        logger.warning(
+            "discord.metric_alert.no_channel",
+            extra={"guild_id": incident.identifier},
+        )
+        return
+
+    message = DiscordMetricAlertMessageBuilder(
+        incident.alert_rule, incident, new_status, metric_value, chart_url, notification_uuid
+    )
+
+    client = DiscordClient(integration_id=incident.identifier)
+    try:
+        client.send_message(channel, message)
+    except ApiError as error:
+        logger.warning(
+            "discord.metric_alert.messsage_send_failure",
+            extra={"error": error, "guild_id": incident.identifier, "channel_id": channel},
+        )

+ 40 - 0
tests/sentry/api/serializers/test_alert_rule_trigger_action.py

@@ -1,7 +1,10 @@
+import responses
+
 from sentry.api.serializers import serialize
 from sentry.incidents.logic import create_alert_rule_trigger, create_alert_rule_trigger_action
 from sentry.incidents.models import AlertRuleTriggerAction
 from sentry.incidents.serializers import ACTION_TARGET_TYPE_TO_STRING
+from sentry.models.integrations.integration import Integration
 from sentry.testutils.cases import TestCase
 
 
@@ -34,3 +37,40 @@ class AlertRuleTriggerActionSerializerTest(TestCase):
         )
         result = serialize(action)
         self.assert_action_serialized(action, result)
+        assert result["desc"] == action.target_display
+
+    @responses.activate
+    def test_discord(self):
+        base_url: str = "https://discord.com/api/v10"
+        responses.add(
+            method=responses.GET,
+            url=f"{base_url}/channels/channel-id",
+            json={
+                "guild_id": "guild_id",
+                "name": "guild_id",
+            },
+        )
+
+        alert_rule = self.create_alert_rule()
+        integration = Integration.objects.create(
+            provider="discord",
+            name="Example Discord",
+            external_id="guild_id",
+            metadata={
+                "guild_id": "guild_id",
+                "name": "guild_name",
+            },
+        )
+        trigger = create_alert_rule_trigger(alert_rule, "hi", 1000)
+        with self.feature("organizations:integrations-discord-metric-alerts"):
+            action = create_alert_rule_trigger_action(
+                trigger,
+                AlertRuleTriggerAction.Type.DISCORD,
+                AlertRuleTriggerAction.TargetType.SPECIFIC,
+                target_identifier="channel-id",
+                integration_id=integration.id,
+            )
+
+        result = serialize(action)
+        self.assert_action_serialized(action, result)
+        assert str(action.target_display) in result["desc"]