Skip to content

Commit f614564

Browse files
feat(np): Adds Discord metric alert renderer
1 parent 266c9b6 commit f614564

File tree

4 files changed

+221
-2
lines changed

4 files changed

+221
-2
lines changed

src/sentry/notifications/platform/discord/provider.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,14 @@ def get_renderer(
142142
cls, *, data: NotificationData, category: NotificationCategory
143143
) -> type[NotificationRenderer[DiscordRenderable]]:
144144
from sentry.notifications.platform.discord.renderers.issue import IssueDiscordRenderer
145+
from sentry.notifications.platform.discord.renderers.metric_alert import (
146+
DiscordMetricAlertRenderer,
147+
)
145148

146149
if category == NotificationCategory.ISSUE:
147150
return IssueDiscordRenderer
151+
if category == NotificationCategory.METRIC_ALERT:
152+
return DiscordMetricAlertRenderer
148153
return cls.default_renderer
149154

150155
@classmethod
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from sentry.incidents.models.incident import IncidentStatus
4+
from sentry.integrations.discord.message_builder import INCIDENT_COLOR_MAPPING, LEVEL_TO_COLOR
5+
from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
6+
from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed
7+
from sentry.integrations.discord.message_builder.base.embed.image import DiscordMessageEmbedImage
8+
from sentry.integrations.discord.message_builder.metric_alerts import get_started_at
9+
from sentry.integrations.metric_alerts import get_status_text
10+
from sentry.notifications.platform.discord.provider import DiscordRenderable
11+
from sentry.notifications.platform.renderer import NotificationRenderer
12+
from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData
13+
from sentry.notifications.platform.types import (
14+
NotificationData,
15+
NotificationProviderKey,
16+
NotificationRenderedTemplate,
17+
)
18+
19+
20+
class DiscordMetricAlertRenderer(NotificationRenderer[DiscordRenderable]):
21+
provider_key = NotificationProviderKey.DISCORD
22+
23+
@classmethod
24+
def render[DataT: NotificationData](
25+
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
26+
) -> DiscordRenderable:
27+
if not isinstance(data, MetricAlertNotificationData):
28+
raise ValueError(
29+
f"DiscordMetricAlertRenderer does not support {data.__class__.__name__}"
30+
)
31+
32+
status = get_status_text(IncidentStatus(data.new_status))
33+
description = f"{data.text}{get_started_at(data.open_period_context.date_started)}"
34+
color = LEVEL_TO_COLOR.get(INCIDENT_COLOR_MAPPING.get(status, ""))
35+
36+
embed = DiscordMessageEmbed(
37+
title=data.title,
38+
url=data.title_link,
39+
description=description,
40+
color=color,
41+
image=(DiscordMessageEmbedImage(url=data.chart_url) if data.chart_url else None),
42+
)
43+
44+
return DiscordMessageBuilder(embeds=[embed]).build()
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
from typing import Any
5+
6+
import pytest
7+
8+
from sentry.incidents.models.incident import IncidentStatus
9+
from sentry.incidents.typings.metric_detector import OpenPeriodContext
10+
from sentry.integrations.discord.message_builder import INCIDENT_COLOR_MAPPING, LEVEL_TO_COLOR
11+
from sentry.notifications.platform.discord.provider import (
12+
DiscordNotificationProvider,
13+
)
14+
from sentry.notifications.platform.discord.renderers.metric_alert import (
15+
DiscordMetricAlertRenderer,
16+
)
17+
from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData
18+
from sentry.notifications.platform.templates.seer import SeerAutofixError
19+
from sentry.notifications.platform.types import (
20+
NotificationCategory,
21+
NotificationRenderedTemplate,
22+
)
23+
from sentry.testutils.cases import TestCase
24+
from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import (
25+
MetricAlertHandlerBase,
26+
)
27+
28+
MOCK_CHART_URL = "https://chart.example.com/metric.png"
29+
30+
31+
def _make_notification_data(**overrides: Any) -> MetricAlertNotificationData:
32+
defaults: dict[str, Any] = dict(
33+
group_id=1,
34+
organization_id=1,
35+
notification_uuid="test-uuid",
36+
action_id=1,
37+
open_period_context=OpenPeriodContext(
38+
id=1,
39+
date_started=datetime(2024, 1, 1, tzinfo=timezone.utc),
40+
),
41+
new_status=IncidentStatus.CRITICAL.value,
42+
title="Critical: Test Alert",
43+
title_link="https://sentry.io/alerts/1/",
44+
text="123 events in the last 5 minutes",
45+
)
46+
defaults.update(overrides)
47+
return MetricAlertNotificationData(**defaults)
48+
49+
50+
class DiscordMetricAlertRendererInvalidDataTest(TestCase):
51+
def test_render_raises_on_invalid_data_type(self) -> None:
52+
invalid_data = SeerAutofixError(error_message="not a metric alert")
53+
rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[])
54+
55+
with pytest.raises(ValueError, match="does not support"):
56+
DiscordMetricAlertRenderer.render(
57+
data=invalid_data,
58+
rendered_template=rendered_template,
59+
)
60+
61+
62+
class DiscordMetricAlertProviderDispatchTest(TestCase):
63+
def test_provider_returns_metric_alert_renderer(self) -> None:
64+
data = _make_notification_data()
65+
renderer = DiscordNotificationProvider.get_renderer(
66+
data=data,
67+
category=NotificationCategory.METRIC_ALERT,
68+
)
69+
assert renderer is DiscordMetricAlertRenderer
70+
71+
def test_provider_returns_default_for_unknown_category(self) -> None:
72+
data = _make_notification_data()
73+
renderer = DiscordNotificationProvider.get_renderer(
74+
data=data,
75+
category=NotificationCategory.DEBUG,
76+
)
77+
assert renderer is DiscordNotificationProvider.default_renderer
78+
79+
80+
class DiscordMetricAlertRendererTest(MetricAlertHandlerBase):
81+
def setUp(self) -> None:
82+
super().setUp()
83+
self.create_models()
84+
85+
open_period_context = OpenPeriodContext.from_group(self.group)
86+
87+
self.notification_data = _make_notification_data(
88+
group_id=self.group.id,
89+
organization_id=self.organization.id,
90+
open_period_context=open_period_context,
91+
title=f"Critical: {self.detector.name}",
92+
title_link="https://sentry.io/alerts/1/",
93+
text="123.45 events in the last minute",
94+
)
95+
self.rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[])
96+
97+
def test_render_produces_embed(self) -> None:
98+
result = DiscordMetricAlertRenderer.render(
99+
data=self.notification_data,
100+
rendered_template=self.rendered_template,
101+
)
102+
103+
assert "embeds" in result
104+
embeds = result["embeds"]
105+
assert len(embeds) == 1
106+
107+
embed = embeds[0]
108+
assert embed["title"] == f"Critical: {self.detector.name}"
109+
assert embed["url"] == "https://sentry.io/alerts/1/"
110+
assert "123.45 events in the last minute" in embed["description"]
111+
assert "Started <t:" in embed["description"]
112+
# Critical maps to "fatal" color
113+
assert embed["color"] == LEVEL_TO_COLOR[INCIDENT_COLOR_MAPPING["Critical"]]
114+
115+
def test_render_includes_image_when_chart_url_set(self) -> None:
116+
data_with_chart = _make_notification_data(
117+
group_id=self.group.id,
118+
organization_id=self.organization.id,
119+
open_period_context=OpenPeriodContext.from_group(self.group),
120+
title=f"Critical: {self.detector.name}",
121+
title_link="https://sentry.io/alerts/1/",
122+
text="123.45 events in the last minute",
123+
chart_url=MOCK_CHART_URL,
124+
)
125+
126+
result = DiscordMetricAlertRenderer.render(
127+
data=data_with_chart,
128+
rendered_template=self.rendered_template,
129+
)
130+
131+
embed = result["embeds"][0]
132+
assert embed["title"] == f"Critical: {self.detector.name}"
133+
assert embed["url"] == "https://sentry.io/alerts/1/"
134+
assert "123.45 events in the last minute" in embed["description"]
135+
assert embed["image"]["url"] == MOCK_CHART_URL
136+
137+
def test_render_without_chart_url(self) -> None:
138+
result = DiscordMetricAlertRenderer.render(
139+
data=self.notification_data,
140+
rendered_template=self.rendered_template,
141+
)
142+
143+
embed = result["embeds"][0]
144+
assert embed["title"] == f"Critical: {self.detector.name}"
145+
assert embed["url"] == "https://sentry.io/alerts/1/"
146+
assert "123.45 events in the last minute" in embed["description"]
147+
assert "image" not in embed or embed.get("image") is None
148+
149+
def test_render_resolved_status(self) -> None:
150+
resolved_data = _make_notification_data(
151+
group_id=self.group.id,
152+
organization_id=self.organization.id,
153+
open_period_context=OpenPeriodContext.from_group(self.group),
154+
title=f"Resolved: {self.detector.name}",
155+
title_link="https://sentry.io/alerts/1/",
156+
text="",
157+
new_status=IncidentStatus.CLOSED.value,
158+
)
159+
160+
result = DiscordMetricAlertRenderer.render(
161+
data=resolved_data,
162+
rendered_template=self.rendered_template,
163+
)
164+
165+
embed = result["embeds"][0]
166+
assert embed["title"] == f"Resolved: {self.detector.name}"
167+
assert embed["url"] == "https://sentry.io/alerts/1/"
168+
assert "Started <t:" in embed["description"]
169+
assert embed["color"] == LEVEL_TO_COLOR[INCIDENT_COLOR_MAPPING["Resolved"]]

tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77

8+
from sentry.incidents.models.incident import IncidentStatus
89
from sentry.incidents.typings.metric_detector import OpenPeriodContext
910
from sentry.notifications.platform.slack.provider import SlackNotificationProvider
1011
from sentry.notifications.platform.slack.renderers.metric_alert import SlackMetricAlertRenderer
@@ -32,7 +33,7 @@ def _make_notification_data(**overrides: Any) -> MetricAlertNotificationData:
3233
id=1,
3334
date_started=datetime(2024, 1, 1, tzinfo=timezone.utc),
3435
),
35-
new_status=20, # IncidentStatus.CRITICAL
36+
new_status=IncidentStatus.CRITICAL.value,
3637
title="Critical: Test Alert",
3738
title_link="https://sentry.io/alerts/1/",
3839
text="123 events in the last 5 minutes",
@@ -147,7 +148,7 @@ def test_render_resolved_status(self) -> None:
147148
title=f"Resolved: {self.detector.name}",
148149
title_link="https://sentry.io/alerts/1/",
149150
text="",
150-
new_status=2, # IncidentStatus.CLOSED
151+
new_status=IncidentStatus.CLOSED.value,
151152
)
152153

153154
result = SlackMetricAlertRenderer.render(

0 commit comments

Comments
 (0)