Skip to content

Commit efa89ae

Browse files
leeandherandrewshie-sentry
authored andcommitted
feat(notif-platform): Implement a notification provider registry (#93119)
Follows the scheme from workflow of using a decorator for registration. Registers the classes behind the providers rather than instances (as described in the spec). I did see some existing types for a few registrations (Slack and MSTeams) but since this they're loaded on app init, they were having circular import issues. Gonna work through that when I start digging into individual providers, so skipping it for now,.
1 parent 9ac9fe8 commit efa89ae

File tree

20 files changed

+282
-0
lines changed

20 files changed

+282
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ module = [
335335
"sentry.nodestore.django.models",
336336
"sentry.nodestore.filesystem.backend",
337337
"sentry.nodestore.models",
338+
"sentry.notifications.platform.*",
338339
"sentry.notifications.services.*",
339340
"sentry.options.rollout",
340341
"sentry.organizations.*",

src/sentry/conf/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ def env(
408408
"sentry.sentry_apps",
409409
"sentry.integrations",
410410
"sentry.notifications",
411+
"sentry.notifications.platform",
411412
"sentry.flags",
412413
"sentry.monitors",
413414
"sentry.uptime",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Notification Platform
2+
3+
TODO(ecosystem): Fill this out
4+
5+
## NotificationProvider
6+
7+
A notification provider is the system which Sentry will trigger in order to notify NotificationTargets, for example; Email or Slack.
8+
9+
To register a new provider:
10+
11+
1. Add a key to `NotificationProviderKey` in [`.types`](./types.py)
12+
2. Add a directory for your new provider.
13+
3. Create a provider module in the new directory.
14+
- Extend `NotificationProvider` from [`.provider`](./provider.py) and implement its methods/variables.
15+
- Import `provider_registry` from [`.registry`](./registry.py) and add it via decorator: `@provider_registry.register(<YOUR-KEY-HERE>)`
16+
4. In [.apps](./apps.py), explicitly import the module to register the provider on initialization.

src/sentry/notifications/platform/__init__.py

Whitespace-only changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from django.apps import AppConfig
2+
3+
4+
class Config(AppConfig):
5+
name = "sentry.notifications.platform"
6+
7+
def ready(self) -> None:
8+
# Register the providers providers
9+
from .discord.provider import DiscordNotificationProvider # NOQA
10+
from .email.provider import EmailNotificationProvider # NOQA
11+
from .msteams.provider import MSTeamsNotificationProvider # NOQA
12+
from .slack.provider import SlackNotificationProvider # NOQA

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

Whitespace-only changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Any
2+
3+
from sentry.notifications.platform.provider import NotificationProvider
4+
from sentry.notifications.platform.registry import provider_registry
5+
from sentry.notifications.platform.renderer import NotificationRenderer
6+
from sentry.notifications.platform.types import NotificationProviderKey
7+
from sentry.organizations.services.organization.model import RpcOrganizationSummary
8+
9+
# TODO(ecosystem): Proper typing - https://discord.com/developers/docs/resources/message#create-message
10+
type DiscordRenderable = Any
11+
12+
13+
class DiscordRenderer(NotificationRenderer[DiscordRenderable]):
14+
provider_key = NotificationProviderKey.DISCORD
15+
16+
17+
@provider_registry.register(NotificationProviderKey.DISCORD)
18+
class DiscordNotificationProvider(NotificationProvider[DiscordRenderable]):
19+
key = NotificationProviderKey.DISCORD
20+
default_renderer = DiscordRenderer
21+
22+
@classmethod
23+
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
24+
# TODO(ecosystem): Check for the integration, maybe a feature as well
25+
return False

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

Whitespace-only changes.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from typing import Any
2+
3+
from sentry.notifications.platform.provider import NotificationProvider
4+
from sentry.notifications.platform.registry import provider_registry
5+
from sentry.notifications.platform.renderer import NotificationRenderer
6+
from sentry.notifications.platform.types import NotificationProviderKey
7+
from sentry.organizations.services.organization.model import RpcOrganizationSummary
8+
9+
# TODO(ecosystem): Proper typing for email payloads (HTML + txt)
10+
type EmailRenderable = Any
11+
12+
13+
class EmailRenderer(NotificationRenderer[EmailRenderable]):
14+
provider_key = NotificationProviderKey.EMAIL
15+
16+
17+
@provider_registry.register(NotificationProviderKey.EMAIL)
18+
class EmailNotificationProvider(NotificationProvider[EmailRenderable]):
19+
key = NotificationProviderKey.EMAIL
20+
default_renderer = EmailRenderer
21+
22+
@classmethod
23+
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
24+
return True

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

Whitespace-only changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Any
2+
3+
from sentry.notifications.platform.provider import NotificationProvider
4+
from sentry.notifications.platform.registry import provider_registry
5+
from sentry.notifications.platform.renderer import NotificationRenderer
6+
from sentry.notifications.platform.types import NotificationProviderKey
7+
from sentry.organizations.services.organization.model import RpcOrganizationSummary
8+
9+
# TODO(ecosystem): Figure out a way to use 'AdaptiveCard' type
10+
type MSTeamsRenderable = Any
11+
12+
13+
class MSTeamsRenderer(NotificationRenderer[MSTeamsRenderable]):
14+
provider_key = NotificationProviderKey.MSTEAMS
15+
16+
17+
@provider_registry.register(NotificationProviderKey.MSTEAMS)
18+
class MSTeamsNotificationProvider(NotificationProvider[MSTeamsRenderable]):
19+
key = NotificationProviderKey.MSTEAMS
20+
default_renderer = MSTeamsRenderer
21+
22+
@classmethod
23+
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
24+
# TODO(ecosystem): Check for the integration, maybe a feature as well
25+
return False
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import TYPE_CHECKING, Protocol
2+
3+
from sentry.notifications.platform.types import NotificationProviderKey
4+
from sentry.organizations.services.organization.model import RpcOrganizationSummary
5+
6+
if TYPE_CHECKING:
7+
from sentry.notifications.platform.renderer import NotificationRenderer
8+
9+
10+
class NotificationProvider[T](Protocol):
11+
"""
12+
A protocol metaclass for all notification providers.
13+
14+
Accepts a renderable object type that is understood by the notification provider.
15+
For example, Email might expect HTML, or raw text; Slack might expect a JSON Block Kit object.
16+
"""
17+
18+
key: NotificationProviderKey
19+
default_renderer: type["NotificationRenderer[T]"]
20+
21+
@classmethod
22+
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
23+
"""
24+
Returns `True` if the provider is available given the key word arguments.
25+
"""
26+
...
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from sentry.notifications.platform.provider import NotificationProvider
6+
from sentry.organizations.services.organization.model import RpcOrganizationSummary
7+
from sentry.utils.registry import Registry
8+
9+
10+
class NotificationProviderRegistry(Registry[type[NotificationProvider[Any]]]):
11+
"""
12+
A registry for notification providers. Adds `get_all` and `get_available` methods to the base registry.
13+
"""
14+
15+
def get_all(self) -> list[type[NotificationProvider[Any]]]:
16+
"""
17+
Returns every NotificationProvider that has been registered. Some providers may not be
18+
available generally available to all customers. For only released providers, use `get_available` instead.
19+
"""
20+
return list(self.registrations.values())
21+
22+
def get_available(
23+
self, *, organization: RpcOrganizationSummary | None = None
24+
) -> list[type[NotificationProvider[Any]]]:
25+
"""
26+
Returns every registered NotificationProvider that has been released to all customers.
27+
"""
28+
return [
29+
provider
30+
for provider in self.registrations.values()
31+
if provider.is_available(organization=organization)
32+
]
33+
34+
35+
provider_registry = NotificationProviderRegistry()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Any, Protocol
2+
3+
from sentry.notifications.platform.types import NotificationProviderKey
4+
5+
type NotificationTemplate = Any
6+
type NotificationData = Any
7+
8+
9+
# TODO(ecosystem): Evaluate whether or not this even makes sense as a protocol, or we can just use a typed Callable.
10+
# If there is only one method, and the class usage is just to call a method, the Callable route might make more sense.
11+
# The typing T is also sketchy being in only the return position, and not inherently connected to the provider class.
12+
# The concept of renderers could just be a subset of functionality on the base provider class.
13+
class NotificationRenderer[T](Protocol):
14+
"""
15+
A protocol metaclass for all notification renderers.
16+
Accepts the renderable object type that matches the connected provider.
17+
"""
18+
19+
provider_key: NotificationProviderKey
20+
21+
@classmethod
22+
def render(cls, *, data: NotificationData, template: NotificationTemplate) -> T:
23+
"""
24+
Convert template, and data into a renderable object.
25+
The form of the renderable object is defined by the provider.
26+
"""
27+
...

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

Whitespace-only changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Any
2+
3+
from sentry.notifications.platform.provider import NotificationProvider
4+
from sentry.notifications.platform.registry import provider_registry
5+
from sentry.notifications.platform.renderer import NotificationRenderer
6+
from sentry.notifications.platform.types import NotificationProviderKey
7+
from sentry.organizations.services.organization.model import RpcOrganizationSummary
8+
9+
# TODO(ecosystem): Figure out a way to use 'SlackBlock' type
10+
type SlackRenderable = Any
11+
12+
13+
class SlackRenderer(NotificationRenderer[SlackRenderable]):
14+
provider_key = NotificationProviderKey.SLACK
15+
16+
17+
@provider_registry.register(NotificationProviderKey.SLACK)
18+
class SlackNotificationProvider(NotificationProvider[SlackRenderable]):
19+
key = NotificationProviderKey.SLACK
20+
default_renderer = SlackRenderer
21+
22+
@classmethod
23+
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
24+
# TODO(ecosystem): Check for the integration, maybe a feature as well
25+
return False
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from enum import StrEnum
2+
3+
from sentry.integrations.types import ExternalProviderEnum
4+
5+
6+
class NotificationProviderKey(StrEnum):
7+
"""
8+
The unique keys for each registered notification provider.
9+
"""
10+
11+
EMAIL = ExternalProviderEnum.EMAIL
12+
SLACK = ExternalProviderEnum.SLACK
13+
MSTEAMS = ExternalProviderEnum.MSTEAMS
14+
DISCORD = ExternalProviderEnum.DISCORD

tests/sentry/notifications/platform/__init__.py

Whitespace-only changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from sentry.notifications.platform.registry import provider_registry
2+
from sentry.notifications.platform.types import NotificationProviderKey
3+
from sentry.organizations.services.organization.serial import serialize_organization_summary
4+
from sentry.testutils.cases import TestCase
5+
6+
7+
class NotificationProviderTest(TestCase):
8+
def test_all_registrants_follow_protocol(self):
9+
for provider in provider_registry.get_all():
10+
# Ensures the provider can be instantiated, does not test functionality
11+
provider()
12+
# Ensures protocol properties are present and correct
13+
assert provider.key in NotificationProviderKey
14+
# Ensures the default renderer links back to its connected provider key
15+
assert provider.default_renderer.provider_key == provider.key
16+
assert isinstance(provider.is_available(), bool)
17+
assert isinstance(
18+
provider.is_available(
19+
organization=serialize_organization_summary(self.organization)
20+
),
21+
bool,
22+
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from sentry.notifications.platform.discord.provider import DiscordNotificationProvider
2+
from sentry.notifications.platform.email.provider import EmailNotificationProvider
3+
from sentry.notifications.platform.msteams.provider import MSTeamsNotificationProvider
4+
from sentry.notifications.platform.registry import provider_registry
5+
from sentry.notifications.platform.slack.provider import SlackNotificationProvider
6+
from sentry.testutils.cases import TestCase
7+
8+
9+
class NotificationProviderRegistryTest(TestCase):
10+
def test_get_all(self):
11+
providers = provider_registry.get_all()
12+
expected_providers = [
13+
EmailNotificationProvider,
14+
SlackNotificationProvider,
15+
MSTeamsNotificationProvider,
16+
DiscordNotificationProvider,
17+
]
18+
19+
assert len(providers) == len(expected_providers)
20+
for provider in expected_providers:
21+
assert provider in providers
22+
23+
def test_get_available(self):
24+
providers = provider_registry.get_available()
25+
expected_providers = [EmailNotificationProvider]
26+
27+
assert len(providers) == len(expected_providers)
28+
for provider in expected_providers:
29+
assert provider in providers

0 commit comments

Comments
 (0)