Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions .agents/skills/notification-platform/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Sentry's NotificationPlatform is a provider-based system for sending notificatio
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `NotificationData` | Protocol. Frozen dataclass carrying the payload for a single notification. Must declare a `source` class variable. | `types.py` |
| `NotificationTemplate` | Abstract class. Converts `NotificationData` into a `NotificationRenderedTemplate`. Registered per `NotificationSource`. | `types.py` |
| `NotificationRenderedTemplate` | Dataclass. Provider-agnostic output: subject, body blocks, actions, chart, footer, optional email paths. | `types.py` |
| `NotificationRenderedTemplate` | Dataclass. Provider-agnostic output: subject (str or text blocks), body (sections containing text blocks), actions, chart, footer (str or text blocks), optional email paths. | `types.py` |
| `NotificationProvider` | Protocol. Knows how to validate a target, pick a renderer, and send the final renderable (Email, Slack, etc.). | `provider.py` |
| `NotificationRenderer` | Protocol. Converts a `NotificationRenderedTemplate` into a provider-specific renderable (HTML email, Slack blocks, etc.). | `renderer.py` |
| `NotificationTarget` | Protocol. Identifies the recipient: email address, channel ID, or DM user ID. Two concrete classes: `GenericNotificationTarget` (email) and `IntegrationNotificationTarget` (Slack/Discord/MSTeams). | `target.py` |
Expand Down Expand Up @@ -98,7 +98,7 @@ from sentry.notifications.platform.types import (
NotificationRenderedAction,
NotificationRenderedTemplate,
NotificationTemplate,
ParagraphBlock,
ParagraphSection,
PlainTextBlock,
)

Expand All @@ -114,17 +114,37 @@ class MyNotificationTemplate(NotificationTemplate[MyNotificationData]):
return NotificationRenderedTemplate(
subject=data.title,
body=[
ParagraphBlock(blocks=[PlainTextBlock(text="Something happened.")])
ParagraphSection(blocks=[PlainTextBlock(text="Something happened.")])
],
actions=[
NotificationRenderedAction(label="View Details", link=data.detail_url)
],
)
```

**Available body block types:**
**Available types:**

Refer to `src/sentry/notifications/platform/types.py` for the latest available block types.
A notification is composed of **sections** and **text blocks**. Text blocks can appear in sections (for the body), and also directly in the `subject` and `footer` fields (as `list[NotificationTextBlock]` instead of plain `str`).

Sections (portions of a message, used in `body`):

| Section | Description |
| ------------------- | ------------------------------------------------- |
| `ParagraphSection` | A block of text with a line break before. |
| `CodeSection` | A code block with a line break before. |
| `BlockQuoteSection` | A quoted block of text, rendered as a blockquote. |

Text blocks (parts of text — used inside sections, and also in `subject` and `footer`):

| Block | Description |
| ----------------- | ----------------------------------------- |
| `PlainTextBlock` | Unformatted text. |
| `BoldTextBlock` | Bold text. |
| `ItalicTextBlock` | Italic text. |
| `CodeTextBlock` | Inline code. |
| `LinkTextBlock` | A hyperlink with `text` and `url` fields. |

Refer to `src/sentry/notifications/platform/types.py` for the full definitions.

**Register the import** in `templates/__init__.py`:

Expand Down Expand Up @@ -186,7 +206,7 @@ if NotificationService.has_access(organization, data.source):

## Step 6: Add a Custom Renderer

Custom renderers bypass the default template-to-renderable conversion for a specific provider + category combination. Use when the default block-based rendering is too limiting (e.g., interactive Slack buttons, rich card layouts).
Custom renderers bypass the default template-to-renderable conversion for a specific provider + category combination. Use when the default section/block-based rendering is too limiting (e.g., interactive Slack buttons, rich card layouts).

**When to use:**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

The default flow is: `NotificationData` → `NotificationTemplate.render()` → `NotificationRenderedTemplate` → `NotificationRenderer.render()` → provider-specific renderable.

Custom renderers replace the last step. The provider's `get_renderer()` method dispatches to a custom renderer class based on category or data type, bypassing the default block-to-renderable conversion.
Custom renderers replace the last step. The provider's `get_renderer()` method dispatches to a custom renderer class based on category or data type, bypassing the default section/block-to-renderable conversion.

```
Template.render(data) → NotificationRenderedTemplate
Expand All @@ -27,7 +27,7 @@ Use a custom renderer when:

Do NOT use a custom renderer when:

