Skip to content

Commit 2cdca34

Browse files
feat(sentry apps): Add SLOs for select requester process (#87212)
1 parent 14b387c commit 2cdca34

File tree

4 files changed

+330
-97
lines changed

4 files changed

+330
-97
lines changed

src/sentry/sentry_apps/external_requests/select_requester.py

+87-54
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1-
import logging
21
from collections.abc import Sequence
32
from dataclasses import dataclass, field
43
from typing import Annotated, Any, TypedDict
54
from urllib.parse import urlencode, urlparse, urlunparse
65
from uuid import uuid4
76

87
from django.utils.functional import cached_property
8+
from requests import RequestException
99

1010
from sentry.http import safe_urlread
1111
from sentry.sentry_apps.external_requests.utils import send_and_save_sentry_app_request, validate
12+
from sentry.sentry_apps.metrics import (
13+
SentryAppEventType,
14+
SentryAppExternalRequestFailureReason,
15+
SentryAppExternalRequestHaltReason,
16+
SentryAppInteractionEvent,
17+
SentryAppInteractionType,
18+
)
1219
from sentry.sentry_apps.services.app import RpcSentryAppInstallation
1320
from sentry.sentry_apps.services.app.model import RpcSentryApp
14-
from sentry.sentry_apps.utils.errors import SentryAppIntegratorError
21+
from sentry.sentry_apps.utils.errors import SentryAppIntegratorError, SentryAppSentryError
1522
from sentry.utils import json
1623

17-
logger = logging.getLogger("sentry.sentry_apps.external_requests")
24+
FAILURE_REASON_BASE = f"{SentryAppEventType.SELECT_OPTIONS_REQUESTED}.{{}}"
1825

1926

2027
class SelectRequesterResult(TypedDict, total=False):
@@ -41,66 +48,90 @@ class SelectRequester:
4148
dependent_data: str | None = field(default=None)
4249

4350
def run(self) -> SelectRequesterResult:
51+
4452
response: list[dict[str, str]] = []
4553
url = None
46-
try:
47-
url = self._build_url()
48-
body = safe_urlread(
49-
send_and_save_sentry_app_request(
50-
url,
51-
self.sentry_app,
52-
self.install.organization_id,
53-
"select_options.requested",
54-
headers=self._build_headers(),
55-
)
56-
)
5754

58-
response = json.loads(body)
59-
except Exception as e:
60-
extra = {
55+
with SentryAppInteractionEvent(
56+
operation_type=SentryAppInteractionType.EXTERNAL_REQUEST,
57+
event_type=SentryAppEventType.SELECT_OPTIONS_REQUESTED,
58+
).capture() as lifecycle:
59+
extras: dict[str, Any] = {
6160
"sentry_app_slug": self.sentry_app.slug,
6261
"install_uuid": self.install.uuid,
6362
"project_slug": self.project_slug,
6463
}
64+
lifecycle.add_extras(extras)
65+
66+
try:
67+
url = self._build_url()
68+
extras.update({"url": url})
69+
70+
body = safe_urlread(
71+
send_and_save_sentry_app_request(
72+
url,
73+
self.sentry_app,
74+
self.install.organization_id,
75+
SentryAppEventType.SELECT_OPTIONS_REQUESTED,
76+
headers=self._build_headers(),
77+
)
78+
)
6579

66-
if not url:
67-
extra.update(
68-
{
69-
"uri": self.uri,
70-
"dependent_data": self.dependent_data,
71-
"webhook_url": self.sentry_app.webhook_url,
72-
}
80+
response = json.loads(body)
81+
extras.update({"response": response})
82+
except RequestException as e:
83+
halt_reason = FAILURE_REASON_BASE.format(
84+
SentryAppExternalRequestHaltReason.BAD_RESPONSE
85+
)
86+
lifecycle.record_halt(halt_reason=e, extra={"reason": halt_reason, **extras})
87+
88+
raise SentryAppIntegratorError(
89+
message=f"Something went wrong while getting options for Select FormField from {self.sentry_app.slug}",
90+
webhook_context={"error_type": halt_reason, **extras},
91+
status_code=500,
92+
) from e
93+
94+
except Exception as e:
95+
failure_reason = FAILURE_REASON_BASE.format(
96+
SentryAppExternalRequestFailureReason.UNEXPECTED_ERROR
97+
)
98+
99+
if not url:
100+
failure_reason = FAILURE_REASON_BASE.format(
101+
SentryAppExternalRequestFailureReason.MISSING_URL
102+
)
103+
extras.update(
104+
{
105+
"uri": self.uri,
106+
"dependent_data": self.dependent_data,
107+
"webhook_url": self.sentry_app.webhook_url,
108+
}
109+
)
110+
111+
raise SentryAppSentryError(
112+
message="Something went wrong while preparing to get Select FormField options",
113+
webhook_context={"error_type": failure_reason, **extras},
114+
) from e
115+
116+
if not self._validate_response(response):
117+
halt_reason = FAILURE_REASON_BASE.format(
118+
SentryAppExternalRequestHaltReason.MISSING_FIELDS
119+
)
120+
lifecycle.record_halt(halt_reason=halt_reason, extra=extras)
121+
122+
raise SentryAppIntegratorError(
123+
message=f"Invalid response format for Select FormField in {self.sentry_app.slug} from uri: {self.uri}",
124+
webhook_context={
125+
"error_type": halt_reason,
126+
**extras,
127+
},
73128
)
74-
message = "select-requester.missing-url"
75-
else:
76-
extra.update({"url": url})
77-
message = "select-requester.request-failed"
78-
79-
logger.info(message, exc_info=e, extra=extra)
80-
raise SentryAppIntegratorError(
81-
message=f"Something went wrong while getting options for Select FormField from {self.sentry_app.slug}",
82-
webhook_context={"error_type": message, **extra},
83-
status_code=500,
84-
) from e
85-
86-
if not self._validate_response(response):
87-
extras = {
88-
"response": response,
89-
"sentry_app_slug": self.sentry_app.slug,
90-
"install_uuid": self.install.uuid,
91-
"project_slug": self.project_slug,
92-
"url": url,
93-
}
94-
logger.info("select-requester.invalid-response", extra=extras)
95129

96-
raise SentryAppIntegratorError(
97-
message=f"Invalid response format for Select FormField in {self.sentry_app.slug} from uri: {self.uri}",
98-
webhook_context={
99-
"error_type": "select-requester.invalid-integrator-response",
100-
**extras,
101-
},
102-
)
103-
return self._format_response(response)
130+
try:
131+
return self._format_response(response)
132+
except SentryAppIntegratorError as e:
133+
lifecycle.record_halt(halt_reason=e, extra={**extras})
134+
raise
104135

105136
def _build_url(self) -> str:
106137
urlparts: list[str] = [url_part for url_part in urlparse(self.sentry_app.webhook_url)]
@@ -137,7 +168,9 @@ def _format_response(self, resp: Sequence[dict[str, Any]]) -> SelectRequesterRes
137168
raise SentryAppIntegratorError(
138169
message="Missing `value` or `label` in option data for Select FormField",
139170
webhook_context={
140-
"error_type": "select-requester.missing-fields",
171+
"error_type": FAILURE_REASON_BASE.format(
172+
SentryAppExternalRequestHaltReason.MISSING_FIELDS
173+
),
141174
"response": resp,
142175
},
143176
status_code=500,

src/sentry/sentry_apps/external_requests/utils.py

+50-34
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22
from typing import Any
33

44
from jsonschema import Draft7Validator
5+
from requests import RequestException
56
from requests.exceptions import ConnectionError, Timeout
67
from requests.models import Response
78

89
from sentry.http import safe_urlopen
10+
from sentry.sentry_apps.metrics import (
11+
SentryAppEventType,
12+
SentryAppInteractionEvent,
13+
SentryAppInteractionType,
14+
)
915
from sentry.sentry_apps.models.sentry_app import SentryApp, track_response_code
1016
from sentry.sentry_apps.services.app.model import RpcSentryApp
1117
from sentry.utils.sentry_apps import SentryAppWebhookRequestsBuffer
@@ -61,43 +67,53 @@ def send_and_save_sentry_app_request(
6167
6268
kwargs ends up being the arguments passed into safe_urlopen
6369
"""
64-
buffer = SentryAppWebhookRequestsBuffer(sentry_app)
65-
slug = sentry_app.slug_for_metrics
66-
67-
try:
68-
resp = safe_urlopen(url=url, **kwargs)
69-
except (Timeout, ConnectionError) as e:
70-
error_type = e.__class__.__name__.lower()
71-
logger.info(
72-
"send_and_save_sentry_app_request.timeout",
73-
extra={
74-
"error_type": error_type,
75-
"organization_id": org_id,
76-
"integration_slug": sentry_app.slug,
77-
"url": url,
78-
},
79-
)
80-
track_response_code(error_type, slug, event)
70+
71+
with SentryAppInteractionEvent(
72+
operation_type=SentryAppInteractionType.EXTERNAL_REQUEST,
73+
event_type=SentryAppEventType(event),
74+
).capture() as lifecycle:
75+
buffer = SentryAppWebhookRequestsBuffer(sentry_app)
76+
slug = sentry_app.slug_for_metrics
77+
78+
try:
79+
resp = safe_urlopen(url=url, **kwargs)
80+
except (Timeout, ConnectionError) as e:
81+
error_type = e.__class__.__name__.lower()
82+
lifecycle.add_extras(
83+
{
84+
"reason": "send_and_save_sentry_app_request.timeout",
85+
"error_type": error_type,
86+
"organization_id": org_id,
87+
"integration_slug": sentry_app.slug,
88+
"url": url,
89+
},
90+
)
91+
track_response_code(error_type, slug, event)
92+
buffer.add_request(
93+
response_code=TIMEOUT_STATUS_CODE,
94+
org_id=org_id,
95+
event=event,
96+
url=url,
97+
headers=kwargs.get("headers"),
98+
)
99+
lifecycle.record_halt(e)
100+
# Re-raise the exception because some of these tasks might retry on the exception
101+
raise
102+
103+
track_response_code(resp.status_code, slug, event)
81104
buffer.add_request(
82-
response_code=TIMEOUT_STATUS_CODE,
105+
response_code=resp.status_code,
83106
org_id=org_id,
84107
event=event,
85108
url=url,
109+
error_id=resp.headers.get("Sentry-Hook-Error"),
110+
project_id=resp.headers.get("Sentry-Hook-Project"),
111+
response=resp,
86112
headers=kwargs.get("headers"),
87113
)
88-
# Re-raise the exception because some of these tasks might retry on the exception
89-
raise
90-
91-
track_response_code(resp.status_code, slug, event)
92-
buffer.add_request(
93-
response_code=resp.status_code,
94-
org_id=org_id,
95-
event=event,
96-
url=url,
97-
error_id=resp.headers.get("Sentry-Hook-Error"),
98-
project_id=resp.headers.get("Sentry-Hook-Project"),
99-
response=resp,
100-
headers=kwargs.get("headers"),
101-
)
102-
resp.raise_for_status()
103-
return resp
114+
try:
115+
resp.raise_for_status()
116+
except RequestException as e:
117+
lifecycle.record_halt(e)
118+
raise
119+
return resp

src/sentry/sentry_apps/metrics.py

+19
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ class SentryAppInteractionType(StrEnum):
1414
PREPARE_WEBHOOK = "prepare_webhook"
1515
SEND_WEBHOOK = "send_webhook"
1616

17+
# External Requests
18+
EXTERNAL_REQUEST = "external_request"
19+
1720

1821
@dataclass
1922
class SentryAppInteractionEvent(EventLifecycleMetric):
@@ -60,6 +63,21 @@ class SentryAppWebhookHaltReason(StrEnum):
6063
INTEGRATOR_ERROR = "integrator_error"
6164

6265

66+
class SentryAppExternalRequestFailureReason(StrEnum):
67+
"""Reasons why sentry app external request processes can fail"""
68+
69+
MISSING_URL = "missing_url"
70+
UNEXPECTED_ERROR = "unexpected_error"
71+
INVALID_EVENT = "invalid_event"
72+
73+
74+
class SentryAppExternalRequestHaltReason(StrEnum):
75+
"""Reasons why sentry app external request processes can halt"""
76+
77+
MISSING_FIELDS = "missing_fields"
78+
BAD_RESPONSE = "bad_response"
79+
80+
6381
class SentryAppEventType(StrEnum):
6482
"""Events/features that Sentry Apps can do"""
6583

@@ -74,6 +92,7 @@ class SentryAppEventType(StrEnum):
7492
EXTERNAL_ISSUE_CREATED = "external_issue.created"
7593
EXTERNAL_ISSUE_LINKED = "external_issue.linked"
7694
SELECT_OPTIONS_REQUESTED = "select_options.requested"
95+
ALERT_RULE_ACTION_REQUESTED = "alert_rule_action.requested"
7796

7897
# metric alert webhooks
7998
METRIC_ALERT_OPEN = "metric_alert.open"

0 commit comments

Comments
 (0)