Skip to content
6 changes: 6 additions & 0 deletions src/sentry/notifications/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ class Config(AppConfig):
def ready(self) -> None:
# Imports to populate registries
import sentry.notifications.platform.discord.provider # noqa: F401
import sentry.notifications.platform.discord.renderers.issue # noqa: F401
import sentry.notifications.platform.discord.renderers.metric_alert # noqa: F401
import sentry.notifications.platform.email.provider # noqa: F401
import sentry.notifications.platform.msteams.provider # noqa: F401
import sentry.notifications.platform.slack.provider # noqa: F401
import sentry.notifications.platform.slack.renderers.issue # noqa: F401
import sentry.notifications.platform.slack.renderers.metric_alert # noqa: F401
import sentry.notifications.platform.slack.renderers.seer # noqa: F401
import sentry.notifications.platform.slack.renderers.seer_agent_write_approval # noqa: F401
import sentry.notifications.platform.templates # noqa: F401
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def serialize_slack_preview[T: NotificationData](
) -> dict[str, Any]:
data = template.example_data
rendered_template = template.render_example()
renderer = SlackNotificationProvider.get_renderer(data=data, category=template.category)
renderer = SlackNotificationProvider.get_renderer(data=data)
message = renderer.render(data=data, rendered_template=rendered_template)

