Skip to content
Merged
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
23 changes: 21 additions & 2 deletions src/sentry/grouping/parameterization.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,27 @@ def _get_pattern(self, raw_pattern: str) -> str:
(\d{4}-?[01]\d-?[0-3]\d[\sT][0-2]\d:?[0-5]\d) # no seconds
)
|
# Kitchen
([1-9]\d?:\d{2}(:\d{2})?(?:\s?[aApP][Mm])?)
# Kitchen, 12-hr
(
(?<!\d) # Negative lookbehind to ensure hour has at most two digits
([1-9]|1[0-2]) # Hour, no leading zero, 1-12 hours
:[0-5]\d # Minute
(:[0-5]\d)? # Optional second
(?![\d:]) # Negative lookahead to ensure second (or minute, if there are no seconds)
# has at most two digits, and to make sure that if there are seconds, they
# get consumed by the optional seconds part of the pattern (and are
# thereby forced to abide by its restrictions on possible values)
(?:\s?[aApP][Mm])? # Optional, optionally-space-separated AM/PM
)
|
# Kitchen, 24-hr
(
(?<!\d) # Negative lookbehind (same logic as 12-hr pattern above)
(0?\d|1\d|2[0-3]) # Hour, optional leading zero, 0-23 hours
:[0-5]\d # Minute
(:[0-5]\d)? # Optional second
(?![\d:]) # Negative lookahead (same logic as in 12-hr pattern above)
)
|
# Date
(\d{4}-[01]\d-[0-3]\d)
Expand Down
10 changes: 7 additions & 3 deletions src/sentry/incidents/typings/metric_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from datetime import datetime
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel

from sentry.incidents.models.alert_rule import (
AlertRule,
AlertRuleDetectionType,
Expand Down Expand Up @@ -281,15 +283,17 @@ def from_legacy_models(
)


@dataclass
class OpenPeriodContext:
class OpenPeriodContext(BaseModel):
"""
We want to eventually delete this class. it serves as a way to pass data around
that we used to use `incident` for.
"""

class Config:
frozen = True

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

@classmethod
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/notifications/notification_action/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ def invoke_legacy_registry(cls, invocation: ActionInvocation) -> None:
"notification_context": asdict(notification_context),
"alert_context": asdict(alert_context),
"metric_issue_context": asdict(metric_issue_context),
"open_period_context": asdict(open_period_context),
"open_period_context": open_period_context.dict(),
"trigger_status": trigger_status,
},
)
Expand Down
28 changes: 22 additions & 6 deletions src/sentry/notifications/platform/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ class NotificationServiceError(Exception):
pass


class NotificationRenderError(NotificationServiceError):
pass


class NotificationService[T: NotificationData]:
def __init__(self, *, data: T):
self.data: Final[T] = data
Expand Down Expand Up @@ -94,9 +98,15 @@ def notify_target(

# Update the lifecycle with the notification category now that we know it
event_lifecycle.notification_category = template.category
renderable = NotificationService.render_template(
data=self.data, template=template, provider=provider
)
try:
renderable = NotificationService.render_template(
data=self.data, template=template, provider=provider
)
except Exception as e:
lifecycle.record_failure(failure_reason=e, create_issue=True)
raise NotificationRenderError(
f"Failed to render notification for source={self.data.source}"
) from e

# Step 3: Resolve thread if threading requested
thread_context: ThreadContext | None = None
Expand Down Expand Up @@ -321,9 +331,15 @@ def notify_target_async(
template_cls = template_registry.get(notification_data.source)
template = template_cls()
lifecycle_metric.notification_category = template.category
renderable = NotificationService.render_template(
data=notification_data, template=template, provider=provider
)
try:
renderable = NotificationService.render_template(
data=notification_data, template=template, provider=provider
)
except Exception as e:
lifecycle.record_failure(failure_reason=e, create_issue=True)
raise NotificationRenderError(
f"Failed to render notification for source={notification_data.source}"
) from e

# Step 4: Resolve thread if threading requested
thread_context: ThreadContext | None = None
Expand Down
8 changes: 7 additions & 1 deletion src/sentry/notifications/platform/slack/provider.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, TypedDict
from typing import TYPE_CHECKING, NotRequired, TypedDict

from slack_sdk.models.blocks import (
ActionsBlock,
Expand Down Expand Up @@ -59,6 +59,7 @@ class SlackProviderThreadingContext(ProviderThreadingContext):
class SlackRenderable(TypedDict):
blocks: list[Block]
text: str
color: NotRequired[str]


class SlackRenderer(NotificationRenderer[SlackRenderable]):
Expand Down Expand Up @@ -138,12 +139,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
110 changes: 110 additions & 0 deletions src/sentry/notifications/platform/slack/renderers/metric_alert.py
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)
group = Group.objects.get_from_cache(id=data.group_id)
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)

organization = Organization.objects.get_from_cache(id=data.organization_id)
detector = Detector.objects.get(id=data.detector_id)
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", ""),
)
if (color := slack_body.get("color")) is not None:
renderable["color"] = color

return renderable
3 changes: 3 additions & 0 deletions src/sentry/notifications/platform/templates/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from .data_export import DataExportFailureTemplate, DataExportSuccessTemplate
from .issue import IssueNotificationTemplate
from .metric_alert import ActivityMetricAlertNotificationTemplate, MetricAlertNotificationTemplate

__all__ = (
"DataExportSuccessTemplate",
"DataExportFailureTemplate",
"IssueNotificationTemplate",
"MetricAlertNotificationTemplate",
"ActivityMetricAlertNotificationTemplate",
)
# All templates should be imported here so they are registered in the notifications Django app.
# See sentry/notifications/apps.py
Expand Down
Loading
Loading