Skip to content

Commit ae24177

Browse files
chore(sentry apps): Add slos for metric alert webhooks (#86748)
1 parent 54aa7ce commit ae24177

File tree

3 files changed

+177
-44
lines changed

3 files changed

+177
-44
lines changed

src/sentry/integrations/services/integration/impl.py

+36-35
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,15 @@
4141
)
4242
from sentry.rules.actions.notify_event_service import find_alert_rule_action_ui_component
4343
from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent
44+
from sentry.sentry_apps.metrics import (
45+
SentryAppEventType,
46+
SentryAppInteractionEvent,
47+
SentryAppInteractionType,
48+
)
4449
from sentry.sentry_apps.models.sentry_app import SentryApp
4550
from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
4651
from sentry.shared_integrations.exceptions import ApiError
47-
from sentry.utils import json, metrics
52+
from sentry.utils import json
4853
from sentry.utils.sentry_apps import send_and_save_webhook_request
4954

5055
if TYPE_CHECKING:
@@ -368,44 +373,40 @@ def send_incident_alert_notification(
368373
notification_uuid: str | None = None,
369374
) -> bool:
370375
try:
371-
sentry_app = SentryApp.objects.get(id=sentry_app_id)
372-
except SentryApp.DoesNotExist:
373-
logger.info(
374-
"metric_alert_webhook.missing_sentryapp",
375-
extra={
376-
"sentry_app_id": sentry_app_id,
377-
"organization_id": organization_id,
378-
},
379-
)
376+
new_status_str = INCIDENT_STATUS[IncidentStatus(new_status)].lower()
377+
event = SentryAppEventType(f"metric_alert.{new_status_str}")
378+
except ValueError as e:
379+
sentry_sdk.capture_exception(e)
380380
return False
381381

382-
metrics.incr("notifications.sent", instance=sentry_app.slug, skip_internal=False)
382+
with SentryAppInteractionEvent(
383+
operation_type=SentryAppInteractionType.PREPARE_WEBHOOK,
384+
event_type=event,
385+
).capture() as lifecycle:
386+
try:
387+
sentry_app = SentryApp.objects.get(id=sentry_app_id)
388+
except SentryApp.DoesNotExist as e:
389+
sentry_sdk.capture_exception(e)
390+
lifecycle.record_failure(e)
391+
return False
383392

384-
try:
385-
install = SentryAppInstallation.objects.get(
386-
organization_id=organization_id,
387-
sentry_app=sentry_app,
388-
status=SentryAppInstallationStatus.INSTALLED,
389-
)
390-
except SentryAppInstallation.DoesNotExist:
391-
logger.info(
392-
"metric_alert_webhook.missing_installation",
393-
extra={
394-
"action": action_id,
395-
"incident": incident_id,
396-
"organization_id": organization_id,
397-
"sentry_app_id": sentry_app.id,
398-
},
399-
exc_info=True,
400-
)
401-
return False
393+
try:
394+
install = SentryAppInstallation.objects.get(
395+
organization_id=organization_id,
396+
sentry_app=sentry_app,
397+
status=SentryAppInstallationStatus.INSTALLED,
398+
)
399+
except SentryAppInstallation.DoesNotExist as e:
400+
sentry_sdk.capture_exception(e)
401+
lifecycle.record_failure(e)
402+
return False
402403

403-
app_platform_event = AppPlatformEvent(
404-
resource="metric_alert",
405-
action=INCIDENT_STATUS[IncidentStatus(new_status)].lower(),
406-
install=install,
407-
data=json.loads(incident_attachment_json),
408-
)
404+
app_platform_event = AppPlatformEvent(
405+
resource="metric_alert",
406+
action=new_status_str,
407+
install=install,
408+
data=json.loads(incident_attachment_json),
409+
)
409410

410411
# Can raise errors if client returns >= 400
411412
send_and_save_webhook_request(

tests/sentry/incidents/action_handlers/test_sentry_app.py

+121-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
from sentry.incidents.action_handlers import SentryAppActionHandler
66
from sentry.incidents.models.alert_rule import AlertRuleTriggerAction
77
from sentry.incidents.models.incident import IncidentStatus
8+
from sentry.integrations.types import EventLifecycleOutcome
9+
from sentry.sentry_apps.metrics import SentryAppWebhookHaltReason
10+
from sentry.testutils.asserts import (
11+
assert_count_of_metric,
12+
assert_halt_metric,
13+
assert_success_metric,
14+
)
815
from sentry.testutils.helpers.datetime import freeze_time
916
from sentry.utils import json
1017

@@ -63,7 +70,8 @@ def run_test(self, incident, method):
6370
)
6471

6572
@responses.activate
66-
def test_rule_snoozed(self):
73+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
74+
def test_rule_snoozed(self, mock_record):
6775
alert_rule = self.create_alert_rule()
6876
incident = self.create_incident(alert_rule=alert_rule, status=IncidentStatus.CLOSED.value)
6977
self.snooze_rule(alert_rule=alert_rule)
@@ -88,11 +96,82 @@ def test_rule_snoozed(self):
8896

8997
assert len(responses.calls) == 0
9098

