-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(notifications): Add notification data and renderer for metric alerts #111674
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 15 commits
c87ee9e
9fbe081
211a27f
0e49895
d8b63f1
184b059
c833e69
e1bc9b5
0eb7c28
caad88e
3636c7e
dc5aefb
4bee1c4
3238579
5452b67
df4eaee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import sentry_sdk | ||
|
|
||
| from sentry import features | ||
| from sentry.incidents.charts import build_metric_alert_chart | ||
| from sentry.incidents.typings.metric_detector import MetricIssueContext | ||
| from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder | ||
| from sentry.models.group import Group | ||
| from sentry.models.organization import Organization | ||
| from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( | ||
| get_alert_rule_serializer, | ||
| get_detector_serializer, | ||
| ) | ||
| from sentry.notifications.notification_action.types import BaseMetricAlertHandler | ||
| from sentry.notifications.platform.renderer import NotificationRenderer | ||
| from sentry.notifications.platform.slack.provider import SlackRenderable | ||
| from sentry.notifications.platform.templates.metric_alert import ( | ||
| ActivityMetricAlertNotificationData, | ||
| BaseMetricAlertNotificationData, | ||
| MetricAlertNotificationData, | ||
| ) | ||
| from sentry.notifications.platform.types import ( | ||
| NotificationData, | ||
| NotificationProviderKey, | ||
| NotificationRenderedTemplate, | ||
| ) | ||
| from sentry.services import eventstore | ||
| from sentry.services.eventstore.models import GroupEvent | ||
| from sentry.workflow_engine.models.detector import Detector | ||
|
|
||
|
|
||
| def _build_metric_issue_context_from_group_event( | ||
| data: MetricAlertNotificationData, | ||
| ) -> MetricIssueContext: | ||
| event = eventstore.backend.get_event_by_id( | ||
| data.project_id, data.event_id, group_id=data.group_id | ||
| ) | ||
| if event is None: | ||
| raise ValueError(f"Event {data.event_id} not found") | ||
| elif not isinstance(event, GroupEvent): | ||
| raise ValueError(f"Event {data.event_id} is not a GroupEvent") | ||
|
|
||
| evidence_data, priority = BaseMetricAlertHandler._extract_from_group_event(event) | ||
| return MetricIssueContext.from_group_event(event.group, evidence_data, priority) | ||
|
|
||
|
|
||
| def _build_metric_issue_context_from_activity( | ||
| data: ActivityMetricAlertNotificationData, | ||
| ) -> MetricIssueContext: | ||
| from sentry.models.activity import Activity | ||
|
|
||
| activity = Activity.objects.get(id=data.activity_id) | ||
|
Check warning on line 53 in src/sentry/notifications/platform/slack/renderers/metric_alert.py
|
||
| group = Group.objects.get_from_cache(id=data.group_id) | ||
|
Check warning on line 54 in src/sentry/notifications/platform/slack/renderers/metric_alert.py
|
||
| evidence_data, priority = BaseMetricAlertHandler._extract_from_activity(activity) | ||
| return MetricIssueContext.from_group_event(group, evidence_data, priority) | ||
|
|
||
|
|
||
| class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]): | ||
| provider_key = NotificationProviderKey.SLACK | ||
|
|
||
| @classmethod | ||
| def render[DataT: NotificationData]( | ||
| cls, *, data: DataT, rendered_template: NotificationRenderedTemplate | ||
| ) -> SlackRenderable: | ||
| if not isinstance(data, BaseMetricAlertNotificationData): | ||
| raise ValueError(f"SlackMetricAlertRenderer does not support {data.__class__.__name__}") | ||
|
|
||
| if isinstance(data, MetricAlertNotificationData): | ||
| metric_issue_context = _build_metric_issue_context_from_group_event(data) | ||
| elif isinstance(data, ActivityMetricAlertNotificationData): | ||
| metric_issue_context = _build_metric_issue_context_from_activity(data) | ||
|
Christinarlong marked this conversation as resolved.
Christinarlong marked this conversation as resolved.
|
||
|
|
||
| organization = Organization.objects.get_from_cache(id=data.organization_id) | ||
|
Check warning on line 74 in src/sentry/notifications/platform/slack/renderers/metric_alert.py
|
||
| detector = Detector.objects.get(id=data.detector_id) | ||
|
Check warning on line 75 in src/sentry/notifications/platform/slack/renderers/metric_alert.py
|
||
| alert_context = data.alert_context.to_alert_context() | ||
| open_period_context = data.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=get_alert_rule_serializer(detector), | ||
| 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=get_detector_serializer(detector), | ||
| ) | ||
| except Exception as e: | ||
| sentry_sdk.capture_exception(e) | ||
|
|
||
| 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() | ||
|
|
||
| renderable = SlackRenderable( | ||
| blocks=slack_body.get("blocks", []), | ||
| text=slack_body.get("text", ""), | ||
| ) | ||
|
Christinarlong marked this conversation as resolved.
Christinarlong marked this conversation as resolved.
|
||
| if (color := slack_body.get("color")) is not None: | ||
| renderable["color"] = color | ||
|
|
||
| return renderable | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from datetime import datetime | ||
| from typing import Self | ||
|
|
||
| from pydantic import BaseModel, ConfigDict | ||
|
|
||
| from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext | ||
| 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 | ||
|
|
||
|
|
||
| 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) | ||
|
Check warning on line 56 in src/sentry/notifications/platform/templates/metric_alert.py
|
||
| 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 BaseMetricAlertNotificationData(NotificationData): | ||
| group_id: int | ||
| organization_id: int | ||
| detector_id: int | ||
|
|
||
| alert_context: SerializableAlertContext | ||
| open_period_context: OpenPeriodContext | ||
|
|
||
| notification_uuid: str | ||
|
|
||
|
|
||
| class MetricAlertNotificationData(BaseMetricAlertNotificationData): | ||
| """GroupEvent / firing path. Renderer re-fetches GroupEvent from Snuba.""" | ||
|
|
||
| source: NotificationSource = NotificationSource.METRIC_ALERT | ||
|
|
||
| event_id: str | ||
| project_id: int | ||
|
|
||
|
|
||
| class ActivityMetricAlertNotificationData(BaseMetricAlertNotificationData): | ||
| """Activity / SET_RESOLVED path. Renderer re-fetches Activity from Postgres.""" | ||
|
|
||
| source: NotificationSource = NotificationSource.ACTIVITY_METRIC_ALERT | ||
|
|
||
| activity_id: int | ||
|
|
||
|
|
||
| _EXAMPLE_ALERT_CONTEXT = SerializableAlertContext( | ||
| name="Example Alert", | ||
| action_identifier_id=1, | ||
| detection_type="static", | ||
| ) | ||
| _EXAMPLE_OPEN_PERIOD_CONTEXT = OpenPeriodContext( | ||
| id=1, | ||
| date_started=datetime(2024, 1, 1, 0, 0, 0), | ||
| ) | ||
|
|
||
|
|
||
| @template_registry.register(NotificationSource.METRIC_ALERT) | ||
| class MetricAlertNotificationTemplate(NotificationTemplate[MetricAlertNotificationData]): | ||
| category = NotificationCategory.METRIC_ALERT | ||
| hide_from_debugger = True | ||
| example_data = MetricAlertNotificationData( | ||
| event_id="abc123", | ||
| project_id=1, | ||
| group_id=1, | ||
| organization_id=1, | ||
| detector_id=1, | ||
| alert_context=_EXAMPLE_ALERT_CONTEXT, | ||
| open_period_context=_EXAMPLE_OPEN_PERIOD_CONTEXT, | ||
| notification_uuid="test-uuid", | ||
| ) | ||
|
|
||
| def render(self, data: MetricAlertNotificationData) -> NotificationRenderedTemplate: | ||
| return NotificationRenderedTemplate(subject="Metric Alert", body=[]) | ||
|
|
||
|
|
||
| @template_registry.register(NotificationSource.ACTIVITY_METRIC_ALERT) | ||
| class ActivityMetricAlertNotificationTemplate( | ||
| NotificationTemplate[ActivityMetricAlertNotificationData] | ||
| ): | ||
| category = NotificationCategory.METRIC_ALERT | ||
| hide_from_debugger = True | ||
| example_data = ActivityMetricAlertNotificationData( | ||
| group_id=1, | ||
| organization_id=1, | ||
| detector_id=1, | ||
| alert_context=_EXAMPLE_ALERT_CONTEXT, | ||
| open_period_context=_EXAMPLE_OPEN_PERIOD_CONTEXT, | ||
| notification_uuid="test-uuid", | ||
| activity_id=1, | ||
| ) | ||
|
|
||
| def render(self, data: ActivityMetricAlertNotificationData) -> NotificationRenderedTemplate: | ||
| return NotificationRenderedTemplate(subject="Metric Alert", body=[]) | ||
Uh oh!
There was an error while loading. Please reload this page.