From bd4be1e21d3f3f61fcdc21e5f01f64b2df7cbfbb Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 2 Apr 2026 14:29:48 -0700 Subject: [PATCH 1/8] add a new task for sentry app metric alert actions --- src/sentry/incidents/action_handlers.py | 11 +- .../sentry_app_metric_alert_handler.py | 1 + .../rules/actions/notify_event_service.py | 17 +-- src/sentry/sentry_apps/tasks/sentry_apps.py | 121 +++++++++++++++++- 4 files changed, 129 insertions(+), 21 deletions(-) diff --git a/src/sentry/incidents/action_handlers.py b/src/sentry/incidents/action_handlers.py index e6a8b00fc500..206406c7dbe7 100644 --- a/src/sentry/incidents/action_handlers.py +++ b/src/sentry/incidents/action_handlers.py @@ -462,22 +462,15 @@ def send_alert( incident_serialized_response = serialize(incident, serializer=IncidentSerializer()) - success = send_incident_alert_notification( + send_incident_alert_notification( notification_context=notification_context, alert_context=alert_context, metric_issue_context=metric_issue_context, incident_serialized_response=incident_serialized_response, organization=incident.organization, + project_id=project.id, notification_uuid=notification_uuid, ) - if success: - self.record_alert_sent_analytics( - organization_id=incident.organization.id, - project_id=project.id, - alert_id=incident.alert_rule.id, - external_id=action.sentry_app_id, - notification_uuid=notification_uuid, - ) def format_duration(minutes): diff --git a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py index 8037d57b8214..2c9b1f79c09d 100644 --- a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py +++ b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py @@ -54,5 +54,6 @@ def send_alert( metric_issue_context=metric_issue_context, incident_serialized_response=incident_serialized_response, organization=organization, + project_id=project.id, notification_uuid=notification_uuid, ) diff --git a/src/sentry/rules/actions/notify_event_service.py b/src/sentry/rules/actions/notify_event_service.py index 31fe09bf88bc..11cc62285daa 100644 --- a/src/sentry/rules/actions/notify_event_service.py +++ b/src/sentry/rules/actions/notify_event_service.py @@ -13,7 +13,6 @@ NotificationContext, ) from sentry.integrations.metric_alerts import incident_attachment_info -from sentry.integrations.services.integration import integration_service from sentry.models.organization import Organization from sentry.plugins.base import plugins from sentry.rules.actions.base import EventAction @@ -21,7 +20,7 @@ from sentry.rules.base import CallbackFuture from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent from sentry.sentry_apps.services.app import RpcSentryAppService, app_service -from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app +from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app, send_metric_alert_webhook from sentry.services.eventstore.models import GroupEvent from sentry.utils import json, metrics from sentry.utils.forms import set_field_choices @@ -64,15 +63,15 @@ def send_incident_alert_notification( metric_issue_context: MetricIssueContext, incident_serialized_response: IncidentSerializerResponse, organization: Organization, + project_id: int, notification_uuid: str | None = None, -) -> bool: +) -> None: """ When a metric alert is triggered, send incident data to the SentryApp's webhook. :param action: The triggered `AlertRuleTriggerAction`. :param incident: The `Incident` for which to build a payload. :param metric_value: The value of the metric that triggered this alert to fire. - :return: """ incident_attachment = build_incident_attachment( alert_context, @@ -85,17 +84,15 @@ def send_incident_alert_notification( if notification_context.sentry_app_id is None: raise ValueError("Sentry app ID is required") - success = integration_service.send_incident_alert_notification( + send_metric_alert_webhook.delay( sentry_app_id=notification_context.sentry_app_id, new_status=metric_issue_context.new_status.value, incident_attachment_json=json.dumps(incident_attachment), organization_id=organization.id, - # TODO(iamrajjoshi): The rest of the params are unused - action_id=-1, - incident_id=-1, - metric_value=-1, + project_id=project_id, + alert_id=alert_context.action_identifier_id, + notification_uuid=notification_uuid, ) - return success def find_alert_rule_action_ui_component(app_platform_event: AppPlatformEvent) -> bool: diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index 3800cb619db7..b78c74eda6c1 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -17,6 +17,7 @@ from sentry.analytics.events.alert_rule_ui_component_webhook_sent import ( AlertRuleUiComponentWebhookSentEvent, ) +from sentry.analytics.events.alert_sent import AlertSentEvent from sentry.analytics.events.comment_webhooks import ( CommentCreatedEvent, CommentDeletedEvent, @@ -36,6 +37,7 @@ from sentry.db.models.base import Model from sentry.exceptions import RestrictedIPAddress from sentry.hybridcloud.rpc.caching import cell_caching_service +from sentry.incidents.models.incident import INCIDENT_STATUS, IncidentStatus from sentry.issues.issue_occurrence import IssueOccurrence from sentry.models.activity import Activity from sentry.models.group import Group @@ -63,7 +65,11 @@ ) from sentry.sentry_apps.services.hook.service import hook_service from sentry.sentry_apps.utils.errors import SentryAppSentryError -from sentry.sentry_apps.utils.webhooks import IssueAlertActionType, SentryAppResourceType +from sentry.sentry_apps.utils.webhooks import ( + IssueAlertActionType, + MetricAlertActionType, + SentryAppResourceType, +) from sentry.services.eventstore.models import BaseEvent, Event, GroupEvent from sentry.shared_integrations.exceptions import ApiHostError, ApiTimeoutError, ClientError from sentry.silo.base import SiloMode @@ -72,7 +78,7 @@ from sentry.types.rules import RuleFuture from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service -from sentry.utils import metrics +from sentry.utils import json, metrics from sentry.utils.function_cache import cache_func_for_models from sentry.utils.http import absolute_uri from sentry.utils.sentry_apps import send_and_save_webhook_request @@ -908,6 +914,117 @@ def regenerate_service_hooks_for_installation( ) +def _record_metric_alert_sent_analytics( + organization_id: int, + project_id: int, + alert_id: int, + sentry_app_id: int, + notification_uuid: str | None, +) -> None: + try: + analytics.record( + AlertSentEvent( + organization_id=organization_id, + project_id=project_id, + alert_id=alert_id, + alert_type="metric_alert", + provider="sentry_app", + external_id=str(sentry_app_id), + notification_uuid=notification_uuid, + ) + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + +def _record_metric_alert_ui_component_analytics( + organization_id: int, + sentry_app_id: int, + app_platform_event: AppPlatformEvent, +) -> None: + triggers = ( + app_platform_event.data.get("metric_alert", {}).get("alert_rule", {}).get("triggers", []) + ) + has_ui_component = any( + action.get("type") == "sentry_app" and action.get("settings") is not None + for trigger in triggers + for action in trigger.get("actions", []) + ) + if not has_ui_component: + return + try: + analytics.record( + AlertRuleUiComponentWebhookSentEvent( + organization_id=organization_id, + sentry_app_id=sentry_app_id, + event=f"{app_platform_event.resource}.{app_platform_event.action}", + ) + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + +@instrumented_task( + name="sentry.sentry_apps.tasks.sentry_apps.send_metric_alert_webhook", + namespace=sentryapp_tasks, + retry=Retry(times=3, delay=60 * 5), + silo_mode=SiloMode.CELL, +) +@retry_decorator +def send_metric_alert_webhook( + sentry_app_id: int, + new_status: int, + incident_attachment_json: str, + organization_id: int, + project_id: int, + alert_id: int, + notification_uuid: str | None = None, + **kwargs: Any, +) -> None: + try: + new_status_str = INCIDENT_STATUS[IncidentStatus(new_status)].lower() + event = SentryAppEventType( + f"{SentryAppResourceType.METRIC_ALERT}.{MetricAlertActionType(new_status_str)}" + ) + except ValueError as e: + sentry_sdk.capture_exception(e) + return + + with SentryAppInteractionEvent( + operation_type=SentryAppInteractionType.PREPARE_WEBHOOK, + event_type=event, + ).capture() as lifecycle: + sentry_app = app_service.get_sentry_app_by_id(id=sentry_app_id) + if sentry_app is None: + raise SentryAppSentryError(message=SentryAppWebhookFailureReason.MISSING_SENTRY_APP) + + installations = app_service.get_many( + filter=dict( + organization_id=organization_id, + app_ids=[sentry_app.id], + status=SentryAppInstallationStatus.INSTALLED, + ) + ) + if not installations: + lifecycle.record_halt(halt_reason=SentryAppWebhookHaltReason.MISSING_INSTALLATION) + return + (install,) = installations + + app_platform_event = AppPlatformEvent( + resource=SentryAppResourceType.METRIC_ALERT, + action=MetricAlertActionType(new_status_str), + install=install, + data=json.loads(incident_attachment_json), + ) + + send_and_save_webhook_request(sentry_app, app_platform_event) + + _record_metric_alert_sent_analytics( + organization_id, project_id, alert_id, sentry_app.id, notification_uuid + ) + _record_metric_alert_ui_component_analytics(organization_id, sentry_app.id, app_platform_event) + + @instrumented_task( name="sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization", namespace=sentryapp_tasks, From 127f1e072659979bfb9ceeedb57ef00a8e52269f Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 2 Apr 2026 15:02:20 -0700 Subject: [PATCH 2/8] add tests --- .../test_sentry_app_metric_alert_handler.py | 1 + .../actions/test_notify_event_service.py | 104 ++++++- .../sentry_apps/tasks/test_sentry_apps.py | 263 ++++++++++++++++++ 3 files changed, 366 insertions(+), 2 deletions(-) diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py index 2e691a8334cb..92328e7e9328 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py @@ -85,6 +85,7 @@ def test_send_alert(self, mock_send_incident_alert_notification: mock.MagicMock) alert_context=alert_context, metric_issue_context=metric_issue_context, organization=self.detector.project.organization, + project_id=self.detector.project.id, notification_uuid=notification_uuid, incident_serialized_response=get_incident_serializer(self.open_period), ) diff --git a/tests/sentry/rules/actions/test_notify_event_service.py b/tests/sentry/rules/actions/test_notify_event_service.py index ed459cae6b46..0cf3a2c772a7 100644 --- a/tests/sentry/rules/actions/test_notify_event_service.py +++ b/tests/sentry/rules/actions/test_notify_event_service.py @@ -1,19 +1,31 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 +import pytest import responses from django.utils import timezone from requests.exceptions import HTTPError +from sentry.api.serializers import serialize from sentry.eventstream.types import EventStreamEventType from sentry.grouping.grouptype import ErrorGroupType +from sentry.incidents.endpoints.serializers.incident import IncidentSerializer +from sentry.incidents.models.incident import IncidentStatus +from sentry.incidents.typings.metric_detector import ( + AlertContext, + MetricIssueContext, + NotificationContext, +) from sentry.models.rule import Rule from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin -from sentry.rules.actions.notify_event_service import NotifyEventServiceAction +from sentry.rules.actions.notify_event_service import ( + NotifyEventServiceAction, + send_incident_alert_notification, +) from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app from sentry.silo.base import SiloMode from sentry.tasks.post_process import post_process_group -from sentry.testutils.cases import RuleTestCase +from sentry.testutils.cases import RuleTestCase, TestCase from sentry.testutils.helpers.eventprocessing import write_event_to_cache from sentry.testutils.silo import assume_test_silo_mode from sentry.testutils.skips import requires_snuba @@ -248,3 +260,91 @@ def test_sentry_app_installed(self) -> None: results = rule.get_services() assert len(results) == 0 + + +class TestSendIncidentAlertNotification(TestCase): + def setUp(self) -> None: + self.project = self.create_project(organization=self.organization) + self.sentry_app = self.create_sentry_app(organization=self.organization) + self.alert_rule = self.create_alert_rule() + self.incident = self.create_incident(alert_rule=self.alert_rule) + self.alert_context = AlertContext.from_alert_rule_incident(self.alert_rule) + self.metric_issue_context = MetricIssueContext.from_legacy_models( + self.incident, IncidentStatus.CRITICAL, metric_value=100.0 + ) + self.incident_serialized_response = serialize( + self.incident, serializer=IncidentSerializer() + ) + self.notification_context = NotificationContext( + id=1, + sentry_app_id=str(self.sentry_app.id), + ) + self.notification_uuid = str(uuid4()) + + @patch("sentry.rules.actions.notify_event_service.send_metric_alert_webhook") + def test_dispatches_task_with_correct_kwargs(self, mock_task: MagicMock) -> None: + send_incident_alert_notification( + notification_context=self.notification_context, + alert_context=self.alert_context, + metric_issue_context=self.metric_issue_context, + incident_serialized_response=self.incident_serialized_response, + organization=self.organization, + project_id=self.project.id, + notification_uuid=self.notification_uuid, + ) + + mock_task.delay.assert_called_once() + call_kwargs = mock_task.delay.call_args.kwargs + assert call_kwargs["sentry_app_id"] == str(self.sentry_app.id) + assert call_kwargs["new_status"] == IncidentStatus.CRITICAL.value + assert call_kwargs["organization_id"] == self.organization.id + assert call_kwargs["project_id"] == self.project.id + assert call_kwargs["alert_id"] == self.alert_rule.id + assert call_kwargs["notification_uuid"] == self.notification_uuid + + attachment = json.loads(call_kwargs["incident_attachment_json"]) + assert "metric_alert" in attachment + assert "description_title" in attachment + assert "description_text" in attachment + assert "web_url" in attachment + + @patch("sentry.rules.actions.notify_event_service.send_metric_alert_webhook") + def test_raises_when_sentry_app_id_is_none(self, mock_task: MagicMock) -> None: + notification_context_no_app = NotificationContext(id=1, sentry_app_id=None) + + with pytest.raises(ValueError, match="Sentry app ID is required"): + send_incident_alert_notification( + notification_context=notification_context_no_app, + alert_context=self.alert_context, + metric_issue_context=self.metric_issue_context, + incident_serialized_response=self.incident_serialized_response, + organization=self.organization, + project_id=self.project.id, + notification_uuid=self.notification_uuid, + ) + + mock_task.delay.assert_not_called() + + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") + def test_end_to_end_sends_webhook(self, safe_urlopen: MagicMock) -> None: + safe_urlopen.return_value = MagicMock(status_code=200, headers={}) + install = self.create_sentry_app_installation( + organization=self.organization, slug=self.sentry_app.slug + ) + + with self.tasks(): + send_incident_alert_notification( + notification_context=self.notification_context, + alert_context=self.alert_context, + metric_issue_context=self.metric_issue_context, + incident_serialized_response=self.incident_serialized_response, + organization=self.organization, + project_id=self.project.id, + notification_uuid=self.notification_uuid, + ) + + safe_urlopen.assert_called_once() + _, call_kwargs = safe_urlopen.call_args + body = json.loads(call_kwargs["data"]) + assert body["action"] == "critical" + assert body["installation"]["uuid"] == install.uuid diff --git a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py index 141cc6f7f250..bba7e50e7377 100644 --- a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py @@ -7,11 +7,16 @@ from requests import HTTPError from requests.exceptions import ChunkedEncodingError, ConnectionError, Timeout +from sentry.analytics.events.alert_rule_ui_component_webhook_sent import ( + AlertRuleUiComponentWebhookSentEvent, +) +from sentry.analytics.events.alert_sent import AlertSentEvent from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework import convert_dict_key_case, snake_to_camel_case from sentry.constants import SentryAppStatus from sentry.eventstream.types import EventStreamEventType from sentry.exceptions import RestrictedIPAddress +from sentry.incidents.models.incident import IncidentStatus from sentry.integrations.types import EventLifecycleOutcome from sentry.issues.ingest import save_issue_occurrence from sentry.models.activity import Activity @@ -26,6 +31,7 @@ process_resource_change_bound, regenerate_service_hooks_for_installation, send_alert_webhook_v2, + send_metric_alert_webhook, send_webhooks, workflow_notification, ) @@ -1849,3 +1855,260 @@ def test_regenerate_service_hook_for_installation_with_empty_app_events(self) -> with assume_test_silo_mode(SiloMode.CELL): hook.refresh_from_db() assert hook.events == [] + + +class TestSendMetricAlertWebhook(TestCase): + def setUp(self) -> None: + self.sentry_app = self.create_sentry_app(organization=self.organization) + self.install = self.create_sentry_app_installation( + organization=self.organization, slug=self.sentry_app.slug + ) + self.project = self.create_project(organization=self.organization) + self.alert_id = 42 + self.incident_attachment_json = json.dumps( + { + "metric_alert": { + "alert_rule": { + "triggers": [], + } + }, + "description_text": "Something went wrong", + "description_title": "Test Alert", + "web_url": "http://example.com/alert/1", + } + ) + + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_missing_sentry_app(self, mock_record: MagicMock, safe_urlopen: MagicMock) -> None: + send_metric_alert_webhook( + sentry_app_id=9999, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=self.incident_attachment_json, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + assert not safe_urlopen.called + assert_failure_metric( + mock_record=mock_record, + error_msg=SentryAppSentryError( + message=SentryAppWebhookFailureReason.MISSING_SENTRY_APP + ), + ) + # PREPARE_WEBHOOK (failure) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=1 + ) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1 + ) + + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_missing_installation(self, mock_record: MagicMock, safe_urlopen: MagicMock) -> None: + # Create a sentry app with no installation in this organization + other_org = self.create_organization() + uninstalled_app = self.create_sentry_app(organization=other_org) + + send_metric_alert_webhook( + sentry_app_id=uninstalled_app.id, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=self.incident_attachment_json, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + assert not safe_urlopen.called + assert_halt_metric( + mock_record=mock_record, + error_msg=SentryAppWebhookHaltReason.MISSING_INSTALLATION, + ) + # PREPARE_WEBHOOK (halt) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=1 + ) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.HALTED, outcome_count=1 + ) + + @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_successful_send_critical_status( + self, + mock_record: MagicMock, + safe_urlopen: MagicMock, + mock_analytics_record: MagicMock, + ) -> None: + notification_uuid = "test-notification-uuid" + send_metric_alert_webhook( + sentry_app_id=self.sentry_app.id, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=self.incident_attachment_json, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + notification_uuid=notification_uuid, + ) + + assert safe_urlopen.called + ((args, kwargs),) = safe_urlopen.call_args_list + data = json.loads(kwargs["data"]) + assert data["action"] == "critical" + assert data["installation"]["uuid"] == self.install.uuid + assert data["data"]["metric_alert"]["alert_rule"]["triggers"] == [] + + buffer = SentryAppWebhookRequestsBuffer(self.sentry_app) + requests = buffer.get_requests() + assert len(requests) == 1 + assert requests[0]["response_code"] == 200 + assert requests[0]["event_type"] == "metric_alert.critical" + + assert_success_metric(mock_record=mock_record) + # PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2 + ) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=2 + ) + + mock_analytics_record.assert_any_call( + AlertSentEvent( + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + alert_type="metric_alert", + provider="sentry_app", + external_id=str(self.sentry_app.id), + notification_uuid=notification_uuid, + ) + ) + + @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_successful_send_closed_status( + self, + mock_record: MagicMock, + safe_urlopen: MagicMock, + mock_analytics_record: MagicMock, + ) -> None: + send_metric_alert_webhook( + sentry_app_id=self.sentry_app.id, + new_status=IncidentStatus.CLOSED.value, + incident_attachment_json=self.incident_attachment_json, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + assert safe_urlopen.called + ((args, kwargs),) = safe_urlopen.call_args_list + data = json.loads(kwargs["data"]) + assert data["action"] == "resolved" + + buffer = SentryAppWebhookRequestsBuffer(self.sentry_app) + requests = buffer.get_requests() + assert len(requests) == 1 + assert requests[0]["event_type"] == "metric_alert.resolved" + + assert_success_metric(mock_record=mock_record) + + @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_ui_component_analytics_recorded_when_sentry_app_action_with_settings( + self, + mock_record: MagicMock, + safe_urlopen: MagicMock, + mock_analytics_record: MagicMock, + ) -> None: + attachment_with_ui_component = json.dumps( + { + "metric_alert": { + "alert_rule": { + "triggers": [ + { + "actions": [ + { + "type": "sentry_app", + "settings": {"key": "value"}, + } + ] + } + ] + } + }, + "description_text": "Something happened", + "description_title": "Test Alert", + "web_url": "http://example.com/alert/1", + } + ) + + send_metric_alert_webhook( + sentry_app_id=self.sentry_app.id, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=attachment_with_ui_component, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + mock_analytics_record.assert_any_call( + AlertRuleUiComponentWebhookSentEvent( + organization_id=self.organization.id, + sentry_app_id=self.sentry_app.id, + event="metric_alert.critical", + ) + ) + + @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_ui_component_analytics_not_recorded_without_settings( + self, + mock_record: MagicMock, + safe_urlopen: MagicMock, + mock_analytics_record: MagicMock, + ) -> None: + attachment_without_settings = json.dumps( + { + "metric_alert": { + "alert_rule": { + "triggers": [ + { + "actions": [ + { + "type": "sentry_app", + # no "settings" key + } + ] + } + ] + } + }, + "description_text": "Something happened", + "description_title": "Test Alert", + "web_url": "http://example.com/alert/1", + } + ) + + send_metric_alert_webhook( + sentry_app_id=self.sentry_app.id, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=attachment_without_settings, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + ui_component_calls = [ + call + for call in mock_analytics_record.call_args_list + if isinstance(call.args[0], AlertRuleUiComponentWebhookSentEvent) + ] + assert len(ui_component_calls) == 0 From 7bad82cf489037d0fc5b83e16bac566189969d61 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 2 Apr 2026 15:11:02 -0700 Subject: [PATCH 3/8] some metrics nits --- src/sentry/sentry_apps/tasks/sentry_apps.py | 8 +++----- tests/sentry/sentry_apps/tasks/test_sentry_apps.py | 10 ++++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index b78c74eda6c1..d1aeb803cf17 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -993,7 +993,7 @@ def send_metric_alert_webhook( with SentryAppInteractionEvent( operation_type=SentryAppInteractionType.PREPARE_WEBHOOK, event_type=event, - ).capture() as lifecycle: + ).capture(): sentry_app = app_service.get_sentry_app_by_id(id=sentry_app_id) if sentry_app is None: raise SentryAppSentryError(message=SentryAppWebhookFailureReason.MISSING_SENTRY_APP) @@ -1006,14 +1006,12 @@ def send_metric_alert_webhook( ) ) if not installations: - lifecycle.record_halt(halt_reason=SentryAppWebhookHaltReason.MISSING_INSTALLATION) - return - (install,) = installations + raise SentryAppSentryError(message=SentryAppWebhookFailureReason.MISSING_INSTALLATION) app_platform_event = AppPlatformEvent( resource=SentryAppResourceType.METRIC_ALERT, action=MetricAlertActionType(new_status_str), - install=install, + install=installations[0], data=json.loads(incident_attachment_json), ) diff --git a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py index bba7e50e7377..9ebcc7afb060 100644 --- a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py @@ -1922,16 +1922,18 @@ def test_missing_installation(self, mock_record: MagicMock, safe_urlopen: MagicM ) assert not safe_urlopen.called - assert_halt_metric( + assert_failure_metric( mock_record=mock_record, - error_msg=SentryAppWebhookHaltReason.MISSING_INSTALLATION, + error_msg=SentryAppSentryError( + message=SentryAppWebhookFailureReason.MISSING_INSTALLATION + ), ) - # PREPARE_WEBHOOK (halt) + # PREPARE_WEBHOOK (failure) assert_count_of_metric( mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=1 ) assert_count_of_metric( - mock_record=mock_record, outcome=EventLifecycleOutcome.HALTED, outcome_count=1 + mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1 ) @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") From a2d48c4ef173085927d8e0bd7cac1c29b34fe4cc Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 2 Apr 2026 15:25:12 -0700 Subject: [PATCH 4/8] record and return instead of raise --- src/sentry/sentry_apps/tasks/sentry_apps.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index d1aeb803cf17..fea6dc562270 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -993,10 +993,11 @@ def send_metric_alert_webhook( with SentryAppInteractionEvent( operation_type=SentryAppInteractionType.PREPARE_WEBHOOK, event_type=event, - ).capture(): + ).capture() as lifecycle: sentry_app = app_service.get_sentry_app_by_id(id=sentry_app_id) if sentry_app is None: - raise SentryAppSentryError(message=SentryAppWebhookFailureReason.MISSING_SENTRY_APP) + lifecycle.record_failure(SentryAppWebhookFailureReason.MISSING_SENTRY_APP) + return installations = app_service.get_many( filter=dict( @@ -1006,7 +1007,8 @@ def send_metric_alert_webhook( ) ) if not installations: - raise SentryAppSentryError(message=SentryAppWebhookFailureReason.MISSING_INSTALLATION) + lifecycle.record_failure(SentryAppWebhookFailureReason.MISSING_INSTALLATION) + return app_platform_event = AppPlatformEvent( resource=SentryAppResourceType.METRIC_ALERT, From 8e8252a6c7863d6f4ed86be15ab4195cdfd1813c Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Fri, 3 Apr 2026 09:55:40 -0700 Subject: [PATCH 5/8] address PR comments, add new failure type for mult. installations, use helper etc. --- .../integrations/services/integration/impl.py | 7 +++-- .../rules/actions/notify_event_service.py | 29 ++----------------- src/sentry/sentry_apps/metrics.py | 1 + src/sentry/sentry_apps/tasks/sentry_apps.py | 15 ++++------ src/sentry/sentry_apps/utils/webhooks.py | 29 ++++++++++++++++++- .../sentry_apps/tasks/test_sentry_apps.py | 13 +++------ 6 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/sentry/integrations/services/integration/impl.py b/src/sentry/integrations/services/integration/impl.py index f33d6e750b05..1a628be719f7 100644 --- a/src/sentry/integrations/services/integration/impl.py +++ b/src/sentry/integrations/services/integration/impl.py @@ -45,7 +45,6 @@ serialize_integration_external_project, serialize_organization_integration, ) -from sentry.rules.actions.notify_event_service import find_alert_rule_action_ui_component from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent from sentry.sentry_apps.metrics import ( SentryAppEventType, @@ -54,7 +53,11 @@ ) from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation -from sentry.sentry_apps.utils.webhooks import MetricAlertActionType, SentryAppResourceType +from sentry.sentry_apps.utils.webhooks import ( + MetricAlertActionType, + SentryAppResourceType, + find_alert_rule_action_ui_component, +) from sentry.shared_integrations.exceptions import ApiError from sentry.utils import json from sentry.utils.sentry_apps import send_and_save_webhook_request diff --git a/src/sentry/rules/actions/notify_event_service.py b/src/sentry/rules/actions/notify_event_service.py index 11cc62285daa..d794f172b669 100644 --- a/src/sentry/rules/actions/notify_event_service.py +++ b/src/sentry/rules/actions/notify_event_service.py @@ -18,7 +18,6 @@ from sentry.rules.actions.base import EventAction from sentry.rules.actions.services import PluginService from sentry.rules.base import CallbackFuture -from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent from sentry.sentry_apps.services.app import RpcSentryAppService, app_service from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app, send_metric_alert_webhook from sentry.services.eventstore.models import GroupEvent @@ -70,8 +69,8 @@ def send_incident_alert_notification( When a metric alert is triggered, send incident data to the SentryApp's webhook. :param action: The triggered `AlertRuleTriggerAction`. :param incident: The `Incident` for which to build a payload. - :param metric_value: The value of the metric that triggered this alert to - fire. + :param metric_value: The value of the metric that triggered this alert to fire. + :param project_id: project id will be used for analytics after sending the webhook. """ incident_attachment = build_incident_attachment( alert_context, @@ -85,7 +84,7 @@ def send_incident_alert_notification( raise ValueError("Sentry app ID is required") send_metric_alert_webhook.delay( - sentry_app_id=notification_context.sentry_app_id, + sentry_app_id=int(notification_context.sentry_app_id), new_status=metric_issue_context.new_status.value, incident_attachment_json=json.dumps(incident_attachment), organization_id=organization.id, @@ -95,28 +94,6 @@ def send_incident_alert_notification( ) -def find_alert_rule_action_ui_component(app_platform_event: AppPlatformEvent) -> bool: - """ - Loop through the triggers for the alert rule event. For each trigger, check - if an action is an alert rule UI Component - """ - triggers = ( - getattr(app_platform_event, "data", {}) - .get("metric_alert", {}) - .get("alert_rule", {}) - .get("triggers", []) - ) - - actions = [ - action - for trigger in triggers - for action in trigger.get("actions", {}) - if (action.get("type") == "sentry_app" and action.get("settings") is not None) - ] - - return bool(len(actions)) - - class NotifyEventServiceForm(forms.Form): service = forms.ChoiceField(choices=()) diff --git a/src/sentry/sentry_apps/metrics.py b/src/sentry/sentry_apps/metrics.py index 72eb5fea395f..be0004ad528b 100644 --- a/src/sentry/sentry_apps/metrics.py +++ b/src/sentry/sentry_apps/metrics.py @@ -60,6 +60,7 @@ class SentryAppWebhookFailureReason(StrEnum): EVENT_NOT_IN_SERVCEHOOK = "event_not_in_servicehook" MISSING_ISSUE_OCCURRENCE = "missing_issue_occurrence" MISSING_USER = "missing_user" + MULTIPLE_INSTALLATIONS = "multiple_installations" class SentryAppWebhookHaltReason(StrEnum): diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index fea6dc562270..518c6d0b5233 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -69,6 +69,7 @@ IssueAlertActionType, MetricAlertActionType, SentryAppResourceType, + find_alert_rule_action_ui_component, ) from sentry.services.eventstore.models import BaseEvent, Event, GroupEvent from sentry.shared_integrations.exceptions import ApiHostError, ApiTimeoutError, ClientError @@ -942,15 +943,7 @@ def _record_metric_alert_ui_component_analytics( sentry_app_id: int, app_platform_event: AppPlatformEvent, ) -> None: - triggers = ( - app_platform_event.data.get("metric_alert", {}).get("alert_rule", {}).get("triggers", []) - ) - has_ui_component = any( - action.get("type") == "sentry_app" and action.get("settings") is not None - for trigger in triggers - for action in trigger.get("actions", []) - ) - if not has_ui_component: + if not find_alert_rule_action_ui_component(app_platform_event): return try: analytics.record( @@ -1010,6 +1003,10 @@ def send_metric_alert_webhook( lifecycle.record_failure(SentryAppWebhookFailureReason.MISSING_INSTALLATION) return + if len(installations) > 1: + lifecycle.record_failure(SentryAppWebhookFailureReason.MULTIPLE_INSTALLATIONS) + return + app_platform_event = AppPlatformEvent( resource=SentryAppResourceType.METRIC_ALERT, action=MetricAlertActionType(new_status_str), diff --git a/src/sentry/sentry_apps/utils/webhooks.py b/src/sentry/sentry_apps/utils/webhooks.py index 16f3dcb820eb..105fa2ec9c0a 100644 --- a/src/sentry/sentry_apps/utils/webhooks.py +++ b/src/sentry/sentry_apps/utils/webhooks.py @@ -1,5 +1,10 @@ +from __future__ import annotations + from enum import StrEnum -from typing import Final +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent class SentryAppActionType(StrEnum): @@ -106,3 +111,25 @@ def map_sentry_app_webhook_events( # per-event-type (issue.created, project.deleted, etc.). These are valid # resources a Sentry App may subscribe to. VALID_EVENT_RESOURCES = EVENT_EXPANSION.keys() + + +def find_alert_rule_action_ui_component( + app_platform_event: AppPlatformEvent[dict[str, Any]], +) -> bool: + """ + Returns True if the metric alert event contains a sentry app action with UI component settings. + Used to gate recording of AlertRuleUiComponentWebhookSentEvent analytics. + """ + triggers = ( + getattr(app_platform_event, "data", {}) + .get("metric_alert", {}) + .get("alert_rule", {}) + .get("triggers", []) + ) + actions = [ + action + for trigger in triggers + for action in trigger.get("actions", {}) + if (action.get("type") == "sentry_app" and action.get("settings") is not None) + ] + return bool(len(actions)) diff --git a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py index 9ebcc7afb060..04f980fe8c0c 100644 --- a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py @@ -1893,9 +1893,7 @@ def test_missing_sentry_app(self, mock_record: MagicMock, safe_urlopen: MagicMoc assert not safe_urlopen.called assert_failure_metric( mock_record=mock_record, - error_msg=SentryAppSentryError( - message=SentryAppWebhookFailureReason.MISSING_SENTRY_APP - ), + error_msg=SentryAppWebhookFailureReason.MISSING_SENTRY_APP, ) # PREPARE_WEBHOOK (failure) assert_count_of_metric( @@ -1908,7 +1906,6 @@ def test_missing_sentry_app(self, mock_record: MagicMock, safe_urlopen: MagicMoc @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_missing_installation(self, mock_record: MagicMock, safe_urlopen: MagicMock) -> None: - # Create a sentry app with no installation in this organization other_org = self.create_organization() uninstalled_app = self.create_sentry_app(organization=other_org) @@ -1924,13 +1921,11 @@ def test_missing_installation(self, mock_record: MagicMock, safe_urlopen: MagicM assert not safe_urlopen.called assert_failure_metric( mock_record=mock_record, - error_msg=SentryAppSentryError( - message=SentryAppWebhookFailureReason.MISSING_INSTALLATION - ), + error_msg=SentryAppWebhookFailureReason.MISSING_INSTALLATION, ) - # PREPARE_WEBHOOK (failure) + # APP_CREATE (success) -> PREPARE_WEBHOOK (failure) assert_count_of_metric( - mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=1 + mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2 ) assert_count_of_metric( mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1 From 3837b9c73d787353cf5edc9004f4ed36e76a98c1 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Fri, 3 Apr 2026 10:03:20 -0700 Subject: [PATCH 6/8] update test --- tests/sentry/rules/actions/test_notify_event_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sentry/rules/actions/test_notify_event_service.py b/tests/sentry/rules/actions/test_notify_event_service.py index 0cf3a2c772a7..864ff8666b0e 100644 --- a/tests/sentry/rules/actions/test_notify_event_service.py +++ b/tests/sentry/rules/actions/test_notify_event_service.py @@ -277,7 +277,7 @@ def setUp(self) -> None: ) self.notification_context = NotificationContext( id=1, - sentry_app_id=str(self.sentry_app.id), + sentry_app_id=self.sentry_app.id, ) self.notification_uuid = str(uuid4()) @@ -295,7 +295,7 @@ def test_dispatches_task_with_correct_kwargs(self, mock_task: MagicMock) -> None mock_task.delay.assert_called_once() call_kwargs = mock_task.delay.call_args.kwargs - assert call_kwargs["sentry_app_id"] == str(self.sentry_app.id) + assert call_kwargs["sentry_app_id"] == self.sentry_app.id assert call_kwargs["new_status"] == IncidentStatus.CRITICAL.value assert call_kwargs["organization_id"] == self.organization.id assert call_kwargs["project_id"] == self.project.id From 703687bf77128634a660a8c09544732f6462f984 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Fri, 3 Apr 2026 10:12:23 -0700 Subject: [PATCH 7/8] actions is actually a list --- src/sentry/sentry_apps/utils/webhooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/sentry_apps/utils/webhooks.py b/src/sentry/sentry_apps/utils/webhooks.py index 105fa2ec9c0a..c4f112e229aa 100644 --- a/src/sentry/sentry_apps/utils/webhooks.py +++ b/src/sentry/sentry_apps/utils/webhooks.py @@ -129,7 +129,7 @@ def find_alert_rule_action_ui_component( actions = [ action for trigger in triggers - for action in trigger.get("actions", {}) + for action in trigger.get("actions", []) if (action.get("type") == "sentry_app" and action.get("settings") is not None) ] return bool(len(actions)) From d36848b2c42b319978552ade1bac77a0dc1d468d Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Fri, 3 Apr 2026 14:16:26 -0700 Subject: [PATCH 8/8] register metric alert task --- src/sentry/sentry_apps/tasks/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/sentry_apps/tasks/__init__.py b/src/sentry/sentry_apps/tasks/__init__.py index 8cda77d55dd8..f3baee45c680 100644 --- a/src/sentry/sentry_apps/tasks/__init__.py +++ b/src/sentry/sentry_apps/tasks/__init__.py @@ -8,6 +8,7 @@ regenerate_service_hooks_for_installation, send_alert_webhook, send_alert_webhook_v2, + send_metric_alert_webhook, send_resource_change_webhook, workflow_notification, ) @@ -26,4 +27,5 @@ "send_alert_webhook_v2", "send_resource_change_webhook", "workflow_notification", + "send_metric_alert_webhook", )