Skip to content

Commit d8b63f1

Browse files
support activity metric alerts path too for data + renderer
1 parent 0e49895 commit d8b63f1

File tree

6 files changed

+272
-68
lines changed

6 files changed

+272
-68
lines changed

src/sentry/notifications/platform/slack/renderers/metric_alert.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder
88
from sentry.notifications.platform.renderer import NotificationRenderer
99
from sentry.notifications.platform.slack.provider import SlackRenderable
10-
from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData
10+
from sentry.notifications.platform.templates.metric_alert import BaseMetricAlertNotificationData
1111
from sentry.notifications.platform.types import (
1212
NotificationData,
1313
NotificationProviderKey,
@@ -22,15 +22,13 @@ class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]):
2222
def render[DataT: NotificationData](
2323
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
2424
) -> SlackRenderable:
25-
if not isinstance(data, MetricAlertNotificationData):
25+
if not isinstance(data, BaseMetricAlertNotificationData):
2626
raise ValueError(f"SlackMetricAlertRenderer does not support {data.__class__.__name__}")
2727

28-
# Re-fetch GroupEvent — needed to rebuild MetricIssueContext
29-
event = data.event
3028
organization = data.organization
3129

32-
# Rebuild MetricIssueContext (the only context that holds ORM instances)
33-
metric_issue_context = MetricAlertNotificationData.get_metric_issue_context(event)
30+
# Rebuild MetricIssueContext — each subclass implements this differently
31+
metric_issue_context = data.build_metric_issue_context()
3432

3533
# Deserialize pre-computed contexts (no Action/Detector/GroupOpenPeriod re-queries)
3634
alert_context = data.alert_context.to_alert_context()

src/sentry/notifications/platform/templates/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from .data_export import DataExportFailureTemplate, DataExportSuccessTemplate
22
from .issue import IssueNotificationTemplate
3-
from .metric_alert import MetricAlertNotificationTemplate
3+
from .metric_alert import ActivityMetricAlertNotificationTemplate, MetricAlertNotificationTemplate
44

55
__all__ = (
66
"DataExportSuccessTemplate",
77
"DataExportFailureTemplate",
88
"IssueNotificationTemplate",
99
"MetricAlertNotificationTemplate",
10+
"ActivityMetricAlertNotificationTemplate",
1011
)
1112
# All templates should be imported here so they are registered in the notifications Django app.
1213
# See sentry/notifications/apps.py

src/sentry/notifications/platform/templates/metric_alert.py

Lines changed: 82 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
MetricIssueContext,
1111
OpenPeriodContext,
1212
)
13+
from sentry.models.activity import Activity
1314
from sentry.models.group import Group
1415
from sentry.models.organization import Organization
1516
from sentry.notifications.platform.registry import template_registry
@@ -107,37 +108,24 @@ def to_open_period_context(self) -> OpenPeriodContext:
107108
)
108109

109110

110-
class MetricAlertNotificationData(NotificationData):
111-
source: NotificationSource = NotificationSource.METRIC_ALERT
111+
class BaseMetricAlertNotificationData(NotificationData):
112+
"""
113+
Shared fields and properties for metric alert notification data.
112114
113-
# For re-fetching GroupEvent via eventstore (MetricIssueContext has ORM instances)
114-
event_id: str
115-
project_id: int
116-
group_id: int
115+
Subclasses differ only in how they source MetricIssueContext
116+
- MetricAlertNotificationData: re-fetches GroupEvent from Snuba
117+
- ActivityMetricAlertNotificationData: re-fetches Activity
118+
"""
117119

118-
# For feature flag check(chartcuterie) + message builder
120+
group_id: int
119121
organization_id: int
120-
# To rebuild the contexts
121122
detector_id: int
122123

123-
# Pre-computed serializable contexts
124124
alert_context: SerializableAlertContext
125125
open_period_context: SerializableOpenPeriodContext
126126

127127
notification_uuid: str
128128

129-
@property
130-
def event(self) -> GroupEvent:
131-
event = eventstore.backend.get_event_by_id(
132-
self.project_id, self.event_id, group_id=self.group_id
133-
)
134-
if event is None:
135-
raise ValueError(f"Event {self.event_id} not found")
136-
elif not isinstance(event, GroupEvent):
137-
raise ValueError(f"Event {self.event_id} is not a GroupEvent")
138-
139-
return event
140-
141129
@property
142130
def organization(self) -> Organization:
143131
return Organization.objects.get_from_cache(id=self.organization_id)
@@ -166,41 +154,97 @@ def serialized_detector(self) -> DetectorSerializerResponse:
166154

167155
return get_detector_serializer(self.detector)
168156

