Skip to content

Commit fa1e9a8

Browse files
iamrajjoshiandrewshie-sentry
authored andcommitted
🔧 fix(aci): deduplicate actions before triggering (#97444)
1 parent ad4effd commit fa1e9a8

File tree

10 files changed

+640
-78
lines changed

10 files changed

+640
-78
lines changed

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,6 @@ module = [
642642
"sentry.web.frontend.cli",
643643
"sentry.web.frontend.csv",
644644
"sentry.web.frontend.mixins.*",
645-
"sentry.workflow_engine.handlers.action.*",
646645
"sentry.workflow_engine.handlers.condition.*",
647646
"sentry.workflow_engine.migrations.*",
648647
"sentry.workflow_engine.processors.*",

src/sentry/workflow_engine/handlers/action/__init__.py

Whitespace-only changes.

src/sentry/workflow_engine/handlers/action/notification/plugin_handler.py

Lines changed: 0 additions & 36 deletions
This file was deleted.

src/sentry/workflow_engine/handlers/action/notification/webhook_handler.py

Lines changed: 0 additions & 36 deletions
This file was deleted.

src/sentry/workflow_engine/models/action.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ def trigger(self, event_data: WorkflowEventData, detector: Detector) -> None:
110110
},
111111
)
112112

113+
def get_dedup_key(self) -> str:
114+
key_parts = [self.type]
115+
116+
if self.integration_id:
117+
key_parts.append(str(self.integration_id))
118+
119+
if self.config:
120+
config = self.config.copy()
121+
config.pop("target_display", None)
122+
key_parts.append(str(config))
123+
124+
if self.data:
125+
data = self.data.copy()
126+
if "dynamic_form_fields" in data:
127+
data = data["dynamic_form_fields"]
128+
129+
key_parts.append(str(data))
130+
131+
return ":".join(key_parts)
132+
113133

114134
@receiver(pre_save, sender=Action)
115135
def enforce_config_schema(sender, instance: Action, **kwargs):

src/sentry/workflow_engine/processors/action.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,22 @@ def update_workflow_action_group_statuses(
121121
)
122122

123123

124+
def deduplicate_actions(
125+
actions_queryset: BaseQuerySet[Action], # decorated with the workflow_ids
126+
) -> BaseQuerySet[Action]:
127+
"""
128+
Deduplicates actions based on their handler's dedup_key method.
129+
Returns a de-duplicated queryset of actions.
130+
"""
131+
dedup_key_to_action_id: dict[str, int] = {}
132+
133+
for action in actions_queryset:
134+
dedup_key = action.get_dedup_key()
135+
dedup_key_to_action_id[dedup_key] = action.id
136+
137+
return actions_queryset.filter(id__in=dedup_key_to_action_id.values())
138+
139+
124140
def filter_recently_fired_workflow_actions(
125141
filtered_action_groups: set[DataConditionGroup], event_data: WorkflowEventData
126142
) -> BaseQuerySet[Action]:
@@ -158,16 +174,20 @@ def filter_recently_fired_workflow_actions(
158174
)
159175
update_workflow_action_group_statuses(now, statuses_to_update, missing_statuses)
160176

177+
actions_queryset = Action.objects.filter(id__in=list(action_to_workflow_ids.keys()))
178+
161179
# annotate actions with workflow_id they are firing for (deduped)
162180
workflow_id_cases = [
163181
When(id=action_id, then=Value(workflow_id))
164182
for action_id, workflow_id in action_to_workflow_ids.items()
165183
]
166184