91-
def test_fire_metric_alert(self):
99+
# SLO asserts
100+
# PREPARE_WEBHOOK (never got to this point)
101+
assert_count_of_metric(
102+
mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=0
103+
)
104+
assert_count_of_metric(
105+
mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=0
106+
)
107+
108+
@responses.activate
109+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
110+
def test_rule_bad_response(self, mock_record):
111+
alert_rule = self.create_alert_rule()
112+
incident = self.create_incident(alert_rule=alert_rule, status=IncidentStatus.CLOSED.value)
113+
114+
responses.add(
115+
method=responses.POST,
116+
url="https://example.com/webhook",
117+
status=400,
118+
content_type="application/json",
119+
body=json.dumps({"ok": "true"}),
120+
)
121+
122+
metric_value = 1000
123+
with self.tasks():
124+
self.handler.fire(
125+
self.action, incident, self.project, metric_value, IncidentStatus(incident.status)
126+
)
127+
128+
assert len(responses.calls) == 1
129+
130+
# SLO asserts
131+
assert_halt_metric(
132+
mock_record=mock_record,
133+
error_msg=f"send_and_save_webhook_request.{SentryAppWebhookHaltReason.GOT_CLIENT_ERROR}_{400}",
134+
)
135+
136+
# PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (halt)
137+
assert_count_of_metric(
138+
mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2
139+
)
140+
assert_count_of_metric(
141+
mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=1
142+
)
143+
assert_count_of_metric(
144+
mock_record=mock_record, outcome=EventLifecycleOutcome.HALTED, outcome_count=1
145+
)
146+
147+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
148+
def test_fire_metric_alert(self, mock_record):
92149
self.run_fire_test()
93150

94-
def test_resolve_metric_alert(self):
151+
# SLO asserts
152+
assert_success_metric(mock_record)
153+
154+
# PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success)
155+
assert_count_of_metric(
156+
mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2
157+
)
158+
assert_count_of_metric(
159+
mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=2
160+
)
161+
162+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
163+
def test_resolve_metric_alert(self, mock_record):
95164
self.run_fire_test("resolve")
165+
# SLO asserts
166+
assert_success_metric(mock_record)
167+
168+
# PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success)
169+
assert_count_of_metric(
170+
mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2
171+
)
172+
assert_count_of_metric(
173+
mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=2
174+
)
96175

97176

98177
@freeze_time()
@@ -169,14 +248,39 @@ def run_test(self, incident, method):
169248
{"name": "teamId", "value": 1},
170249
]
171250

172-
def test_fire_metric_alert(self):
251+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
252+
def test_fire_metric_alert(self, mock_record):
173253
self.run_fire_test()
174254

175-
def test_resolve_metric_alert(self):
255+
# SLO asserts
256+
assert_success_metric(mock_record)
257+
258+
# PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success)
259+
assert_count_of_metric(
260+
mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2
261+
)
262+
assert_count_of_metric(
263+
mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=2
264+
)
265+
266+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
267+
def test_resolve_metric_alert(self, mock_record):
176268
self.run_fire_test("resolve")
177269

270+
# SLO asserts
271+
assert_success_metric(mock_record)
272+
273+
# PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success)
274+
assert_count_of_metric(
275+
mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2
276+
)
277+
assert_count_of_metric(
278+
mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=2
279+
)
280+
281+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
178282
@patch("sentry.analytics.record")
179-
def test_alert_sent_recorded(self, mock_record):
283+
def test_alert_sent_recorded(self, mock_record, mock_record_event):
180284
self.run_fire_test()
181285
mock_record.assert_called_with(
182286
"alert.sent",
@@ -188,3 +292,14 @@ def test_alert_sent_recorded(self, mock_record):
188292
external_id=str(self.action.sentry_app_id),
189293
notification_uuid="",
190294
)
295+
296+
# SLO asserts
297+
assert_success_metric(mock_record_event)
298+
299+
# PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success)
300+
assert_count_of_metric(
301+
mock_record=mock_record_event, outcome=EventLifecycleOutcome.STARTED, outcome_count=2
302+
)
303+
assert_count_of_metric(
304+
mock_record=mock_record_event, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=2
305+
)

tests/sentry/integrations/services/test_integration.py

+20-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from datetime import datetime, timezone
4+
from unittest.mock import patch
45

56
from sentry.constants import ObjectStatus
67
from sentry.integrations.base import IntegrationFeatures
@@ -16,8 +17,10 @@
1617
serialize_integration,
1718
serialize_organization_integration,
1819
)
19-
from sentry.integrations.types import ExternalProviders
20+
from sentry.integrations.types import EventLifecycleOutcome, ExternalProviders
21+
from sentry.sentry_apps.models.sentry_app import SentryApp
2022
from sentry.silo.base import SiloMode
23+
from sentry.testutils.asserts import assert_count_of_metric, assert_failure_metric
2124
from sentry.testutils.cases import TestCase
2225
from sentry.testutils.helpers.datetime import freeze_time
2326
from sentry.testutils.silo import all_silo_test, assume_test_silo_mode
@@ -326,14 +329,28 @@ def test_update_organization_integrations(self) -> None:
326329
assert oi.config == new_config
327330
assert oi.grace_period_end is None
328331

329-
def test_send_incident_alert_missing_sentryapp(self) -> None:
332+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
333+
def test_send_incident_alert_missing_sentryapp(self, mock_record) -> None:
330334
result = integration_service.send_incident_alert_notification(
331335
sentry_app_id=9876, # does not exist
332336
action_id=1,
333337
incident_id=1,
334-
new_status=0,
338+
new_status=10,
335339
metric_value=100,
336340
organization_id=self.organization.id,
337341
incident_attachment_json="{}",
338342
)
339343
assert not result
344+
345+
# SLO asserts
346+
assert_failure_metric(
347+
mock_record, SentryApp.DoesNotExist("SentryApp matching query does not exist.")
348+
)
349+
350+
# PREPARE_WEBHOOK (failure)
351+
assert_count_of_metric(
352+
mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=1
353+
)
354+
assert_count_of_metric(
355+
mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1
356+
)

0 commit comments

Comments
 (0)