Skip to content

Commit 474b5c5

Browse files
authored
♻️ ref(slo): Add SLOs for Slack Service (#81866)
Slack Service makes some external API calls, so adding SLOs here.
1 parent 48b88e1 commit 474b5c5

File tree

4 files changed

+113
-42
lines changed

4 files changed

+113
-42
lines changed

src/sentry/integrations/messaging/metrics.py

+3
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ class MessagingInteractionType(StrEnum):
4646
SEND_INCIDENT_ALERT_NOTIFICATION = "SEND_INCIDENT_ALERT_NOTIFICATION"
4747
SEND_ISSUE_ALERT_NOTIFICATION = "SEND_ISSUE_ALERT_NOTIFICATION"
4848

49+
SEND_ACTIVITY_NOTIFICATION = "SEND_ACTIVITY_NOTIFICATION"
50+
SEND_GENERIC_NOTIFICATION = "SEND_GENERIC_NOTIFICATION"
51+
4952

5053
@dataclass
5154
class MessagingInteractionEvent(IntegrationEventLifecycleMetric):

src/sentry/integrations/slack/metrics.py

+19
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# metrics constants
22

3+
from slack_sdk.errors import SlackApiError
4+
5+
from sentry.integrations.slack.utils.errors import (
6+
SLACK_SDK_HALT_ERROR_CATEGORIES,
7+
unpack_slack_api_error,
8+
)
9+
from sentry.integrations.utils.metrics import EventLifecycle
10+
311
SLACK_ISSUE_ALERT_SUCCESS_DATADOG_METRIC = "sentry.integrations.slack.issue_alert.success"
412
SLACK_ISSUE_ALERT_FAILURE_DATADOG_METRIC = "sentry.integrations.slack.issue_alert.failure"
513
SLACK_ACTIVITY_THREAD_SUCCESS_DATADOG_METRIC = "sentry.integrations.slack.activity_thread.success"
@@ -79,3 +87,14 @@
7987
# Middleware Parsers
8088
SLACK_MIDDLE_PARSERS_SUCCESS_DATADOG_METRIC = "sentry.middleware.integrations.slack.parsers.success"
8189
SLACK_MIDDLE_PARSERS_FAILURE_DATADOG_METRIC = "sentry.middleware.integrations.slack.parsers.failure"
90+
91+
92+
def record_lifecycle_termination_level(lifecycle: EventLifecycle, error: SlackApiError) -> None:
93+
if (
94+
(reason := unpack_slack_api_error(error))
95+
and reason is not None
96+
and reason in SLACK_SDK_HALT_ERROR_CATEGORIES
97+
):
98+
lifecycle.record_halt(reason.message)
99+
else:
100+
lifecycle.record_failure(error)

src/sentry/integrations/slack/service.py

+77-40
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
from slack_sdk.errors import SlackApiError
1111

1212
from sentry.constants import ISSUE_ALERTS_THREAD_DEFAULT
13+
from sentry.integrations.messaging.metrics import (
14+
MessagingInteractionEvent,
15+
MessagingInteractionType,
16+
)
1317
from sentry.integrations.models.integration import Integration
1418
from sentry.integrations.notifications import get_context
1519
from sentry.integrations.repository import get_default_issue_alert_repository
@@ -25,8 +29,10 @@
2529
SLACK_ACTIVITY_THREAD_SUCCESS_DATADOG_METRIC,
2630
SLACK_NOTIFY_RECIPIENT_FAILURE_DATADOG_METRIC,
2731
SLACK_NOTIFY_RECIPIENT_SUCCESS_DATADOG_METRIC,
32+
record_lifecycle_termination_level,
2833
)
2934
from sentry.integrations.slack.sdk_client import SlackSdkClient
35+
from sentry.integrations.slack.spec import SlackMessagingSpec
3036
from sentry.integrations.slack.threads.activity_notifications import (
3137
AssignedActivityNotification,
3238
ExternalIssueCreatedActivityNotification,
@@ -182,12 +188,23 @@ def notify_all_threads_for_activity(self, activity: Activity) -> None:
182188
slack_client = SlackSdkClient(integration_id=integration.id)
183189

184190
# Get all parent notifications, which will have the message identifier to use to reply in a thread
185-
parent_notifications = (
186-
self._notification_message_repository.get_all_parent_notification_messages_by_filters(
191+
with MessagingInteractionEvent(
192+
interaction_type=MessagingInteractionType.GET_PARENT_NOTIFICATION,
193+
spec=SlackMessagingSpec(),
194+
).capture() as lifecycle:
195+
lifecycle.add_extras(
196+
{
197+
"activity_id": activity.id,
198+
"group_id": activity.group.id,
199+
"project_id": activity.project.id,
200+
}
201+
)
202+
parent_notifications = self._notification_message_repository.get_all_parent_notification_messages_by_filters(
187203
group_ids=[activity.group.id],
188204
project_ids=[activity.project.id],
189205
)
190-
)
206+
207+
# We don't wrap this in a lifecycle because _handle_parent_notification is already wrapped in a lifecycle
191208
for parent_notification in parent_notifications:
192209
try:
193210
self._handle_parent_notification(
@@ -196,6 +213,7 @@ def notify_all_threads_for_activity(self, activity: Activity) -> None:
196213
client=slack_client,
197214
)
198215
except Exception as err:
216+
# TODO(iamrajjoshi): We can probably swallow this error once we audit the lifecycle
199217
self._logger.info(
200218
"failed to send notification",
201219
exc_info=err,
@@ -254,25 +272,33 @@ def _handle_parent_notification(
254272
"rule_action_uuid": parent_notification.rule_action_uuid,
255273
}
256274

257-
try:
258-
client.chat_postMessage(
259-
channel=channel_id,
260-
thread_ts=parent_notification.message_identifier,
261-
text=notification_to_send,
262-
blocks=json_blocks,
263-
)
264-
metrics.incr(SLACK_ACTIVITY_THREAD_SUCCESS_DATADOG_METRIC, sample_rate=1.0)
265-
except SlackApiError as e:
266-
self._logger.info(
267-
"failed to post message to slack",
268-
extra={"error": str(e), "blocks": json_blocks, **extra},
269-
)
270-
metrics.incr(
271-
SLACK_ACTIVITY_THREAD_FAILURE_DATADOG_METRIC,
272-
sample_rate=1.0,
273-
tags={"ok": e.response.get("ok", False), "status": e.response.status_code},
274-
)
275-
raise
275+
with MessagingInteractionEvent(
276+
interaction_type=MessagingInteractionType.SEND_ACTIVITY_NOTIFICATION,
277+
spec=SlackMessagingSpec(),
278+
).capture() as lifecycle:
279+
try:
280+
client.chat_postMessage(
281+
channel=channel_id,
282+
thread_ts=parent_notification.message_identifier,
283+
text=notification_to_send,
284+
blocks=json_blocks,
285+
)
286+
# TODO(iamrajjoshi): Remove this after we validate lifecycle
287+
metrics.incr(SLACK_ACTIVITY_THREAD_SUCCESS_DATADOG_METRIC, sample_rate=1.0)
288+
except SlackApiError as e:
289+
# TODO(iamrajjoshi): Remove this after we validate lifecycle
290+
self._logger.info(
291+
"failed to post message to slack",
292+
extra={"error": str(e), "blocks": json_blocks, **extra},
293+
)
294+
metrics.incr(
295+
SLACK_ACTIVITY_THREAD_FAILURE_DATADOG_METRIC,
296+
sample_rate=1.0,
297+
tags={"ok": e.response.get("ok", False), "status": e.response.status_code},
298+
)
299+
lifecycle.add_extras({"rule_action_uuid": parent_notification.rule_action_uuid})
300+
record_lifecycle_termination_level(lifecycle, e)
301+
raise
276302

277303
def _get_notification_message_to_send(self, activity: Activity) -> str | None:
278304
"""
@@ -427,21 +453,32 @@ def send_message_to_slack_channel(
427453
"""Execution of send_notification_as_slack."""
428454

429455
client = SlackSdkClient(integration_id=integration_id)
430-
try:
431-
client.chat_postMessage(
432-
blocks=str(payload.get("blocks", "")),
433-
text=str(payload.get("text", "")),
434-
channel=str(payload.get("channel", "")),
435-
unfurl_links=False,
436-
unfurl_media=False,
437-
callback_id=str(payload.get("callback_id", "")),
438-
)
439-
metrics.incr(SLACK_NOTIFY_RECIPIENT_SUCCESS_DATADOG_METRIC, sample_rate=1.0)
440-
except SlackApiError as e:
441-
extra = {"error": str(e), **log_params}
442-
self._logger.info(log_error_message, extra=extra)
443-
metrics.incr(
444-
SLACK_NOTIFY_RECIPIENT_FAILURE_DATADOG_METRIC,
445-
sample_rate=1.0,
446-
tags={"ok": e.response.get("ok", False), "status": e.response.status_code},
447-
)
456+
with MessagingInteractionEvent(
457+
interaction_type=MessagingInteractionType.SEND_GENERIC_NOTIFICATION,
458+
spec=SlackMessagingSpec(),
459+
).capture() as lifecycle:
460+
try:
461+
lifecycle.add_extras({"integration_id": integration_id})
462+
client.chat_postMessage(
463+
blocks=str(payload.get("blocks", "")),
464+
text=str(payload.get("text", "")),
465+
channel=str(payload.get("channel", "")),
466+
unfurl_links=False,
467+
unfurl_media=False,
468+
callback_id=str(payload.get("callback_id", "")),
469+
)
470+
# TODO(iamrajjoshi): Remove this after we validate lifecycle
471+
metrics.incr(SLACK_NOTIFY_RECIPIENT_SUCCESS_DATADOG_METRIC, sample_rate=1.0)
472+
except SlackApiError as e:
473+
# TODO(iamrajjoshi): Remove this after we validate lifecycle
474+
extra = {"error": str(e), **log_params}
475+
self._logger.info(log_error_message, extra=extra)
476+
metrics.incr(
477+
SLACK_NOTIFY_RECIPIENT_FAILURE_DATADOG_METRIC,
478+
sample_rate=1.0,
479+
tags={"ok": e.response.get("ok", False), "status": e.response.status_code},
480+
)
481+
lifecycle.add_extras(
482+
{k: str(v) for k, v in log_params.items() if isinstance(v, (int, str))}
483+
)
484+
record_lifecycle_termination_level(lifecycle, e)

tests/sentry/integrations/slack/service/test_slack_service.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
from sentry.integrations.repository.issue_alert import IssueAlertNotificationMessage
1010
from sentry.integrations.slack.sdk_client import SlackSdkClient
1111
from sentry.integrations.slack.service import RuleDataError, SlackService
12+
from sentry.integrations.types import EventLifecycleOutcome
1213
from sentry.models.activity import Activity
1314
from sentry.models.options.organization_option import OrganizationOption
1415
from sentry.models.rulefirehistory import RuleFireHistory
1516
from sentry.notifications.models.notificationmessage import NotificationMessage
1617
from sentry.silo.base import SiloMode
18+
from sentry.testutils.asserts import assert_slo_metric
1719
from sentry.testutils.cases import TestCase
1820
from sentry.testutils.silo import assume_test_silo_mode
1921
from sentry.types.activity import ActivityType
@@ -157,8 +159,9 @@ def test_no_parent_notification(self, mock_handle):
157159
self.service.notify_all_threads_for_activity(activity=self.activity)
158160
assert not mock_handle.called
159161

162+
@mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
160163
@mock.patch("sentry.integrations.slack.service.SlackService._handle_parent_notification")
161-
def test_calls_handle_parent_notification_sdk_client(self, mock_handle):
164+
def test_calls_handle_parent_notification_sdk_client(self, mock_handle, mock_record):
162165
parent_notification = IssueAlertNotificationMessage.from_model(
163166
instance=self.parent_notification
164167
)
@@ -170,6 +173,8 @@ def test_calls_handle_parent_notification_sdk_client(self, mock_handle):
170173
# check client type
171174
assert isinstance(mock_handle.call_args.kwargs["client"], SlackSdkClient)
172175

176+
assert_slo_metric(mock_record, EventLifecycleOutcome.SUCCESS)
177+
173178

174179
class TestHandleParentNotification(TestCase):
175180
def setUp(self) -> None:
@@ -233,8 +238,9 @@ def setUp(self) -> None:
233238
rule_fire_history=self.slack_rule_fire_history,
234239
)
235240

241+
@mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
236242
@mock.patch("slack_sdk.web.client.WebClient._perform_urllib_http_request")
237-
def test_handles_parent_notification_sdk(self, mock_api_call):
243+
def test_handles_parent_notification_sdk(self, mock_api_call, mock_record):
238244
mock_api_call.return_value = {
239245
"body": orjson.dumps({"ok": True}).decode(),
240246
"headers": {},
@@ -246,8 +252,12 @@ def test_handles_parent_notification_sdk(self, mock_api_call):
246252
client=SlackSdkClient(integration_id=self.integration.id),
247253
)
248254

255+
assert_slo_metric(mock_record, EventLifecycleOutcome.SUCCESS)
256+
257+
@mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
249258
def test_handles_parent_notification_sdk_error(
250259
self,
260+
mock_record,
251261
) -> None:
252262
with pytest.raises(SlackApiError):
253263
self.service._handle_parent_notification(
@@ -256,6 +266,8 @@ def test_handles_parent_notification_sdk_error(
256266
client=SlackSdkClient(integration_id=self.integration.id),
257267
)
258268

269+
assert_slo_metric(mock_record, EventLifecycleOutcome.FAILURE)
270+
259271
def test_raises_exception_when_parent_notification_does_not_have_rule_fire_history_data(
260272
self,
261273
) -> None:

0 commit comments

Comments
 (0)