From b70208148fd8ac44fe6e18f46f3762569e4caaad Mon Sep 17 00:00:00 2001 From: Josh Callender <1569818+saponifi3d@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:46:43 -0700 Subject: [PATCH 1/8] Update the WorkflowEventData to allow the event to also be an 'Activity' --- src/sentry/workflow_engine/types.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/sentry/workflow_engine/types.py b/src/sentry/workflow_engine/types.py index fb9ab50048ab21..dd981a4f28ec6f 100644 --- a/src/sentry/workflow_engine/types.py +++ b/src/sentry/workflow_engine/types.py @@ -12,6 +12,7 @@ from sentry.eventstream.base import GroupState from sentry.issues.issue_occurrence import IssueOccurrence from sentry.issues.status_change_message import StatusChangeMessage + from sentry.models.activity import Activity from sentry.models.environment import Environment from sentry.snuba.models import SnubaQueryEventType from sentry.workflow_engine.endpoints.validators.base import BaseDetectorTypeValidator @@ -59,7 +60,14 @@ class DetectorEvaluationResult: @dataclass(frozen=True) class WorkflowEventData: - event: GroupEvent + """ + A WorkflowEventData instance is created by the issue platform in `post_process` after a detectors issue occurrence has been processed. + + This class contains data the event from the issue platform, and additional information about the `Job` that it's running in. + TODO - @saponifi3d - add additional information about how this is used in `process_workflows` + """ + + event: GroupEvent | Activity group_state: GroupState | None = None has_reappeared: bool | None = None has_escalated: bool | None = None @@ -68,6 +76,13 @@ class WorkflowEventData: class ActionHandler: + """ + This is the abstract base class for an action handler. It's used to define + the interface for actions, ensuring the `execute` method is implemented. + + `execute` - This method invokes the action with the data for notification templates or for future implementation ideas (e.g., AutoFix). + """ + config_schema: ClassVar[dict[str, Any]] data_schema: ClassVar[dict[str, Any]] From 32f1e1552d7b436c70fb9c0d9d4e7c037c917513 Mon Sep 17 00:00:00 2001 From: Josh Callender <1569818+saponifi3d@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:38:48 -0700 Subject: [PATCH 2/8] add a registry to the activity model to allow for hooks when the activity is created. --- src/sentry/models/activity.py | 11 ++++++++++- tests/sentry/models/test_activity.py | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/sentry/models/activity.py b/src/sentry/models/activity.py index 363c6cd7dd7f1a..a3a56fc9a051d4 100644 --- a/src/sentry/models/activity.py +++ b/src/sentry/models/activity.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar @@ -27,6 +27,7 @@ from sentry.tasks import activity from sentry.types.activity import CHOICES, STATUS_CHANGE_ACTIVITY_TYPES, ActivityType from sentry.types.group import PriorityLevel +from sentry.utils.registry import Registry if TYPE_CHECKING: from sentry.models.group import Group @@ -98,9 +99,13 @@ def create_group_activity( if user_id is not None: activity_args["user_id"] = user_id activity = self.create(**activity_args) + if send_notification: activity.send_notification() + for handler in activity_creation_registry.registrations.values(): + handler(activity) + return activity @@ -218,3 +223,7 @@ class ActivityIntegration(Enum): MSTEAMS = "msteams" DISCORD = "discord" SUSPECT_COMMITTER = "suspectCommitter" + + +ActivityCreationHandler = Callable[[Activity], None] +activity_creation_registry = Registry[ActivityCreationHandler](enable_reverse_lookup=False) diff --git a/tests/sentry/models/test_activity.py b/tests/sentry/models/test_activity.py index 61527f1fb618d6..0315ed3e3ff5ad 100644 --- a/tests/sentry/models/test_activity.py +++ b/tests/sentry/models/test_activity.py @@ -1,4 +1,5 @@ import logging +from unittest import mock from unittest.mock import MagicMock, patch from sentry.event_manager import EventManager @@ -344,3 +345,25 @@ def test_skips_status_change_notifications_if_disabled( ) mock_send_activity_notifications.assert_not_called() + + +class TestActivityCreationHandlers(TestCase): + def test_create_group_activity(self): + project = self.create_project(name="test_create_group_activity") + group = self.create_group(project) + user = self.create_user() + + with mock.patch("sentry.models.activity.activity_creation_registry") as mock_registry: + mock_handler = mock.Mock() + mock_registry.registrations = {"test_handler": mock_handler} + + activity = Activity.objects.create_group_activity( + group=group, + type=ActivityType.SET_UNRESOLVED, + user=user, + data=None, + send_notification=False, + ) + + assert mock_handler.called + assert mock_handler.call_args[0][0] == activity From 1d17bdf5153cfd7b67097b0f93585b2de0f0183b Mon Sep 17 00:00:00 2001 From: Josh Callender <1569818+saponifi3d@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:06:41 -0700 Subject: [PATCH 3/8] setup initial handler for process_workflows --- src/sentry/workflow_engine/processors/workflow.py | 14 ++++++++++++++ .../workflow_engine/processors/test_workflow.py | 11 +++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index 051ac17d2d56e5..45523db84c30f0 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -8,6 +8,7 @@ from sentry import buffer, features from sentry.eventstore.models import GroupEvent +from sentry.models.activity import Activity, activity_creation_registry from sentry.models.environment import Environment from sentry.utils import json from sentry.workflow_engine.models import ( @@ -311,3 +312,16 @@ def process_workflows(event_data: WorkflowEventData) -> set[Workflow]: metrics_incr("process_workflows.fired_actions") return triggered_workflows + + +@activity_creation_registry.register("workflow_engine:process_workflows") +def handle_activity_creation(activity: Activity) -> None: + workflow_event_data = WorkflowEventData( + event=activity, + has_reappeared=None, + has_escalated=None, + workflow_id=None, + workflow_env=None, + ) + + process_workflows(workflow_event_data) diff --git a/tests/sentry/workflow_engine/processors/test_workflow.py b/tests/sentry/workflow_engine/processors/test_workflow.py index 10b79cba05f6b8..50d098c031ac0b 100644 --- a/tests/sentry/workflow_engine/processors/test_workflow.py +++ b/tests/sentry/workflow_engine/processors/test_workflow.py @@ -8,6 +8,7 @@ from sentry.grouping.grouptype import ErrorGroupType from sentry.models.environment import Environment from sentry.models.rule import Rule +from sentry.testutils.cases import TestCase from sentry.testutils.factories import Factories from sentry.testutils.helpers import with_feature from sentry.testutils.helpers.datetime import before_now, freeze_time @@ -30,6 +31,7 @@ enqueue_workflow, evaluate_workflow_triggers, evaluate_workflows_action_filters, + handle_activity_creation, process_workflows, ) from sentry.workflow_engine.types import ActionHandler, WorkflowEventData @@ -745,3 +747,12 @@ def test_delete_workflow__no_workflow_triggers(self): workflow_id = self.workflow.id delete_workflow(self.workflow) assert not Workflow.objects.filter(id=workflow_id).exists() + + +class TestHandleActivityCreation(TestCase): + def test(self): + with patch( + "sentry.workflow_engine.processors.workflow.process_workflows" + ) as mock_process_workflows: + handle_activity_creation(self.activity) + mock_process_workflows.assert_called_once_with(WorkflowEventData(event=self.activity)) From 29475728c29ec4be202dded192e55d1308de32da Mon Sep 17 00:00:00 2001 From: Josh Callender <1569818+saponifi3d@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:42:01 -0700 Subject: [PATCH 4/8] Add hook for a workflow activity event creation - Start creating the WorkflowEventData fro `process_workflows`. --- .../workflow_engine/processors/workflow.py | 16 +++++++--- .../processors/test_workflow.py | 31 +++++++++++++++++-- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index 45523db84c30f0..3936a121140948 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -10,6 +10,8 @@ from sentry.eventstore.models import GroupEvent from sentry.models.activity import Activity, activity_creation_registry from sentry.models.environment import Environment +from sentry.types.activity import ActivityType +from sentry.types.group import GroupSubStatus from sentry.utils import json from sentry.workflow_engine.models import ( Action, @@ -314,14 +316,20 @@ def process_workflows(event_data: WorkflowEventData) -> set[Workflow]: return triggered_workflows +supported_activity_types = [ActivityType.SET_RESOLVED.value] + + @activity_creation_registry.register("workflow_engine:process_workflows") def handle_activity_creation(activity: Activity) -> None: + if activity.type not in supported_activity_types: + # TODO - Only support activity types that we have triggers for + # For example, we don't want to process a workflow activity for DEPLOY + return + workflow_event_data = WorkflowEventData( event=activity, - has_reappeared=None, - has_escalated=None, - workflow_id=None, - workflow_env=None, + has_reappeared=activity.group.substatus == GroupSubStatus.REGRESSED, + has_escalated=activity.group.substatus == GroupSubStatus.ESCALATING, ) process_workflows(workflow_event_data) diff --git a/tests/sentry/workflow_engine/processors/test_workflow.py b/tests/sentry/workflow_engine/processors/test_workflow.py index 50d098c031ac0b..c2ab2b131cfa3b 100644 --- a/tests/sentry/workflow_engine/processors/test_workflow.py +++ b/tests/sentry/workflow_engine/processors/test_workflow.py @@ -6,6 +6,7 @@ from sentry import buffer from sentry.eventstream.base import GroupState from sentry.grouping.grouptype import ErrorGroupType +from sentry.models.activity import Activity from sentry.models.environment import Environment from sentry.models.rule import Rule from sentry.testutils.cases import TestCase @@ -14,6 +15,7 @@ from sentry.testutils.helpers.datetime import before_now, freeze_time from sentry.testutils.helpers.redis import mock_redis_buffer from sentry.testutils.pytest.fixtures import django_db_all +from sentry.types.activity import ActivityType from sentry.utils import json from sentry.workflow_engine.models import ( Action, @@ -750,9 +752,32 @@ def test_delete_workflow__no_workflow_triggers(self): class TestHandleActivityCreation(TestCase): - def test(self): + def test_note(self): with patch( "sentry.workflow_engine.processors.workflow.process_workflows" ) as mock_process_workflows: - handle_activity_creation(self.activity) - mock_process_workflows.assert_called_once_with(WorkflowEventData(event=self.activity)) + activity = Activity.objects.create( + group=self.group, + project=self.project, + type=ActivityType.NOTE.value, + user_id=self.user.id, + data={}, + ) + + handle_activity_creation(activity) + assert mock_process_workflows.called is False + + def test_resolved(self): + with patch( + "sentry.workflow_engine.processors.workflow.process_workflows" + ) as mock_process_workflows: + activity = Activity.objects.create( + group=self.group, + project=self.project, + type=ActivityType.SET_RESOLVED.value, + user_id=self.user.id, + data={}, + ) + + handle_activity_creation(activity) + mock_process_workflows.assert_called_once_with(WorkflowEventData(event=activity)) From bd1234e0a49f119b97f8d033d3b78454adfbf1a0 Mon Sep 17 00:00:00 2001 From: Josh Callender <1569818+saponifi3d@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:51:24 -0700 Subject: [PATCH 5/8] add more tests, 'fixed' has_reappeared an has_escalated --- .../workflow_engine/processors/workflow.py | 12 ++- .../processors/test_workflow.py | 82 +++++++++++++------ 2 files changed, 64 insertions(+), 30 deletions(-) diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index 3936a121140948..5c96bc5389d61f 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -316,20 +316,26 @@ def process_workflows(event_data: WorkflowEventData) -> set[Workflow]: return triggered_workflows +# TODO - @saponifi3d - add support for more activity types supported_activity_types = [ActivityType.SET_RESOLVED.value] @activity_creation_registry.register("workflow_engine:process_workflows") -def handle_activity_creation(activity: Activity) -> None: +def workflow_issue_status_change(activity: Activity) -> None: if activity.type not in supported_activity_types: # TODO - Only support activity types that we have triggers for # For example, we don't want to process a workflow activity for DEPLOY return + has_reappeared = has_escalated = False + if activity.group is not None: + has_reappeared = activity.group.substatus == GroupSubStatus.REGRESSED + has_escalated = activity.group.substatus == GroupSubStatus.ESCALATING + workflow_event_data = WorkflowEventData( event=activity, - has_reappeared=activity.group.substatus == GroupSubStatus.REGRESSED, - has_escalated=activity.group.substatus == GroupSubStatus.ESCALATING, + has_reappeared=has_reappeared, + has_escalated=has_escalated, ) process_workflows(workflow_event_data) diff --git a/tests/sentry/workflow_engine/processors/test_workflow.py b/tests/sentry/workflow_engine/processors/test_workflow.py index c2ab2b131cfa3b..c2d2c2514d8e93 100644 --- a/tests/sentry/workflow_engine/processors/test_workflow.py +++ b/tests/sentry/workflow_engine/processors/test_workflow.py @@ -16,6 +16,7 @@ from sentry.testutils.helpers.redis import mock_redis_buffer from sentry.testutils.pytest.fixtures import django_db_all from sentry.types.activity import ActivityType +from sentry.types.group import GroupSubStatus from sentry.utils import json from sentry.workflow_engine.models import ( Action, @@ -33,8 +34,8 @@ enqueue_workflow, evaluate_workflow_triggers, evaluate_workflows_action_filters, - handle_activity_creation, process_workflows, + workflow_issue_status_change, ) from sentry.workflow_engine.types import ActionHandler, WorkflowEventData from tests.sentry.workflow_engine.test_base import BaseWorkflowTest @@ -751,33 +752,60 @@ def test_delete_workflow__no_workflow_triggers(self): assert not Workflow.objects.filter(id=workflow_id).exists() +@patch("sentry.workflow_engine.processors.workflow.process_workflows") class TestHandleActivityCreation(TestCase): - def test_note(self): - with patch( - "sentry.workflow_engine.processors.workflow.process_workflows" - ) as mock_process_workflows: - activity = Activity.objects.create( - group=self.group, - project=self.project, - type=ActivityType.NOTE.value, - user_id=self.user.id, - data={}, - ) + """ + Ensure the handler is configured, and we are only using support activities + """ - handle_activity_creation(activity) - assert mock_process_workflows.called is False + def test_note(self, mock_process_workflows): + activity = Activity.objects.create( + group=self.group, + project=self.project, + type=ActivityType.NOTE.value, + user_id=self.user.id, + data={}, + ) - def test_resolved(self): - with patch( - "sentry.workflow_engine.processors.workflow.process_workflows" - ) as mock_process_workflows: - activity = Activity.objects.create( - group=self.group, - project=self.project, - type=ActivityType.SET_RESOLVED.value, - user_id=self.user.id, - data={}, - ) + workflow_issue_status_change(activity) + assert mock_process_workflows.called is False + + def test_resolved(self, mock_process_workflows): + activity = Activity.objects.create( + group=self.group, + project=self.project, + type=ActivityType.SET_RESOLVED.value, + user_id=self.user.id, + data={}, + ) + + workflow_issue_status_change(activity) - handle_activity_creation(activity) - mock_process_workflows.assert_called_once_with(WorkflowEventData(event=activity)) + expected_result = WorkflowEventData( + event=activity, + has_reappeared=False, + has_escalated=False, + ) + + mock_process_workflows.assert_called_once_with(expected_result) + + def test_has_reappeared(self, mock_process_workflows): + self.group.substatus = GroupSubStatus.REGRESSED + self.group.save() + + activity = Activity.objects.create( + group=self.group, + project=self.project, + type=ActivityType.SET_RESOLVED.value, + user_id=self.user.id, + data={}, + ) + + workflow_issue_status_change(activity) + + expected_data = WorkflowEventData( + event=activity, + has_reappeared=True, + has_escalated=False, + ) + mock_process_workflows.assert_called_once_with(expected_data) From 3d26d0c8dd82fcc9a4b21740e5e6f2635a071324 Mon Sep 17 00:00:00 2001 From: Josh Callender <1569818+saponifi3d@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:57:03 -0700 Subject: [PATCH 6/8] Add some protection on event.group.priority to the data condition handler, all other workflow triggers will evaluate as expected for now --- .../handlers/condition/new_high_priority_issue_handler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry/workflow_engine/handlers/condition/new_high_priority_issue_handler.py b/src/sentry/workflow_engine/handlers/condition/new_high_priority_issue_handler.py index c637439d73ff96..061bcce0402457 100644 --- a/src/sentry/workflow_engine/handlers/condition/new_high_priority_issue_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/new_high_priority_issue_handler.py @@ -19,4 +19,5 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: if not event.project.flags.has_high_priority_alerts: return is_new - return is_new and event.group.priority == PriorityLevel.HIGH + priority = event.group.priority if event.group else 0 + return is_new and priority == PriorityLevel.HIGH From d16cb03fbdcc1cbe3b0fde94ad47b332d7d54801 Mon Sep 17 00:00:00 2001 From: Josh Callender <1569818+saponifi3d@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:32:21 -0700 Subject: [PATCH 7/8] Update remaining spots that are getting an activity, that don't know what to do with it --- .../handlers/condition/age_comparison_handler.py | 5 +++++ .../handlers/condition/assigned_to_handler.py | 5 +++++ .../handlers/condition/event_attribute_handler.py | 4 ++++ .../condition/event_created_by_detector_handler.py | 5 +++++ .../handlers/condition/event_seen_count_handler.py | 5 +++++ .../handlers/condition/issue_category_handler.py | 5 +++++ .../condition/issue_priority_deescalating_handler.py | 5 +++++ .../condition/issue_priority_greater_or_equal_handler.py | 5 +++++ .../handlers/condition/latest_adopted_release_handler.py | 3 +++ .../handlers/condition/latest_release_handler.py | 3 +++ .../workflow_engine/handlers/condition/level_handler.py | 5 +++++ .../handlers/condition/tagged_event_handler.py | 5 +++++ src/sentry/workflow_engine/processors/workflow.py | 9 +++++++-- 13 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/sentry/workflow_engine/handlers/condition/age_comparison_handler.py b/src/sentry/workflow_engine/handlers/condition/age_comparison_handler.py index 7bd650af16a17d..bb79abafdd3941 100644 --- a/src/sentry/workflow_engine/handlers/condition/age_comparison_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/age_comparison_handler.py @@ -2,6 +2,7 @@ from django.utils import timezone +from sentry.eventstore.models import GroupEvent from sentry.rules.age import AgeComparisonType, age_comparison_map from sentry.rules.filters.age_comparison import timeranges from sentry.workflow_engine.models.data_condition import Condition @@ -31,6 +32,10 @@ class AgeComparisonConditionHandler(DataConditionHandler[WorkflowEventData]): @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: event = event_data.event + if not isinstance(event, GroupEvent) or event.group is None: + # If the event is not a GroupEvent, we cannot evaluate age comparison + return False + first_seen = event.group.first_seen current_time = timezone.now() comparison_type = comparison["comparison_type"] diff --git a/src/sentry/workflow_engine/handlers/condition/assigned_to_handler.py b/src/sentry/workflow_engine/handlers/condition/assigned_to_handler.py index e2bb332dc1cf4a..1036229b9eb6cc 100644 --- a/src/sentry/workflow_engine/handlers/condition/assigned_to_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/assigned_to_handler.py @@ -45,6 +45,11 @@ def get_assignees(group: Group) -> Sequence[GroupAssignee]: def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: event = event_data.event target_type = AssigneeTargetType(comparison.get("target_type")) + + if event.group is None: + # If we don't have an event group, we cannot evaluate the assignees + return False + assignees = AssignedToConditionHandler.get_assignees(event.group) if target_type == AssigneeTargetType.UNASSIGNED: diff --git a/src/sentry/workflow_engine/handlers/condition/event_attribute_handler.py b/src/sentry/workflow_engine/handlers/condition/event_attribute_handler.py index 02e6d056c5a1b3..7739d684c279ea 100644 --- a/src/sentry/workflow_engine/handlers/condition/event_attribute_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/event_attribute_handler.py @@ -76,6 +76,10 @@ def get_attribute_values(event: GroupEvent, attribute: str) -> list[str]: @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: + if not isinstance(event_data.event, GroupEvent): + # We got a status update, so we cannot evaluate the event attribute conditions + return False + event = event_data.event attribute = comparison.get("attribute", "") attribute_values = EventAttributeConditionHandler.get_attribute_values(event, attribute) diff --git a/src/sentry/workflow_engine/handlers/condition/event_created_by_detector_handler.py b/src/sentry/workflow_engine/handlers/condition/event_created_by_detector_handler.py index a09bc50f2c13fe..6c58d9450ccc6f 100644 --- a/src/sentry/workflow_engine/handlers/condition/event_created_by_detector_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/event_created_by_detector_handler.py @@ -1,5 +1,6 @@ from typing import Any +from sentry.eventstore.models import GroupEvent from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.registry import condition_handler_registry from sentry.workflow_engine.types import DataConditionHandler, WorkflowEventData @@ -12,6 +13,10 @@ class EventCreatedByDetectorConditionHandler(DataConditionHandler[WorkflowEventD @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: event = event_data.event + if not isinstance(event, GroupEvent): + # If this is a status update, we don't need to evaluate the condition + return False + if event.occurrence is None or event.occurrence.evidence_data is None: return False diff --git a/src/sentry/workflow_engine/handlers/condition/event_seen_count_handler.py b/src/sentry/workflow_engine/handlers/condition/event_seen_count_handler.py index 34fd71b9fadbb9..108ff8975b0490 100644 --- a/src/sentry/workflow_engine/handlers/condition/event_seen_count_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/event_seen_count_handler.py @@ -1,5 +1,6 @@ from typing import Any +from sentry.eventstore.models import GroupEvent from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.registry import condition_handler_registry from sentry.workflow_engine.types import DataConditionHandler, WorkflowEventData @@ -12,4 +13,8 @@ class EventSeenCountConditionHandler(DataConditionHandler[WorkflowEventData]): @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: event = event_data.event + if not isinstance(event, GroupEvent): + # We can only count the events for a GroupEvent + return False + return event.group.times_seen == comparison diff --git a/src/sentry/workflow_engine/handlers/condition/issue_category_handler.py b/src/sentry/workflow_engine/handlers/condition/issue_category_handler.py index 8fc013f7c2979a..4b609e721b1508 100644 --- a/src/sentry/workflow_engine/handlers/condition/issue_category_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/issue_category_handler.py @@ -1,5 +1,6 @@ from typing import Any +from sentry.eventstore.models import GroupEvent from sentry.issues.grouptype import GroupCategory from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.registry import condition_handler_registry @@ -21,6 +22,10 @@ class IssueCategoryConditionHandler(DataConditionHandler[WorkflowEventData]): @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: + if not isinstance(event_data.event, GroupEvent): + # The event is not a GroupEvent, so we cannot evaluate the issue category + return False + group = event_data.event.group try: diff --git a/src/sentry/workflow_engine/handlers/condition/issue_priority_deescalating_handler.py b/src/sentry/workflow_engine/handlers/condition/issue_priority_deescalating_handler.py index 7b9df2b06602a3..59f3c650da06ac 100644 --- a/src/sentry/workflow_engine/handlers/condition/issue_priority_deescalating_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/issue_priority_deescalating_handler.py @@ -1,5 +1,6 @@ from typing import Any +from sentry.eventstore.models import GroupEvent from sentry.models.group import GroupStatus from sentry.models.groupopenperiod import get_latest_open_period from sentry.workflow_engine.models.data_condition import Condition, DataConditionEvaluationException @@ -14,6 +15,10 @@ class IssuePriorityDeescalatingConditionHandler(DataConditionHandler[WorkflowEve @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: + if not isinstance(event_data.event, GroupEvent): + # this condition only applies to group events + return False + group = event_data.event.group # we will fire actions on de-escalation if the priority seen is >= the threshold diff --git a/src/sentry/workflow_engine/handlers/condition/issue_priority_greater_or_equal_handler.py b/src/sentry/workflow_engine/handlers/condition/issue_priority_greater_or_equal_handler.py index cf8bcac6a0ae35..49c144ed55170b 100644 --- a/src/sentry/workflow_engine/handlers/condition/issue_priority_greater_or_equal_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/issue_priority_greater_or_equal_handler.py @@ -1,5 +1,6 @@ from typing import Any +from sentry.eventstore.models import GroupEvent from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.registry import condition_handler_registry from sentry.workflow_engine.types import DataConditionHandler, WorkflowEventData @@ -12,5 +13,9 @@ class IssuePriorityGreaterOrEqualConditionHandler(DataConditionHandler[WorkflowE @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: + if not isinstance(event_data.event, GroupEvent): + # If the event is not a GroupEvent, we cannot evaluate priority + return False + group = event_data.event.group return group.priority >= comparison diff --git a/src/sentry/workflow_engine/handlers/condition/latest_adopted_release_handler.py b/src/sentry/workflow_engine/handlers/condition/latest_adopted_release_handler.py index 11d65ab7d86887..6156e0fc8ef2e6 100644 --- a/src/sentry/workflow_engine/handlers/condition/latest_adopted_release_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/latest_adopted_release_handler.py @@ -1,5 +1,6 @@ from typing import Any +from sentry.eventstore.models import GroupEvent from sentry.models.environment import Environment from sentry.models.release import follows_semver_versioning_scheme from sentry.rules.age import AgeComparisonType, ModelAgeType @@ -39,6 +40,8 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: environment_name = comparison["environment"] event = event_data.event + if not isinstance(event, GroupEvent): + return False if follows_semver_versioning_scheme(event.organization.id, event.project.id): order_type = LatestReleaseOrders.SEMVER diff --git a/src/sentry/workflow_engine/handlers/condition/latest_release_handler.py b/src/sentry/workflow_engine/handlers/condition/latest_release_handler.py index 15ba839cff78a0..7de971b91707fe 100644 --- a/src/sentry/workflow_engine/handlers/condition/latest_release_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/latest_release_handler.py @@ -48,6 +48,9 @@ class LatestReleaseConditionHandler(DataConditionHandler[WorkflowEventData]): @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: event = event_data.event + if not isinstance(event, GroupEvent): + # The latest release condition can only be evaluated for a GroupEvent + return False latest_release = get_latest_release_for_env(event_data.workflow_env, event) if not latest_release: diff --git a/src/sentry/workflow_engine/handlers/condition/level_handler.py b/src/sentry/workflow_engine/handlers/condition/level_handler.py index b9370f9c656933..c4f71c2fad245b 100644 --- a/src/sentry/workflow_engine/handlers/condition/level_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/level_handler.py @@ -1,6 +1,7 @@ from typing import Any from sentry.constants import LOG_LEVELS_MAP +from sentry.eventstore.models import GroupEvent from sentry.rules import MatchType from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.registry import condition_handler_registry @@ -25,6 +26,10 @@ class LevelConditionHandler(DataConditionHandler[WorkflowEventData]): @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: event = event_data.event + if not isinstance(event, GroupEvent): + # If the event is not a group event, we cannot evaluate the level condition + return False + level_name = event.get_tag("level") if level_name is None: return False diff --git a/src/sentry/workflow_engine/handlers/condition/tagged_event_handler.py b/src/sentry/workflow_engine/handlers/condition/tagged_event_handler.py index 76b9a051c0e94a..96e49ba23ab0b5 100644 --- a/src/sentry/workflow_engine/handlers/condition/tagged_event_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/tagged_event_handler.py @@ -1,6 +1,7 @@ from typing import Any from sentry import tagstore +from sentry.eventstore.models import GroupEvent from sentry.rules import MatchType, match_values from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.registry import condition_handler_registry @@ -51,6 +52,10 @@ class TaggedEventConditionHandler(DataConditionHandler[WorkflowEventData]): @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: event = event_data.event + if not isinstance(event, GroupEvent): + # This condition is not applicable to status updates + return False + raw_tags = event.tags key = comparison["key"] match = comparison["match"] diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index 5c96bc5389d61f..5da35a0c88552d 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -101,7 +101,7 @@ def evaluate_workflow_triggers( for workflow in workflows: evaluation, remaining_conditions = workflow.evaluate_trigger_conditions(event_data) - if remaining_conditions: + if remaining_conditions and isinstance(event_data.event, GroupEvent): enqueue_workflow( workflow, remaining_conditions, @@ -125,11 +125,16 @@ def evaluate_workflow_triggers( except Environment.DoesNotExist: return set() + event_id = ( + event_data.event.id if isinstance(event_data.event, Activity) else event_data.event.event_id + ) + logger.info( "workflow_engine.process_workflows.triggered_workflows", extra={ "group_id": event_data.event.group_id, - "event_id": event_data.event.event_id, + "is_activity": isinstance(event_data.event, Activity), + "event_id": event_id, "event_data": asdict(event_data), "event_environment_id": environment.id, "triggered_workflows": [workflow.id for workflow in triggered_workflows], From 551ca1f3feeb5e0654eabc9b48c199fcfca5a85c Mon Sep 17 00:00:00 2001 From: Josh Callender <1569818+saponifi3d@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:37:52 -0700 Subject: [PATCH 8/8] WIP --- .../workflow_engine/processors/detector.py | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/sentry/workflow_engine/processors/detector.py b/src/sentry/workflow_engine/processors/detector.py index bacad1c1df6aa5..97c806ab01da75 100644 --- a/src/sentry/workflow_engine/processors/detector.py +++ b/src/sentry/workflow_engine/processors/detector.py @@ -2,8 +2,10 @@ import logging +from sentry.eventstore.models import GroupEvent from sentry.issues.issue_occurrence import IssueOccurrence from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka +from sentry.models.activity import Activity from sentry.utils import metrics from sentry.workflow_engine.models import DataPacket, Detector from sentry.workflow_engine.types import ( @@ -15,8 +17,10 @@ logger = logging.getLogger(__name__) -def get_detector_by_event(event_data: WorkflowEventData) -> Detector: - evt = event_data.event +def get_detector_by_group_type(evt: GroupEvent) -> Detector: + """ + Get the detector from an occurrence. + """ issue_occurrence = evt.occurrence try: @@ -48,6 +52,33 @@ def get_detector_by_event(event_data: WorkflowEventData) -> Detector: return detector +def get_detector_from_activity(activity: Activity): + """ + Get the detector from an groups Activity event. + + TODO - figure out how to set this on the StatusMessageData + to be proxied to here (will likely require us to proxy the `data` + when creating the activity). + """ + pass + + +def get_detector_by_event(event_data: WorkflowEventData) -> Detector | None: + evt = event_data.event + + if isinstance(evt, GroupEvent): + detector = get_detector_by_group_type(evt) + elif isinstance(evt, Activity): + # detector = get_detector_from_activity(evt) + # For now, create a metric so we can see that we are trying to resolve a detector + metrics.incr("workflow_engine.detectors.fetch_by_activity") + return None + else: + raise ValueError(f"Unsupported event type: {type(evt)}. Expected GroupEvent or Activity.") + + return detector + + def create_issue_platform_payload(result: DetectorEvaluationResult): occurrence, status_change = None, None