169-
@classmethod
170-
def get_metric_issue_context(cls, event: GroupEvent) -> MetricIssueContext:
157+
def build_metric_issue_context(self) -> MetricIssueContext:
158+
raise NotImplementedError
159+
160+
161+
class MetricAlertNotificationData(BaseMetricAlertNotificationData):
162+
source: NotificationSource = NotificationSource.METRIC_ALERT
163+
164+
event_id: str
165+
project_id: int
166+
167+
@property
168+
def event(self) -> GroupEvent:
169+
event = eventstore.backend.get_event_by_id(
170+
self.project_id, self.event_id, group_id=self.group_id
171+
)
172+
if event is None:
173+
raise ValueError(f"Event {self.event_id} not found")
174+
elif not isinstance(event, GroupEvent):
175+
raise ValueError(f"Event {self.event_id} is not a GroupEvent")
176+
177+
return event
178+
179+
def build_metric_issue_context(self) -> MetricIssueContext:
171180
from sentry.notifications.notification_action.types import BaseMetricAlertHandler
172181

182+
event = self.event
173183
evidence_data, priority = BaseMetricAlertHandler._extract_from_group_event(event)
174184
return MetricIssueContext.from_group_event(event.group, evidence_data, priority)
175185

176186

187+
class ActivityMetricAlertNotificationData(BaseMetricAlertNotificationData):
188+
source: NotificationSource = NotificationSource.ACTIVITY_METRIC_ALERT
189+
190+
activity_id: int
191+
192+
@property
193+
def activity(self) -> Activity:
194+
return Activity.objects.get(id=self.activity_id)
195+
196+
def build_metric_issue_context(self) -> MetricIssueContext:
197+
from sentry.notifications.notification_action.types import BaseMetricAlertHandler
198+
199+
evidence_data, priority = BaseMetricAlertHandler._extract_from_activity(self.activity)
200+
return MetricIssueContext.from_group_event(self.group, evidence_data, priority)
201+
202+
203+
_EXAMPLE_ALERT_CONTEXT = SerializableAlertContext(
204+
name="Example Alert",
205+
action_identifier_id=1,
206+
detection_type="static",
207+
)
208+
_EXAMPLE_OPEN_PERIOD_CONTEXT = SerializableOpenPeriodContext(
209+
id=1,
210+
date_started=datetime(2024, 1, 1, 0, 0, 0),
211+
)
212+
213+
177214
@template_registry.register(NotificationSource.METRIC_ALERT)
178215
class MetricAlertNotificationTemplate(NotificationTemplate[MetricAlertNotificationData]):
179216
category = NotificationCategory.METRIC_ALERT
180-
# hide_from_debugger because this template uses a custom renderer that bypasses
181-
# the standard NotificationRenderedTemplate rendering path so wouldn't load correctly in the debugger.
182217
hide_from_debugger = True
183218
example_data = MetricAlertNotificationData(
184219
event_id="abc123",
185220
project_id=1,
186221
group_id=1,
187222
organization_id=1,
188223
detector_id=1,
189-
alert_context=SerializableAlertContext(
190-
name="Example Alert",
191-
action_identifier_id=1,
192-
detection_type="static",
193-
),
194-
open_period_context=SerializableOpenPeriodContext(
195-
id=1,
196-
date_started=datetime(2024, 1, 1, 0, 0, 0),
197-
),
224+
alert_context=_EXAMPLE_ALERT_CONTEXT,
225+
open_period_context=_EXAMPLE_OPEN_PERIOD_CONTEXT,
198226
notification_uuid="test-uuid",
199227
)
200228

201229
def render(self, data: MetricAlertNotificationData) -> NotificationRenderedTemplate:
202-
# The actual rendering is handled by the provider-specific custom renderer
203-
# (e.g. SlackMetricAlertRenderer), which rebuilds MetricIssueContext from the
204-
# re-fetched GroupEvent and builds the full payload via SlackIncidentsMessageBuilder.
205-
# This method returns a minimal fallback for providers without a custom renderer.
230+
return NotificationRenderedTemplate(subject="Metric Alert", body=[])
231+
232+
233+
@template_registry.register(NotificationSource.ACTIVITY_METRIC_ALERT)
234+
class ActivityMetricAlertNotificationTemplate(
235+
NotificationTemplate[ActivityMetricAlertNotificationData]
236+
):
237+
category = NotificationCategory.METRIC_ALERT
238+
hide_from_debugger = True
239+
example_data = ActivityMetricAlertNotificationData(
240+
group_id=1,
241+
organization_id=1,
242+
detector_id=1,
243+
alert_context=_EXAMPLE_ALERT_CONTEXT,
244+
open_period_context=_EXAMPLE_OPEN_PERIOD_CONTEXT,
245+
notification_uuid="test-uuid",
246+
activity_id=1,
247+
)
248+
249+
def render(self, data: ActivityMetricAlertNotificationData) -> NotificationRenderedTemplate:
206250
return NotificationRenderedTemplate(subject="Metric Alert", body=[])