- The default block types (`ParagraphBlock`, `CodeBlock`, `PlainTextBlock`, `BoldTextBlock`, `CodeTextBlock`) are sufficient
- The default section and block types (`ParagraphSection`, `CodeSection`, `BlockQuoteSection`, `PlainTextBlock`, `BoldTextBlock`, `ItalicTextBlock`, `CodeTextBlock`, `LinkTextBlock`) are sufficient
- You only need to tweak styling — the default renderers establish common styles that the majority of notifications should abide by.

## File Placement
Expand Down Expand Up @@ -139,6 +139,6 @@ class SeerAutofixUpdateTemplate(NotificationTemplate[SeerAutofixUpdate]):
def render(self, data: SeerAutofixUpdate) -> NotificationRenderedTemplate:
return NotificationRenderedTemplate(
subject="Seer Autofix Update",
body=[ParagraphBlock(blocks=[PlainTextBlock(text="Update")])],
body=[ParagraphSection(blocks=[PlainTextBlock(text="Update")])],
)
```
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ from sentry.notifications.platform.types import (
NotificationRenderedTemplate,
NotificationSource,
NotificationTemplate,
ParagraphBlock,
ParagraphSection,
PlainTextBlock,
)

Expand Down Expand Up @@ -46,7 +46,7 @@ class DataExportSuccessTemplate(NotificationTemplate[DataExportSuccess]):
return NotificationRenderedTemplate(
subject="Your data is ready.",
body=[
ParagraphBlock(
ParagraphSection(
blocks=[
PlainTextBlock(
text="See, that wasn't so bad. We're all done assembling your download."
Expand All @@ -59,7 +59,7 @@ class DataExportSuccessTemplate(NotificationTemplate[DataExportSuccess]):
)
```

## Complete Example: DataExportFailure (with CodeBlock)
## Complete Example: DataExportFailure (with CodeSection)

```python
@dataclass(frozen=True)
Expand All @@ -83,20 +83,20 @@ class DataExportFailureTemplate(NotificationTemplate[DataExportFailure]):
return NotificationRenderedTemplate(
subject="We couldn't export your data.",
body=[
ParagraphBlock(
ParagraphSection(
blocks=[
PlainTextBlock(
text=f"The data export you created at {format_date(data.creation_date)} didn't work."
)
]
),
ParagraphBlock(
ParagraphSection(
blocks=[
PlainTextBlock(text="It looks like there was an error: "),
CodeTextBlock(text=data.error_message),
]
),
CodeBlock(blocks=[PlainTextBlock(text=orjson.dumps(data.error_payload).decode())]),
CodeSection(blocks=[PlainTextBlock(text=orjson.dumps(data.error_payload).decode())]),
],
actions=[
NotificationRenderedAction(label="Documentation", link="https://docs.sentry.io/"),
Expand All @@ -110,15 +110,15 @@ class DataExportFailureTemplate(NotificationTemplate[DataExportFailure]):

## NotificationRenderedTemplate Field Reference

| Field | Type | Required | Description |
| ----------------- | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `subject` | `str` | Yes | Title/subject line. No formatting — displayed as-is. |
| `body` | `list[NotificationBodyFormattingBlock]` | Yes | Main content using block types below. |
| `actions` | `list[NotificationRenderedAction]` | No | Buttons/links. Each has `label` (str) and `link` (str). |
| `chart` | `NotificationRenderedImage` | No | Image with `url` and `alt_text` fields. |
| `footer` | `str` | No | Extra text after actions. No formatting. |
| `email_html_path` | `str` | No | Custom Django HTML template path. Data class passed as context. Default: `sentry/emails/platform/default.html`. |
| `email_text_path` | `str` | No | Custom Django text template path. Data class passed as context. |
| Field | Type | Required | Description |
| ----------------- | ------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subject` | `str \| list[NotificationTextBlock]` | Yes | Title/subject line. Can be plain text or a list of text blocks for rich formatting. |
| `body` | `list[NotificationSection]` | Yes | Main content. A list of sections (`ParagraphSection`, `CodeSection`, `BlockQuoteSection`), each containing text blocks (`PlainTextBlock`, `BoldTextBlock`, `ItalicTextBlock`, `CodeTextBlock`, `LinkTextBlock`). |
| `actions` | `list[NotificationRenderedAction]` | No | Buttons/links. Each has `label` (str) and `link` (str). |
| `chart` | `NotificationRenderedImage` | No | Image with `url` and `alt_text` fields. |
| `footer` | `str \| list[NotificationTextBlock]` | No | Extra text after actions. Can be plain text or a list of text blocks. |
| `email_html_path` | `str` | No | Custom Django HTML template path. Data class passed as context. Default: `sentry/emails/platform/default.html`. |
| `email_text_path` | `str` | No | Custom Django text template path. Data class passed as context. |

