diff --git a/src/sentry/digests/notifications.py b/src/sentry/digests/notifications.py index c12eebe5531c..0568b708ed83 100644 --- a/src/sentry/digests/notifications.py +++ b/src/sentry/digests/notifications.py @@ -3,17 +3,15 @@ import logging from collections import defaultdict from collections.abc import Mapping, Sequence +from dataclasses import replace from typing import Any, NamedTuple, TypeAlias -import sentry_sdk - from sentry import tsdb from sentry.digests.types import IdentifierKey, Notification, Record, RecordWithRuleObjects from sentry.models.group import Group, GroupStatus from sentry.models.project import Project from sentry.models.rule import Rule -from sentry.notifications.types import ActionTargetType, FallthroughChoiceType -from sentry.notifications.utils.rules import get_rule_or_workflow_id +from sentry.notifications.types import ActionTargetType, FallthroughChoiceType, NotificationRule from sentry.services.eventstore.models import Event, GroupEvent from sentry.tsdb.base import TSDBModel from sentry.workflow_engine.models import Workflow @@ -21,7 +19,7 @@ logger = logging.getLogger("sentry.digests") -Digest: TypeAlias = dict[Rule, dict[Group, list[RecordWithRuleObjects]]] +Digest: TypeAlias = dict[NotificationRule, dict[Group, list[RecordWithRuleObjects]]] class DigestInfo(NamedTuple): @@ -78,7 +76,7 @@ def unsplit_key( def event_to_record( event: Event | GroupEvent, - rules: Sequence[Rule], + rules: Sequence[NotificationRule], notification_uuid: str | None = None, identifier_key: IdentifierKey = IdentifierKey.RULE, ) -> Record: @@ -88,7 +86,11 @@ def event_to_record( # TODO(iamrajjoshi): The typing on this function is wrong, the type should be GroupEvent # TODO(iamrajjoshi): Creating a PR to fix this assert event.group is not None - rule_ids = [int(get_rule_or_workflow_id(rule)[1]) for rule in rules] + rule_ids = [] + for rule in rules: + rule_id = rule.legacy_rule_id if identifier_key == IdentifierKey.RULE else rule.workflow_id + assert rule_id is not None + rule_ids.append(rule_id) return Record( event.event_id, Notification(event, rule_ids, notification_uuid, identifier_key), @@ -97,7 +99,7 @@ def event_to_record( def _bind_records( - records: Sequence[Record], groups: dict[int, Group], rules: dict[int, Rule] + records: Sequence[Record], groups: dict[int, Group], rules: dict[int, NotificationRule] ) -> list[RecordWithRuleObjects]: ret = [] for record in records: @@ -123,7 +125,9 @@ def _bind_records( def _group_records( - records: Sequence[RecordWithRuleObjects], groups: dict[int, Group], rules: dict[int, Rule] + records: Sequence[RecordWithRuleObjects], + groups: dict[int, Group], + rules: dict[int, NotificationRule], ) -> Digest: grouped: Digest = defaultdict(lambda: defaultdict(list)) for record in records: @@ -161,7 +165,7 @@ def _sort_digest( def _build_digest_impl( records: Sequence[Record], groups: dict[int, Group], - rules: dict[int, Rule], + rules: dict[int, NotificationRule], event_counts: dict[int, int], user_counts: Mapping[Any, int], ) -> Digest: @@ -171,8 +175,10 @@ def _build_digest_impl( return _sort_digest(grouped, event_counts=event_counts, user_counts=user_counts) -def get_rules_from_workflows(project: Project, workflow_ids: set[int]) -> dict[int, Rule]: - rules: dict[int, Rule] = {} +def get_rules_from_workflows( + project: Project, workflow_ids: set[int] +) -> dict[int, NotificationRule]: + rules: dict[int, NotificationRule] = {} if not workflow_ids: return rules @@ -194,27 +200,22 @@ def get_rules_from_workflows(project: Project, workflow_ids: set[int]) -> dict[i if alert_workflow: if rule := bulk_rules.get(alert_workflow.rule_id): assert rule.project_id == project.id, "Rule must belong to Project" - rule.environment_id = workflow.environment_id - try: - rule.data["actions"][0]["legacy_rule_id"] = rule.id - rule.data["actions"][0]["workflow_id"] = workflow_id - except KeyError: - # This shouldn't happen, but isn't a deal breaker if it does - sentry_sdk.capture_exception( - Exception(f"Rule {rule.id} does not have a legacy_rule_id"), - level="warning", - ) - rules[workflow_id] = rule + rules[workflow_id] = replace( + NotificationRule.from_deprecated_legacy_rule( + rule, project=project, workflow_id=workflow_id + ), + environment_id=workflow.environment_id, + ) continue - # Create synthetic Rule when no AlertRuleWorkflow or no Rule found - rules[workflow_id] = Rule( + rules[workflow_id] = NotificationRule( label=workflow.name, - id=workflow_id, - project_id=project.id, + action_id=None, + project=project, environment_id=workflow.environment_id, - # We need to do this so that the links are built correctly downstream data={"actions": [{"workflow_id": workflow_id}]}, + workflow_id=workflow_id, + legacy_rule_id=None, ) return rules @@ -245,17 +246,10 @@ def build_digest(project: Project, records: Sequence[Record]) -> DigestInfo: groups = Group.objects.in_bulk(record.value.event.group_id for record in records) group_ids = list(groups) - rules = Rule.objects.in_bulk(rule_ids) - - for rule in rules.values(): - try: - rule.data["actions"][0]["legacy_rule_id"] = rule.id - except KeyError: - # This shouldn't happen, but isn't a deal breaker if it does - sentry_sdk.capture_exception( - Exception(f"Rule {rule.id} does not have a legacy_rule_id"), - level="warning", - ) + rules = { + rule_id: NotificationRule.from_deprecated_legacy_rule(rule, project=project) + for rule_id, rule in Rule.objects.in_bulk(rule_ids).items() + } rules.update(get_rules_from_workflows(project, workflow_ids)) diff --git a/src/sentry/digests/types.py b/src/sentry/digests/types.py index c1c1aa5c0d56..dc3ed112cd66 100644 --- a/src/sentry/digests/types.py +++ b/src/sentry/digests/types.py @@ -8,7 +8,7 @@ from sentry.utils.dates import to_datetime if TYPE_CHECKING: - from sentry.models.rule import Rule + from sentry.notifications.types import NotificationRule from sentry.services.eventstore.models import Event, GroupEvent @@ -23,7 +23,7 @@ class Notification(NamedTuple): notification_uuid: str | None = None identifier_key: IdentifierKey = IdentifierKey.RULE - def with_rules(self, rules: list[Rule]) -> NotificationWithRuleObjects: + def with_rules(self, rules: list[NotificationRule]) -> NotificationWithRuleObjects: return NotificationWithRuleObjects( event=self.event, rules=rules, @@ -41,7 +41,7 @@ class Record(NamedTuple): def datetime(self) -> datetime_mod.datetime: return to_datetime(self.timestamp) - def with_rules(self, rules: list[Rule]) -> RecordWithRuleObjects: + def with_rules(self, rules: list[NotificationRule]) -> RecordWithRuleObjects: return RecordWithRuleObjects( key=self.key, value=self.value.with_rules(rules), @@ -51,7 +51,7 @@ def with_rules(self, rules: list[Rule]) -> RecordWithRuleObjects: class NotificationWithRuleObjects(NamedTuple): event: Event | GroupEvent - rules: list[Rule] + rules: list[NotificationRule] notification_uuid: str | None diff --git a/src/sentry/digests/utils.py b/src/sentry/digests/utils.py index 811358ccfcd4..4d44265daddd 100644 --- a/src/sentry/digests/utils.py +++ b/src/sentry/digests/utils.py @@ -11,8 +11,7 @@ from sentry.models.group import Group from sentry.models.project import Project from sentry.models.projectownership import ProjectOwnership -from sentry.models.rule import Rule -from sentry.notifications.types import ActionTargetType, FallthroughChoiceType +from sentry.notifications.types import ActionTargetType, FallthroughChoiceType, NotificationRule from sentry.notifications.utils.participants import get_send_to from sentry.services.eventstore.models import Event, GroupEvent from sentry.types.actor import Actor @@ -166,7 +165,7 @@ def sort_func(record: Record) -> datetime: return sorted(records, key=sort_func, reverse=True) -def get_groups(digest: Digest) -> Sequence[tuple[Rule, Group, Event | GroupEvent]]: +def get_groups(digest: Digest) -> Sequence[tuple[NotificationRule, Group, Event | GroupEvent]]: """ Split a digest into groups and return it as a tuple of: the applicable rule, the group, and the group's first event. diff --git a/src/sentry/integrations/discord/message_builder/issues.py b/src/sentry/integrations/discord/message_builder/issues.py index 452f963907ce..e06191aec11c 100644 --- a/src/sentry/integrations/discord/message_builder/issues.py +++ b/src/sentry/integrations/discord/message_builder/issues.py @@ -23,9 +23,9 @@ from sentry.integrations.types import ExternalProviders from sentry.models.group import Group, GroupStatus from sentry.models.project import Project -from sentry.models.rule import Rule from sentry.notifications.notifications.base import ProjectNotification -from sentry.notifications.utils.rules import RuleIdType, get_rule_or_workflow_id +from sentry.notifications.types import NotificationRule +from sentry.notifications.utils.rules import get_rule_or_workflow_id from sentry.services.eventstore.models import GroupEvent from ..message_builder.base.component import DiscordComponentCustomIds as CustomIds @@ -37,7 +37,7 @@ def __init__( group: Group, event: GroupEvent | None = None, tags: set[str] | None = None, - rules: list[Rule] | None = None, + rules: list[NotificationRule] | None = None, link_to_event: bool = False, issue_details: bool = False, notification: ProjectNotification | None = None, @@ -57,39 +57,40 @@ def build(self, notification_uuid: str | None = None) -> DiscordMessage: max(self.group.last_seen, self.event.datetime) if self.event else self.group.last_seen ) obj: Group | GroupEvent = self.event if self.event is not None else self.group - rule_id = None + rule_id: int | None = None rule_environment_id = None - key: RuleIdType = "legacy_rule_id" + is_workflow = False if self.rules: rule_environment_id = self.rules[0].environment_id - key, rule_id = get_rule_or_workflow_id(self.rules[0], prefer="workflow_id") + key, rule_id_value = get_rule_or_workflow_id(self.rules[0], prefer="workflow_id") + is_workflow = key == "workflow_id" + rule_id = int(rule_id_value) url = None - match key: - case "workflow_id": - url = get_title_link_workflow_engine_ui( - self.group, - self.event, - self.link_to_event, - self.issue_details, - self.notification, - ExternalProviders.DISCORD, - int(rule_id) if rule_id else None, - rule_environment_id, - notification_uuid=notification_uuid, - ) - case "legacy_rule_id": - url = get_title_link( - self.group, - self.event, - self.link_to_event, - self.issue_details, - self.notification, - ExternalProviders.DISCORD, - int(rule_id) if rule_id else None, - rule_environment_id, - notification_uuid=notification_uuid, - ) + if is_workflow: + url = get_title_link_workflow_engine_ui( + self.group, + self.event, + self.link_to_event, + self.issue_details, + self.notification, + ExternalProviders.DISCORD, + rule_id, + rule_environment_id, + notification_uuid=notification_uuid, + ) + else: + url = get_title_link( + self.group, + self.event, + self.link_to_event, + self.issue_details, + self.notification, + ExternalProviders.DISCORD, + rule_id, + rule_environment_id, + notification_uuid=notification_uuid, + ) embeds = [ DiscordMessageEmbed( diff --git a/src/sentry/integrations/github/actions/create_ticket.py b/src/sentry/integrations/github/actions/create_ticket.py index 585c1334a20c..578b5aa2ade1 100644 --- a/src/sentry/integrations/github/actions/create_ticket.py +++ b/src/sentry/integrations/github/actions/create_ticket.py @@ -14,4 +14,4 @@ class GitHubCreateTicketAction(TicketEventAction): provider = IntegrationProviderSlug.GITHUB.value def generate_footer(self, rule_url: str) -> str: - return f"\nThis issue was automatically created by Sentry via [{self.rule.label}]({absolute_uri(rule_url)})" + return f"\nThis issue was automatically created by Sentry via [{self.rule_context.label}]({absolute_uri(rule_url)})" diff --git a/src/sentry/integrations/github_enterprise/actions/create_ticket.py b/src/sentry/integrations/github_enterprise/actions/create_ticket.py index b3848dee32e9..ca7fe0a342a1 100644 --- a/src/sentry/integrations/github_enterprise/actions/create_ticket.py +++ b/src/sentry/integrations/github_enterprise/actions/create_ticket.py @@ -14,4 +14,4 @@ class GitHubEnterpriseCreateTicketAction(TicketEventAction): provider = IntegrationProviderSlug.GITHUB_ENTERPRISE.value def generate_footer(self, rule_url: str) -> str: - return f"\nThis issue was automatically created by Sentry via [{self.rule.label}]({absolute_uri(rule_url)})" + return f"\nThis issue was automatically created by Sentry via [{self.rule_context.label}]({absolute_uri(rule_url)})" diff --git a/src/sentry/integrations/jira/actions/create_ticket.py b/src/sentry/integrations/jira/actions/create_ticket.py index 68170054e498..c43dd1acf7f1 100644 --- a/src/sentry/integrations/jira/actions/create_ticket.py +++ b/src/sentry/integrations/jira/actions/create_ticket.py @@ -24,7 +24,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.data["fixVersions"] = [fix_versions] def generate_footer(self, rule_url: str) -> str: - return f"This ticket was automatically created by Sentry via [{self.rule.label}|{absolute_uri(rule_url)}]" + return f"This ticket was automatically created by Sentry via [{self.rule_context.label}|{absolute_uri(rule_url)}]" def translate_integration(self, integration: RpcIntegration) -> str: name = integration.metadata.get("domain_name", integration.name) diff --git a/src/sentry/integrations/jira_server/actions/create_ticket.py b/src/sentry/integrations/jira_server/actions/create_ticket.py index 5a7b87448658..0b436a36ec52 100644 --- a/src/sentry/integrations/jira_server/actions/create_ticket.py +++ b/src/sentry/integrations/jira_server/actions/create_ticket.py @@ -24,7 +24,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.data["fixVersions"] = [fix_versions] def generate_footer(self, rule_url: str) -> str: - return f"This ticket was automatically created by Sentry via [{self.rule.label}|{absolute_uri(rule_url)}]" + return f"This ticket was automatically created by Sentry via [{self.rule_context.label}|{absolute_uri(rule_url)}]" def translate_integration(self, integration: RpcIntegration) -> str: return integration.metadata.get("domain_name", integration.name) diff --git a/src/sentry/integrations/messaging/message_builder.py b/src/sentry/integrations/messaging/message_builder.py index 2d2fad7e921d..21cae0889c66 100644 --- a/src/sentry/integrations/messaging/message_builder.py +++ b/src/sentry/integrations/messaging/message_builder.py @@ -9,12 +9,12 @@ from sentry.models.environment import Environment from sentry.models.group import Group from sentry.models.project import Project -from sentry.models.rule import Rule from sentry.models.team import Team from sentry.notifications.notifications.base import BaseNotification from sentry.notifications.notifications.rules import AlertRuleNotification +from sentry.notifications.types import NotificationRule from sentry.notifications.utils.links import create_link_to_workflow -from sentry.notifications.utils.rules import get_key_from_rule_data, get_rule_or_workflow_id +from sentry.notifications.utils.rules import get_rule_or_workflow_id from sentry.services.eventstore.models import Event, GroupEvent from sentry.users.services.user import RpcUser from sentry.utils.http import absolute_uri @@ -250,10 +250,11 @@ def build_attachment_replay_link( return None -def build_rule_url(rule: Any, group: Group, project: Project) -> str: +def build_rule_url(rule: NotificationRule, group: Group, project: Project) -> str: org_slug = group.organization.slug project_slug = project.slug - rule_id = get_key_from_rule_data(rule, "legacy_rule_id") + rule_id = rule.legacy_rule_id + assert rule_id is not None rule_url = f"/organizations/{org_slug}/issues/alerts/rules/{project_slug}/{rule_id}/details/" return absolute_uri(rule_url) @@ -263,7 +264,7 @@ def build_footer( group: Group, project: Project, url_format: str, - rules: Sequence[Rule] | None = None, + rules: Sequence[NotificationRule] | None = None, ) -> str: footer = f"{group.qualified_short_id}" if rules: diff --git a/src/sentry/integrations/msteams/actions/notification.py b/src/sentry/integrations/msteams/actions/notification.py index a437aaa2df46..8008e2d90479 100644 --- a/src/sentry/integrations/msteams/actions/notification.py +++ b/src/sentry/integrations/msteams/actions/notification.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Generator, Sequence + from sentry.integrations.messaging.metrics import ( MessagingInteractionEvent, MessagingInteractionType, @@ -11,7 +13,9 @@ from sentry.integrations.msteams.spec import MsTeamsMessagingSpec from sentry.integrations.services.integration import RpcIntegration from sentry.integrations.types import IntegrationProviderSlug +from sentry.notifications.types import RuleFuture from sentry.rules.actions import IntegrationEventAction +from sentry.rules.base import CallbackFuture from sentry.services.eventstore.models import GroupEvent from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.utils import metrics @@ -41,14 +45,16 @@ def get_integrations(self) -> list[RpcIntegration]: a for a in super().get_integrations() if a.metadata.get("installation_type") != "tenant" ] - def after(self, event: GroupEvent, notification_uuid: str | None = None): + def after( + self, event: GroupEvent, notification_uuid: str | None = None + ) -> Generator[CallbackFuture]: channel = self.get_option("channel_id") integration = self.get_integration() if not integration: return - def send_notification(event, futures): + def send_notification(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: rules = [f.rule for f in futures] card = MSTeamsIssueMessageBuilder( event.group, event, rules, integration diff --git a/src/sentry/integrations/msteams/card_builder/issues.py b/src/sentry/integrations/msteams/card_builder/issues.py index 0f1e28f15705..b9bc43d762f7 100644 --- a/src/sentry/integrations/msteams/card_builder/issues.py +++ b/src/sentry/integrations/msteams/card_builder/issues.py @@ -29,7 +29,7 @@ from sentry.integrations.types import IntegrationProviderSlug from sentry.models.group import Group, GroupStatus from sentry.models.project import Project -from sentry.models.rule import Rule +from sentry.notifications.types import NotificationRule from sentry.services.eventstore.models import Event, GroupEvent from .base import MSTeamsMessageBuilder @@ -52,16 +52,8 @@ logger = logging.getLogger(__name__) -def get_workflow_ids(rules: Sequence[Rule]) -> list[int]: - workflow_ids = [] - for rule in rules: - action = rule.data.get("actions", [{}])[0] - workflow_id = action.get("workflow_id") - - if workflow_id is not None: - workflow_ids.append(int(workflow_id)) - - return workflow_ids +def get_workflow_ids(rules: Sequence[NotificationRule]) -> list[int]: + return [rule.workflow_id for rule in rules if rule.workflow_id is not None] class MSTeamsIssueMessageBuilder(MSTeamsMessageBuilder): @@ -69,7 +61,7 @@ def __init__( self, group: Group, event: Event | GroupEvent | None, - rules: Sequence[Rule], + rules: Sequence[NotificationRule], integration: RpcIntegration, workflow_ids: Sequence[int] = (), ): @@ -87,7 +79,7 @@ def generate_action_payload(self, action_type: ACTION_TYPE) -> Any: "actionType": action_type, "groupId": self.group.id, "eventId": self.event.event_id if self.event else None, - "rules": [rule.id for rule in self.rules], + "rules": [rule.broken_rule_id for rule in self.rules], "workflows": list(dict.fromkeys([*workflow_ids, *self.workflow_ids])), "integrationId": self.integration.id, } diff --git a/src/sentry/integrations/msteams/webhook.py b/src/sentry/integrations/msteams/webhook.py index 777d0ea8ca3b..301e7ab01fc8 100644 --- a/src/sentry/integrations/msteams/webhook.py +++ b/src/sentry/integrations/msteams/webhook.py @@ -51,6 +51,7 @@ from sentry.models.apikey import ApiKey from sentry.models.group import Group from sentry.models.rule import Rule +from sentry.notifications.types import NotificationRule from sentry.services import eventstore from sentry.silo.base import SiloMode from sentry.users.services.user.service import user_service @@ -653,7 +654,10 @@ def _handle_action_submitted(self, request: Request) -> Response: # get the rules from the payload rule_ids = payload.get("rules", []) workflow_ids = payload.get("workflows", []) - rules = tuple(Rule.objects.filter(id__in=rule_ids, project_id=group.project_id)) + rules = tuple( + NotificationRule.from_deprecated_legacy_rule(rule, project=group.project) + for rule in Rule.objects.filter(id__in=rule_ids, project_id=group.project_id) + ) metrics.incr( "integrations.msteams.action.rule_lookup", tags={ diff --git a/src/sentry/integrations/opsgenie/actions/notification.py b/src/sentry/integrations/opsgenie/actions/notification.py index fde38da45ce4..686d650153e5 100644 --- a/src/sentry/integrations/opsgenie/actions/notification.py +++ b/src/sentry/integrations/opsgenie/actions/notification.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Generator, Sequence from typing import cast import sentry_sdk @@ -14,7 +15,10 @@ from sentry.integrations.opsgenie.utils import get_team from sentry.integrations.services.integration import integration_service from sentry.integrations.types import IntegrationProviderSlug +from sentry.notifications.types import RuleFuture from sentry.rules.actions import IntegrationEventAction +from sentry.rules.base import CallbackFuture +from sentry.services.eventstore.models import GroupEvent from sentry.shared_integrations.exceptions import ApiError logger = logging.getLogger("sentry.integrations.opsgenie") @@ -43,7 +47,9 @@ def __init__(self, *args, **kwargs): }, } - def after(self, event, notification_uuid: str | None = None): + def after( + self, event: GroupEvent, notification_uuid: str | None = None + ) -> Generator[CallbackFuture]: integration = self.get_integration() if not integration: logger.warning("Integration removed, but the rule still refers to it") @@ -66,7 +72,7 @@ def after(self, event, notification_uuid: str | None = None): ) return - def send_notification(event, futures): + def send_notification(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: installation = integration.get_installation(self.project.organization_id) try: client: OpsgenieClient = installation.get_keyring_client(self.get_option("team")) diff --git a/src/sentry/integrations/opsgenie/client.py b/src/sentry/integrations/opsgenie/client.py index 937c47b2e91c..d397d1e7fb58 100644 --- a/src/sentry/integrations/opsgenie/client.py +++ b/src/sentry/integrations/opsgenie/client.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Literal +from collections.abc import Sequence +from typing import Any, Literal from sentry.integrations.client import ApiClient from sentry.integrations.models.integration import Integration @@ -9,8 +10,9 @@ from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.types import IntegrationProviderSlug from sentry.models.group import Group +from sentry.notifications.types import NotificationRule from sentry.notifications.utils.links import create_link_to_workflow -from sentry.notifications.utils.rules import get_key_from_rule_data, split_rules_by_rule_workflow_id +from sentry.notifications.utils.rules import split_rules_by_rule_workflow_id from sentry.services.eventstore.models import Event, GroupEvent from sentry.shared_integrations.exceptions import ApiError @@ -41,35 +43,38 @@ def get_alerts(self, limit: int | None = 1) -> object | None: path = f"/alerts?limit={limit}" return self.get(path=path, headers=self._get_auth_headers()) - def _get_workflow_urls(self, group, rules): + def _get_workflow_urls(self, group: Group, rules: Sequence[NotificationRule]) -> list[str]: organization = group.project.organization workflow_urls = [] for rule in rules: - # fetch the workflow_id from the rule.data - workflow_id = get_key_from_rule_data(rule, "workflow_id") + workflow_id = rule.workflow_id + assert workflow_id is not None workflow_urls.append( - organization.absolute_url(create_link_to_workflow(organization.slug, workflow_id)) + organization.absolute_url( + create_link_to_workflow(organization.slug, str(workflow_id)) + ) ) return workflow_urls - def _get_rule_urls(self, group, rules): + def _get_rule_urls(self, group: Group, rules: Sequence[NotificationRule]) -> list[str]: organization = group.project.organization rule_urls = [] for rule in rules: - rule_id = get_key_from_rule_data(rule, "legacy_rule_id") + rule_id = rule.legacy_rule_id + assert rule_id is not None path = f"/organizations/{organization.slug}/issues/alerts/rules/{group.project.slug}/{rule_id}/details/" rule_urls.append(organization.absolute_url(path)) return rule_urls def build_issue_alert_payload( self, - data, - rules, + data: Any, + rules: Sequence[NotificationRule], event: Event | GroupEvent, group: Group | None, priority: OpsgeniePriority | None = "P3", notification_uuid: str | None = None, - ): + ) -> dict[str, Any]: payload = { "message": event.message or event.title, "source": "Sentry", diff --git a/src/sentry/integrations/pagerduty/actions/notification.py b/src/sentry/integrations/pagerduty/actions/notification.py index ffbe449773ef..d1c144faef1a 100644 --- a/src/sentry/integrations/pagerduty/actions/notification.py +++ b/src/sentry/integrations/pagerduty/actions/notification.py @@ -14,8 +14,7 @@ build_pagerduty_event_payload, ) from sentry.integrations.types import IntegrationProviderSlug -from sentry.models.rule import Rule -from sentry.notifications.types import RuleFuture +from sentry.notifications.types import NotificationRule, RuleFuture from sentry.rules.actions import IntegrationEventAction from sentry.rules.base import CallbackFuture from sentry.services.eventstore.models import GroupEvent @@ -106,7 +105,7 @@ def send_notification(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: severity=severity, ) - rules: list[Rule] = [f.rule for f in futures] + rules: list[NotificationRule] = [f.rule for f in futures] rule = rules[0] if rules else None if rule and rule.label: diff --git a/src/sentry/integrations/slack/actions/notification.py b/src/sentry/integrations/slack/actions/notification.py index a97c05e28a42..6aa4446aadae 100644 --- a/src/sentry/integrations/slack/actions/notification.py +++ b/src/sentry/integrations/slack/actions/notification.py @@ -29,9 +29,8 @@ from sentry.integrations.slack.utils.threads import NotificationActionThreadUtils from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.metrics import EventLifecycle -from sentry.models.rule import Rule from sentry.notifications.additional_attachment_manager import get_additional_attachment -from sentry.notifications.types import RuleFuture +from sentry.notifications.types import NotificationRule, RuleFuture from sentry.notifications.utils.open_period import open_period_start_for_group from sentry.rules.actions import IntegrationEventAction from sentry.rules.base import CallbackFuture @@ -77,7 +76,7 @@ def _should_send_nudge(self, channel_id: str | None) -> bool: def _build_notification_blocks( self, event: GroupEvent, - rules: Sequence[Rule], + rules: Sequence[NotificationRule], tags: set, integration: RpcIntegration, notification_uuid: str | None = None, @@ -241,7 +240,13 @@ def _send_notification_action_notification( rule = rules[0] if rules else None rule_to_use = self.rule if self.rule else rule # In the NOA, we will store the action id in the rule id field - action_id = rule_to_use.id if rule_to_use else None + action_id = ( + rule_to_use.broken_rule_id + if isinstance(rule_to_use, NotificationRule) + else rule_to_use.id + if rule_to_use + else None + ) if not action_id: # We are logging because this should never happen, all actions should have an uuid diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py index a219941ae9d1..046f90960ec5 100644 --- a/src/sentry/integrations/slack/message_builder/issues.py +++ b/src/sentry/integrations/slack/message_builder/issues.py @@ -48,10 +48,10 @@ from sentry.models.project import Project from sentry.models.projectownership import ProjectOwnership from sentry.models.release import Release -from sentry.models.rule import Rule from sentry.models.team import Team from sentry.notifications.notifications.base import ProjectNotification from sentry.notifications.platform.slack.renderers.seer import SeerSlackRenderer +from sentry.notifications.types import NotificationRule from sentry.notifications.utils.actions import BlockKitMessageAction, MessageAction from sentry.notifications.utils.participants import ( dedupe_suggested_assignees, @@ -65,7 +65,6 @@ from sentry.types.actor import Actor from sentry.types.group import SUBSTATUS_TO_STR from sentry.users.services.user.model import RpcUser -from sentry.workflow_engine.models import Workflow STATUSES = {"resolved": "resolved", "ignored": "ignored", "unresolved": "re-opened"} MAX_BLOCK_TEXT_LENGTH = 256 @@ -73,7 +72,7 @@ MAX_SUGGESTED_ASSIGNEES = 3 -def get_group_users_count(group: Group, rules: list[Rule] | None = None) -> int: +def get_group_users_count(group: Group, rules: list[NotificationRule] | None = None) -> int: environment_ids: list[int] | None = None if rules: environment_ids = [rule.environment_id for rule in rules if rule.environment_id is not None] @@ -195,7 +194,7 @@ def get_tags( return fields -def get_context(group: Group, rules: list[Rule] | None = None) -> str: +def get_context(group: Group, rules: list[NotificationRule] | None = None) -> str: context_text = "" context = group.issue_type.notification_config.context.copy() @@ -416,7 +415,7 @@ def __init__( tags: set[str] | None = None, identity: RpcIdentity | None = None, actions: Sequence[MessageAction | BlockKitMessageAction] | None = None, - rules: list[Rule] | None = None, + rules: list[NotificationRule] | None = None, link_to_event: bool = False, issue_details: bool = False, notification: ProjectNotification | None = None, @@ -613,18 +612,10 @@ def build(self, notification_uuid: str | None = None) -> SlackBlock: # handler, so it keeps preferring the legacy rule id. _, value = get_rule_or_workflow_id(self.rules[0]) rule_id = int(value) - action = self.rules[0].data.get("actions", [{}])[0] - if action.get("workflow_id") is not None: - workflow_id = int(action["workflow_id"]) - + workflow_id = self.rules[0].workflow_id or workflow_id link_key, link_value = get_rule_or_workflow_id(self.rules[0], prefer="workflow_id") link_id = int(link_value) - match link_key: - case "workflow_id": - workflow = Workflow.objects.filter(id=link_id).first() - rule_environment_id = workflow.environment_id if workflow else None - case "legacy_rule_id": - rule_environment_id = self.rules[0].environment_id + rule_environment_id = self.rules[0].environment_id # build up actions text if self.actions and self.identity and not action_text: diff --git a/src/sentry/integrations/slack/message_builder/util.py b/src/sentry/integrations/slack/message_builder/util.py index 2c302ecb4914..9ba5d49dbcd9 100644 --- a/src/sentry/integrations/slack/message_builder/util.py +++ b/src/sentry/integrations/slack/message_builder/util.py @@ -4,7 +4,7 @@ from sentry.integrations.slack.message_builder.types import SLACK_URL_FORMAT from sentry.models.group import Group from sentry.models.project import Project -from sentry.models.rule import Rule +from sentry.notifications.types import NotificationRule from sentry.notifications.utils.links import create_link_to_workflow from sentry.notifications.utils.rules import get_rule_or_workflow_id from sentry.utils.http import absolute_uri @@ -13,7 +13,7 @@ def build_slack_footer( group: Group, project: Project, - rules: Sequence[Rule] | None = None, + rules: Sequence[NotificationRule] | None = None, ) -> str: footer = f"{group.qualified_short_id}" diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index d3f8c1c8a8f7..a9ba7986aba5 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -59,6 +59,7 @@ from sentry.models.organizationmember import InviteStatus, OrganizationMember from sentry.models.rule import Rule from sentry.notifications.services import notifications_service +from sentry.notifications.types import NotificationRule from sentry.notifications.utils.actions import BlockKitMessageAction, MessageAction from sentry.seer.entrypoints.operator import SeerAutofixOperator from sentry.seer.entrypoints.slack.entrypoint import SlackAutofixEntrypoint @@ -136,18 +137,18 @@ def update_group( return resp -def get_rule(rule_id: int | None, organization_id: int) -> Rule | None: +def get_rule(rule_id: int | None, organization_id: int) -> NotificationRule | None: """Get the rule that fired""" if not rule_id: return None try: - # Scope the callback-provided rule ID to the integration-validated organization + # Scope the callback-provided rule ID to the integration-validated organization. rule = Rule.objects.get(id=rule_id, project__organization_id=organization_id) - # We need to add the legacy_rule_id field to the rule data since the message builder will use it to build the link to the rule + # The callback contract puns Rule and Workflow IDs, so preserve Rule.id here. rule.data["actions"][0]["legacy_rule_id"] = rule.id except Rule.DoesNotExist: return None - return rule + return NotificationRule.from_deprecated_legacy_rule(rule) def get_group(slack_request: SlackActionRequest) -> Group | None: diff --git a/src/sentry/integrations/vsts/actions/create_ticket.py b/src/sentry/integrations/vsts/actions/create_ticket.py index 100fd6d3ce9d..4b5e7c96829c 100644 --- a/src/sentry/integrations/vsts/actions/create_ticket.py +++ b/src/sentry/integrations/vsts/actions/create_ticket.py @@ -15,4 +15,4 @@ class AzureDevopsCreateTicketAction(TicketEventAction): provider = IntegrationProviderSlug.AZURE_DEVOPS.value def generate_footer(self, rule_url: str) -> str: - return f"\nThis work item was automatically created by Sentry via [{self.rule.label}]({absolute_uri(rule_url)})" + return f"\nThis work item was automatically created by Sentry via [{self.rule_context.label}]({absolute_uri(rule_url)})" diff --git a/src/sentry/mail/adapter.py b/src/sentry/mail/adapter.py index dfeb6e731737..999aa965fab8 100644 --- a/src/sentry/mail/adapter.py +++ b/src/sentry/mail/adapter.py @@ -65,7 +65,7 @@ def rule_notify( log_event = "dispatched" for future in futures: rules.append(future.rule) - extra["rule_id"] = future.rule.id + extra["rule_id"] = future.rule.broken_rule_id if not future.kwargs: continue raise NotImplementedError( diff --git a/src/sentry/notifications/notification_action/types.py b/src/sentry/notifications/notification_action/types.py index 2500403bb124..6aca62dd26ec 100644 --- a/src/sentry/notifications/notification_action/types.py +++ b/src/sentry/notifications/notification_action/types.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Collection, Sequence from dataclasses import asdict -from typing import Any, ClassVar, NotRequired, Protocol, TypedDict +from typing import Any, ClassVar, Protocol from django.core.exceptions import ValidationError from taskbroker_client.retry import RetryTaskError @@ -23,8 +23,13 @@ from sentry.models.activity import Activity from sentry.models.organization import Organization from sentry.models.project import Project -from sentry.models.rule import Rule, RuleSource -from sentry.notifications.types import TEST_NOTIFICATION_ID, RuleFuture +from sentry.models.rule import Rule +from sentry.notifications.types import ( + TEST_NOTIFICATION_ID, + NotificationRule, + NotificationRuleData, + RuleFuture, +) from sentry.notifications.utils.issue_notification_context import IssueNotificationContext from sentry.rules.processing.processor import activate_downstream_actions from sentry.services.eventstore.models import GroupEvent @@ -52,11 +57,6 @@ FutureCallback = Callable[[GroupEvent, Sequence[RuleFuture]], Any] -class RuleData(TypedDict): - actions: list[dict[str, Any]] - legacy_rule_id: NotRequired[int] - - class LegacyRegistryHandler(ABC): """ Abstract base class that defines the interface for notification handlers. @@ -206,7 +206,7 @@ def create_rule_instance_from_action( detector: Detector, event_data: WorkflowEventData, workflow_id: WorkflowId, - ) -> Rule: + ) -> NotificationRule: """ Creates a Rule instance from the Action model. :param action: Action @@ -217,7 +217,7 @@ def create_rule_instance_from_action( """ environment_id = event_data.workflow_env.id if event_data.workflow_env else None - data: RuleData = { + data: NotificationRuleData = { "actions": [ cls.build_rule_action_blob(action, detector.linked_project.organization.id) ], @@ -271,14 +271,14 @@ def create_rule_instance_from_action( # mail action needs to have skipDigests set to True data["actions"][0]["skipDigests"] = True - rule = Rule( - id=action.id, + rule = NotificationRule( + action_id=action.id, project=detector.linked_project, environment_id=environment_id, label=label, - data=dict(data), - status=ObjectStatus.ACTIVE, - source=RuleSource.ISSUE, + data=data, + workflow_id=(None if workflow_id == TEST_NOTIFICATION_ID else workflow_id), + legacy_rule_id=(data["actions"][0].get("legacy_rule_id")), ) return rule @@ -286,7 +286,7 @@ def create_rule_instance_from_action( @staticmethod def get_rule_futures( event_data: WorkflowEventData, - rule: Rule, + rule: NotificationRule, notification_uuid: str, ) -> Collection[tuple[Callable[[GroupEvent, Sequence[RuleFuture]], None], list[RuleFuture]]]: """ @@ -360,7 +360,7 @@ def invoke_legacy_registry(cls, invocation: ActionInvocation) -> None: "action_id": invocation.action.id, "detector_id": invocation.detector.id, "event_data": asdict(invocation.event_data), - "rule_id": rule.id, + "rule_id": rule.broken_rule_id, "rule_project_id": rule.project.id, "rule_environment_id": rule.environment_id, "rule_label": rule.label, @@ -372,7 +372,7 @@ def invoke_legacy_registry(cls, invocation: ActionInvocation) -> None: # Execute the futures # If the rule id is -1, we are sending a test notification - if rule.id == TEST_NOTIFICATION_ID: + if rule.is_test_notification: cls.send_test_notification(invocation.event_data, futures) else: cls.execute_futures(invocation.event_data, futures) diff --git a/src/sentry/notifications/notifications/digest.py b/src/sentry/notifications/notifications/digest.py index 1cb14dfc3e03..85ddf3eaa4b8 100644 --- a/src/sentry/notifications/notifications/digest.py +++ b/src/sentry/notifications/notifications/digest.py @@ -265,7 +265,7 @@ def send(self) -> None: def get_log_params(self, recipient: Actor) -> Mapping[str, Any]: try: - alert_id = list(self.digest.digest)[0].id + alert_id = list(self.digest.digest)[0].broken_rule_id except Exception: alert_id = None diff --git a/src/sentry/notifications/notifications/rules.py b/src/sentry/notifications/notifications/rules.py index 5606c67321a1..63f03b82399e 100644 --- a/src/sentry/notifications/notifications/rules.py +++ b/src/sentry/notifications/notifications/rules.py @@ -28,6 +28,7 @@ from sentry.notifications.types import ( ActionTargetType, FallthroughChoiceType, + NotificationRule, NotificationSettingEnum, ) from sentry.notifications.utils import ( @@ -97,7 +98,12 @@ def __init__( self.target_type = target_type self.target_identifier = target_identifier self.fallthrough_choice = fallthrough_choice - self.rules = notification.rules + self.rules = [ + rule + if isinstance(rule, NotificationRule) + else NotificationRule.from_deprecated_legacy_rule(rule, project=project) + for rule in notification.rules + ] if ( event.group.issue_category in GROUP_CATEGORIES_CUSTOM_EMAIL @@ -355,7 +361,7 @@ def get_log_params(self, recipient: Actor) -> Mapping[str, Any]: return { "target_type": self.target_type, "target_identifier": self.target_identifier, - "alert_id": self.rules[0].id if self.rules else None, + "alert_id": self.rules[0].broken_rule_id if self.rules else None, **super().get_log_params(recipient), } diff --git a/src/sentry/notifications/platform/discord/renderers/issue.py b/src/sentry/notifications/platform/discord/renderers/issue.py index e1dfe0a31aa7..70fc2a1ff3a9 100644 --- a/src/sentry/notifications/platform/discord/renderers/issue.py +++ b/src/sentry/notifications/platform/discord/renderers/issue.py @@ -50,7 +50,7 @@ def render[DataT: NotificationData]( except Exception: raise NotificationRenderError(f"Failed to retrieve event {data.event_id}") - rules = [data.rule.to_rule()] if data.rule else [] + rules = [data.rule.to_notification_rule(group.project)] if data.rule else [] return DiscordIssuesMessageBuilder( group=group, diff --git a/src/sentry/notifications/platform/msteams/renderers/issue.py b/src/sentry/notifications/platform/msteams/renderers/issue.py index 29a3974e207b..9e92eec0c33a 100644 --- a/src/sentry/notifications/platform/msteams/renderers/issue.py +++ b/src/sentry/notifications/platform/msteams/renderers/issue.py @@ -8,7 +8,6 @@ from sentry.integrations.types import IntegrationProviderSlug from sentry.models.group import Group, GroupStatus from sentry.models.project import Project -from sentry.models.rule import Rule from sentry.notifications.platform.msteams.provider import MSTeamsRenderable from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer @@ -20,6 +19,7 @@ NotificationRenderedTemplate, NotificationSource, ) +from sentry.notifications.types import NotificationRule from sentry.services.eventstore.models import Event, GroupEvent from sentry.types.actor import Actor @@ -65,7 +65,7 @@ def render[DataT: NotificationData]( except Exception: raise NotificationRenderError(f"Failed to retrieve event {data.event_id}") - rules = [data.rule.to_rule()] if data.rule else [] + rules = [data.rule.to_notification_rule(group.project)] if data.rule else [] issue_url = cls.build_issue_url(group=group, notification_uuid=data.notification_uuid) fields: list[Block | None] = [ @@ -125,7 +125,7 @@ def build_footer( *, group: Group, event: Event | GroupEvent | None, - rules: Sequence[Rule], + rules: Sequence[NotificationRule], ) -> ColumnSetBlock: from sentry.integrations.messaging.message_builder import build_footer from sentry.integrations.msteams.card_builder import MSTEAMS_URL_FORMAT @@ -186,7 +186,11 @@ def build_assignee_note(cls, group: Group) -> TextBlock | None: @classmethod def build_action_payload( - cls, *, action_type: ACTION_TYPE, data: IssueNotificationData, rules: Sequence[Rule] + cls, + *, + action_type: ACTION_TYPE, + data: IssueNotificationData, + rules: Sequence[NotificationRule], ) -> dict[str, Any]: # Keep this lazy to avoid initializing the msteams package during notifications app startup. from sentry.integrations.msteams.card_builder.issues import get_workflow_ids @@ -198,7 +202,7 @@ def build_action_payload( "actionType": action_type, "groupId": data.group_id, "eventId": data.event_id, - "rules": [rule.id for rule in rules], + "rules": [rule.broken_rule_id for rule in rules], "workflows": get_workflow_ids(rules), } } @@ -213,7 +217,7 @@ def build_action( reverse_action: ACTION_TYPE, reverse_action_title: str, data: IssueNotificationData, - rules: Sequence[Rule], + rules: Sequence[NotificationRule], **card_kwargs: Any, ) -> Action: """ @@ -253,7 +257,7 @@ def build_assignee_choices(cls, group: Group) -> Sequence[tuple[str, str]]: @classmethod def build_actions( - cls, *, group: Group, data: IssueNotificationData, rules: Sequence[Rule] + cls, *, group: Group, data: IssueNotificationData, rules: Sequence[NotificationRule] ) -> ContainerBlock: from sentry.integrations.msteams.card_builder import ME from sentry.integrations.msteams.card_builder.block import ( diff --git a/src/sentry/notifications/platform/slack/renderers/issue.py b/src/sentry/notifications/platform/slack/renderers/issue.py index 76642c29fb1f..1447ba55c90b 100644 --- a/src/sentry/notifications/platform/slack/renderers/issue.py +++ b/src/sentry/notifications/platform/slack/renderers/issue.py @@ -34,7 +34,7 @@ def render[DataT: NotificationData]( group=group, event=event, tags=set(data.tags) if data.tags else None, - rules=[data.rule.to_rule()] if data.rule else None, + rules=[data.rule.to_notification_rule(group.project)] if data.rule else None, notes=data.notes, link_to_event=True, ).build(notification_uuid=data.notification_uuid) diff --git a/src/sentry/notifications/platform/templates/issue.py b/src/sentry/notifications/platform/templates/issue.py index 1873bd6c9a3e..af3de9d4e7a3 100644 --- a/src/sentry/notifications/platform/templates/issue.py +++ b/src/sentry/notifications/platform/templates/issue.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict -from sentry.models.rule import Rule +from sentry.models.project import Project from sentry.notifications.platform.registry import template_registry from sentry.notifications.platform.types import ( NotificationCategory, @@ -13,6 +13,7 @@ NotificationSource, NotificationTemplate, ) +from sentry.notifications.types import TEST_NOTIFICATION_ID, NotificationRule, NotificationRuleData class SerializableRuleProxy(BaseModel): @@ -23,36 +24,59 @@ class SerializableRuleProxy(BaseModel): model_config = ConfigDict(frozen=True) id: int + action_id: int | None = None label: str data: dict[str, Any] environment_id: int | None = None project_id: int + workflow_id: int | None = None + legacy_rule_id: int | None = None @classmethod - def from_rule(cls, rule: Rule) -> SerializableRuleProxy: - """ - Temporary method to convert a Rule to a NotificationRuleInfo. This will - be removed once we no longer rely on the Rule ORM model. - """ + def from_rule(cls, rule: NotificationRule) -> SerializableRuleProxy: + """Create a serializable representation of a notification rule.""" return cls( - id=rule.id, + id=rule.broken_rule_id, + action_id=rule.action_id, label=rule.label, data=rule.data, environment_id=rule.environment_id, project_id=rule.project.id, + workflow_id=rule.workflow_id, + legacy_rule_id=rule.legacy_rule_id, ) - def to_rule(self) -> Rule: - """ - Temporary method to convert a NotificationRuleInfo to a Rule. This will - be removed once we no longer rely on the Rule ORM model. - """ - return Rule( - id=self.id, + def to_notification_rule(self, project: Project) -> NotificationRule: + workflow_id = self.workflow_id + legacy_rule_id = self.legacy_rule_id + actions = self.data.get("actions") + if ( + not isinstance(actions, list) + or not actions + or not all(isinstance(action, dict) for action in actions) + ): + actions = [{}] + data: NotificationRuleData = {"actions": [dict(action) for action in actions]} + if workflow_id is None and legacy_rule_id is None: + # Compatibility for payloads serialized before identities became top-level fields. + action = actions[0] + workflow_id = action.get("workflow_id") + legacy_rule_id = action.get("legacy_rule_id") + workflow_id = int(workflow_id) if workflow_id is not None else None + legacy_rule_id = int(legacy_rule_id) if legacy_rule_id is not None else None + if workflow_id == TEST_NOTIFICATION_ID or legacy_rule_id == TEST_NOTIFICATION_ID: + workflow_id = None + legacy_rule_id = TEST_NOTIFICATION_ID + elif workflow_id is None and legacy_rule_id is None: + legacy_rule_id = self.id + return NotificationRule( + action_id=self.action_id if "action_id" in self.__fields_set__ else self.id, label=self.label, - data=self.data, + data=data, environment_id=self.environment_id, - project_id=self.project_id, + project=project, + workflow_id=workflow_id, + legacy_rule_id=legacy_rule_id, ) @@ -83,6 +107,8 @@ class IssueNotificationTemplate(NotificationTemplate[IssueNotificationData]): data={ "actions": [{"workflow_id": 3}], }, + workflow_id=3, + legacy_rule_id=None, ), ) hide_from_debugger = True diff --git a/src/sentry/notifications/types.py b/src/sentry/notifications/types.py index c76f0f41f638..56ea8b547e54 100644 --- a/src/sentry/notifications/types.py +++ b/src/sentry/notifications/types.py @@ -2,17 +2,156 @@ from dataclasses import dataclass from enum import Enum, StrEnum -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, TypedDict from sentry.hybridcloud.rpc import ValueEqualityEnum if TYPE_CHECKING: from sentry.models.organization import Organization + from sentry.models.project import Project from sentry.models.rule import Rule +class NotificationRuleData(TypedDict): + """Configuration for legacy action instantiation, not notification identity.""" + + actions: list[dict[str, Any]] + + +@dataclass(eq=False, frozen=True) +class NotificationRule: + """Rule-like notification context for the legacy action registry. + + ``workflow_id`` and ``legacy_rule_id`` are the canonical notification identities. + Notification code must not recover identity from ``data["actions"]``. Action data + exists only to configure the legacy action registry. Parsing identity from action + data is restricted to explicit compatibility boundaries for deprecated Rule rows + and payloads serialized before these top-level fields existed. + """ + + action_id: int | None + label: str + data: NotificationRuleData + project: Project + environment_id: int | None + workflow_id: int | None + legacy_rule_id: int | None + + @classmethod + def from_deprecated_legacy_rule( + cls, + rule: Rule, + *, + project: Project | None = None, + workflow_id: int | None = None, + ) -> NotificationRule: + actions = rule.data.get("actions") + if ( + not isinstance(actions, list) + or not actions + or not all(isinstance(action, dict) for action in actions) + ): + # Deprecated rules can reach render-only paths without action data. + actions = [{}] + + first_action = actions[0] + embedded_workflow_id = first_action.get("workflow_id") + embedded_legacy_rule_id = first_action.get("legacy_rule_id") + if embedded_workflow_id is not None: + embedded_workflow_id = int(embedded_workflow_id) + if embedded_legacy_rule_id is not None: + embedded_legacy_rule_id = int(embedded_legacy_rule_id) + if embedded_legacy_rule_id == TEST_NOTIFICATION_ID: + effective_workflow_id = None + legacy_rule_id = TEST_NOTIFICATION_ID + elif workflow_id is not None: + effective_workflow_id = workflow_id + legacy_rule_id = rule.id + elif embedded_legacy_rule_id is not None: + effective_workflow_id = embedded_workflow_id + legacy_rule_id = embedded_legacy_rule_id + elif embedded_workflow_id is not None: + effective_workflow_id = embedded_workflow_id + legacy_rule_id = None + else: + effective_workflow_id = None + legacy_rule_id = rule.id + + return cls( + action_id=None, + label=rule.label, + data={"actions": [dict(action) for action in actions]}, + project=project or rule.project, + environment_id=rule.environment_id, + workflow_id=effective_workflow_id, + legacy_rule_id=legacy_rule_id, + ) + + def __post_init__(self) -> None: + if not self.data["actions"]: + raise ValueError("NotificationRule requires at least one action") + + if self.legacy_rule_id == TEST_NOTIFICATION_ID: + if self.workflow_id is not None: + raise ValueError("Test notification cannot have a workflow ID") + elif self.workflow_id == TEST_NOTIFICATION_ID: + raise ValueError("Workflow ID cannot be the test notification ID") + elif self.workflow_id is None and self.legacy_rule_id is None: + raise ValueError("NotificationRule requires a workflow or legacy rule ID") + + @property + def identifier(self) -> str: + if self.is_test_notification and self.action_id is not None: + return f"test:{self.action_id}" + if self.workflow_id is not None: + return f"workflow:{self.workflow_id}" + assert self.legacy_rule_id is not None + return f"legacy:{self.legacy_rule_id}" + + @property + def broken_rule_id(self) -> int: + """Preserve callers that historically treated several ID domains as Rule.id.""" + if self.action_id is not None: + return self.action_id + if self.legacy_rule_id is not None: + return self.legacy_rule_id + assert self.workflow_id is not None + return self.workflow_id + + def __eq__(self, other: object) -> bool: + if not isinstance(other, NotificationRule): + return NotImplemented + return self.identifier == other.identifier + + def __hash__(self) -> int: + return hash(self.identifier) + + @property + def is_test_notification(self) -> bool: + return self.legacy_rule_id == TEST_NOTIFICATION_ID + + @property + def is_workflow_only(self) -> bool: + return self.workflow_id is not None and self.legacy_rule_id is None + + @property + def is_workflow_with_legacy_rule(self) -> bool: + return self.workflow_id is not None and self.legacy_rule_id is not None + + @property + def is_legacy_rule_only(self) -> bool: + return self.workflow_id is None and self.legacy_rule_id not in ( + None, + TEST_NOTIFICATION_ID, + ) + + @property + def project_id(self) -> int: + return self.project.id + + class RuleFuture(NamedTuple): - rule: Rule + rule: NotificationRule kwargs: dict[str, Any] diff --git a/src/sentry/notifications/utils/links.py b/src/sentry/notifications/utils/links.py index ce3785371ce1..c014759b922c 100644 --- a/src/sentry/notifications/utils/links.py +++ b/src/sentry/notifications/utils/links.py @@ -8,11 +8,8 @@ from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project -from sentry.models.rule import Rule -from sentry.notifications.utils.rules import ( - get_key_from_rule_data, - split_rules_by_rule_workflow_id, -) +from sentry.notifications.types import NotificationRule +from sentry.notifications.utils.rules import split_rules_by_rule_workflow_id from sentry.types.rules import NotificationRuleDetails """ @@ -106,7 +103,10 @@ def get_issue_replay_link(group: Group, sentry_query_params: str = "") -> str: def get_rules( - rules: Sequence[Rule], organization: Organization, project: Project, type_id: int | None = None + rules: Sequence[NotificationRule], + organization: Organization, + project: Project, + type_id: int | None = None, ) -> list[NotificationRuleDetails]: rules_and_workflows = split_rules_by_rule_workflow_id(rules) @@ -115,21 +115,13 @@ def get_rules( ) + get_rules_with_legacy_ids(rules_and_workflows.rules, organization, project) -def _fetch_rule_id(rule: Rule, type_id: int | None = None) -> int: - # Try to fetch the legacy rule id, if it fails, return the rule id - # This allows us to support both legacy and new rule ids - try: - return int(get_key_from_rule_data(rule, "legacy_rule_id")) - except AssertionError: - return rule.id - - def get_rules_with_legacy_ids( - rules: Sequence[Rule], organization: Organization, project: Project + rules: Sequence[NotificationRule], organization: Organization, project: Project ) -> list[NotificationRuleDetails]: rules_with_legacy_ids = [] for rule in rules: - rule_id = _fetch_rule_id(rule) + rule_id = rule.legacy_rule_id + assert rule_id is not None rules_with_legacy_ids.append( NotificationRuleDetails( rule_id, @@ -141,16 +133,17 @@ def get_rules_with_legacy_ids( def get_workflow_links( - rules: Sequence[Rule], organization: Organization, project: Project + rules: Sequence[NotificationRule], organization: Organization, project: Project ) -> list[NotificationRuleDetails]: workflow_links = [] for rule in rules: - workflow_id = get_key_from_rule_data(rule, "workflow_id") + workflow_id = rule.workflow_id + assert workflow_id is not None workflow_links.append( NotificationRuleDetails( - int(workflow_id), + workflow_id, rule.label, - create_link_to_workflow(organization.slug, workflow_id), + create_link_to_workflow(organization.slug, str(workflow_id)), ) ) return workflow_links diff --git a/src/sentry/notifications/utils/rules.py b/src/sentry/notifications/utils/rules.py index 39de8a922488..93e39dba09fc 100644 --- a/src/sentry/notifications/utils/rules.py +++ b/src/sentry/notifications/utils/rules.py @@ -2,24 +2,20 @@ from dataclasses import dataclass from typing import Literal -from sentry.models.rule import Rule +from sentry.notifications.types import NotificationRule RuleIdType = Literal["workflow_id", "legacy_rule_id"] -def get_key_from_rule_data(rule: Rule, key: str) -> str: - value = rule.data.get("actions", [{}])[0].get(key) - assert value is not None - return value - - @dataclass class RulesAndWorkflows: - rules: list[Rule] - workflow_rules: list[Rule] # workflows as fake Rules + rules: list[NotificationRule] + workflow_rules: list[NotificationRule] -def split_rules_by_rule_workflow_id(rules: Sequence[Rule]) -> RulesAndWorkflows: +def split_rules_by_rule_workflow_id( + rules: Sequence[NotificationRule], +) -> RulesAndWorkflows: parsed_rules = [] workflow_rules = [] for rule in rules: @@ -33,7 +29,7 @@ def split_rules_by_rule_workflow_id(rules: Sequence[Rule]) -> RulesAndWorkflows: def get_rule_or_workflow_id( - rule: Rule, *, prefer: RuleIdType = "legacy_rule_id" + rule: NotificationRule, *, prefer: RuleIdType = "legacy_rule_id" ) -> tuple[RuleIdType, str]: """ Returns which id the rule data carries, and its value. When both a legacy @@ -45,8 +41,7 @@ def get_rule_or_workflow_id( else ("legacy_rule_id", "workflow_id") ) for key in keys: - try: - return (key, get_key_from_rule_data(rule, key)) - except AssertionError: - pass - return ("legacy_rule_id", str(rule.id)) + value = rule.workflow_id if key == "workflow_id" else rule.legacy_rule_id + if value is not None: + return (key, str(value)) + raise AssertionError("NotificationRule must have a workflow or legacy rule ID") diff --git a/src/sentry/rules/actions/base.py b/src/sentry/rules/actions/base.py index 4ac119e9c094..0b6ad05ecc30 100644 --- a/src/sentry/rules/actions/base.py +++ b/src/sentry/rules/actions/base.py @@ -2,16 +2,19 @@ import abc import logging -from collections.abc import Generator +from collections.abc import Generator, MutableMapping +from typing import Any -from sentry.models.rule import Rule +from sentry.notifications.types import NotificationRule from sentry.rules.base import CallbackFuture, RuleBase from sentry.services.eventstore.models import GroupEvent logger = logging.getLogger("sentry.rules") -def instantiate_action(rule: Rule, action): +def instantiate_action( + rule: NotificationRule, action: MutableMapping[str, Any] +) -> EventAction | None: from sentry.rules import rules action_id = action["id"] diff --git a/src/sentry/rules/actions/integrations/base.py b/src/sentry/rules/actions/integrations/base.py index b88b38de1b42..ee0a0aadbd89 100644 --- a/src/sentry/rules/actions/integrations/base.py +++ b/src/sentry/rules/actions/integrations/base.py @@ -15,8 +15,7 @@ ) from sentry.mail.analytics import EmailNotificationSent from sentry.models.organization import OrganizationStatus -from sentry.models.rule import Rule -from sentry.notifications.types import RuleFuture +from sentry.notifications.types import NotificationRule, RuleFuture from sentry.rules.actions import EventAction from sentry.rules.base import CallbackFuture from sentry.services.eventstore.models import GroupEvent @@ -110,7 +109,7 @@ def record_notification_sent( self, event: GroupEvent, external_id: str, - rule: Rule | None = None, + rule: NotificationRule | None = None, notification_uuid: str | None = None, ) -> None: from sentry.integrations.discord.analytics import DiscordIntegrationNotificationSent @@ -129,6 +128,10 @@ def record_notification_sent( "slack": SlackIntegrationNotificationSent, "email": EmailNotificationSent, } + alert_id = None + if rule is not None: + alert_id = rule.broken_rule_id + try: if event_class := PROVIDER_TO_EVENT_CLASS.get(self.provider): analytics.record( @@ -137,7 +140,7 @@ def record_notification_sent( project_id=event.project_id, group_id=event.group_id, notification_uuid=notification_uuid if notification_uuid else "", - alert_id=rule.id if rule else None, + alert_id=alert_id, category="issue_alert", ) ) @@ -148,7 +151,7 @@ def record_notification_sent( analytics.record( AlertSentEvent( provider=self.provider, - alert_id=rule.id if rule else "", + alert_id=alert_id if alert_id is not None else "", alert_type="issue_alert", organization_id=event.organization.id, project_id=event.project_id, diff --git a/src/sentry/rules/actions/integrations/create_ticket/base.py b/src/sentry/rules/actions/integrations/create_ticket/base.py index 5a5a630a2f3b..a303de710311 100644 --- a/src/sentry/rules/actions/integrations/create_ticket/base.py +++ b/src/sentry/rules/actions/integrations/create_ticket/base.py @@ -5,7 +5,7 @@ from typing import Any from sentry.integrations.services.integration import RpcIntegration -from sentry.models.rule import Rule +from sentry.notifications.types import NotificationRule from sentry.rules.actions.integrations.base import IntegrationEventAction from sentry.rules.actions.integrations.create_ticket.form import IntegrationNotifyServiceForm from sentry.rules.actions.integrations.create_ticket.utils import create_issue @@ -18,7 +18,6 @@ class TicketEventAction(IntegrationEventAction, abc.ABC): integration_key = "integration" link: str | None - rule: Rule def __init__(self, *args: Any, **kwargs: Any) -> None: super(IntegrationEventAction, self).__init__(*args, **kwargs) @@ -47,6 +46,12 @@ def render_label(self) -> str: label: str = self.label.format(integration=self.get_integration_name()) return label + @property + def rule_context(self) -> NotificationRule: + if self.rule is None: + raise TypeError("Ticket delivery requires a rule context") + return self.rule + @property @abc.abstractmethod def ticket_type(self) -> str: diff --git a/src/sentry/rules/actions/integrations/create_ticket/utils.py b/src/sentry/rules/actions/integrations/create_ticket/utils.py index d0f385a73f9b..5a2345edae54 100644 --- a/src/sentry/rules/actions/integrations/create_ticket/utils.py +++ b/src/sentry/rules/actions/integrations/create_ticket/utils.py @@ -138,7 +138,7 @@ def create_issue(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: organization = event.group.project.organization for future in futures: - rule_id = future.rule.id + action_id = future.rule.broken_rule_id data: dict[str, Any] = future.kwargs["data"] provider = future.kwargs.get("provider") integration_id = future.kwargs.get("integration_id") @@ -146,8 +146,7 @@ def create_issue(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: # If we invoked this handler from the notification action, we need to replace the rule_id with the legacy_rule_id, so we link notifications correctly # In the Notification Action, we store the rule_id in the action_id field - action_id = rule_id - rule_id = data.get("legacy_rule_id", rule_id) + legacy_rule_id = future.rule.legacy_rule_id integration = integration_service.get_integration( integration_id=integration_id, @@ -166,13 +165,16 @@ def create_issue(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: ) data["title"] = installation.get_group_title(event.group, event) - workflow_id = data.get("workflow_id") + workflow_id = future.rule.workflow_id if workflow_id is not None: data["description"] = build_description_workflow_engine_ui( event, workflow_id, installation, generate_footer ) else: - data["description"] = build_description(event, rule_id, installation, generate_footer) + assert isinstance(legacy_rule_id, int) + data["description"] = build_description( + event, legacy_rule_id, installation, generate_footer + ) if data.get("dynamic_form_fields"): del data["dynamic_form_fields"] @@ -182,7 +184,7 @@ def create_issue(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: "%s.rule_trigger.link_already_exists", provider, extra={ - "rule_id": rule_id, + "rule_id": legacy_rule_id, "project_id": event.group.project.id, "group_id": event.group.id, }, @@ -195,7 +197,7 @@ def create_issue(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: ).capture() as lifecycle: lifecycle.add_extra("provider", provider) lifecycle.add_extra("integration_id", integration.id) - lifecycle.add_extra("rule_id", rule_id) + lifecycle.add_extra("rule_id", legacy_rule_id) if action_id: lifecycle.add_extra("action_id", action_id) diff --git a/src/sentry/rules/actions/notify_event.py b/src/sentry/rules/actions/notify_event.py index 650a81ab67aa..dca5b998e109 100644 --- a/src/sentry/rules/actions/notify_event.py +++ b/src/sentry/rules/actions/notify_event.py @@ -23,10 +23,14 @@ class NotifyEventAction(EventAction): def after( self, event: GroupEvent, notification_uuid: str | None = None ) -> Generator[CallbackFuture]: + rule_id = None + if self.rule is not None: + rule_id = self.rule.broken_rule_id + logger.info( "notify_event.legacy_plugin_action_noop", extra={ - "rule_id": self.rule.id if self.rule else None, + "rule_id": rule_id, "event_id": event.event_id, "action": self.id, }, diff --git a/src/sentry/rules/base.py b/src/sentry/rules/base.py index 97ecbdf94aad..8137ae93eaa5 100644 --- a/src/sentry/rules/base.py +++ b/src/sentry/rules/base.py @@ -2,19 +2,15 @@ import abc import logging -from collections import namedtuple from collections.abc import Callable, MutableMapping, Sequence -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any, ClassVar, NamedTuple from django import forms from sentry.models.project import Project -from sentry.notifications.types import RuleFuture +from sentry.notifications.types import NotificationRule, RuleFuture from sentry.services.eventstore.models import GroupEvent -if TYPE_CHECKING: - from sentry.models.rule import Rule - """ Rules apply either before an event gets stored, or immediately after. @@ -45,10 +41,14 @@ - [ACTION:I want to group events when] [RULE:an event matches [FORM]] """ + # Encapsulates a reference to the callback, including arguments. The `key` # attribute may be specifically used to key the callbacks when they are # collated during rule processing. -CallbackFuture = namedtuple("CallbackFuture", ["callback", "kwargs", "key"]) +class CallbackFuture(NamedTuple): + callback: Callable[[GroupEvent, Sequence[RuleFuture]], None] + kwargs: dict[str, Any] + key: str | None class RuleBase(abc.ABC): @@ -58,7 +58,7 @@ def __init__( self, project: Project, data: MutableMapping[str, Any] | None = None, - rule: Rule | None = None, + rule: NotificationRule | None = None, ) -> None: self.project = project self.data = data or {} diff --git a/src/sentry/rules/processing/processor.py b/src/sentry/rules/processing/processor.py index ce43c1f8b625..ca978af417eb 100644 --- a/src/sentry/rules/processing/processor.py +++ b/src/sentry/rules/processing/processor.py @@ -4,8 +4,7 @@ from collections.abc import Callable, Mapping, MutableMapping, Sequence from typing import Any -from sentry.models.rule import Rule -from sentry.notifications.types import RuleFuture +from sentry.notifications.types import NotificationRule, RuleFuture from sentry.rules import rules from sentry.rules.actions.base import instantiate_action from sentry.services.eventstore.models import GroupEvent @@ -64,14 +63,16 @@ def split_conditions_and_filters( def activate_downstream_actions( - rule: Rule, + rule: NotificationRule, event: GroupEvent, notification_uuid: str | None = None, ) -> MutableMapping[ - str, tuple[Callable[[GroupEvent, Sequence[RuleFuture]], None], list[RuleFuture]] + str | Callable[[GroupEvent, Sequence[RuleFuture]], None], + tuple[Callable[[GroupEvent, Sequence[RuleFuture]], None], list[RuleFuture]], ]: grouped_futures: MutableMapping[ - str, tuple[Callable[[GroupEvent, Sequence[RuleFuture]], None], list[RuleFuture]] + str | Callable[[GroupEvent, Sequence[RuleFuture]], None], + tuple[Callable[[GroupEvent, Sequence[RuleFuture]], None], list[RuleFuture]], ] = {} instantiated_actions = 0 diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index 059299a46842..f8742488ff2d 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -46,7 +46,6 @@ from sentry.models.organizationmapping import OrganizationMapping from sentry.models.project import Project from sentry.notifications.types import RuleFuture -from sentry.notifications.utils.rules import get_rule_or_workflow_id from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent from sentry.sentry_apps.event_types import SentryAppEventType from sentry.sentry_apps.metrics import ( @@ -808,18 +807,16 @@ def notify_sentry_app(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: # If the future comes from a rule with a UI component form in the schema, append the issue alert payload # TODO(ecosystem): We need to change this payload format after alerts create issues - id = f.rule.id - - # if we are using the new workflow engine, we need to use the legacy rule id - # Ignore test notifications - if int(id) != -1: - _, id = get_rule_or_workflow_id(f.rule) + rule_or_workflow_id = ( + f.rule.legacy_rule_id if f.rule.legacy_rule_id is not None else f.rule.workflow_id + ) + assert rule_or_workflow_id is not None settings = f.kwargs.get("schema_defined_settings") if settings: extra_kwargs["additional_payload_key"] = "issue_alert" extra_kwargs["additional_payload"] = { - "id": int(id), + "id": int(rule_or_workflow_id), "title": f.rule.label, "sentry_app_id": f.kwargs["sentry_app"].id, "settings": settings, diff --git a/src/sentry/templates/sentry/emails/_group.html b/src/sentry/templates/sentry/emails/_group.html index c8cebdc850a1..651760e1d251 100644 --- a/src/sentry/templates/sentry/emails/_group.html +++ b/src/sentry/templates/sentry/emails/_group.html @@ -6,7 +6,7 @@