serialized_blocks = []
Expand Down
18 changes: 0 additions & 18 deletions src/sentry/notifications/platform/discord/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from sentry.notifications.platform.threading import ThreadContext
from sentry.notifications.platform.types import (
LinkTextBlock,
NotificationCategory,
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
Expand All @@ -40,8 +39,6 @@


class DiscordRenderer(NotificationRenderer[DiscordRenderable]):
provider_key = NotificationProviderKey.DISCORD

@classmethod
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
Expand Down Expand Up @@ -152,21 +149,6 @@ def is_available(cls, *, organization: RpcOrganizationSummary | None = None) ->
# TODO(ecosystem): Check for the integration, maybe a feature as well
return False

@classmethod
def get_renderer(
cls, *, data: NotificationData, category: NotificationCategory
) -> type[NotificationRenderer[DiscordRenderable]]:
from sentry.notifications.platform.discord.renderers.issue import IssueDiscordRenderer
from sentry.notifications.platform.discord.renderers.metric_alert import (
DiscordMetricAlertRenderer,
)

if category == NotificationCategory.ISSUE:
return IssueDiscordRenderer
if category == NotificationCategory.METRIC_ALERT:
return DiscordMetricAlertRenderer
return cls.default_renderer

@classmethod
def send(
cls,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
from __future__ import annotations

from sentry import eventstore
from sentry.integrations.discord.message_builder.issues import DiscordIssuesMessageBuilder
from sentry.models.group import Group
from sentry.notifications.platform.discord.provider import DiscordRenderable
from sentry.notifications.platform.registry import renderer_registry
from sentry.notifications.platform.renderer import NotificationRenderer
from sentry.notifications.platform.service import NotificationRenderError
from sentry.notifications.platform.templates.issue import IssueNotificationData
from sentry.notifications.platform.types import (
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
NotificationSource,
)
from sentry.services.eventstore.models import Event


@renderer_registry.register(NotificationProviderKey.DISCORD, sources=[NotificationSource.ISSUE])
class IssueDiscordRenderer(NotificationRenderer[DiscordRenderable]):
provider_key = NotificationProviderKey.DISCORD

@classmethod
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
) -> DiscordRenderable:
if not isinstance(data, IssueNotificationData):
raise ValueError(f"IssueDiscordRenderer does not support {data.__class__.__name__}")

from sentry.integrations.discord.message_builder.issues import DiscordIssuesMessageBuilder

# Retrieving Group and Event data is an anti-pattern, do not do this
# in permanent renderers.
try:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
from __future__ import annotations

from sentry.incidents.models.incident import IncidentStatus
from sentry.integrations.discord.message_builder import INCIDENT_COLOR_MAPPING, LEVEL_TO_COLOR
from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed
from sentry.integrations.discord.message_builder.base.embed.image import DiscordMessageEmbedImage
from sentry.integrations.discord.message_builder.metric_alerts import get_started_at
from sentry.integrations.metric_alerts import get_status_text
from sentry.notifications.platform.discord.provider import DiscordRenderable
from sentry.notifications.platform.registry import renderer_registry
from sentry.notifications.platform.renderer import NotificationRenderer
from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData
from sentry.notifications.platform.types import (
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
NotificationSource,
)


@renderer_registry.register(
NotificationProviderKey.DISCORD, sources=[NotificationSource.METRIC_ALERT]
)
class DiscordMetricAlertRenderer(NotificationRenderer[DiscordRenderable]):
provider_key = NotificationProviderKey.DISCORD

@classmethod
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
Expand All @@ -29,6 +26,18 @@ def render[DataT: NotificationData](
f"DiscordMetricAlertRenderer does not support '{data.__class__.__name__}'. Provide a MetricAlertNotificationData instead."
)

from sentry.integrations.discord.message_builder import (
INCIDENT_COLOR_MAPPING,
LEVEL_TO_COLOR,
)
from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed
from sentry.integrations.discord.message_builder.base.embed.image import (
DiscordMessageEmbedImage,
)
from sentry.integrations.discord.message_builder.metric_alerts import get_started_at
from sentry.integrations.metric_alerts import get_status_text

status = get_status_text(IncidentStatus(data.new_status))
description = f"{data.text}{get_started_at(data.open_period_context.date_started)}"
color = LEVEL_TO_COLOR.get(INCIDENT_COLOR_MAPPING.get(status, ""))
Expand Down
2 changes: 0 additions & 2 deletions src/sentry/notifications/platform/email/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@


class EmailRenderer(NotificationRenderer[EmailRenderable]):
provider_key = NotificationProviderKey.EMAIL

@classmethod
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
Expand Down
2 changes: 0 additions & 2 deletions src/sentry/notifications/platform/msteams/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@


class MSTeamsRenderer(NotificationRenderer[MSTeamsRenderable]):
provider_key = NotificationProviderKey.MSTEAMS

@classmethod
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
Expand Down
15 changes: 10 additions & 5 deletions src/sentry/notifications/platform/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from sentry.notifications.platform.target import IntegrationNotificationTarget
from sentry.notifications.platform.threading import ThreadContext
from sentry.notifications.platform.types import (
NotificationCategory,
NotificationData,
NotificationProviderKey,
NotificationTarget,
Expand Down Expand Up @@ -106,6 +105,8 @@ class NotificationProvider[RenderableT](Protocol):
"""

key: NotificationProviderKey
renderer_key: NotificationProviderKey | None = None
"""The key renderers are registered against, when it differs from `key`."""
default_renderer: type[NotificationRenderer[RenderableT]]
target_class: type[NotificationTarget]
target_resource_types: list[NotificationTargetResourceType]
Expand Down Expand Up @@ -133,15 +134,19 @@ def validate_target(cls, *, target: NotificationTarget) -> None:
return

@classmethod
def get_renderer(
cls, *, data: NotificationData, category: NotificationCategory
) -> type[NotificationRenderer[RenderableT]]:
def get_renderer(cls, *, data: NotificationData) -> type[NotificationRenderer[RenderableT]]:
"""
Returns an instance of a renderer for a given notification, falling back to the default renderer.
Override this to method to permit different renderers for the provider, though keep in mind
that this may produce inconsistencies between notifications.
"""
return cls.default_renderer
# Imported here since the registry imports this module to type its registrations.
from sentry.notifications.platform.registry import renderer_registry

renderer = renderer_registry.get(
provider_key=cls.renderer_key or cls.key, source=data.source
)
return renderer or cls.default_renderer
Comment on lines +143 to +149

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just used as a fallback? Would it be worth just having the notification service and delivery tasks access the registry directly? I could see a case for either approach tbh.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about calling the registry directly, but this couples the cls.renderer_key or cls.key and cls.default_renderer in here nicely, so the caller doesn't need to worry about those details.


@classmethod
def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool:
Expand Down
54 changes: 52 additions & 2 deletions src/sentry/notifications/platform/registry.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from __future__ import annotations

from collections.abc import Callable, Sequence
from typing import Any

from sentry.notifications.platform.provider import NotificationProvider
from sentry.notifications.platform.types import NotificationTemplate
from sentry.notifications.platform.renderer import NotificationRenderer
from sentry.notifications.platform.types import (
NotificationProviderKey,
NotificationSource,
NotificationTemplate,
)
from sentry.organizations.services.organization.model import RpcOrganizationSummary
from sentry.utils.registry import Registry
from sentry.utils.registry import AlreadyRegisteredError, Registry


class NotificationProviderRegistry(Registry[type[NotificationProvider[Any]]]):
Expand Down Expand Up @@ -33,5 +39,49 @@ def get_available(
]


class NotificationRendererRegistry:
"""
A registry for renderers which override a provider's default renderer. Renderers are keyed by
the provider they produce output for, and the notification sources they render.
"""

def __init__(self) -> None:
self.registrations: dict[
tuple[NotificationProviderKey, NotificationSource], type[NotificationRenderer[Any]]
] = {}

def register[RenderableT](
self, provider_key: NotificationProviderKey, sources: Sequence[NotificationSource]
) -> Callable[
[type[NotificationRenderer[RenderableT]]], type[NotificationRenderer[RenderableT]]
]:
if not sources:
raise ValueError("At least one notification source is required to register a renderer")

def inner(
renderer: type[NotificationRenderer[RenderableT]],
) -> type[NotificationRenderer[RenderableT]]:
for source in sources:
key = (provider_key, source)
if key in self.registrations:
raise AlreadyRegisteredError(
f"A registration already exists for {key}: {self.registrations[key]}"
)
self.registrations[key] = renderer
return renderer

return inner

def get(
self, *, provider_key: NotificationProviderKey, source: NotificationSource
) -> type[NotificationRenderer[Any]] | None:
"""
Returns the registered renderer for the provider/source pair, or `None` if the provider has
no override and should fall back to its default renderer.
"""
return self.registrations.get((provider_key, source))


provider_registry = NotificationProviderRegistry()
template_registry = Registry[type[NotificationTemplate[Any]]]()
renderer_registry = NotificationRendererRegistry()
3 changes: 0 additions & 3 deletions src/sentry/notifications/platform/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from sentry.notifications.platform.types import (
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
)

Expand All @@ -17,8 +16,6 @@ class NotificationRenderer[RenderableT](Protocol):
RenderableT is a type that matches the connected provider.
"""

provider_key: NotificationProviderKey

@classmethod
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/notifications/platform/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def render_template[RenderableT](
provider: type[NotificationProvider[RenderableT]],
) -> RenderableT:
rendered_template = template.render(data=data)
renderer = provider.get_renderer(data=data, category=template.category)
renderer = provider.get_renderer(data=data)
return renderer.render(data=data, rendered_template=rendered_template)

@staticmethod
Expand Down
30 changes: 1 addition & 29 deletions src/sentry/notifications/platform/slack/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,11 @@
from sentry.notifications.platform.threading import ThreadContext
from sentry.notifications.platform.types import (
LinkTextBlock,
NotificationCategory,
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
NotificationSection,
NotificationSectionType,
NotificationSource,
NotificationTarget,
NotificationTargetResourceType,
NotificationTextBlock,
Expand All @@ -65,8 +63,6 @@ class SlackRenderable(TypedDict):


class SlackRenderer(NotificationRenderer[SlackRenderable]):
provider_key = NotificationProviderKey.SLACK

@classmethod
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
Expand Down Expand Up @@ -144,31 +140,6 @@ def is_available(cls, *, organization: RpcOrganizationSummary | None = None) ->
# I currently view this as akin to a rollout or feature flag for the registry
return False

@classmethod
def get_renderer(
cls, *, data: NotificationData, category: NotificationCategory
) -> type[NotificationRenderer[SlackRenderable]]:
from sentry.notifications.platform.slack.renderers.issue import (
IssueSlackRenderer,
)
from sentry.notifications.platform.slack.renderers.metric_alert import (
SlackMetricAlertRenderer,
)
from sentry.notifications.platform.slack.renderers.seer import SeerSlackRenderer
from sentry.notifications.platform.slack.renderers.seer_agent_write_approval import (
SeerAgentWriteApprovalSlackRenderer,
)

if category == NotificationCategory.SEER:
if data.source == NotificationSource.SEER_AGENT_WRITE_APPROVAL:
return SeerAgentWriteApprovalSlackRenderer
return SeerSlackRenderer
if category == NotificationCategory.ISSUE:
return IssueSlackRenderer
if category == NotificationCategory.METRIC_ALERT:
return SlackMetricAlertRenderer
return cls.default_renderer

@classmethod
def send(
cls,
Expand Down Expand Up @@ -228,3 +199,4 @@ def _send_with_threading(
@provider_registry.register(NotificationProviderKey.SLACK_STAGING)
class SlackStagingNotificationProvider(SlackNotificationProvider):
key = NotificationProviderKey.SLACK_STAGING
renderer_key = NotificationProviderKey.SLACK
5 changes: 3 additions & 2 deletions src/sentry/notifications/platform/slack/renderers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@

from sentry import eventstore
from sentry.models.group import Group
from sentry.notifications.platform.registry import renderer_registry
from sentry.notifications.platform.renderer import NotificationRenderer
from sentry.notifications.platform.slack.provider import SlackRenderable
from sentry.notifications.platform.templates.issue import IssueNotificationData
from sentry.notifications.platform.types import (
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
NotificationSource,
)


@renderer_registry.register(NotificationProviderKey.SLACK, sources=[NotificationSource.ISSUE])
class IssueSlackRenderer(NotificationRenderer[SlackRenderable]):
provider_key = NotificationProviderKey.SLACK

@classmethod
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
Expand Down
Loading
Loading