Skip to content

Commit 873856f

Browse files
chore(webhooks): Initial attempt to type AppPlatformEvent (#96376)
1 parent c29721c commit 873856f

File tree

12 files changed

+234
-78
lines changed

12 files changed

+234
-78
lines changed

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
)
5252
from sentry.sentry_apps.models.sentry_app import SentryApp
5353
from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
54+
from sentry.sentry_apps.utils.webhooks import MetricAlertActionType, SentryAppResourceType
5455
from sentry.shared_integrations.exceptions import ApiError
5556
from sentry.utils import json
5657
from sentry.utils.sentry_apps import send_and_save_webhook_request
@@ -380,7 +381,9 @@ def send_incident_alert_notification(
380381
) -> bool:
381382
try:
382383
new_status_str = INCIDENT_STATUS[IncidentStatus(new_status)].lower()
383-
event = SentryAppEventType(f"metric_alert.{new_status_str}")
384+
event = SentryAppEventType(
385+
f"{SentryAppResourceType.METRIC_ALERT}.{MetricAlertActionType(new_status_str)}"
386+
)
384387
except ValueError as e:
385388
sentry_sdk.capture_exception(e)
386389
return False
@@ -408,8 +411,8 @@ def send_incident_alert_notification(
408411
return False
409412

410413
app_platform_event = AppPlatformEvent(
411-
resource="metric_alert",
412-
action=new_status_str,
414+
resource=SentryAppResourceType.METRIC_ALERT,
415+
action=MetricAlertActionType(new_status_str),
413416
install=install,
414417
data=json.loads(incident_attachment_json),
415418
)

src/sentry/sentry_apps/api/parsers/sentry_app.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,8 @@
99
from sentry.integrations.models.integration_feature import Feature
1010
from sentry.models.apiscopes import ApiScopes
1111
from sentry.sentry_apps.api.parsers.schema import validate_ui_element_schema
12-
from sentry.sentry_apps.models.sentry_app import (
13-
REQUIRED_EVENT_PERMISSIONS,
14-
UUID_CHARS_IN_SLUG,
15-
VALID_EVENT_RESOURCES,
16-
)
12+
from sentry.sentry_apps.models.sentry_app import REQUIRED_EVENT_PERMISSIONS, UUID_CHARS_IN_SLUG
13+
from sentry.sentry_apps.utils.webhooks import VALID_EVENT_RESOURCES
1714

1815

1916
@extend_schema_field(build_typed_list(OpenApiTypes.STR))

src/sentry/sentry_apps/api/serializers/app_platform_event.py

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,96 @@
1+
from collections.abc import Mapping
2+
from enum import StrEnum
13
from time import time
4+
from typing import Any, TypedDict
25
from uuid import uuid4
36

7+
from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
8+
from sentry.sentry_apps.services.app.model import RpcSentryAppInstallation
9+
from sentry.sentry_apps.utils.webhooks import SentryAppActionType, SentryAppResourceType
10+
from sentry.users.models.user import User
11+
from sentry.users.services.user import RpcUser
412
from sentry.utils import json
513

614

7-
class AppPlatformEvent:
15+
class AppPlatformEventActorType(StrEnum):
16+
USER = "user"
17+
APPLICATION = "application"
18+
19+
20+
class AppPlatformEventActor(TypedDict):
21+
type: AppPlatformEventActorType
22+
id: str | int
23+
name: str
24+
25+
26+
class AppPlatformEventInstallation(TypedDict):
27+
uuid: str
28+
29+
30+
class AppPlatformEventBody[T: Mapping[str, Any]](TypedDict):
31+
action: SentryAppActionType
32+
installation: AppPlatformEventInstallation
33+
data: T
34+
actor: AppPlatformEventActor
35+
36+
37+
class AppPlatformEvent[T: Mapping[str, Any]]():
838
"""
939
This data structure encapsulates the payload sent to a SentryApp's webhook.
40+
41+
The data field is generic and should be typed with a TypedDict specified by the user.
1042
"""
1143

12-
def __init__(self, resource, action, install, data, actor=None):
44+
def __init__(
45+
self,
46+
resource: SentryAppResourceType,
47+
action: SentryAppActionType,
48+
install: RpcSentryAppInstallation | SentryAppInstallation,
49+
data: T,
50+
actor: RpcUser | User | None = None,
51+
):
1352
self.resource = resource
1453
self.action = action
1554
self.install = install
1655
self.data = data
1756
self.actor = actor
1857

19-
def get_actor(self):
58+
def get_actor(self) -> AppPlatformEventActor:
2059
# when sentry auto assigns, auto resolves, etc.
2160
# or when an alert rule is triggered
2261
if not self.actor:
23-
return {"type": "application", "id": "sentry", "name": "Sentry"}
62+
return AppPlatformEventActor(
63+
type=AppPlatformEventActorType.APPLICATION,
64+
id="sentry",
65+
name="Sentry",
66+
)
2467

2568
if self.actor.is_sentry_app:
26-
return {
27-
"type": "application",
28-
"id": self.install.sentry_app.uuid,
29-
"name": self.install.sentry_app.name,
30-
}
69+
return AppPlatformEventActor(
70+
type=AppPlatformEventActorType.APPLICATION,
71+
id=self.install.sentry_app.uuid,
72+
name=self.install.sentry_app.name,
73+
)
3174

32-
return {"type": "user", "id": self.actor.id, "name": self.actor.name}
75+
return AppPlatformEventActor(
76+
type=AppPlatformEventActorType.USER,
77+
id=self.actor.id,
78+
name=self.actor.name,
79+
)
3380

3481
@property
35-
def body(self):
82+
def body(self) -> str:
3683
return json.dumps(
37-
{
38-
"action": self.action,
39-
"installation": {"uuid": self.install.uuid},
40-
"data": self.data,
41-
"actor": self.get_actor(),
42-
},
84+
AppPlatformEventBody(
85+
action=self.action,
86+
installation=AppPlatformEventInstallation(uuid=self.install.uuid),
87+
data=self.data,
88+
actor=self.get_actor(),
89+
)
4390
)
4491

4592
@property
46-
def headers(self):
93+
def headers(self) -> dict[str, str]:
4794
request_uuid = uuid4().hex
4895

4996
return {

src/sentry/sentry_apps/api/serializers/sentry_app_installation.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import MutableMapping, Sequence
4-
from typing import Any
4+
from typing import Any, NotRequired, TypedDict
55

66
from django.contrib.auth.models import AnonymousUser
77

@@ -14,6 +14,24 @@
1414
from sentry.users.services.user import RpcUser
1515

1616

17+
class SentryAppInstallationAppResult(TypedDict):
18+
uuid: str
19+
slug: str
20+
21+
22+
class SentryAppInstallationOrganizationResult(TypedDict):
23+
slug: str
24+
id: int
25+
26+
27+
class SentryAppInstallationResult(TypedDict):
28+
app: SentryAppInstallationAppResult
29+
organization: SentryAppInstallationOrganizationResult
30+
uuid: str
31+
status: str
32+
code: NotRequired[str]
33+
34+
1735
@register(SentryAppInstallation)
1836
class SentryAppInstallationSerializer(Serializer):
1937
def get_attrs(
@@ -41,9 +59,11 @@ def get_attrs(
4159
}
4260
return result
4361

44-
def serialize(self, obj, attrs, user: User | RpcUser | AnonymousUser, **kwargs):
62+
def serialize(
63+
self, obj, attrs, user: User | RpcUser | AnonymousUser, **kwargs
64+
) -> SentryAppInstallationResult:
4565
access = kwargs.get("access")
46-
data = {
66+
data: SentryAppInstallationResult = {
4767
"app": {"uuid": attrs["sentry_app"].uuid, "slug": attrs["sentry_app"].slug},
4868
"organization": {"slug": attrs["organization"].slug, "id": attrs["organization"].id},
4969
"uuid": obj.uuid,

src/sentry/sentry_apps/installations.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import dataclasses
44
import datetime
55
from functools import cached_property
6+
from typing import TypedDict
67

78
from django.db import router, transaction
89
from django.http.request import HttpRequest
@@ -15,6 +16,7 @@
1516
from sentry.models.apitoken import ApiToken
1617
from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent
1718
from sentry.sentry_apps.api.serializers.sentry_app_installation import (
19+
SentryAppInstallationResult,
1820
SentryAppInstallationSerializer,
1921
)
2022
from sentry.sentry_apps.metrics import (
@@ -28,6 +30,7 @@
2830
from sentry.sentry_apps.services.hook import hook_service
2931
from sentry.sentry_apps.tasks.sentry_apps import installation_webhook
3032
from sentry.sentry_apps.utils.errors import SentryAppSentryError
33+
from sentry.sentry_apps.utils.webhooks import InstallationActionType, SentryAppResourceType
3134
from sentry.users.models.user import User
3235
from sentry.users.services.user.model import RpcUser
3336
from sentry.utils import metrics
@@ -190,6 +193,10 @@ def sentry_app(self) -> SentryApp:
190193
return SentryApp.objects.get(slug=self.slug)
191194

192195

196+
class SentryAppInstallationWebhookData(TypedDict):
197+
installation: SentryAppInstallationResult
198+
199+
193200
@dataclasses.dataclass
194201
class SentryAppInstallationNotifier:
195202
sentry_app_installation: SentryAppInstallation
@@ -205,19 +212,19 @@ def run(self) -> None:
205212
send_and_save_webhook_request(self.sentry_app, self.request)
206213

207214
@property
208-
def request(self) -> AppPlatformEvent:
215+
def request(self) -> AppPlatformEvent[SentryAppInstallationWebhookData]:
209216
data = serialize(
210217
self.sentry_app_installation,
211218
user=self.user,
212219
serializer=SentryAppInstallationSerializer(),
213220
is_webhook=True,
214221
)
215222

216-
return AppPlatformEvent(
217-
resource="installation",
218-
action=self.action,
223+
return AppPlatformEvent[SentryAppInstallationWebhookData](
224+
resource=SentryAppResourceType.INSTALLATION,
225+
action=InstallationActionType(self.action),
219226
install=self.sentry_app_installation,
220-
data={"installation": data},
227+
data=SentryAppInstallationWebhookData(installation=data),
221228
actor=self.user,
222229
)
223230

src/sentry/sentry_apps/logic.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
SentryAppInteractionType,
3434
)
3535
from sentry.sentry_apps.models.sentry_app import (
36-
EVENT_EXPANSION,
3736
REQUIRED_EVENT_PERMISSIONS,
3837
UUID_CHARS_IN_SLUG,
3938
SentryApp,
@@ -42,6 +41,7 @@
4241
from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
4342
from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
4443
from sentry.sentry_apps.tasks.sentry_apps import create_or_update_service_hooks_for_sentry_app
44+
from sentry.sentry_apps.utils.webhooks import EVENT_EXPANSION, SentryAppResourceType
4545
from sentry.users.models.user import User
4646
from sentry.users.services.user.model import RpcUser
4747
from sentry.utils.sentry_apps.service_hook_manager import (
@@ -71,14 +71,18 @@ def expand_events(rolled_up_events: list[str]) -> list[str]:
7171
"""
7272
Convert a list of rolled up events ('issue', etc) into a list of raw event
7373
types ('issue.created', etc.)
74+
75+
Can also be given a list of event types (e.g. ['issue.created', 'issue.resolved'])
7476
"""
75-
return sorted(
76-
{
77-
translated
78-
for event in rolled_up_events
79-
for translated in EVENT_EXPANSION.get(event, [event])
80-
}
81-
)
77+
78+
expanded_events = []
79+
for event in rolled_up_events:
80+
if event in EVENT_EXPANSION:
81+
expanded_events.extend(EVENT_EXPANSION.get(SentryAppResourceType(event), [event]))
82+
else:
83+
expanded_events.append(event)
84+
85+
return sorted(set(expanded_events))
8286

8387

8488
# TODO(schew2381): Delete this method after staff is GA'd and the options are removed

src/sentry/sentry_apps/models/sentry_app.py

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,29 +31,10 @@
3131
from sentry.hybridcloud.models.outbox import ControlOutbox, outbox_context
3232
from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope
3333
from sentry.models.apiscopes import HasApiScopes
34+
from sentry.sentry_apps.utils.webhooks import EVENT_EXPANSION
3435
from sentry.types.region import find_all_region_names, find_regions_for_sentry_app
3536
from sentry.utils import metrics
3637

37-
# When a developer selects to receive "<Resource> Webhooks" it really means
38-
# listening to a list of specific events. This is a mapping of what those
39-
# specific events are for each resource.
40-
EVENT_EXPANSION = {
41-
"issue": [
42-
"issue.assigned",
43-
"issue.created",
44-
"issue.ignored",
45-
"issue.resolved",
46-
"issue.unresolved",
47-
],
48-
"error": ["error.created"],
49-
"comment": ["comment.created", "comment.deleted", "comment.updated"],
50-
}
51-
52-
# We present Webhook Subscriptions per-resource (Issue, Project, etc.), not
53-
# per-event-type (issue.created, project.deleted, etc.). These are valid
54-
# resources a Sentry App may subscribe to.
55-
VALID_EVENT_RESOURCES = ("issue", "error", "comment")
56-
5738
REQUIRED_EVENT_PERMISSIONS = {
5839
"issue": "event:read",
5940
"error": "event:read",

src/sentry/sentry_apps/tasks/sentry_apps.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
)
5050
from sentry.sentry_apps.services.hook.service import hook_service
5151
from sentry.sentry_apps.utils.errors import SentryAppSentryError
52+
from sentry.sentry_apps.utils.webhooks import IssueAlertActionType, SentryAppResourceType
5253
from sentry.shared_integrations.exceptions import ApiHostError, ApiTimeoutError, ClientError
5354
from sentry.silo.base import SiloMode
5455
from sentry.tasks.base import instrumented_task, retry
@@ -257,7 +258,10 @@ def send_alert_webhook_v2(
257258
data[additional_payload_key] = additional_payload
258259

259260
request_data = AppPlatformEvent(
260-
resource="event_alert", action="triggered", install=install, data=data
261+
resource=SentryAppResourceType.EVENT_ALERT,
262+
action=IssueAlertActionType.TRIGGERED,
263+
install=install,
264+
data=data,
261265
)
262266

263267
send_and_save_webhook_request(sentry_app, request_data)

0 commit comments

Comments
 (0)