Skip to content

Commit cc85440

Browse files
authored
feat(notif-platform): Prototype the service layer (#93863)
Prototyping the service layer, with some added boilerplate for targets. They'll need to have the integration data attached by the time we call `.notify()` so I've added a step in `prepare_targets`, which sidesteps the frozen data class to ensure they have all the data to send out to providers. Also changed `NotificationType` to `NotificationCategory` since type is a keyword (and a bit overloaded)
1 parent 37659e3 commit cc85440

File tree

18 files changed

+434
-59
lines changed

18 files changed

+434
-59
lines changed

src/sentry/notifications/platform/discord/provider.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sentry.notifications.platform.types import (
88
NotificationData,
99
NotificationProviderKey,
10+
NotificationTarget,
1011
NotificationTargetResourceType,
1112
NotificationTemplate,
1213
)
@@ -20,7 +21,9 @@ class DiscordRenderer(NotificationRenderer[DiscordRenderable]):
2021
provider_key = NotificationProviderKey.DISCORD
2122

2223
@classmethod
23-
def render(cls, *, data: NotificationData, template: NotificationTemplate) -> DiscordRenderable:
24+
def render[
25+
DataT: NotificationData
26+
](cls, *, data: DataT, template: NotificationTemplate[DataT]) -> DiscordRenderable:
2427
return {}
2528

2629

@@ -38,3 +41,7 @@ class DiscordNotificationProvider(NotificationProvider[DiscordRenderable]):
3841
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
3942
# TODO(ecosystem): Check for the integration, maybe a feature as well
4043
return False
44+
45+
@classmethod
46+
def send(cls, *, target: NotificationTarget, renderable: DiscordRenderable) -> None:
47+
pass

src/sentry/notifications/platform/email/provider.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from sentry.notifications.platform.provider import NotificationProvider
44
from sentry.notifications.platform.registry import provider_registry
55
from sentry.notifications.platform.renderer import NotificationRenderer
6-
from sentry.notifications.platform.target import NotificationTarget
6+
from sentry.notifications.platform.target import GenericNotificationTarget
77
from sentry.notifications.platform.types import (
88
NotificationData,
99
NotificationProviderKey,
10+
NotificationTarget,
1011
NotificationTargetResourceType,
1112
NotificationTemplate,
1213
)
@@ -20,17 +21,23 @@ class EmailRenderer(NotificationRenderer[EmailRenderable]):
2021
provider_key = NotificationProviderKey.EMAIL
2122

2223
@classmethod
23-
def render(cls, *, data: NotificationData, template: NotificationTemplate) -> EmailRenderable:
24+
def render[
25+
DataT: NotificationData
26+
](cls, *, data: DataT, template: NotificationTemplate[DataT]) -> EmailRenderable:
2427
return {}
2528

2629

2730
@provider_registry.register(NotificationProviderKey.EMAIL)
2831
class EmailNotificationProvider(NotificationProvider[EmailRenderable]):
2932
key = NotificationProviderKey.EMAIL
3033
default_renderer = EmailRenderer
31-
target_class = NotificationTarget
34+
target_class = GenericNotificationTarget
3235
target_resource_types = [NotificationTargetResourceType.EMAIL]
3336

3437
@classmethod
3538
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
3639
return True
40+
41+
@classmethod
42+
def send(cls, *, target: NotificationTarget, renderable: EmailRenderable) -> None:
43+
pass

src/sentry/notifications/platform/msteams/provider.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sentry.notifications.platform.types import (
88
NotificationData,
99
NotificationProviderKey,
10+
NotificationTarget,
1011
NotificationTargetResourceType,
1112
NotificationTemplate,
1213
)
@@ -20,7 +21,9 @@ class MSTeamsRenderer(NotificationRenderer[MSTeamsRenderable]):
2021
provider_key = NotificationProviderKey.MSTEAMS
2122

2223
@classmethod
23-
def render(cls, *, data: NotificationData, template: NotificationTemplate) -> MSTeamsRenderable:
24+
def render[
25+
DataT: NotificationData
26+
](cls, *, data: DataT, template: NotificationTemplate[DataT]) -> MSTeamsRenderable:
2427
return {}
2528

2629

@@ -38,3 +41,7 @@ class MSTeamsNotificationProvider(NotificationProvider[MSTeamsRenderable]):
3841
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
3942
# TODO(ecosystem): Check for the integration, maybe a feature as well
4043
return False
44+
45+
@classmethod
46+
def send(cls, *, target: NotificationTarget, renderable: MSTeamsRenderable) -> None:
47+
pass

src/sentry/notifications/platform/provider.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
from typing import TYPE_CHECKING, Protocol
1+
from typing import Protocol
22

3+
from sentry.notifications.platform.renderer import NotificationRenderer
34
from sentry.notifications.platform.types import (
5+
NotificationCategory,
46
NotificationProviderKey,
7+
NotificationTarget,
58
NotificationTargetResourceType,
6-
NotificationType,
79
)
810
from sentry.organizations.services.organization.model import RpcOrganizationSummary
911

10-
if TYPE_CHECKING:
11-
from sentry.notifications.platform.renderer import NotificationRenderer
12-
from sentry.notifications.platform.target import NotificationTarget
13-
1412

1513
class NotificationProviderError(Exception):
1614
pass
@@ -25,12 +23,12 @@ class NotificationProvider[RenderableT](Protocol):
2523
"""
2624

2725
key: NotificationProviderKey
28-
default_renderer: type["NotificationRenderer[RenderableT]"]
29-
target_class: type["NotificationTarget"]
26+
default_renderer: type[NotificationRenderer[RenderableT]]
27+
target_class: type[NotificationTarget]
3028
target_resource_types: list[NotificationTargetResourceType]
3129

3230
@classmethod
33-
def validate_target(cls, *, target: "NotificationTarget") -> None:
31+
def validate_target(cls, *, target: NotificationTarget) -> None:
3432
"""
3533
Validates that a given target is permissible for the provider.
3634
"""
@@ -49,13 +47,14 @@ def validate_target(cls, *, target: "NotificationTarget") -> None:
4947
f"Target with resource type '{target.resource_type}' is not supported by {cls.__name__}"
5048
f"Supported resource types: {', '.join(t.value for t in cls.target_resource_types)}"
5149
)
52-
5350
return
5451

5552
@classmethod
56-
def get_renderer(cls, *, type: NotificationType) -> type["NotificationRenderer[RenderableT]"]:
53+
def get_renderer(
54+
cls, *, category: NotificationCategory
55+
) -> type[NotificationRenderer[RenderableT]]:
5756
"""
58-
Returns the renderer for a given notification type, falling back to the default renderer.
57+
Returns an instance of a renderer for a given notification, falling back to the default renderer.
5958
Override this to method to permit different renderers for the provider, though keep in mind
6059
that this may produce inconsistencies between notifications.
6160
"""
@@ -67,3 +66,10 @@ def is_available(cls, *, organization: RpcOrganizationSummary | None = None) ->
6766
Returns `True` if the provider is available given the key word arguments.
6867
"""
6968
...
69+
70+
@classmethod
71+
def send(cls, *, target: NotificationTarget, renderable: RenderableT) -> None:
72+
"""
73+
Using the renderable format for the provider, send a notification to the target.
74+
"""
75+
...

src/sentry/notifications/platform/renderer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ class NotificationRenderer[RenderableT](Protocol):
2020
provider_key: NotificationProviderKey
2121

2222
@classmethod
23-
def render(cls, *, data: NotificationData, template: NotificationTemplate) -> RenderableT:
23+
def render[
24+
DataT: NotificationData
25+
](cls, *, data: DataT, template: NotificationTemplate[DataT]) -> RenderableT:
2426
"""
2527
Convert template, and data into a renderable object.
2628
The form of the renderable object is defined by the provider.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import logging
2+
from typing import Final
3+
4+
from sentry.notifications.platform.registry import provider_registry
5+
from sentry.notifications.platform.target import prepare_targets
6+
from sentry.notifications.platform.types import (
7+
NotificationData,
8+
NotificationStrategy,
9+
NotificationTarget,
10+
NotificationTemplate,
11+
)
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class NotificationServiceError(Exception):
17+
pass
18+
19+
20+
class NotificationService[T: NotificationData]:
21+
def __init__(self, *, data: T):
22+
self.data: Final[T] = data
23+
24+
def notify_prepared_target(
25+
self,
26+
*,
27+
target: NotificationTarget,
28+
template: NotificationTemplate[T],
29+
) -> None:
30+
"""
31+
Send a notification directly to a prepared target.
32+
NOTE: This method ignores notification settings. When possible, consider using a strategy instead of
33+
using this method directly to prevent unwanted noise associated with your notifications.
34+
"""
35+
if not self.data:
36+
raise NotificationServiceError(
37+
"Notification service must be initialized with data before sending!"
38+
)
39+
40+
# Step 1: Ensure the target has already been prepared.
41+
if not target.is_prepared:
42+
raise NotificationServiceError(
43+
"Target must have `prepare_targets` called prior to sending"
44+
)
45+
46+
# Step 3: Get the provider, and validate the target against it
47+
provider = provider_registry.get(target.provider_key)
48+
provider.validate_target(target=target)
49+
50+
# Step 4: Render the template
51+
renderer = provider.get_renderer(category=self.data.category)
52+
renderable = renderer.render(data=self.data, template=template)
53+
54+
# Step 5: Send the notification
55+
provider.send(target=target, renderable=renderable)
56+
57+
def notify(
58+
self,
59+
*,
60+
strategy: NotificationStrategy | None = None,
61+
targets: list[NotificationTarget] | None = None,
62+
template: NotificationTemplate[T],
63+
) -> None:
64+
65+
if not strategy and not targets:
66+
raise NotificationServiceError(
67+
"Must provide either a strategy or targets. Strategy is preferred."
68+
)
69+
if strategy and targets:
70+
raise NotificationServiceError(
71+
"Cannot provide both strategy and targets, only one is permitted. Strategy is preferred."
72+
)
73+
if strategy:
74+
targets = strategy.get_targets()
75+
if not targets:
76+
logger.info("Strategy '%s' did not yield targets", strategy.__class__.__name__)
77+
return
78+
79+
# Prepare the targets for sending by fetching integration data, etc.
80+
prepare_targets(targets=targets)
81+
82+
for target in targets:
83+
self.notify_prepared_target(target=target, template=template)

src/sentry/notifications/platform/slack/provider.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sentry.notifications.platform.types import (
88
NotificationData,
99
NotificationProviderKey,
10+
NotificationTarget,
1011
NotificationTargetResourceType,
1112
NotificationTemplate,
1213
)
@@ -17,10 +18,12 @@
1718

1819

1920
class SlackRenderer(NotificationRenderer[SlackRenderable]):
20-
provider_key = NotificationProviderKey.SLACK
21+
provider_key = NotificationProviderKey.DISCORD
2122

2223
@classmethod
23-
def render(cls, *, data: NotificationData, template: NotificationTemplate) -> SlackRenderable:
24+
def render[
25+
DataT: NotificationData
26+
](cls, *, data: DataT, template: NotificationTemplate[DataT]) -> SlackRenderable:
2427
return {}
2528

2629

@@ -38,3 +41,7 @@ class SlackNotificationProvider(NotificationProvider[SlackRenderable]):
3841
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
3942
# TODO(ecosystem): Check for the integration, maybe a feature as well
4043
return False
44+
45+
@classmethod
46+
def send(cls, *, target: NotificationTarget, renderable: SlackRenderable) -> None:
47+
pass

src/sentry/notifications/platform/target.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
from dataclasses import dataclass, field
22
from typing import Any
33

4+
from sentry.integrations.services.integration.model import (
5+
RpcIntegration,
6+
RpcOrganizationIntegration,
7+
)
48
from sentry.notifications.platform.types import (
59
NotificationProviderKey,
10+
NotificationTarget,
611
NotificationTargetResourceType,
712
)
813

914

15+
class NotificationTargetError(Exception):
16+
pass
17+
18+
1019
@dataclass(kw_only=True, frozen=True)
11-
class NotificationTarget:
20+
class GenericNotificationTarget(NotificationTarget):
1221
"""
1322
A designated recipient for a notification. This could be a user, a team, or a channel.
1423
Accepts the renderable object type that matches the connected provider.
1524
"""
1625

26+
is_prepared: bool = field(init=False, default=False)
1727
provider_key: NotificationProviderKey
1828
resource_type: NotificationTargetResourceType
1929
resource_id: str
@@ -31,11 +41,29 @@ class NotificationTarget:
3141

3242

3343
@dataclass(kw_only=True, frozen=True)
34-
class IntegrationNotificationTarget(NotificationTarget):
44+
class IntegrationNotificationTarget(GenericNotificationTarget):
3545
"""
3646
Adds necessary properties and methods to designate a target within an integration.
3747
Accepts the renderable object type that matches the connected provider.
3848
"""
3949

4050
integration_id: int
4151
organization_id: int
52+
integration: RpcIntegration = field(init=False)
53+
"""
54+
The integration associated with the target, must call `prepare_targets` to populate.
55+
"""
56+
organization_integration: RpcOrganizationIntegration = field(init=False)
57+
"""
58+
The integration associated with the target, must call `prepare_targets` to populate.
59+
"""
60+
61+
62+
def prepare_targets(targets: list[NotificationTarget]) -> None:
63+
"""
64+
This method is used to prepare the targets for receiving a notification.
65+
For example, with IntegrationNotificationTargets, this will populate the `integration` and
66+
`organization_integration` fields by making RPC/DB calls.
67+
"""
68+
for target in targets:
69+
object.__setattr__(target, "is_prepared", True)

0 commit comments

Comments
 (0)