src/sentry/notifications/platform/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class NotificationSource(StrEnum):
5858

5959
# METRIC_ALERT
6060
METRIC_ALERT = "metric-alert"
61+
ACTIVITY_METRIC_ALERT = "activity-metric-alert"
6162

6263
# SEER
6364
SEER_AUTOFIX_ERROR = "seer-autofix-error"
@@ -93,6 +94,7 @@ class NotificationSource(StrEnum):
9394
],
9495
NotificationCategory.METRIC_ALERT: [
9596
NotificationSource.METRIC_ALERT,
97+
NotificationSource.ACTIVITY_METRIC_ALERT,
9698
],
9799
NotificationCategory.SEER: [
98100
NotificationSource.SEER_AUTOFIX_TRIGGER,

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
from __future__ import annotations
22

3+
from dataclasses import asdict
34
from datetime import datetime, timezone
45
from typing import Any
56
from unittest.mock import MagicMock, patch
67

78
import pytest
89

910
from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext
11+
from sentry.models.activity import Activity
1012
from sentry.notifications.platform.slack.provider import SlackNotificationProvider
1113
from sentry.notifications.platform.slack.renderers.metric_alert import SlackMetricAlertRenderer
1214
from sentry.notifications.platform.templates.metric_alert import (
15+
ActivityMetricAlertNotificationData,
1316
MetricAlertNotificationData,
1417
SerializableAlertContext,
1518
SerializableOpenPeriodContext,
@@ -21,6 +24,7 @@
2124
)
2225
from sentry.testutils.cases import TestCase
2326
from sentry.testutils.helpers.features import with_feature
27+
from sentry.types.activity import ActivityType
2428
from sentry.workflow_engine.types import DetectorPriorityLevel
2529
from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import (
2630
MetricAlertHandlerBase,
@@ -186,3 +190,72 @@ def test_render_continues_when_chart_fails(
186190
blocks: list[Any] = result["blocks"]
187191
assert len(blocks) == 1
188192
assert blocks[0]["type"] == "section"
193+
194+
195+
class SlackActivityMetricAlertRendererTest(MetricAlertHandlerBase):
196+
def setUp(self) -> None:
197+
super().setUp()
198+
self.create_models()
199+
200+
activity = Activity(
201+
project=self.project,
202+
group=self.group,
203+
type=ActivityType.SET_RESOLVED.value,
204+
data=asdict(self.evidence_data),
205+
)
206+
activity.save()
207+
208+
alert_context = AlertContext.from_workflow_engine_models(
209+
self.detector,
210+
self.evidence_data,
211+
self.group.status,
212+
DetectorPriorityLevel.HIGH,
213+
)
214+
open_period_context = OpenPeriodContext.from_group(self.group)
215+
216+
self.notification_data = ActivityMetricAlertNotificationData(
217+
group_id=self.group.id,
218+
organization_id=self.organization.id,
219+
detector_id=self.detector.id,
220+
alert_context=SerializableAlertContext.from_alert_context(alert_context),
221+
open_period_context=SerializableOpenPeriodContext.from_open_period_context(
222+
open_period_context
223+
),
224+
activity_id=activity.id,
225+
notification_uuid="test-uuid",
226+
)
227+
self.rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[])
228+
229+
def test_render_produces_blocks_without_snuba(self) -> None:
230+
# The Activity path re-fetches from Postgres only (no Snuba) — no eventstore mock needed
231+
result = SlackMetricAlertRenderer.render(
232+
data=self.notification_data,
233+
rendered_template=self.rendered_template,
234+
)
235+
236+
blocks: list[Any] = result["blocks"]
237+
assert len(blocks) == 1
238+
assert blocks[0]["type"] == "section"
239+
assert blocks[0]["text"]["type"] == "mrkdwn"
240+
# "Resolved" appears in the fallback title (result["text"]), not the metric body block
241+
assert "Resolved" in result["text"]
242+
assert self.detector.name in result["text"]
243+
244+
@patch(
245+
"sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart",
246+
return_value=MOCK_CHART_URL,
247+
)
248+
@with_feature({"organizations:metric-alert-chartcuterie": True})
249+
def test_render_includes_image_block_when_chart_enabled(self, mock_chart: MagicMock) -> None:
250+
result = SlackMetricAlertRenderer.render(
251+
data=self.notification_data,
252+
rendered_template=self.rendered_template,
253+
)
254+
255+
blocks: list[Any] = result["blocks"]
256+
assert len(blocks) == 2
257+
assert blocks[0]["type"] == "section"
258+
assert "Resolved" in result["text"]
259+
assert blocks[1]["type"] == "image"
260+
assert blocks[1]["image_url"] == MOCK_CHART_URL
261+
assert blocks[1]["alt_text"] == "Metric Alert Chart"

0 commit comments

Comments
 (0)