From c87ee9ef2822c9e52c6cb7b004aff5a0a9993fb3 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 26 Mar 2026 13:07:43 -0700 Subject: [PATCH 01/14] add data for metric alerts np --- .../notifications/platform/slack/provider.py | 5 + .../platform/slack/renderers/metric_alert.py | 67 ++++++ .../platform/templates/__init__.py | 2 + .../platform/templates/metric_alert.py | 206 ++++++++++++++++++ src/sentry/notifications/platform/types.py | 7 + 5 files changed, 287 insertions(+) create mode 100644 src/sentry/notifications/platform/slack/renderers/metric_alert.py create mode 100644 src/sentry/notifications/platform/templates/metric_alert.py diff --git a/src/sentry/notifications/platform/slack/provider.py b/src/sentry/notifications/platform/slack/provider.py index 52ea7fe619745c..9647f09d0620fa 100644 --- a/src/sentry/notifications/platform/slack/provider.py +++ b/src/sentry/notifications/platform/slack/provider.py @@ -138,12 +138,17 @@ def get_renderer( from sentry.notifications.platform.slack.renderers.issue import ( IssueSlackRenderer, ) + from sentry.notifications.platform.slack.renderers.metric_alert import ( + SlackMetricAlertRenderer, + ) from sentry.notifications.platform.slack.renderers.seer import SeerSlackRenderer if category == NotificationCategory.SEER: return SeerSlackRenderer if category == NotificationCategory.ISSUE: return IssueSlackRenderer + if category == NotificationCategory.METRIC_ALERT: + return SlackMetricAlertRenderer return cls.default_renderer @classmethod diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py new file mode 100644 index 00000000000000..6e63fe79e6d06b --- /dev/null +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import sentry_sdk + +from sentry import features +from sentry.incidents.charts import build_metric_alert_chart +from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder +from sentry.notifications.platform.renderer import NotificationRenderer +from sentry.notifications.platform.slack.provider import SlackRenderable +from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData +from sentry.notifications.platform.types import ( + NotificationData, + NotificationProviderKey, + NotificationRenderedTemplate, +) + + +class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]): + provider_key = NotificationProviderKey.SLACK + + @classmethod + def render[DataT: NotificationData]( + cls, *, data: DataT, rendered_template: NotificationRenderedTemplate + ) -> SlackRenderable: + if not isinstance(data, MetricAlertNotificationData): + raise ValueError(f"SlackMetricAlertRenderer does not support {data.__class__.__name__}") + + # Re-fetch GroupEvent — needed to rebuild MetricIssueContext + event = data.event + organization = data.organization + + # Rebuild MetricIssueContext (the only context that holds ORM instances) + metric_issue_context = MetricAlertNotificationData.get_metric_issue_context(event) + + # Deserialize pre-computed contexts (no Action/Detector/GroupOpenPeriod re-queries) + alert_context = data.alert_context.to_alert_context() + open_period_context = data.open_period_context.to_open_period_context() + + chart_url = None + if features.has("organizations:metric-alert-chartcuterie", organization): + try: + chart_url = build_metric_alert_chart( + organization=organization, + alert_rule_serialized_response=data.serialized_alert_rule, + snuba_query=metric_issue_context.snuba_query, + alert_context=alert_context, + open_period_context=open_period_context, + subscription=metric_issue_context.subscription, + detector_serialized_response=data.serialized_detector, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + # Build the Slack blocks using the existing metric alert builder + slack_body = SlackIncidentsMessageBuilder( + alert_context=alert_context, + metric_issue_context=metric_issue_context, + organization=organization, + date_started=open_period_context.date_started, + chart_url=chart_url, + notification_uuid=data.notification_uuid, + ).build() + + return SlackRenderable( + blocks=slack_body.get("blocks", []), + text=slack_body.get("text", ""), + ) diff --git a/src/sentry/notifications/platform/templates/__init__.py b/src/sentry/notifications/platform/templates/__init__.py index 22bdbff3a06c79..d83201c45fbdbe 100644 --- a/src/sentry/notifications/platform/templates/__init__.py +++ b/src/sentry/notifications/platform/templates/__init__.py @@ -1,10 +1,12 @@ from .data_export import DataExportFailureTemplate, DataExportSuccessTemplate from .issue import IssueNotificationTemplate +from .metric_alert import MetricAlertNotificationTemplate __all__ = ( "DataExportSuccessTemplate", "DataExportFailureTemplate", "IssueNotificationTemplate", + "MetricAlertNotificationTemplate", ) # All templates should be imported here so they are registered in the notifications Django app. # See sentry/notifications/apps.py diff --git a/src/sentry/notifications/platform/templates/metric_alert.py b/src/sentry/notifications/platform/templates/metric_alert.py new file mode 100644 index 00000000000000..e65aa458dde35a --- /dev/null +++ b/src/sentry/notifications/platform/templates/metric_alert.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Self + +from pydantic import BaseModel, ConfigDict + +from sentry.incidents.typings.metric_detector import ( + AlertContext, + MetricIssueContext, + OpenPeriodContext, +) +from sentry.models.group import Group +from sentry.models.organization import Organization +from sentry.notifications.platform.registry import template_registry +from sentry.notifications.platform.types import ( + NotificationCategory, + NotificationData, + NotificationRenderedTemplate, + NotificationSource, + NotificationTemplate, +) +from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType +from sentry.services import eventstore +from sentry.services.eventstore.models import GroupEvent +from sentry.workflow_engine.models.detector import Detector + +if TYPE_CHECKING: + from sentry.incidents.endpoints.serializers.alert_rule import AlertRuleSerializerResponse + from sentry.workflow_engine.endpoints.serializers.detector_serializer import ( + DetectorSerializerResponse, + ) + + +class SerializableAlertContext(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str + action_identifier_id: int + threshold_type: int | None = None # AlertRuleThresholdType or AnomalyDetectionThresholdType + detection_type: str # AlertRuleDetectionType value (TextChoices str) + comparison_delta: int | None = None + sensitivity: str | None = None + resolve_threshold: float | None = None + alert_threshold: float | None = None + + @classmethod + def from_alert_context(cls, ac: AlertContext) -> Self: + return cls( + name=ac.name, + action_identifier_id=ac.action_identifier_id, + threshold_type=int(ac.threshold_type.value) if ac.threshold_type is not None else None, + detection_type=ac.detection_type.value, + comparison_delta=ac.comparison_delta, + sensitivity=ac.sensitivity, + resolve_threshold=ac.resolve_threshold, + alert_threshold=ac.alert_threshold, + ) + + def to_alert_context(self) -> AlertContext: + from sentry.incidents.models.alert_rule import ( + AlertRuleDetectionType, + AlertRuleThresholdType, + ) + + detection_type = AlertRuleDetectionType(self.detection_type) + + threshold_type: AlertRuleThresholdType | AnomalyDetectionThresholdType | None = None + if self.threshold_type is not None: + if detection_type == AlertRuleDetectionType.DYNAMIC: + threshold_type = AnomalyDetectionThresholdType(self.threshold_type) + else: + threshold_type = AlertRuleThresholdType(self.threshold_type) + + return AlertContext( + name=self.name, + action_identifier_id=self.action_identifier_id, + threshold_type=threshold_type, + detection_type=detection_type, + comparison_delta=self.comparison_delta, + sensitivity=self.sensitivity, + resolve_threshold=self.resolve_threshold, + alert_threshold=self.alert_threshold, + ) + + +class SerializableOpenPeriodContext(BaseModel): + model_config = ConfigDict(frozen=True) + + id: int + date_started: datetime + date_closed: datetime | None = None + + @classmethod + def from_open_period_context(cls, opc: OpenPeriodContext) -> Self: + return cls( + id=opc.id, + date_started=opc.date_started, + date_closed=opc.date_closed, + ) + + def to_open_period_context(self) -> OpenPeriodContext: + return OpenPeriodContext( + id=self.id, + date_started=self.date_started, + date_closed=self.date_closed, + ) + + +class MetricAlertNotificationData(NotificationData): + source: NotificationSource = NotificationSource.METRIC_ALERT + + # For re-fetching GroupEvent via eventstore (MetricIssueContext has ORM instances) + event_id: str + project_id: int + group_id: int + + # For feature flag check(chartcuterie) + message builder + organization_id: int + # To rebuild the contexts + detector_id: int + + # Pre-computed serializable contexts + alert_context: SerializableAlertContext + open_period_context: SerializableOpenPeriodContext + + notification_uuid: str + + @property + def event(self) -> GroupEvent: + event = eventstore.backend.get_event_by_id( + self.project_id, self.event_id, group_id=self.group_id + ) + if event is None: + raise ValueError(f"Event {self.event_id} not found") + elif not isinstance(event, GroupEvent): + raise ValueError(f"Event {self.event_id} is not a GroupEvent") + + return event + + @property + def organization(self) -> Organization: + return Organization.objects.get_from_cache(id=self.organization_id) + + @property + def group(self) -> Group: + return Group.objects.get_from_cache(id=self.group_id) + + @property + def detector(self) -> Detector: + return Detector.objects.get(id=self.detector_id) + + @property + def serialized_alert_rule(self) -> AlertRuleSerializerResponse: + from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( + get_alert_rule_serializer, + ) + + return get_alert_rule_serializer(self.detector) + + @property + def serialized_detector(self) -> DetectorSerializerResponse: + from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( + get_detector_serializer, + ) + + return get_detector_serializer(self.detector) + + @classmethod + def get_metric_issue_context(cls, event: GroupEvent) -> MetricIssueContext: + from sentry.notifications.notification_action.types import BaseMetricAlertHandler + + evidence_data, priority = BaseMetricAlertHandler._extract_from_group_event(event) + return MetricIssueContext.from_group_event(event.group, evidence_data, priority) + + +@template_registry.register(NotificationSource.METRIC_ALERT) +class MetricAlertNotificationTemplate(NotificationTemplate[MetricAlertNotificationData]): + category = NotificationCategory.METRIC_ALERT + # hide_from_debugger because this template uses a custom renderer that bypasses + # the standard NotificationRenderedTemplate rendering path so wouldn't load correctly in the debugger. + hide_from_debugger = True + example_data = MetricAlertNotificationData( + event_id="abc123", + project_id=1, + group_id=1, + organization_id=1, + detector_id=1, + alert_context=SerializableAlertContext( + name="Example Alert", + action_identifier_id=1, + detection_type="static", + ), + open_period_context=SerializableOpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, 0, 0, 0), + ), + notification_uuid="test-uuid", + ) + + def render(self, data: MetricAlertNotificationData) -> NotificationRenderedTemplate: + # The actual rendering is handled by the provider-specific custom renderer + # (e.g. SlackMetricAlertRenderer), which rebuilds MetricIssueContext from the + # re-fetched GroupEvent and builds the full payload via SlackIncidentsMessageBuilder. + # This method returns a minimal fallback for providers without a custom renderer. + return NotificationRenderedTemplate(subject="Metric Alert", body=[]) diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index 3ba8f79db764ef..ae8f80b72acd52 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -24,6 +24,7 @@ class NotificationCategory(StrEnum): REPOSITORY = "repository" SEER = "seer" ISSUE = "issue" + METRIC_ALERT = "metric-alert" def get_sources(self) -> list[NotificationSource]: return NOTIFICATION_SOURCE_MAP[self] @@ -55,6 +56,9 @@ class NotificationSource(StrEnum): # ISSUE_ALERT ISSUE = "issue" + # METRIC_ALERT + METRIC_ALERT = "metric-alert" + # SEER SEER_AUTOFIX_ERROR = "seer-autofix-error" SEER_AUTOFIX_UPDATE = "seer-autofix-update" @@ -87,6 +91,9 @@ class NotificationSource(StrEnum): NotificationCategory.ISSUE: [ NotificationSource.ISSUE, ], + NotificationCategory.METRIC_ALERT: [ + NotificationSource.METRIC_ALERT, + ], NotificationCategory.SEER: [ NotificationSource.SEER_AUTOFIX_TRIGGER, NotificationSource.SEER_AUTOFIX_ERROR, From 9fbe08167c3b6b698f96815423ab2b9ed4300756 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 26 Mar 2026 13:07:59 -0700 Subject: [PATCH 02/14] add tests for metric alert rendere + data --- .../slack/renderers/test_metric_alert.py | 182 ++++++++++++++ .../platform/templates/test_metric_alert.py | 236 ++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py create mode 100644 tests/sentry/notifications/platform/templates/test_metric_alert.py diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py new file mode 100644 index 00000000000000..3f6e23fc069d72 --- /dev/null +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest + +from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext +from sentry.notifications.platform.slack.provider import SlackNotificationProvider +from sentry.notifications.platform.slack.renderers.metric_alert import SlackMetricAlertRenderer +from sentry.notifications.platform.templates.metric_alert import ( + MetricAlertNotificationData, + SerializableAlertContext, + SerializableOpenPeriodContext, +) +from sentry.notifications.platform.templates.seer import SeerAutofixError +from sentry.notifications.platform.types import ( + NotificationCategory, + NotificationRenderedTemplate, +) +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature +from sentry.workflow_engine.types import DetectorPriorityLevel +from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( + MetricAlertHandlerBase, +) + +MOCK_CHART_URL = "https://chart.example.com/metric.png" + + +def _make_notification_data(**overrides: object) -> MetricAlertNotificationData: + defaults: dict[str, object] = dict( + event_id="abc123", + project_id=1, + group_id=1, + organization_id=1, + detector_id=1, + alert_context=SerializableAlertContext( + name="Test Alert", + action_identifier_id=1, + detection_type="static", + ), + open_period_context=SerializableOpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + notification_uuid="test-uuid", + ) + defaults.update(overrides) + return MetricAlertNotificationData(**defaults) + + +class SlackMetricAlertRendererInvalidDataTest(TestCase): + def test_render_raises_on_invalid_data_type(self) -> None: + invalid_data = SeerAutofixError(error_message="not a metric alert") + rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[]) + + with pytest.raises(ValueError, match="does not support"): + SlackMetricAlertRenderer.render( + data=invalid_data, + rendered_template=rendered_template, + ) + + +class SlackMetricAlertProviderDispatchTest(TestCase): + def test_provider_returns_metric_alert_renderer(self) -> None: + data = _make_notification_data() + renderer = SlackNotificationProvider.get_renderer( + data=data, + category=NotificationCategory.METRIC_ALERT, + ) + assert renderer is SlackMetricAlertRenderer + + def test_provider_returns_default_for_unknown_category(self) -> None: + data = _make_notification_data() + renderer = SlackNotificationProvider.get_renderer( + data=data, + category=NotificationCategory.DEBUG, + ) + assert renderer is SlackNotificationProvider.default_renderer + + +class SlackMetricAlertRendererTest(MetricAlertHandlerBase): + def setUp(self) -> None: + super().setUp() + self.create_models() + + alert_context = AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + open_period_context = OpenPeriodContext.from_group(self.group) + + self.notification_data = MetricAlertNotificationData( + event_id=self.group_event.event_id, + project_id=self.project.id, + group_id=self.group.id, + organization_id=self.organization.id, + detector_id=self.detector.id, + alert_context=SerializableAlertContext.from_alert_context(alert_context), + open_period_context=SerializableOpenPeriodContext.from_open_period_context( + open_period_context + ), + notification_uuid="test-uuid", + ) + self.rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[]) + + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart", + return_value=None, + ) + @patch( + "sentry.notifications.platform.templates.metric_alert.eventstore.backend.get_event_by_id" + ) + def test_render_produces_blocks(self, mock_get_event, mock_chart) -> None: + mock_get_event.return_value = self.group_event + + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + # Without a chart: exactly one section block with the metric text + blocks = result["blocks"] + assert len(blocks) == 1 + assert blocks[0]["type"] == "section" + assert blocks[0]["text"]["type"] == "mrkdwn" + assert "123.45 events in the last minute" in blocks[0]["text"]["text"] + # Fallback text should reference the detector/alert name + assert self.detector.name in result["text"] + + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart", + return_value=MOCK_CHART_URL, + ) + @patch( + "sentry.notifications.platform.templates.metric_alert.eventstore.backend.get_event_by_id" + ) + @with_feature({"organizations:metric-alert-chartcuterie": True}) + def test_render_includes_image_block_when_chart_enabled( + self, mock_get_event, mock_chart + ) -> None: + mock_get_event.return_value = self.group_event + + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + # With a chart: section block + image block + blocks = result["blocks"] + assert len(blocks) == 2 + assert blocks[0]["type"] == "section" + assert "123.45 events in the last minute" in blocks[0]["text"]["text"] + assert blocks[1]["type"] == "image" + assert blocks[1]["image_url"] == MOCK_CHART_URL + assert blocks[1]["alt_text"] == "Metric Alert Chart" + + @patch("sentry.notifications.platform.slack.renderers.metric_alert.sentry_sdk") + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart", + side_effect=Exception("chart service unavailable"), + ) + @patch( + "sentry.notifications.platform.templates.metric_alert.eventstore.backend.get_event_by_id" + ) + def test_render_continues_when_chart_fails(self, mock_get_event, mock_chart, mock_sdk) -> None: + mock_get_event.return_value = self.group_event + + with self.feature("organizations:metric-alert-chartcuterie"): + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + mock_sdk.capture_exception.assert_called_once() + # Render completes without the chart — just the section block + blocks = result["blocks"] + assert len(blocks) == 1 + assert blocks[0]["type"] == "section" diff --git a/tests/sentry/notifications/platform/templates/test_metric_alert.py b/tests/sentry/notifications/platform/templates/test_metric_alert.py new file mode 100644 index 00000000000000..849d90e0829087 --- /dev/null +++ b/tests/sentry/notifications/platform/templates/test_metric_alert.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType +from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext +from sentry.notifications.platform.templates.metric_alert import ( + MetricAlertNotificationData, + MetricAlertNotificationTemplate, + SerializableAlertContext, + SerializableOpenPeriodContext, +) +from sentry.notifications.platform.types import NotificationRenderedTemplate, NotificationSource +from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType +from sentry.testutils.cases import TestCase +from sentry.workflow_engine.types import DetectorPriorityLevel +from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( + MetricAlertHandlerBase, +) + + +def _make_notification_data(**overrides: object) -> MetricAlertNotificationData: + """Build a minimal MetricAlertNotificationData with sensible defaults.""" + defaults: dict[str, object] = dict( + event_id="abc123", + project_id=1, + group_id=1, + organization_id=1, + detector_id=1, + alert_context=SerializableAlertContext( + name="Test Alert", + action_identifier_id=1, + detection_type="static", + ), + open_period_context=SerializableOpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + notification_uuid="test-uuid", + ) + defaults.update(overrides) + return MetricAlertNotificationData(**defaults) + + +class SerializableAlertContextTest(TestCase): + def test_round_trip_alert_rule_threshold_type(self) -> None: + original = AlertContext( + name="My Alert", + action_identifier_id=42, + threshold_type=AlertRuleThresholdType.ABOVE, + detection_type=AlertRuleDetectionType.STATIC, + comparison_delta=3600, + sensitivity=None, + resolve_threshold=5.0, + alert_threshold=10.0, + ) + round_tripped = SerializableAlertContext.from_alert_context(original).to_alert_context() + + assert round_tripped.threshold_type == AlertRuleThresholdType.ABOVE + assert round_tripped.detection_type == AlertRuleDetectionType.STATIC + assert round_tripped.comparison_delta == original.comparison_delta + assert round_tripped.resolve_threshold == original.resolve_threshold + assert round_tripped.alert_threshold == original.alert_threshold + + def test_round_trip_anomaly_detection_threshold_type(self) -> None: + original = AlertContext( + name="Anomaly Alert", + action_identifier_id=99, + threshold_type=AnomalyDetectionThresholdType.ABOVE_AND_BELOW, + detection_type=AlertRuleDetectionType.DYNAMIC, + comparison_delta=None, + sensitivity="medium", + resolve_threshold=0.0, + alert_threshold=0.0, + ) + round_tripped = SerializableAlertContext.from_alert_context(original).to_alert_context() + + assert isinstance(round_tripped.threshold_type, AnomalyDetectionThresholdType) + assert round_tripped.threshold_type == AnomalyDetectionThresholdType.ABOVE_AND_BELOW + assert round_tripped.detection_type == AlertRuleDetectionType.DYNAMIC + assert round_tripped.sensitivity == original.sensitivity + + def test_round_trip_none_threshold_type(self) -> None: + original = AlertContext( + name="No Threshold", + action_identifier_id=3, + threshold_type=None, + detection_type=AlertRuleDetectionType.STATIC, + comparison_delta=None, + sensitivity=None, + resolve_threshold=None, + alert_threshold=None, + ) + round_tripped = SerializableAlertContext.from_alert_context(original).to_alert_context() + + assert round_tripped.threshold_type is None + assert round_tripped.resolve_threshold == 2.5 + assert round_tripped.alert_threshold == 7.5 + assert round_tripped.detection_type == AlertRuleDetectionType.PERCENT + + +class SerializableOpenPeriodContextTest(TestCase): + def test_from_and_to_open_period_context_with_date_closed(self) -> None: + date_started = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + date_closed = datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc) + original = OpenPeriodContext( + id=100, + date_started=date_started, + date_closed=date_closed, + ) + serializable = SerializableOpenPeriodContext.from_open_period_context(original) + + assert serializable.id == 100 + assert serializable.date_started == date_started + assert serializable.date_closed == date_closed + + round_tripped = serializable.to_open_period_context() + assert round_tripped.id == original.id + assert round_tripped.date_started == original.date_started + assert round_tripped.date_closed == original.date_closed + + def test_from_and_to_open_period_context_without_date_closed(self) -> None: + date_started = datetime(2024, 6, 15, 9, 30, 0, tzinfo=timezone.utc) + original = OpenPeriodContext( + id=200, + date_started=date_started, + date_closed=None, + ) + serializable = SerializableOpenPeriodContext.from_open_period_context(original) + + assert serializable.id == 200 + assert serializable.date_closed is None + + round_tripped = serializable.to_open_period_context() + assert round_tripped.id == original.id + assert round_tripped.date_closed is None + + +class MetricAlertNotificationDataTest(TestCase): + def test_source(self) -> None: + data = _make_notification_data() + assert data.source == NotificationSource.METRIC_ALERT + + def test_pydantic_serialization_round_trip(self) -> None: + alert_ctx = SerializableAlertContext( + name="Round Trip Alert", + action_identifier_id=5, + threshold_type=int(AlertRuleThresholdType.ABOVE.value), + detection_type="static", + comparison_delta=1800, + sensitivity=None, + resolve_threshold=1.0, + alert_threshold=10.0, + ) + open_period_ctx = SerializableOpenPeriodContext( + id=77, + date_started=datetime(2024, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + date_closed=datetime(2024, 3, 1, 1, 0, 0, tzinfo=timezone.utc), + ) + original = MetricAlertNotificationData( + event_id="evt-999", + project_id=10, + group_id=20, + organization_id=30, + detector_id=40, + alert_context=alert_ctx, + open_period_context=open_period_ctx, + notification_uuid="round-trip-uuid", + ) + + as_dict = original.dict() + restored = MetricAlertNotificationData.validate(as_dict) + + assert restored.event_id == original.event_id + assert restored.project_id == original.project_id + assert restored.group_id == original.group_id + assert restored.organization_id == original.organization_id + assert restored.detector_id == original.detector_id + assert restored.notification_uuid == original.notification_uuid + assert restored.alert_context == original.alert_context + assert restored.open_period_context == original.open_period_context + assert restored.source == NotificationSource.METRIC_ALERT + + +class MetricAlertNotificationDataContextsTest(MetricAlertHandlerBase): + def setUp(self) -> None: + super().setUp() + self.create_models() + + def test_alert_context_round_trips_from_workflow_engine_models(self) -> None: + alert_context = AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + serialized = SerializableAlertContext.from_alert_context(alert_context) + restored = serialized.to_alert_context() + + assert restored.name == alert_context.name + assert restored.action_identifier_id == alert_context.action_identifier_id + assert restored.detection_type == alert_context.detection_type + assert restored.threshold_type == alert_context.threshold_type + assert restored.comparison_delta == alert_context.comparison_delta + + def test_open_period_context_round_trips_from_real_group(self) -> None: + open_period_context = OpenPeriodContext.from_group(self.group) + serialized = SerializableOpenPeriodContext.from_open_period_context(open_period_context) + restored = serialized.to_open_period_context() + + assert restored.id == open_period_context.id + assert restored.date_started == open_period_context.date_started + assert restored.date_closed == open_period_context.date_closed + + +class MetricAlertNotificationTemplateTest(TestCase): + def test_hide_from_debugger_is_true(self) -> None: + assert MetricAlertNotificationTemplate.hide_from_debugger is True + + def test_render_returns_minimal_rendered_template(self) -> None: + template = MetricAlertNotificationTemplate() + data = _make_notification_data() + + result = template.render(data) + + assert isinstance(result, NotificationRenderedTemplate) + assert result.subject == "Metric Alert" + assert result.body == [] + + def test_render_example_returns_rendered_template(self) -> None: + template = MetricAlertNotificationTemplate() + + result = template.render_example() + + assert isinstance(result, NotificationRenderedTemplate) + assert result.subject == "Metric Alert" From 211a27f9a32e0c30302dcce9deb29192ef96dfe4 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 26 Mar 2026 13:46:24 -0700 Subject: [PATCH 03/14] fix typing --- .../slack/renderers/test_metric_alert.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py index 3f6e23fc069d72..e9be9c1762b69f 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -1,7 +1,8 @@ from __future__ import annotations from datetime import datetime, timezone -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch import pytest @@ -114,7 +115,7 @@ def setUp(self) -> None: @patch( "sentry.notifications.platform.templates.metric_alert.eventstore.backend.get_event_by_id" ) - def test_render_produces_blocks(self, mock_get_event, mock_chart) -> None: + def test_render_produces_blocks(self, mock_get_event: MagicMock, mock_chart: MagicMock) -> None: mock_get_event.return_value = self.group_event result = SlackMetricAlertRenderer.render( @@ -123,7 +124,8 @@ def test_render_produces_blocks(self, mock_get_event, mock_chart) -> None: ) # Without a chart: exactly one section block with the metric text - blocks = result["blocks"] + # This is annoying but since Block is not indexable and we want to test the structure we need to say Any + blocks: list[Any] = result["blocks"] assert len(blocks) == 1 assert blocks[0]["type"] == "section" assert blocks[0]["text"]["type"] == "mrkdwn" @@ -140,7 +142,7 @@ def test_render_produces_blocks(self, mock_get_event, mock_chart) -> None: ) @with_feature({"organizations:metric-alert-chartcuterie": True}) def test_render_includes_image_block_when_chart_enabled( - self, mock_get_event, mock_chart + self, mock_get_event: MagicMock, mock_chart: MagicMock ) -> None: mock_get_event.return_value = self.group_event @@ -150,7 +152,8 @@ def test_render_includes_image_block_when_chart_enabled( ) # With a chart: section block + image block - blocks = result["blocks"] + # This is annoying but since Block is not indexable and we want to test the structure we need to say Any + blocks: list[Any] = result["blocks"] assert len(blocks) == 2 assert blocks[0]["type"] == "section" assert "123.45 events in the last minute" in blocks[0]["text"]["text"] @@ -166,7 +169,9 @@ def test_render_includes_image_block_when_chart_enabled( @patch( "sentry.notifications.platform.templates.metric_alert.eventstore.backend.get_event_by_id" ) - def test_render_continues_when_chart_fails(self, mock_get_event, mock_chart, mock_sdk) -> None: + def test_render_continues_when_chart_fails( + self, mock_get_event: MagicMock, mock_chart: MagicMock, mock_sdk: MagicMock + ) -> None: mock_get_event.return_value = self.group_event with self.feature("organizations:metric-alert-chartcuterie"): @@ -177,6 +182,7 @@ def test_render_continues_when_chart_fails(self, mock_get_event, mock_chart, moc mock_sdk.capture_exception.assert_called_once() # Render completes without the chart — just the section block - blocks = result["blocks"] + # This is annoying but since Block is not indexable and we want to test the structure we need to say Any + blocks: list[Any] = result["blocks"] assert len(blocks) == 1 assert blocks[0]["type"] == "section" From 0e498951f63482a1bee7f537d456da0e0c9b1a59 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 26 Mar 2026 13:51:38 -0700 Subject: [PATCH 04/14] fix test --- .../notifications/platform/templates/test_metric_alert.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/sentry/notifications/platform/templates/test_metric_alert.py b/tests/sentry/notifications/platform/templates/test_metric_alert.py index 849d90e0829087..53c6a6654e41f2 100644 --- a/tests/sentry/notifications/platform/templates/test_metric_alert.py +++ b/tests/sentry/notifications/platform/templates/test_metric_alert.py @@ -94,9 +94,6 @@ def test_round_trip_none_threshold_type(self) -> None: round_tripped = SerializableAlertContext.from_alert_context(original).to_alert_context() assert round_tripped.threshold_type is None - assert round_tripped.resolve_threshold == 2.5 - assert round_tripped.alert_threshold == 7.5 - assert round_tripped.detection_type == AlertRuleDetectionType.PERCENT class SerializableOpenPeriodContextTest(TestCase): From d8b63f1f3cbb68ba50cc713c56970836ed489008 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 26 Mar 2026 16:25:33 -0700 Subject: [PATCH 05/14] support activity metric alerts path too for data + renderer --- .../platform/slack/renderers/metric_alert.py | 10 +- .../platform/templates/__init__.py | 3 +- .../platform/templates/metric_alert.py | 120 +++++++++++----- src/sentry/notifications/platform/types.py | 2 + .../slack/renderers/test_metric_alert.py | 73 ++++++++++ .../platform/templates/test_metric_alert.py | 132 +++++++++++++++--- 6 files changed, 272 insertions(+), 68 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index 6e63fe79e6d06b..fefb8934b46746 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -7,7 +7,7 @@ from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable -from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData +from sentry.notifications.platform.templates.metric_alert import BaseMetricAlertNotificationData from sentry.notifications.platform.types import ( NotificationData, NotificationProviderKey, @@ -22,15 +22,13 @@ class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]): def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate ) -> SlackRenderable: - if not isinstance(data, MetricAlertNotificationData): + if not isinstance(data, BaseMetricAlertNotificationData): raise ValueError(f"SlackMetricAlertRenderer does not support {data.__class__.__name__}") - # Re-fetch GroupEvent — needed to rebuild MetricIssueContext - event = data.event organization = data.organization - # Rebuild MetricIssueContext (the only context that holds ORM instances) - metric_issue_context = MetricAlertNotificationData.get_metric_issue_context(event) + # Rebuild MetricIssueContext — each subclass implements this differently + metric_issue_context = data.build_metric_issue_context() # Deserialize pre-computed contexts (no Action/Detector/GroupOpenPeriod re-queries) alert_context = data.alert_context.to_alert_context() diff --git a/src/sentry/notifications/platform/templates/__init__.py b/src/sentry/notifications/platform/templates/__init__.py index d83201c45fbdbe..6ec2b6c884468b 100644 --- a/src/sentry/notifications/platform/templates/__init__.py +++ b/src/sentry/notifications/platform/templates/__init__.py @@ -1,12 +1,13 @@ from .data_export import DataExportFailureTemplate, DataExportSuccessTemplate from .issue import IssueNotificationTemplate -from .metric_alert import MetricAlertNotificationTemplate +from .metric_alert import ActivityMetricAlertNotificationTemplate, MetricAlertNotificationTemplate __all__ = ( "DataExportSuccessTemplate", "DataExportFailureTemplate", "IssueNotificationTemplate", "MetricAlertNotificationTemplate", + "ActivityMetricAlertNotificationTemplate", ) # All templates should be imported here so they are registered in the notifications Django app. # See sentry/notifications/apps.py diff --git a/src/sentry/notifications/platform/templates/metric_alert.py b/src/sentry/notifications/platform/templates/metric_alert.py index e65aa458dde35a..4abc767eaeb12c 100644 --- a/src/sentry/notifications/platform/templates/metric_alert.py +++ b/src/sentry/notifications/platform/templates/metric_alert.py @@ -10,6 +10,7 @@ MetricIssueContext, OpenPeriodContext, ) +from sentry.models.activity import Activity from sentry.models.group import Group from sentry.models.organization import Organization from sentry.notifications.platform.registry import template_registry @@ -107,37 +108,24 @@ def to_open_period_context(self) -> OpenPeriodContext: ) -class MetricAlertNotificationData(NotificationData): - source: NotificationSource = NotificationSource.METRIC_ALERT +class BaseMetricAlertNotificationData(NotificationData): + """ + Shared fields and properties for metric alert notification data. - # For re-fetching GroupEvent via eventstore (MetricIssueContext has ORM instances) - event_id: str - project_id: int - group_id: int + Subclasses differ only in how they source MetricIssueContext + - MetricAlertNotificationData: re-fetches GroupEvent from Snuba + - ActivityMetricAlertNotificationData: re-fetches Activity + """ - # For feature flag check(chartcuterie) + message builder + group_id: int organization_id: int - # To rebuild the contexts detector_id: int - # Pre-computed serializable contexts alert_context: SerializableAlertContext open_period_context: SerializableOpenPeriodContext notification_uuid: str - @property - def event(self) -> GroupEvent: - event = eventstore.backend.get_event_by_id( - self.project_id, self.event_id, group_id=self.group_id - ) - if event is None: - raise ValueError(f"Event {self.event_id} not found") - elif not isinstance(event, GroupEvent): - raise ValueError(f"Event {self.event_id} is not a GroupEvent") - - return event - @property def organization(self) -> Organization: return Organization.objects.get_from_cache(id=self.organization_id) @@ -166,19 +154,66 @@ def serialized_detector(self) -> DetectorSerializerResponse: return get_detector_serializer(self.detector) - @classmethod - def get_metric_issue_context(cls, event: GroupEvent) -> MetricIssueContext: + def build_metric_issue_context(self) -> MetricIssueContext: + raise NotImplementedError + + +class MetricAlertNotificationData(BaseMetricAlertNotificationData): + source: NotificationSource = NotificationSource.METRIC_ALERT + + event_id: str + project_id: int + + @property + def event(self) -> GroupEvent: + event = eventstore.backend.get_event_by_id( + self.project_id, self.event_id, group_id=self.group_id + ) + if event is None: + raise ValueError(f"Event {self.event_id} not found") + elif not isinstance(event, GroupEvent): + raise ValueError(f"Event {self.event_id} is not a GroupEvent") + + return event + + def build_metric_issue_context(self) -> MetricIssueContext: from sentry.notifications.notification_action.types import BaseMetricAlertHandler + event = self.event evidence_data, priority = BaseMetricAlertHandler._extract_from_group_event(event) return MetricIssueContext.from_group_event(event.group, evidence_data, priority) +class ActivityMetricAlertNotificationData(BaseMetricAlertNotificationData): + source: NotificationSource = NotificationSource.ACTIVITY_METRIC_ALERT + + activity_id: int + + @property + def activity(self) -> Activity: + return Activity.objects.get(id=self.activity_id) + + def build_metric_issue_context(self) -> MetricIssueContext: + from sentry.notifications.notification_action.types import BaseMetricAlertHandler + + evidence_data, priority = BaseMetricAlertHandler._extract_from_activity(self.activity) + return MetricIssueContext.from_group_event(self.group, evidence_data, priority) + + +_EXAMPLE_ALERT_CONTEXT = SerializableAlertContext( + name="Example Alert", + action_identifier_id=1, + detection_type="static", +) +_EXAMPLE_OPEN_PERIOD_CONTEXT = SerializableOpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, 0, 0, 0), +) + + @template_registry.register(NotificationSource.METRIC_ALERT) class MetricAlertNotificationTemplate(NotificationTemplate[MetricAlertNotificationData]): category = NotificationCategory.METRIC_ALERT - # hide_from_debugger because this template uses a custom renderer that bypasses - # the standard NotificationRenderedTemplate rendering path so wouldn't load correctly in the debugger. hide_from_debugger = True example_data = MetricAlertNotificationData( event_id="abc123", @@ -186,21 +221,30 @@ class MetricAlertNotificationTemplate(NotificationTemplate[MetricAlertNotificati group_id=1, organization_id=1, detector_id=1, - alert_context=SerializableAlertContext( - name="Example Alert", - action_identifier_id=1, - detection_type="static", - ), - open_period_context=SerializableOpenPeriodContext( - id=1, - date_started=datetime(2024, 1, 1, 0, 0, 0), - ), + alert_context=_EXAMPLE_ALERT_CONTEXT, + open_period_context=_EXAMPLE_OPEN_PERIOD_CONTEXT, notification_uuid="test-uuid", ) def render(self, data: MetricAlertNotificationData) -> NotificationRenderedTemplate: - # The actual rendering is handled by the provider-specific custom renderer - # (e.g. SlackMetricAlertRenderer), which rebuilds MetricIssueContext from the - # re-fetched GroupEvent and builds the full payload via SlackIncidentsMessageBuilder. - # This method returns a minimal fallback for providers without a custom renderer. + return NotificationRenderedTemplate(subject="Metric Alert", body=[]) + + +@template_registry.register(NotificationSource.ACTIVITY_METRIC_ALERT) +class ActivityMetricAlertNotificationTemplate( + NotificationTemplate[ActivityMetricAlertNotificationData] +): + category = NotificationCategory.METRIC_ALERT + hide_from_debugger = True + example_data = ActivityMetricAlertNotificationData( + group_id=1, + organization_id=1, + detector_id=1, + alert_context=_EXAMPLE_ALERT_CONTEXT, + open_period_context=_EXAMPLE_OPEN_PERIOD_CONTEXT, + notification_uuid="test-uuid", + activity_id=1, + ) + + def render(self, data: ActivityMetricAlertNotificationData) -> NotificationRenderedTemplate: return NotificationRenderedTemplate(subject="Metric Alert", body=[]) diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index ae8f80b72acd52..b3b6765173b418 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -58,6 +58,7 @@ class NotificationSource(StrEnum): # METRIC_ALERT METRIC_ALERT = "metric-alert" + ACTIVITY_METRIC_ALERT = "activity-metric-alert" # SEER SEER_AUTOFIX_ERROR = "seer-autofix-error" @@ -93,6 +94,7 @@ class NotificationSource(StrEnum): ], NotificationCategory.METRIC_ALERT: [ NotificationSource.METRIC_ALERT, + NotificationSource.ACTIVITY_METRIC_ALERT, ], NotificationCategory.SEER: [ NotificationSource.SEER_AUTOFIX_TRIGGER, diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py index e9be9c1762b69f..bb36ede6e798ff 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import asdict from datetime import datetime, timezone from typing import Any from unittest.mock import MagicMock, patch @@ -7,9 +8,11 @@ import pytest from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext +from sentry.models.activity import Activity from sentry.notifications.platform.slack.provider import SlackNotificationProvider from sentry.notifications.platform.slack.renderers.metric_alert import SlackMetricAlertRenderer from sentry.notifications.platform.templates.metric_alert import ( + ActivityMetricAlertNotificationData, MetricAlertNotificationData, SerializableAlertContext, SerializableOpenPeriodContext, @@ -21,6 +24,7 @@ ) from sentry.testutils.cases import TestCase from sentry.testutils.helpers.features import with_feature +from sentry.types.activity import ActivityType from sentry.workflow_engine.types import DetectorPriorityLevel from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( MetricAlertHandlerBase, @@ -186,3 +190,72 @@ def test_render_continues_when_chart_fails( blocks: list[Any] = result["blocks"] assert len(blocks) == 1 assert blocks[0]["type"] == "section" + + +class SlackActivityMetricAlertRendererTest(MetricAlertHandlerBase): + def setUp(self) -> None: + super().setUp() + self.create_models() + + activity = Activity( + project=self.project, + group=self.group, + type=ActivityType.SET_RESOLVED.value, + data=asdict(self.evidence_data), + ) + activity.save() + + alert_context = AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + open_period_context = OpenPeriodContext.from_group(self.group) + + self.notification_data = ActivityMetricAlertNotificationData( + group_id=self.group.id, + organization_id=self.organization.id, + detector_id=self.detector.id, + alert_context=SerializableAlertContext.from_alert_context(alert_context), + open_period_context=SerializableOpenPeriodContext.from_open_period_context( + open_period_context + ), + activity_id=activity.id, + notification_uuid="test-uuid", + ) + self.rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[]) + + def test_render_produces_blocks_without_snuba(self) -> None: + # The Activity path re-fetches from Postgres only (no Snuba) — no eventstore mock needed + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + blocks: list[Any] = result["blocks"] + assert len(blocks) == 1 + assert blocks[0]["type"] == "section" + assert blocks[0]["text"]["type"] == "mrkdwn" + # "Resolved" appears in the fallback title (result["text"]), not the metric body block + assert "Resolved" in result["text"] + assert self.detector.name in result["text"] + + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart", + return_value=MOCK_CHART_URL, + ) + @with_feature({"organizations:metric-alert-chartcuterie": True}) + def test_render_includes_image_block_when_chart_enabled(self, mock_chart: MagicMock) -> None: + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + blocks: list[Any] = result["blocks"] + assert len(blocks) == 2 + assert blocks[0]["type"] == "section" + assert "Resolved" in result["text"] + assert blocks[1]["type"] == "image" + assert blocks[1]["image_url"] == MOCK_CHART_URL + assert blocks[1]["alt_text"] == "Metric Alert Chart" diff --git a/tests/sentry/notifications/platform/templates/test_metric_alert.py b/tests/sentry/notifications/platform/templates/test_metric_alert.py index 53c6a6654e41f2..7e099126b1781e 100644 --- a/tests/sentry/notifications/platform/templates/test_metric_alert.py +++ b/tests/sentry/notifications/platform/templates/test_metric_alert.py @@ -1,10 +1,16 @@ from __future__ import annotations +from dataclasses import asdict from datetime import datetime, timezone +from typing import Any from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType +from sentry.incidents.models.incident import IncidentStatus from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext +from sentry.models.activity import Activity from sentry.notifications.platform.templates.metric_alert import ( + ActivityMetricAlertNotificationData, + ActivityMetricAlertNotificationTemplate, MetricAlertNotificationData, MetricAlertNotificationTemplate, SerializableAlertContext, @@ -13,31 +19,31 @@ from sentry.notifications.platform.types import NotificationRenderedTemplate, NotificationSource from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType from sentry.testutils.cases import TestCase +from sentry.types.activity import ActivityType from sentry.workflow_engine.types import DetectorPriorityLevel from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( MetricAlertHandlerBase, ) -def _make_notification_data(**overrides: object) -> MetricAlertNotificationData: - """Build a minimal MetricAlertNotificationData with sensible defaults.""" - defaults: dict[str, object] = dict( - event_id="abc123", - project_id=1, - group_id=1, - organization_id=1, - detector_id=1, - alert_context=SerializableAlertContext( +def _make_notification_data(**overrides: Any) -> MetricAlertNotificationData: + defaults = { + "event_id": "abc123", + "project_id": 1, + "group_id": 1, + "organization_id": 1, + "detector_id": 1, + "alert_context": SerializableAlertContext( name="Test Alert", action_identifier_id=1, detection_type="static", ), - open_period_context=SerializableOpenPeriodContext( + "open_period_context": SerializableOpenPeriodContext( id=1, date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), ), - notification_uuid="test-uuid", - ) + "notification_uuid": "test-uuid", + } defaults.update(overrides) return MetricAlertNotificationData(**defaults) @@ -179,6 +185,63 @@ def test_pydantic_serialization_round_trip(self) -> None: assert restored.source == NotificationSource.METRIC_ALERT +def _make_activity_notification_data(**overrides: Any) -> ActivityMetricAlertNotificationData: + defaults = { + "group_id": 1, + "organization_id": 1, + "detector_id": 1, + "alert_context": SerializableAlertContext( + name="Test Alert", + action_identifier_id=1, + detection_type="static", + ), + "open_period_context": SerializableOpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + "activity_id": 1, + "notification_uuid": "test-uuid", + } + defaults.update(overrides) + return ActivityMetricAlertNotificationData(**defaults) + + +class ActivityMetricAlertNotificationDataTest(TestCase): + def test_source(self) -> None: + data = _make_activity_notification_data() + assert data.source == NotificationSource.ACTIVITY_METRIC_ALERT + + def test_pydantic_serialization_round_trip(self) -> None: + original = _make_activity_notification_data( + group_id=10, + organization_id=20, + detector_id=30, + notification_uuid="activity-uuid", + ) + + as_dict = original.dict() + restored = ActivityMetricAlertNotificationData.validate(as_dict) + + assert restored.group_id == original.group_id + assert restored.organization_id == original.organization_id + assert restored.detector_id == original.detector_id + assert restored.notification_uuid == original.notification_uuid + assert restored.activity_id == original.activity_id + assert restored.source == NotificationSource.ACTIVITY_METRIC_ALERT + + +class ActivityMetricAlertNotificationTemplateTest(TestCase): + def test_render_returns_minimal_rendered_template(self) -> None: + template = ActivityMetricAlertNotificationTemplate() + data = _make_activity_notification_data() + + result = template.render(data) + + assert isinstance(result, NotificationRenderedTemplate) + assert result.subject == "Metric Alert" + assert result.body == [] + + class MetricAlertNotificationDataContextsTest(MetricAlertHandlerBase): def setUp(self) -> None: super().setUp() @@ -200,6 +263,40 @@ def test_alert_context_round_trips_from_workflow_engine_models(self) -> None: assert restored.threshold_type == alert_context.threshold_type assert restored.comparison_delta == alert_context.comparison_delta + def test_activity_build_metric_issue_context_uses_ok_priority(self) -> None: + activity = Activity( + project=self.project, + group=self.group, + type=ActivityType.SET_RESOLVED.value, + data=asdict(self.evidence_data), + ) + activity.save() + + open_period_context = OpenPeriodContext.from_group(self.group) + alert_context = AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + data = ActivityMetricAlertNotificationData( + group_id=self.group.id, + organization_id=self.organization.id, + detector_id=self.detector.id, + alert_context=SerializableAlertContext.from_alert_context(alert_context), + open_period_context=SerializableOpenPeriodContext.from_open_period_context( + open_period_context + ), + activity_id=activity.id, + notification_uuid="test-uuid", + ) + + context = data.build_metric_issue_context() + + # Activity path always uses DetectorPriorityLevel.OK → IncidentStatus.CLOSED + assert context.new_status == IncidentStatus.CLOSED + assert context.metric_value == self.evidence_data.value + def test_open_period_context_round_trips_from_real_group(self) -> None: open_period_context = OpenPeriodContext.from_group(self.group) serialized = SerializableOpenPeriodContext.from_open_period_context(open_period_context) @@ -211,9 +308,6 @@ def test_open_period_context_round_trips_from_real_group(self) -> None: class MetricAlertNotificationTemplateTest(TestCase): - def test_hide_from_debugger_is_true(self) -> None: - assert MetricAlertNotificationTemplate.hide_from_debugger is True - def test_render_returns_minimal_rendered_template(self) -> None: template = MetricAlertNotificationTemplate() data = _make_notification_data() @@ -223,11 +317,3 @@ def test_render_returns_minimal_rendered_template(self) -> None: assert isinstance(result, NotificationRenderedTemplate) assert result.subject == "Metric Alert" assert result.body == [] - - def test_render_example_returns_rendered_template(self) -> None: - template = MetricAlertNotificationTemplate() - - result = template.render_example() - - assert isinstance(result, NotificationRenderedTemplate) - assert result.subject == "Metric Alert" From 184b0593cdc7da95333a467bb6b7701af2dec93b Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 09:23:25 -0700 Subject: [PATCH 06/14] turn openperiod context to pydantic model --- .../incidents/typings/metric_detector.py | 9 ++- .../notification_action/types.py | 2 +- .../notifications/platform/slack/provider.py | 3 +- .../platform/slack/renderers/metric_alert.py | 3 +- .../platform/templates/metric_alert.py | 27 +-------- .../test_metric_alert_registry_handlers.py | 2 +- .../slack/renderers/test_metric_alert.py | 11 +--- .../platform/templates/test_metric_alert.py | 60 +++++++------------ 8 files changed, 40 insertions(+), 77 deletions(-) diff --git a/src/sentry/incidents/typings/metric_detector.py b/src/sentry/incidents/typings/metric_detector.py index 4f81b35dcdcffd..1c44f5fb9fa67c 100644 --- a/src/sentry/incidents/typings/metric_detector.py +++ b/src/sentry/incidents/typings/metric_detector.py @@ -4,6 +4,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any +from pydantic import BaseModel, ConfigDict + from sentry.incidents.models.alert_rule import ( AlertRule, AlertRuleDetectionType, @@ -281,15 +283,16 @@ def from_legacy_models( ) -@dataclass -class OpenPeriodContext: +class OpenPeriodContext(BaseModel): """ We want to eventually delete this class. it serves as a way to pass data around that we used to use `incident` for. """ + model_config = ConfigDict(frozen=True) + date_started: datetime - date_closed: datetime | None + date_closed: datetime | None = None id: int @classmethod diff --git a/src/sentry/notifications/notification_action/types.py b/src/sentry/notifications/notification_action/types.py index 74d73875e85515..fc5bfe53f66a54 100644 --- a/src/sentry/notifications/notification_action/types.py +++ b/src/sentry/notifications/notification_action/types.py @@ -507,7 +507,7 @@ def invoke_legacy_registry(cls, invocation: ActionInvocation) -> None: "notification_context": asdict(notification_context), "alert_context": asdict(alert_context), "metric_issue_context": asdict(metric_issue_context), - "open_period_context": asdict(open_period_context), + "open_period_context": open_period_context.dict(), "trigger_status": trigger_status, }, ) diff --git a/src/sentry/notifications/platform/slack/provider.py b/src/sentry/notifications/platform/slack/provider.py index 9647f09d0620fa..8ae2d64182494e 100644 --- a/src/sentry/notifications/platform/slack/provider.py +++ b/src/sentry/notifications/platform/slack/provider.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, NotRequired, TypedDict from slack_sdk.models.blocks import ( ActionsBlock, @@ -59,6 +59,7 @@ class SlackProviderThreadingContext(ProviderThreadingContext): class SlackRenderable(TypedDict): blocks: list[Block] text: str + color: NotRequired[str] class SlackRenderer(NotificationRenderer[SlackRenderable]): diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index fefb8934b46746..10593ac5b9988a 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -32,7 +32,7 @@ def render[DataT: NotificationData]( # Deserialize pre-computed contexts (no Action/Detector/GroupOpenPeriod re-queries) alert_context = data.alert_context.to_alert_context() - open_period_context = data.open_period_context.to_open_period_context() + open_period_context = data.open_period_context chart_url = None if features.has("organizations:metric-alert-chartcuterie", organization): @@ -62,4 +62,5 @@ def render[DataT: NotificationData]( return SlackRenderable( blocks=slack_body.get("blocks", []), text=slack_body.get("text", ""), + color=slack_body.get("color", ""), ) diff --git a/src/sentry/notifications/platform/templates/metric_alert.py b/src/sentry/notifications/platform/templates/metric_alert.py index 4abc767eaeb12c..3e300480e28fa2 100644 --- a/src/sentry/notifications/platform/templates/metric_alert.py +++ b/src/sentry/notifications/platform/templates/metric_alert.py @@ -85,29 +85,6 @@ def to_alert_context(self) -> AlertContext: ) -class SerializableOpenPeriodContext(BaseModel): - model_config = ConfigDict(frozen=True) - - id: int - date_started: datetime - date_closed: datetime | None = None - - @classmethod - def from_open_period_context(cls, opc: OpenPeriodContext) -> Self: - return cls( - id=opc.id, - date_started=opc.date_started, - date_closed=opc.date_closed, - ) - - def to_open_period_context(self) -> OpenPeriodContext: - return OpenPeriodContext( - id=self.id, - date_started=self.date_started, - date_closed=self.date_closed, - ) - - class BaseMetricAlertNotificationData(NotificationData): """ Shared fields and properties for metric alert notification data. @@ -122,7 +99,7 @@ class BaseMetricAlertNotificationData(NotificationData): detector_id: int alert_context: SerializableAlertContext - open_period_context: SerializableOpenPeriodContext + open_period_context: OpenPeriodContext notification_uuid: str @@ -205,7 +182,7 @@ def build_metric_issue_context(self) -> MetricIssueContext: action_identifier_id=1, detection_type="static", ) -_EXAMPLE_OPEN_PERIOD_CONTEXT = SerializableOpenPeriodContext( +_EXAMPLE_OPEN_PERIOD_CONTEXT = OpenPeriodContext( id=1, date_started=datetime(2024, 1, 1, 0, 0, 0), ) diff --git a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py index ab7a4aa9c1b652..188c245b5c80a3 100644 --- a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py +++ b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py @@ -262,7 +262,7 @@ def assert_open_period_context( date_started: datetime, date_closed: datetime | None, ): - assert asdict(open_period_context) == { + assert open_period_context.dict() == { "id": id, "date_started": date_started, "date_closed": date_closed, diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py index bb36ede6e798ff..2e867d13e259da 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -15,7 +15,6 @@ ActivityMetricAlertNotificationData, MetricAlertNotificationData, SerializableAlertContext, - SerializableOpenPeriodContext, ) from sentry.notifications.platform.templates.seer import SeerAutofixError from sentry.notifications.platform.types import ( @@ -45,7 +44,7 @@ def _make_notification_data(**overrides: object) -> MetricAlertNotificationData: action_identifier_id=1, detection_type="static", ), - open_period_context=SerializableOpenPeriodContext( + open_period_context=OpenPeriodContext( id=1, date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), ), @@ -105,9 +104,7 @@ def setUp(self) -> None: organization_id=self.organization.id, detector_id=self.detector.id, alert_context=SerializableAlertContext.from_alert_context(alert_context), - open_period_context=SerializableOpenPeriodContext.from_open_period_context( - open_period_context - ), + open_period_context=open_period_context, notification_uuid="test-uuid", ) self.rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[]) @@ -218,9 +215,7 @@ def setUp(self) -> None: organization_id=self.organization.id, detector_id=self.detector.id, alert_context=SerializableAlertContext.from_alert_context(alert_context), - open_period_context=SerializableOpenPeriodContext.from_open_period_context( - open_period_context - ), + open_period_context=open_period_context, activity_id=activity.id, notification_uuid="test-uuid", ) diff --git a/tests/sentry/notifications/platform/templates/test_metric_alert.py b/tests/sentry/notifications/platform/templates/test_metric_alert.py index 7e099126b1781e..17ea5b19c9e028 100644 --- a/tests/sentry/notifications/platform/templates/test_metric_alert.py +++ b/tests/sentry/notifications/platform/templates/test_metric_alert.py @@ -14,7 +14,6 @@ MetricAlertNotificationData, MetricAlertNotificationTemplate, SerializableAlertContext, - SerializableOpenPeriodContext, ) from sentry.notifications.platform.types import NotificationRenderedTemplate, NotificationSource from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType @@ -38,7 +37,7 @@ def _make_notification_data(**overrides: Any) -> MetricAlertNotificationData: action_identifier_id=1, detection_type="static", ), - "open_period_context": SerializableOpenPeriodContext( + "open_period_context": OpenPeriodContext( id=1, date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), ), @@ -102,41 +101,31 @@ def test_round_trip_none_threshold_type(self) -> None: assert round_tripped.threshold_type is None -class SerializableOpenPeriodContextTest(TestCase): - def test_from_and_to_open_period_context_with_date_closed(self) -> None: +class OpenPeriodContextTest(TestCase): + def test_fields_with_date_closed(self) -> None: date_started = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) date_closed = datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc) - original = OpenPeriodContext( - id=100, - date_started=date_started, - date_closed=date_closed, - ) - serializable = SerializableOpenPeriodContext.from_open_period_context(original) - - assert serializable.id == 100 - assert serializable.date_started == date_started - assert serializable.date_closed == date_closed + ctx = OpenPeriodContext(id=100, date_started=date_started, date_closed=date_closed) - round_tripped = serializable.to_open_period_context() - assert round_tripped.id == original.id - assert round_tripped.date_started == original.date_started - assert round_tripped.date_closed == original.date_closed + assert ctx.id == 100 + assert ctx.date_started == date_started + assert ctx.date_closed == date_closed - def test_from_and_to_open_period_context_without_date_closed(self) -> None: + def test_fields_without_date_closed(self) -> None: date_started = datetime(2024, 6, 15, 9, 30, 0, tzinfo=timezone.utc) - original = OpenPeriodContext( - id=200, - date_started=date_started, - date_closed=None, - ) - serializable = SerializableOpenPeriodContext.from_open_period_context(original) + ctx = OpenPeriodContext(id=200, date_started=date_started) - assert serializable.id == 200 - assert serializable.date_closed is None + assert ctx.id == 200 + assert ctx.date_closed is None + + def test_pydantic_round_trip(self) -> None: + date_started = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + original = OpenPeriodContext(id=100, date_started=date_started) + restored = OpenPeriodContext.parse_obj(original.dict()) - round_tripped = serializable.to_open_period_context() - assert round_tripped.id == original.id - assert round_tripped.date_closed is None + assert restored.id == original.id + assert restored.date_started == original.date_started + assert restored.date_closed is None class MetricAlertNotificationDataTest(TestCase): @@ -155,7 +144,7 @@ def test_pydantic_serialization_round_trip(self) -> None: resolve_threshold=1.0, alert_threshold=10.0, ) - open_period_ctx = SerializableOpenPeriodContext( + open_period_ctx = OpenPeriodContext( id=77, date_started=datetime(2024, 3, 1, 0, 0, 0, tzinfo=timezone.utc), date_closed=datetime(2024, 3, 1, 1, 0, 0, tzinfo=timezone.utc), @@ -195,7 +184,7 @@ def _make_activity_notification_data(**overrides: Any) -> ActivityMetricAlertNot action_identifier_id=1, detection_type="static", ), - "open_period_context": SerializableOpenPeriodContext( + "open_period_context": OpenPeriodContext( id=1, date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), ), @@ -284,9 +273,7 @@ def test_activity_build_metric_issue_context_uses_ok_priority(self) -> None: organization_id=self.organization.id, detector_id=self.detector.id, alert_context=SerializableAlertContext.from_alert_context(alert_context), - open_period_context=SerializableOpenPeriodContext.from_open_period_context( - open_period_context - ), + open_period_context=open_period_context, activity_id=activity.id, notification_uuid="test-uuid", ) @@ -299,8 +286,7 @@ def test_activity_build_metric_issue_context_uses_ok_priority(self) -> None: def test_open_period_context_round_trips_from_real_group(self) -> None: open_period_context = OpenPeriodContext.from_group(self.group) - serialized = SerializableOpenPeriodContext.from_open_period_context(open_period_context) - restored = serialized.to_open_period_context() + restored = OpenPeriodContext.parse_obj(open_period_context.dict()) assert restored.id == open_period_context.id assert restored.date_started == open_period_context.date_started From c833e69c83be739603fe025f5dd40210a00426b9 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 09:31:45 -0700 Subject: [PATCH 07/14] raise render errors ins ervice --- src/sentry/notifications/platform/service.py | 28 +++++++++++++++---- .../notifications/platform/test_service.py | 16 +++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/sentry/notifications/platform/service.py b/src/sentry/notifications/platform/service.py index 83eb55b4d955d1..5e52c7efea8a1c 100644 --- a/src/sentry/notifications/platform/service.py +++ b/src/sentry/notifications/platform/service.py @@ -49,6 +49,10 @@ class NotificationServiceError(Exception): pass +class NotificationRenderError(NotificationServiceError): + pass + + class NotificationService[T: NotificationData]: def __init__(self, *, data: T): self.data: Final[T] = data @@ -94,9 +98,15 @@ def notify_target( # Update the lifecycle with the notification category now that we know it event_lifecycle.notification_category = template.category - renderable = NotificationService.render_template( - data=self.data, template=template, provider=provider - ) + try: + renderable = NotificationService.render_template( + data=self.data, template=template, provider=provider + ) + except Exception as e: + lifecycle.record_failure(failure_reason=e, create_issue=True) + raise NotificationRenderError( + f"Failed to render notification for source={self.data.source}" + ) from e # Step 3: Resolve thread if threading requested thread_context: ThreadContext | None = None @@ -321,9 +331,15 @@ def notify_target_async( template_cls = template_registry.get(notification_data.source) template = template_cls() lifecycle_metric.notification_category = template.category - renderable = NotificationService.render_template( - data=notification_data, template=template, provider=provider - ) + try: + renderable = NotificationService.render_template( + data=notification_data, template=template, provider=provider + ) + except Exception as e: + lifecycle.record_failure(failure_reason=e, create_issue=True) + raise NotificationRenderError( + f"Failed to render notification for source={notification_data.source}" + ) from e # Step 4: Resolve thread if threading requested thread_context: ThreadContext | None = None diff --git a/tests/sentry/notifications/platform/test_service.py b/tests/sentry/notifications/platform/test_service.py index e8a79d2c6c0e1f..42374385a7ee3b 100644 --- a/tests/sentry/notifications/platform/test_service.py +++ b/tests/sentry/notifications/platform/test_service.py @@ -8,6 +8,7 @@ from sentry.notifications.platform.email.provider import EmailNotificationProvider from sentry.notifications.platform.provider import SendFailure, SendFailureStatus from sentry.notifications.platform.service import ( + NotificationRenderError, NotificationService, NotificationServiceError, deserialize_notification_data, @@ -193,6 +194,21 @@ def test_notify_mixed_targets_async( assert_count_of_metric(mock_record, EventLifecycleOutcome.STARTED, 2) assert_count_of_metric(mock_record, EventLifecycleOutcome.SUCCESS, 2) + @mock.patch("sentry.notifications.platform.service.NotificationService.render_template") + @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_render_error_records_failure_and_raises( + self, mock_record: mock.MagicMock, mock_render: mock.MagicMock + ) -> None: + mock_render.side_effect = ValueError("missing occurrence") + service = NotificationService(data=MockNotification(message="test")) + + with pytest.raises(NotificationRenderError) as exc_info: + with self.tasks(): + service.notify_async(targets=[self.target]) + + assert "missing occurrence" in str(exc_info.value.__cause__) + assert_count_of_metric(mock_record, EventLifecycleOutcome.FAILURE, 1) + class NotificationDataSerializationTest(TestCase): def test_deserialize_raises_error_without_source(self) -> None: From e1bc9b52c052e36d6900ba70d3d313997dcc38f0 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 10:21:00 -0700 Subject: [PATCH 08/14] update helper assert for open period context to have frozen True --- .../notification_action/test_metric_alert_registry_handlers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py index 188c245b5c80a3..46531bf0718ab2 100644 --- a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py +++ b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py @@ -263,6 +263,7 @@ def assert_open_period_context( date_closed: datetime | None, ): assert open_period_context.dict() == { + "model_config": {"frozen": True}, "id": id, "date_started": date_started, "date_closed": date_closed, From 0eb7c28d0292b319e9e3155747b53a2941286c00 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 10:56:02 -0700 Subject: [PATCH 09/14] remove property lookups/queries and fix tests --- .../platform/slack/renderers/metric_alert.py | 59 ++++++++++-- .../platform/templates/metric_alert.py | 92 ++----------------- .../slack/renderers/test_metric_alert.py | 45 ++++++++- .../platform/templates/test_metric_alert.py | 36 -------- 4 files changed, 100 insertions(+), 132 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index 10593ac5b9988a..c61ce4579b03d8 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -4,15 +4,56 @@ from sentry import features from sentry.incidents.charts import build_metric_alert_chart +from sentry.incidents.typings.metric_detector import MetricIssueContext from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder +from sentry.models.group import Group +from sentry.models.organization import Organization +from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( + get_alert_rule_serializer, + get_detector_serializer, +) +from sentry.notifications.notification_action.types import BaseMetricAlertHandler from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable -from sentry.notifications.platform.templates.metric_alert import BaseMetricAlertNotificationData +from sentry.notifications.platform.templates.metric_alert import ( + ActivityMetricAlertNotificationData, + BaseMetricAlertNotificationData, + MetricAlertNotificationData, +) from sentry.notifications.platform.types import ( NotificationData, NotificationProviderKey, NotificationRenderedTemplate, ) +from sentry.services import eventstore +from sentry.services.eventstore.models import GroupEvent +from sentry.workflow_engine.models.detector import Detector + + +def _build_metric_issue_context_from_group_event( + data: MetricAlertNotificationData, +) -> MetricIssueContext: + event = eventstore.backend.get_event_by_id( + data.project_id, data.event_id, group_id=data.group_id + ) + if event is None: + raise ValueError(f"Event {data.event_id} not found") + elif not isinstance(event, GroupEvent): + raise ValueError(f"Event {data.event_id} is not a GroupEvent") + + evidence_data, priority = BaseMetricAlertHandler._extract_from_group_event(event) + return MetricIssueContext.from_group_event(event.group, evidence_data, priority) + + +def _build_metric_issue_context_from_activity( + data: ActivityMetricAlertNotificationData, +) -> MetricIssueContext: + from sentry.models.activity import Activity + + activity = Activity.objects.get(id=data.activity_id) + group = Group.objects.get_from_cache(id=data.group_id) + evidence_data, priority = BaseMetricAlertHandler._extract_from_activity(activity) + return MetricIssueContext.from_group_event(group, evidence_data, priority) class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]): @@ -25,12 +66,13 @@ def render[DataT: NotificationData]( if not isinstance(data, BaseMetricAlertNotificationData): raise ValueError(f"SlackMetricAlertRenderer does not support {data.__class__.__name__}") - organization = data.organization - - # Rebuild MetricIssueContext — each subclass implements this differently - metric_issue_context = data.build_metric_issue_context() + if isinstance(data, MetricAlertNotificationData): + metric_issue_context = _build_metric_issue_context_from_group_event(data) + elif isinstance(data, ActivityMetricAlertNotificationData): + metric_issue_context = _build_metric_issue_context_from_activity(data) - # Deserialize pre-computed contexts (no Action/Detector/GroupOpenPeriod re-queries) + organization = Organization.objects.get_from_cache(id=data.organization_id) + detector = Detector.objects.get(id=data.detector_id) alert_context = data.alert_context.to_alert_context() open_period_context = data.open_period_context @@ -39,17 +81,16 @@ def render[DataT: NotificationData]( try: chart_url = build_metric_alert_chart( organization=organization, - alert_rule_serialized_response=data.serialized_alert_rule, + alert_rule_serialized_response=get_alert_rule_serializer(detector), snuba_query=metric_issue_context.snuba_query, alert_context=alert_context, open_period_context=open_period_context, subscription=metric_issue_context.subscription, - detector_serialized_response=data.serialized_detector, + detector_serialized_response=get_detector_serializer(detector), ) except Exception as e: sentry_sdk.capture_exception(e) - # Build the Slack blocks using the existing metric alert builder slack_body = SlackIncidentsMessageBuilder( alert_context=alert_context, metric_issue_context=metric_issue_context, diff --git a/src/sentry/notifications/platform/templates/metric_alert.py b/src/sentry/notifications/platform/templates/metric_alert.py index 3e300480e28fa2..f313cf05dcfb04 100644 --- a/src/sentry/notifications/platform/templates/metric_alert.py +++ b/src/sentry/notifications/platform/templates/metric_alert.py @@ -1,18 +1,11 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Self +from typing import Self from pydantic import BaseModel, ConfigDict -from sentry.incidents.typings.metric_detector import ( - AlertContext, - MetricIssueContext, - OpenPeriodContext, -) -from sentry.models.activity import Activity -from sentry.models.group import Group -from sentry.models.organization import Organization +from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext from sentry.notifications.platform.registry import template_registry from sentry.notifications.platform.types import ( NotificationCategory, @@ -22,15 +15,6 @@ NotificationTemplate, ) from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType -from sentry.services import eventstore -from sentry.services.eventstore.models import GroupEvent -from sentry.workflow_engine.models.detector import Detector - -if TYPE_CHECKING: - from sentry.incidents.endpoints.serializers.alert_rule import AlertRuleSerializerResponse - from sentry.workflow_engine.endpoints.serializers.detector_serializer import ( - DetectorSerializerResponse, - ) class SerializableAlertContext(BaseModel): @@ -86,14 +70,6 @@ def to_alert_context(self) -> AlertContext: class BaseMetricAlertNotificationData(NotificationData): - """ - Shared fields and properties for metric alert notification data. - - Subclasses differ only in how they source MetricIssueContext - - MetricAlertNotificationData: re-fetches GroupEvent from Snuba - - ActivityMetricAlertNotificationData: re-fetches Activity - """ - group_id: int organization_id: int detector_id: int @@ -103,79 +79,23 @@ class BaseMetricAlertNotificationData(NotificationData): notification_uuid: str - @property - def organization(self) -> Organization: - return Organization.objects.get_from_cache(id=self.organization_id) - - @property - def group(self) -> Group: - return Group.objects.get_from_cache(id=self.group_id) - - @property - def detector(self) -> Detector: - return Detector.objects.get(id=self.detector_id) - - @property - def serialized_alert_rule(self) -> AlertRuleSerializerResponse: - from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( - get_alert_rule_serializer, - ) - - return get_alert_rule_serializer(self.detector) - - @property - def serialized_detector(self) -> DetectorSerializerResponse: - from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( - get_detector_serializer, - ) - - return get_detector_serializer(self.detector) - - def build_metric_issue_context(self) -> MetricIssueContext: - raise NotImplementedError - class MetricAlertNotificationData(BaseMetricAlertNotificationData): + """GroupEvent / firing path. Renderer re-fetches GroupEvent from Snuba.""" + source: NotificationSource = NotificationSource.METRIC_ALERT event_id: str project_id: int - @property - def event(self) -> GroupEvent: - event = eventstore.backend.get_event_by_id( - self.project_id, self.event_id, group_id=self.group_id - ) - if event is None: - raise ValueError(f"Event {self.event_id} not found") - elif not isinstance(event, GroupEvent): - raise ValueError(f"Event {self.event_id} is not a GroupEvent") - - return event - - def build_metric_issue_context(self) -> MetricIssueContext: - from sentry.notifications.notification_action.types import BaseMetricAlertHandler - - event = self.event - evidence_data, priority = BaseMetricAlertHandler._extract_from_group_event(event) - return MetricIssueContext.from_group_event(event.group, evidence_data, priority) - class ActivityMetricAlertNotificationData(BaseMetricAlertNotificationData): + """Activity / SET_RESOLVED path. Renderer re-fetches Activity from Postgres.""" + source: NotificationSource = NotificationSource.ACTIVITY_METRIC_ALERT activity_id: int - @property - def activity(self) -> Activity: - return Activity.objects.get(id=self.activity_id) - - def build_metric_issue_context(self) -> MetricIssueContext: - from sentry.notifications.notification_action.types import BaseMetricAlertHandler - - evidence_data, priority = BaseMetricAlertHandler._extract_from_activity(self.activity) - return MetricIssueContext.from_group_event(self.group, evidence_data, priority) - _EXAMPLE_ALERT_CONTEXT = SerializableAlertContext( name="Example Alert", diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py index 2e867d13e259da..90610473bf1cbf 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -10,7 +10,10 @@ from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext from sentry.models.activity import Activity from sentry.notifications.platform.slack.provider import SlackNotificationProvider -from sentry.notifications.platform.slack.renderers.metric_alert import SlackMetricAlertRenderer +from sentry.notifications.platform.slack.renderers.metric_alert import ( + SlackMetricAlertRenderer, + _build_metric_issue_context_from_activity, +) from sentry.notifications.platform.templates.metric_alert import ( ActivityMetricAlertNotificationData, MetricAlertNotificationData, @@ -254,3 +257,43 @@ def test_render_includes_image_block_when_chart_enabled(self, mock_chart: MagicM assert blocks[1]["type"] == "image" assert blocks[1]["image_url"] == MOCK_CHART_URL assert blocks[1]["alt_text"] == "Metric Alert Chart" + + +class MetricIssueContextBuildersTest(MetricAlertHandlerBase): + def setUp(self) -> None: + super().setUp() + self.create_models() + + def test_build_from_activity_uses_ok_priority(self) -> None: + from sentry.incidents.models.incident import IncidentStatus + + activity = Activity( + project=self.project, + group=self.group, + type=ActivityType.SET_RESOLVED.value, + data=asdict(self.evidence_data), + ) + activity.save() + + data = ActivityMetricAlertNotificationData( + group_id=self.group.id, + organization_id=self.organization.id, + detector_id=self.detector.id, + alert_context=SerializableAlertContext.from_alert_context( + AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + ), + open_period_context=OpenPeriodContext.from_group(self.group), + activity_id=activity.id, + notification_uuid="test-uuid", + ) + + context = _build_metric_issue_context_from_activity(data) + + # Activity path always resolves with DetectorPriorityLevel.OK → IncidentStatus.CLOSED + assert context.new_status == IncidentStatus.CLOSED + assert context.metric_value == self.evidence_data.value diff --git a/tests/sentry/notifications/platform/templates/test_metric_alert.py b/tests/sentry/notifications/platform/templates/test_metric_alert.py index 17ea5b19c9e028..b7cdca7dcfd863 100644 --- a/tests/sentry/notifications/platform/templates/test_metric_alert.py +++ b/tests/sentry/notifications/platform/templates/test_metric_alert.py @@ -1,13 +1,10 @@ from __future__ import annotations -from dataclasses import asdict from datetime import datetime, timezone from typing import Any from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType -from sentry.incidents.models.incident import IncidentStatus from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext -from sentry.models.activity import Activity from sentry.notifications.platform.templates.metric_alert import ( ActivityMetricAlertNotificationData, ActivityMetricAlertNotificationTemplate, @@ -18,7 +15,6 @@ from sentry.notifications.platform.types import NotificationRenderedTemplate, NotificationSource from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType from sentry.testutils.cases import TestCase -from sentry.types.activity import ActivityType from sentry.workflow_engine.types import DetectorPriorityLevel from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( MetricAlertHandlerBase, @@ -252,38 +248,6 @@ def test_alert_context_round_trips_from_workflow_engine_models(self) -> None: assert restored.threshold_type == alert_context.threshold_type assert restored.comparison_delta == alert_context.comparison_delta - def test_activity_build_metric_issue_context_uses_ok_priority(self) -> None: - activity = Activity( - project=self.project, - group=self.group, - type=ActivityType.SET_RESOLVED.value, - data=asdict(self.evidence_data), - ) - activity.save() - - open_period_context = OpenPeriodContext.from_group(self.group) - alert_context = AlertContext.from_workflow_engine_models( - self.detector, - self.evidence_data, - self.group.status, - DetectorPriorityLevel.HIGH, - ) - data = ActivityMetricAlertNotificationData( - group_id=self.group.id, - organization_id=self.organization.id, - detector_id=self.detector.id, - alert_context=SerializableAlertContext.from_alert_context(alert_context), - open_period_context=open_period_context, - activity_id=activity.id, - notification_uuid="test-uuid", - ) - - context = data.build_metric_issue_context() - - # Activity path always uses DetectorPriorityLevel.OK → IncidentStatus.CLOSED - assert context.new_status == IncidentStatus.CLOSED - assert context.metric_value == self.evidence_data.value - def test_open_period_context_round_trips_from_real_group(self) -> None: open_period_context = OpenPeriodContext.from_group(self.group) restored = OpenPeriodContext.parse_obj(open_period_context.dict()) From caad88e879ee19df5bf970f1094fb101d51c5813 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 11:05:29 -0700 Subject: [PATCH 10/14] fix cursor comments --- .../notifications/platform/slack/renderers/metric_alert.py | 2 ++ .../platform/slack/renderers/test_metric_alert.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index c61ce4579b03d8..60007d5865cd58 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -70,6 +70,8 @@ def render[DataT: NotificationData]( metric_issue_context = _build_metric_issue_context_from_group_event(data) elif isinstance(data, ActivityMetricAlertNotificationData): metric_issue_context = _build_metric_issue_context_from_activity(data) + else: + raise ValueError(f"Cannot build MetricIssueContext from {data.__class__.__name__}") organization = Organization.objects.get_from_cache(id=data.organization_id) detector = Detector.objects.get(id=data.detector_id) diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py index 90610473bf1cbf..211431fb4aa22e 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -117,7 +117,7 @@ def setUp(self) -> None: return_value=None, ) @patch( - "sentry.notifications.platform.templates.metric_alert.eventstore.backend.get_event_by_id" + "sentry.notifications.platform.slack.renderers.metric_alert.eventstore.backend.get_event_by_id" ) def test_render_produces_blocks(self, mock_get_event: MagicMock, mock_chart: MagicMock) -> None: mock_get_event.return_value = self.group_event @@ -142,7 +142,7 @@ def test_render_produces_blocks(self, mock_get_event: MagicMock, mock_chart: Mag return_value=MOCK_CHART_URL, ) @patch( - "sentry.notifications.platform.templates.metric_alert.eventstore.backend.get_event_by_id" + "sentry.notifications.platform.slack.renderers.metric_alert.eventstore.backend.get_event_by_id" ) @with_feature({"organizations:metric-alert-chartcuterie": True}) def test_render_includes_image_block_when_chart_enabled( @@ -171,7 +171,7 @@ def test_render_includes_image_block_when_chart_enabled( side_effect=Exception("chart service unavailable"), ) @patch( - "sentry.notifications.platform.templates.metric_alert.eventstore.backend.get_event_by_id" + "sentry.notifications.platform.slack.renderers.metric_alert.eventstore.backend.get_event_by_id" ) def test_render_continues_when_chart_fails( self, mock_get_event: MagicMock, mock_chart: MagicMock, mock_sdk: MagicMock From 3636c7ecf49a63b841324a237c7ada155fb26a59 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 11:15:09 -0700 Subject: [PATCH 11/14] mypy sux --- .../notifications/platform/slack/renderers/metric_alert.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index 60007d5865cd58..c61ce4579b03d8 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -70,8 +70,6 @@ def render[DataT: NotificationData]( metric_issue_context = _build_metric_issue_context_from_group_event(data) elif isinstance(data, ActivityMetricAlertNotificationData): metric_issue_context = _build_metric_issue_context_from_activity(data) - else: - raise ValueError(f"Cannot build MetricIssueContext from {data.__class__.__name__}") organization = Organization.objects.get_from_cache(id=data.organization_id) detector = Detector.objects.get(id=data.detector_id) From 4bee1c40aa8519b1e3d4171c9d918f739e79c9e2 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 11:19:43 -0700 Subject: [PATCH 12/14] fight more cursor comments --- .../notifications/platform/slack/renderers/metric_alert.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index c61ce4579b03d8..9a261d5d2fa101 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -100,8 +100,11 @@ def render[DataT: NotificationData]( notification_uuid=data.notification_uuid, ).build() - return SlackRenderable( + renderable = SlackRenderable( blocks=slack_body.get("blocks", []), text=slack_body.get("text", ""), - color=slack_body.get("color", ""), ) + if slack_body.get("color"): + renderable["color"] = slack_body.get("color") + + return renderable From 5452b679b365ccf89bb5aa3901679786de2f4243 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 14:29:21 -0700 Subject: [PATCH 13/14] typing --- .../notifications/platform/slack/renderers/metric_alert.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index 9a261d5d2fa101..f03196b1c2cd9c 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -104,7 +104,7 @@ def render[DataT: NotificationData]( blocks=slack_body.get("blocks", []), text=slack_body.get("text", ""), ) - if slack_body.get("color"): - renderable["color"] = slack_body.get("color") + if (color := slack_body.get("color")) is not None: + renderable["color"] = color return renderable From df4eaeec9a62b3421e1301201da17ada119f4593 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Mon, 30 Mar 2026 14:47:35 -0700 Subject: [PATCH 14/14] fix pydantic config --- src/sentry/incidents/typings/metric_detector.py | 5 +++-- .../test_metric_alert_registry_handlers.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sentry/incidents/typings/metric_detector.py b/src/sentry/incidents/typings/metric_detector.py index 1c44f5fb9fa67c..60ceac7a4b5513 100644 --- a/src/sentry/incidents/typings/metric_detector.py +++ b/src/sentry/incidents/typings/metric_detector.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel from sentry.incidents.models.alert_rule import ( AlertRule, @@ -289,7 +289,8 @@ class OpenPeriodContext(BaseModel): that we used to use `incident` for. """ - model_config = ConfigDict(frozen=True) + class Config: + frozen = True date_started: datetime date_closed: datetime | None = None diff --git a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py index 46531bf0718ab2..188c245b5c80a3 100644 --- a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py +++ b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py @@ -263,7 +263,6 @@ def assert_open_period_context( date_closed: datetime | None, ): assert open_period_context.dict() == { - "model_config": {"frozen": True}, "id": id, "date_started": date_started, "date_closed": date_closed,