## Notes on Special Template Fields

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ from sentry.notifications.platform.target import (
PreparedIntegrationNotificationTarget,
)
from sentry.notifications.platform.types import (
NotificationBodyFormattingBlock,
NotificationBodyFormattingBlockType,
NotificationBodyTextBlock,
NotificationBodyTextBlockType,
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
NotificationSection,
NotificationSectionType,
NotificationTarget,
NotificationTargetResourceType,
NotificationTextBlock,
NotificationTextBlockType,
)
from sentry.organizations.services.organization.model import RpcOrganizationSummary

Expand All @@ -53,31 +53,37 @@ class MyDefaultRenderer(NotificationRenderer[MyRenderable]):
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
) -> MyRenderable:
# Convert rendered_template blocks into provider-specific format
body = cls.render_body_blocks(rendered_template.body)
# Build and return provider-specific renderable
return {"subject": rendered_template.subject, "body": body}
body = cls.render_sections(rendered_template.body)
subject = rendered_template.subject_text
footer = rendered_template.footer_text
return {"subject": subject, "body": body, "footer": footer}

@classmethod
def render_body_blocks(cls, body: list[NotificationBodyFormattingBlock]) -> str:
def render_sections(cls, sections: list[NotificationSection]) -> str:
parts = []
for block in body:
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
parts.append(cls.render_text_blocks(block.blocks))
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
parts.append(f"```{cls.render_text_blocks(block.blocks)}```")
for section in sections:
if section.type == NotificationSectionType.PARAGRAPH:
parts.append(cls.render_text_blocks(section.blocks))
elif section.type == NotificationSectionType.CODE_BLOCK:
parts.append(f"```{cls.render_text_blocks(section.blocks)}```")
elif section.type == NotificationSectionType.BLOCK_QUOTE:
parts.append(f"> {cls.render_text_blocks(section.blocks)}")
return "\n".join(parts)

@classmethod
def render_text_blocks(cls, blocks: list[NotificationBodyTextBlock]) -> str:
def render_text_blocks(cls, blocks: list[NotificationTextBlock]) -> str:
texts = []
for block in blocks:
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
if block.type == NotificationTextBlockType.PLAIN_TEXT:
texts.append(block.text)
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
elif block.type == NotificationTextBlockType.BOLD_TEXT:
texts.append(f"**{block.text}**")
elif block.type == NotificationBodyTextBlockType.CODE:
elif block.type == NotificationTextBlockType.ITALIC_TEXT:
texts.append(f"_{block.text}_")
elif block.type == NotificationTextBlockType.CODE:
texts.append(f"`{block.text}`")
elif block.type == NotificationTextBlockType.LINK:
texts.append(f"[{block.text}]({block.url})")
return " ".join(texts)


Expand Down
16 changes: 9 additions & 7 deletions src/sentry/notifications/platform/templates/data_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@

from sentry.notifications.platform.registry import template_registry
from sentry.notifications.platform.types import (
CodeBlock,
CodeSection,
CodeTextBlock,
NotificationCategory,
NotificationData,
NotificationRenderedAction,
NotificationRenderedTemplate,
NotificationSource,
NotificationTemplate,
ParagraphBlock,
ParagraphSection,
PlainTextBlock,
)

Expand All @@ -41,7 +41,7 @@ def render(self, data: DataExportSuccess) -> NotificationRenderedTemplate:
return NotificationRenderedTemplate(
subject="Your data is ready.",
body=[
ParagraphBlock(
ParagraphSection(
blocks=[
PlainTextBlock(
text="See, that wasn't so bad. We're all done assembling your download. Now have at it."
Expand Down Expand Up @@ -78,27 +78,29 @@ def render(self, data: DataExportFailure) -> NotificationRenderedTemplate:
return NotificationRenderedTemplate(
subject="We couldn't export your data.",
body=[
ParagraphBlock(
ParagraphSection(
blocks=[
PlainTextBlock(
text=f"Well, this is a little awkward. The data export you created at {format_date(data.creation_date)} didn't work. Sorry about that."
)
]
),
ParagraphBlock(
ParagraphSection(
blocks=[
PlainTextBlock(text="It looks like there was an error: "),
CodeTextBlock(text=data.error_message),
]
),
ParagraphBlock(
ParagraphSection(
blocks=[
PlainTextBlock(
text="This is what you sent us. Maybe it'll help you sort this out: "
)
]
),
CodeBlock(blocks=[PlainTextBlock(text=orjson.dumps(data.error_payload).decode())]),
CodeSection(
blocks=[PlainTextBlock(text=orjson.dumps(data.error_payload).decode())]
),
],
actions=[
NotificationRenderedAction(label="Documentation", link="https://docs.sentry.io/"),
Expand Down
Loading
Loading