Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions src/sentry/notifications/platform/slack/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,17 @@ def get_renderer(
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

if category == NotificationCategory.SEER:
return SeerSlackRenderer
if category == NotificationCategory.ISSUE:
return IssueSlackRenderer
if category == NotificationCategory.METRIC_ALERT:
return SlackMetricAlertRenderer
return cls.default_renderer

@classmethod
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from __future__ import annotations

import sentry_sdk

from sentry import features
from sentry.incidents.charts import build_metric_alert_chart
from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder
from sentry.notifications.platform.renderer import NotificationRenderer
from sentry.notifications.platform.slack.provider import SlackRenderable
from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData
from sentry.notifications.platform.types import (
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
)


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

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

# Re-fetch GroupEvent — needed to rebuild MetricIssueContext
event = data.event
organization = data.organization

# Rebuild MetricIssueContext (the only context that holds ORM instances)
metric_issue_context = MetricAlertNotificationData.get_metric_issue_context(event)

Check failure on line 33 in src/sentry/notifications/platform/slack/renderers/metric_alert.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

QuerySubscription.DoesNotExist not handled in get_metric_issue_context call

The call to `MetricAlertNotificationData.get_metric_issue_context(event)` at line 32-33 internally calls `QuerySubscription.objects.get()` without exception handling. If the subscription was deleted between notification data creation and rendering, this raises `QuerySubscription.DoesNotExist` and crashes the entire render operation. This matches the stale reference pattern (Check 2) seen in 81 production issues with 1.4M events.
Comment thread
Christinarlong marked this conversation as resolved.
Outdated

# Deserialize pre-computed contexts (no Action/Detector/GroupOpenPeriod re-queries)
alert_context = data.alert_context.to_alert_context()
open_period_context = data.open_period_context.to_open_period_context()

chart_url = None
if features.has("organizations:metric-alert-chartcuterie", organization):
try:
chart_url = build_metric_alert_chart(
organization=organization,
alert_rule_serialized_response=data.serialized_alert_rule,
snuba_query=metric_issue_context.snuba_query,
alert_context=alert_context,
open_period_context=open_period_context,
subscription=metric_issue_context.subscription,
detector_serialized_response=data.serialized_detector,
)
except Exception as e:
sentry_sdk.capture_exception(e)

# Build the Slack blocks using the existing metric alert builder
slack_body = SlackIncidentsMessageBuilder(
alert_context=alert_context,
metric_issue_context=metric_issue_context,
organization=organization,
date_started=open_period_context.date_started,
chart_url=chart_url,
notification_uuid=data.notification_uuid,
).build()

return SlackRenderable(
blocks=slack_body.get("blocks", []),
text=slack_body.get("text", ""),
)
Comment thread
Christinarlong marked this conversation as resolved.
Comment thread
Christinarlong marked this conversation as resolved.
2 changes: 2 additions & 0 deletions src/sentry/notifications/platform/templates/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from .data_export import DataExportFailureTemplate, DataExportSuccessTemplate
from .issue import IssueNotificationTemplate
from .metric_alert import MetricAlertNotificationTemplate

__all__ = (
"DataExportSuccessTemplate",
"DataExportFailureTemplate",
"IssueNotificationTemplate",
"MetricAlertNotificationTemplate",
)
# All templates should be imported here so they are registered in the notifications Django app.
# See sentry/notifications/apps.py
Expand Down
206 changes: 206 additions & 0 deletions src/sentry/notifications/platform/templates/metric_alert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING, Self

from pydantic import BaseModel, ConfigDict

from sentry.incidents.typings.metric_detector import (
AlertContext,
MetricIssueContext,
OpenPeriodContext,
)
from sentry.models.group import Group
from sentry.models.organization import Organization
from sentry.notifications.platform.registry import template_registry
from sentry.notifications.platform.types import (
NotificationCategory,
NotificationData,
NotificationRenderedTemplate,
NotificationSource,
NotificationTemplate,
)
from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType
from sentry.services import eventstore
from sentry.services.eventstore.models import GroupEvent
from sentry.workflow_engine.models.detector import Detector

if TYPE_CHECKING:
from sentry.incidents.endpoints.serializers.alert_rule import AlertRuleSerializerResponse
from sentry.workflow_engine.endpoints.serializers.detector_serializer import (
DetectorSerializerResponse,
)


class SerializableAlertContext(BaseModel):
model_config = ConfigDict(frozen=True)

name: str
action_identifier_id: int
threshold_type: int | None = None # AlertRuleThresholdType or AnomalyDetectionThresholdType
detection_type: str # AlertRuleDetectionType value (TextChoices str)
comparison_delta: int | None = None
sensitivity: str | None = None
resolve_threshold: float | None = None
alert_threshold: float | None = None

@classmethod
def from_alert_context(cls, ac: AlertContext) -> Self:
return cls(
name=ac.name,
action_identifier_id=ac.action_identifier_id,
threshold_type=int(ac.threshold_type.value) if ac.threshold_type is not None else None,
detection_type=ac.detection_type.value,
comparison_delta=ac.comparison_delta,
sensitivity=ac.sensitivity,
resolve_threshold=ac.resolve_threshold,
alert_threshold=ac.alert_threshold,
)

def to_alert_context(self) -> AlertContext:
from sentry.incidents.models.alert_rule import (
AlertRuleDetectionType,
AlertRuleThresholdType,
)

detection_type = AlertRuleDetectionType(self.detection_type)

threshold_type: AlertRuleThresholdType | AnomalyDetectionThresholdType | None = None
if self.threshold_type is not None:
if detection_type == AlertRuleDetectionType.DYNAMIC:
threshold_type = AnomalyDetectionThresholdType(self.threshold_type)
else:
threshold_type = AlertRuleThresholdType(self.threshold_type)

return AlertContext(
name=self.name,
action_identifier_id=self.action_identifier_id,
threshold_type=threshold_type,
detection_type=detection_type,
comparison_delta=self.comparison_delta,
sensitivity=self.sensitivity,
resolve_threshold=self.resolve_threshold,
alert_threshold=self.alert_threshold,
)


class SerializableOpenPeriodContext(BaseModel):
model_config = ConfigDict(frozen=True)

id: int
date_started: datetime
date_closed: datetime | None = None

@classmethod
def from_open_period_context(cls, opc: OpenPeriodContext) -> Self:
return cls(
id=opc.id,
date_started=opc.date_started,
date_closed=opc.date_closed,
)

def to_open_period_context(self) -> OpenPeriodContext:
Comment thread
Christinarlong marked this conversation as resolved.
Outdated
return OpenPeriodContext(
id=self.id,
date_started=self.date_started,
date_closed=self.date_closed,
)


class MetricAlertNotificationData(NotificationData):
source: NotificationSource = NotificationSource.METRIC_ALERT

# For re-fetching GroupEvent via eventstore (MetricIssueContext has ORM instances)
event_id: str
project_id: int
group_id: int

# For feature flag check(chartcuterie) + message builder
organization_id: int
# To rebuild the contexts
detector_id: int

# Pre-computed serializable contexts
alert_context: SerializableAlertContext
open_period_context: SerializableOpenPeriodContext

notification_uuid: str

@property
def event(self) -> GroupEvent:
event = eventstore.backend.get_event_by_id(
self.project_id, self.event_id, group_id=self.group_id
)
if event is None:
raise ValueError(f"Event {self.event_id} not found")
elif not isinstance(event, GroupEvent):
raise ValueError(f"Event {self.event_id} is not a GroupEvent")

return event

@property
def organization(self) -> Organization:
return Organization.objects.get_from_cache(id=self.organization_id)

@property
def group(self) -> Group:
return Group.objects.get_from_cache(id=self.group_id)

@property
def detector(self) -> Detector:
return Detector.objects.get(id=self.detector_id)

Check failure on line 151 in src/sentry/notifications/platform/templates/metric_alert.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

[ED9-JBN] QuerySubscription.DoesNotExist not handled in get_metric_issue_context call (additional location)

The call to `MetricAlertNotificationData.get_metric_issue_context(event)` at line 32-33 internally calls `QuerySubscription.objects.get()` without exception handling. If the subscription was deleted between notification data creation and rendering, this raises `QuerySubscription.DoesNotExist` and crashes the entire render operation. This matches the stale reference pattern (Check 2) seen in 81 production issues with 1.4M events.

@property
def serialized_alert_rule(self) -> AlertRuleSerializerResponse:
from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import (
get_alert_rule_serializer,
)

return get_alert_rule_serializer(self.detector)

@property
def serialized_detector(self) -> DetectorSerializerResponse:
from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import (
get_detector_serializer,
)

return get_detector_serializer(self.detector)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

@classmethod
def get_metric_issue_context(cls, event: GroupEvent) -> MetricIssueContext:
from sentry.notifications.notification_action.types import BaseMetricAlertHandler

evidence_data, priority = BaseMetricAlertHandler._extract_from_group_event(event)
return MetricIssueContext.from_group_event(event.group, evidence_data, priority)


@template_registry.register(NotificationSource.METRIC_ALERT)
class MetricAlertNotificationTemplate(NotificationTemplate[MetricAlertNotificationData]):
category = NotificationCategory.METRIC_ALERT
# hide_from_debugger because this template uses a custom renderer that bypasses
# the standard NotificationRenderedTemplate rendering path so wouldn't load correctly in the debugger.
hide_from_debugger = True
example_data = MetricAlertNotificationData(
event_id="abc123",
project_id=1,
group_id=1,
organization_id=1,
detector_id=1,
alert_context=SerializableAlertContext(
name="Example Alert",
action_identifier_id=1,
detection_type="static",
),
open_period_context=SerializableOpenPeriodContext(
id=1,
date_started=datetime(2024, 1, 1, 0, 0, 0),
),
notification_uuid="test-uuid",
)

def render(self, data: MetricAlertNotificationData) -> NotificationRenderedTemplate:
# The actual rendering is handled by the provider-specific custom renderer
# (e.g. SlackMetricAlertRenderer), which rebuilds MetricIssueContext from the
# re-fetched GroupEvent and builds the full payload via SlackIncidentsMessageBuilder.
# This method returns a minimal fallback for providers without a custom renderer.
return NotificationRenderedTemplate(subject="Metric Alert", body=[])
7 changes: 7 additions & 0 deletions src/sentry/notifications/platform/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class NotificationCategory(StrEnum):
REPOSITORY = "repository"
SEER = "seer"
ISSUE = "issue"
METRIC_ALERT = "metric-alert"

def get_sources(self) -> list[NotificationSource]:
return NOTIFICATION_SOURCE_MAP[self]
Expand Down Expand Up @@ -55,6 +56,9 @@ class NotificationSource(StrEnum):
# ISSUE_ALERT
ISSUE = "issue"

# METRIC_ALERT
METRIC_ALERT = "metric-alert"

# SEER
SEER_AUTOFIX_ERROR = "seer-autofix-error"
SEER_AUTOFIX_UPDATE = "seer-autofix-update"
Expand Down Expand Up @@ -87,6 +91,9 @@ class NotificationSource(StrEnum):
NotificationCategory.ISSUE: [
NotificationSource.ISSUE,
],
NotificationCategory.METRIC_ALERT: [
NotificationSource.METRIC_ALERT,
],
NotificationCategory.SEER: [
NotificationSource.SEER_AUTOFIX_TRIGGER,
NotificationSource.SEER_AUTOFIX_ERROR,
Expand Down
Loading
Loading