diff --git a/src/sentry/incidents/endpoints/serializers/workflow_engine_data_condition.py b/src/sentry/incidents/endpoints/serializers/workflow_engine_data_condition.py index c0ff272d242c6d..c2f5e1078e625a 100644 --- a/src/sentry/incidents/endpoints/serializers/workflow_engine_data_condition.py +++ b/src/sentry/incidents/endpoints/serializers/workflow_engine_data_condition.py @@ -78,15 +78,23 @@ def get_attrs( for dcg_ids in detector_to_dcg_ids.values(): all_dcg_ids.update(dcg_ids) - # Map (condition_group_id, comparison) → action-filter DC exists in that DCG - # We need: for a given detector's DCGs + priority level → matching DCG IDs - # NOTE: Assumes DataConditions are limited to what would be dual written. - dcg_comparison_pairs: dict[int, set[int | float]] = defaultdict(set) + # Per action-filter DCG, the priority levels it gates on, and whether it gates on priority + # at all. A DCG with no priority gate fires for any priority, so its actions attach to every trigger. + # DCG_id -> priority number (e.g 75 is HIGH) + # E.g. {1: {75, 50}, 2: {75}} + dcg_priority_comparisons_mapping: dict[int, set[int | float]] = defaultdict(set) + + # A set of DCG ids that have a issue priority condition + dcgs_with_priority_condition: set[int] = set() for dc in DataCondition.objects.filter(condition_group__in=all_dcg_ids): + if dc.type != Condition.ISSUE_PRIORITY_GREATER_OR_EQUAL: + continue + dcgs_with_priority_condition.add(dc.condition_group_id) + # Only collect numeric comparison values; non-numeric values (e.g. dicts # from anomaly detection conditions) don't match condition_result levels. if isinstance(dc.comparison, (int, float)): - dcg_comparison_pairs[dc.condition_group_id].add(dc.comparison) + dcg_priority_comparisons_mapping[dc.condition_group_id].add(dc.comparison) # Bulk-fetch all DCG → action mappings dcg_to_action_ids: dict[int, list[int]] = defaultdict(list) @@ -126,11 +134,12 @@ def get_attrs( detector_id = detector.id if detector else None trigger_dcg_ids = detector_to_dcg_ids.get(detector_id, set()) if detector_id else set() - # Find DCGs in this detector's workflows that match the trigger's priority level + # Find DCGs in this detector's workflows that match the trigger's priority level, or has no priority gate at all. matching_dcg_ids = [ dcg_id for dcg_id in trigger_dcg_ids - if trigger.condition_result in dcg_comparison_pairs.get(dcg_id, set()) + if trigger.condition_result in dcg_priority_comparisons_mapping.get(dcg_id, set()) + or dcg_id not in dcgs_with_priority_condition ] # Collect actions from those DCGs diff --git a/tests/sentry/incidents/serializers/test_workflow_engine_data_condition.py b/tests/sentry/incidents/serializers/test_workflow_engine_data_condition.py index 2a92833242a1ab..a9ff4aef9ab1fa 100644 --- a/tests/sentry/incidents/serializers/test_workflow_engine_data_condition.py +++ b/tests/sentry/incidents/serializers/test_workflow_engine_data_condition.py @@ -15,7 +15,12 @@ migrate_metric_data_conditions, migrate_resolve_threshold_data_condition, ) -from sentry.workflow_engine.models import DataCondition, WorkflowDataConditionGroup +from sentry.workflow_engine.models import ( + Action, + DataCondition, + DataConditionGroup, + WorkflowDataConditionGroup, +) from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.types import DetectorPriorityLevel from tests.sentry.incidents.serializers.test_workflow_engine_base import ( @@ -100,6 +105,63 @@ def test_multiple_actions(self) -> None: expected_trigger["actions"] = expected_actions assert serialized_data_condition == expected_trigger + def test_action_filter_without_priority_condition(self) -> None: + """ + A natively-created connected alert (workflow) seeds its action filter with no conditions + (see automationBuilderContext.tsx) if they don't include an Issue Priority WHEN clause. + Priority-less action filter should fire for any priority. Previously these actions were dropped, + producing an empty `triggers[].actions` in the metric_alert webhook payload + """ + sentry_app = self.create_sentry_app( + organization=self.organization, + published=True, + verify_install=False, + name="Super Awesome App", + schema={"elements": [self.create_alert_rule_action_schema()]}, + ) + self.create_sentry_app_installation( + slug=sentry_app.slug, organization=self.organization, user=self.user + ) + settings = [{"name": "title", "value": "An alert"}] + sentry_app_action = self.create_action( + type=Action.Type.SENTRY_APP, + config={ + "target_type": AlertRuleTriggerAction.TargetType.SENTRY_APP, + "target_identifier": str(sentry_app.id), + "target_display": None, + }, + data={"settings": settings}, + ) + + # Connect a native workflow to the existing detector whose action filter has no + # priority (or any) condition, mirroring how the new Monitors UI creates connected alerts. + workflow = self.create_workflow(organization=self.organization) + self.create_detector_workflow(detector=self.detector, workflow=workflow) + action_filter = self.create_data_condition_group( + organization=self.organization, logic_type=DataConditionGroup.Type.ALL + ) + self.create_workflow_data_condition_group(workflow=workflow, condition_group=action_filter) + self.create_data_condition_group_action( + action=sentry_app_action, condition_group=action_filter + ) + + serialized_data_condition = serialize( + self.critical_detector_trigger, + self.user, + WorkflowEngineDataConditionSerializer(), + ) + + serialized_sentry_app_actions = [ + action + for action in serialized_data_condition["actions"] + if action["sentryAppId"] == sentry_app.id + ] + assert len(serialized_sentry_app_actions) == 1 + serialized_sentry_app_action = serialized_sentry_app_actions[0] + assert serialized_sentry_app_action["type"] == "sentry_app" + assert serialized_sentry_app_action["targetType"] == "sentry_app" + assert serialized_sentry_app_action["settings"] == settings + def test_comparison_delta(self) -> None: comparison_delta_rule = self.create_alert_rule(comparison_delta=60) comparison_delta_trigger = self.create_alert_rule_trigger( @@ -205,6 +267,10 @@ def test_anomaly_detection_with_workflow_actions(self) -> None: assert serialized["thresholdType"] == AlertRuleThresholdType.ABOVE_AND_BELOW.value assert serialized["alertThreshold"] == 0 assert serialized["resolveThreshold"] is None + # The non-numeric ANOMALY_DETECTION condition is skipped during matching without dropping + # the DCG's action (which still matches via the priority condition) or duplicating it. + action_ids = [action["id"] for action in serialized["actions"]] + assert action_ids == [str(trigger_action.id)] def test_multiple_rules(self) -> None: # create another comprehensive alert rule in the DB