167-
return Action.objects.filter(id__in=list(action_to_workflow_ids.keys())).annotate(
185+
decorated_actions = actions_queryset.annotate(
168186
workflow_id=Case(*workflow_id_cases, output_field=models.IntegerField()),
169187
)
170188

189+
return deduplicate_actions(decorated_actions)
190+
171191

172192
def get_available_action_integrations_for_org(organization: Organization) -> list[RpcIntegration]:
173193
providers = [

tests/sentry/incidents/test_metric_issue_post_process.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from sentry.issues.status_change_consumer import update_status
99
from sentry.issues.status_change_message import StatusChangeMessage
1010
from sentry.models.group import Group
11+
from sentry.notifications.models.notificationaction import ActionTarget
1112
from sentry.tasks.post_process import post_process_group
1213
from sentry.testutils.helpers import with_feature
1314
from sentry.testutils.helpers.features import Feature
@@ -57,7 +58,16 @@ def create_metric_issue_workflow(self, detector: Detector):
5758
type=Condition.ISSUE_PRIORITY_DEESCALATING,
5859
condition_group=critical_dcg,
5960
)
60-
critical_action = self.create_action()
61+
62+
critical_action = self.create_action(
63+
integration_id=self.integration.id,
64+
config={
65+
"target_type": ActionTarget.SPECIFIC,
66+
"target_identifier": "channel-123",
67+
"target_display": "Test Channel",
68+
},
69+
)
70+
6171
self.create_data_condition_group_action(critical_action, critical_dcg)
6272

6373
warning_dcg = self.create_data_condition_group(organization=self.organization)
@@ -75,8 +85,15 @@ def create_metric_issue_workflow(self, detector: Detector):
7585
type=Condition.ISSUE_PRIORITY_DEESCALATING,
7686
condition_group=warning_dcg,
7787
)
88+
warning_action = self.create_action(
89+
integration_id=self.integration.id,
90+
config={
91+
"target_type": ActionTarget.SPECIFIC,
92+
"target_identifier": "channel-456",
93+
"target_display": "Test Channel",
94+
},
95+
)
7896

79-
warning_action = self.create_action()
8097
self.create_data_condition_group_action(warning_action, warning_dcg)
8198

8299
return (
@@ -141,6 +158,30 @@ def test_escalation(self, mock_trigger: MagicMock) -> None:
141158
self.call_post_process_group(occurrence)
142159
assert mock_trigger.call_count == 2 # warning + critical actions
143160

161+
def test_escalation_with_deduped_actions(self, mock_trigger: MagicMock) -> None:
162+
163+
# make the warning action same as the critical action
164+
self.warning_action.config = self.critical_action.config
165+
self.warning_action.save()
166+
167+
value = self.warning_detector_trigger.comparison + 1
168+
data_packet = self.create_subscription_packet(value)
169+
occurrence = self.process_packet_and_return_result(data_packet)
170+
assert isinstance(occurrence, IssueOccurrence)
171+
occurrence.save()
172+
self.call_post_process_group(occurrence)
173+
assert mock_trigger.call_count == 1 # just warning action
174+
175+
mock_trigger.reset_mock()
176+
177+
value = self.critical_detector_trigger.comparison + 1
178+
data_packet = self.create_subscription_packet(value, 1000)
179+
occurrence = self.process_packet_and_return_result(data_packet)
180+
assert isinstance(occurrence, IssueOccurrence)
181+
occurrence.save()
182+
self.call_post_process_group(occurrence)
183+
assert mock_trigger.call_count == 1 # just warning action (because we deduped the actions)
184+
144185
def test_deescalation(self, mock_trigger: MagicMock) -> None:
145186
value = self.critical_detector_trigger.comparison + 1
146187
data_packet = self.create_subscription_packet(value)

tests/sentry/workflow_engine/processors/test_action.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ def test_multiple_workflows(self) -> None:
7575
workflow=workflow, action=action_2, group=self.group
7676
)
7777

78-
_, action_3 = self.create_workflow_action(workflow=workflow)
78+
action_3 = self.create_action(type=Action.Type.PLUGIN)
79+
self.create_workflow_action(workflow=workflow, action=action_3)
80+
7981
status_3 = WorkflowActionGroupStatus.objects.create(
8082
workflow=workflow, action=action_3, group=self.group
8183
)

0 commit comments

Comments
 (0)