Repository: getsentry/sentry
Pull request #88775: feat(aci): create WorkflowFireHistory objects for triggered workflows and update when meeting filters
Author: login: cathteng
Labels: name: Scope: Backend
description: Automatically applied to PRs that change backend components
Created: 2025-04-04T02:46:54Z | Merged: 2025-04-08T17:43:07Z

We've decided that `WorkflowFireHistory` will track every time a workflow has been triggered. Note that we only fire actions if subsequent workflow filters are met.

Create these objects for triggered workflows in regular processing and delayed processing.

I added a column (`has_fired_actions`) to track whether a workflow also met its filters and fired actions -- `WorkflowFireHistory` objects with `has_fired_actions=True` should be 1:1 with `RuleFireHistory` today. Update this column if filters are met (there is 1 filter group per `Rule/Workflow` during the rollout period, this is not guaranteed in the future).


Diff:
diff --git a/src/sentry/workflow_engine/processors/action.py b/src/sentry/workflow_engine/processors/action.py
index 52d3a819e5785e..028a9c24d46f83 100644
--- a/src/sentry/workflow_engine/processors/action.py
+++ b/src/sentry/workflow_engine/processors/action.py
@@ -19,8 +19,11 @@
     ActionGroupStatus,
     DataCondition,
     DataConditionGroup,
+    WorkflowDataConditionGroup,
+    WorkflowFireHistory,
 )
 from sentry.workflow_engine.registry import action_handler_registry
+from sentry.workflow_engine.types import WorkflowEventData
 
 EnqueuedAction = tuple[DataConditionGroup, list[DataCondition]]
 
@@ -60,14 +63,34 @@ def get_action_last_updated_statuses(now: datetime, actions: BaseQuerySet[Action
     return statuses
 
 
+def update_workflow_fire_histories(
+    actions_to_fire: BaseQuerySet[Action], event_data: WorkflowEventData
+) -> int:
+    # Update WorkflowFireHistory objects for workflows with actions to fire
+    fired_workflows = set(
+        WorkflowDataConditionGroup.objects.filter(
+            condition_group__dataconditiongroupaction__action__in=actions_to_fire
+        ).values_list("workflow_id", flat=True)
+    )
+
+    updated_rows = WorkflowFireHistory.objects.filter(
+        workflow_id__in=fired_workflows,
+        group=event_data.event.group,
+        event_id=event_data.event.event_id,
+    ).update(has_fired_actions=True)
+
+    return updated_rows
+
+
 # TODO(cathy): only reinforce workflow frequency for certain issue types
 def filter_recently_fired_workflow_actions(
-    filtered_action_groups: set[DataConditionGroup], group: Group
+    filtered_action_groups: set[DataConditionGroup], event_data: WorkflowEventData
 ) -> BaseQuerySet[Action]:
     # get the actions for any of the triggered data condition groups
     actions = Action.objects.filter(
         dataconditiongroupaction__condition_group__in=filtered_action_groups
     ).distinct()
+    group = event_data.event.group
 
     now = timezone.now()
     statuses = get_action_last_updated_statuses(now, actions, group)
@@ -92,6 +115,8 @@ def filter_recently_fired_workflow_actions(
     actions_without_statuses_ids = {action.id for action in actions_without_statuses}
     filtered_actions = actions.filter(id__in=actions_to_include | actions_without_statuses_ids)
 
+    update_workflow_fire_histories(filtered_actions, event_data)
+
     return filtered_actions
 
 
diff --git a/src/sentry/workflow_engine/processors/delayed_workflow.py b/src/sentry/workflow_engine/processors/delayed_workflow.py
index 6616a9f353cc11..e87fdb3f5c8b91 100644
--- a/src/sentry/workflow_engine/processors/delayed_workflow.py
+++ b/src/sentry/workflow_engine/processors/delayed_workflow.py
@@ -33,7 +33,7 @@
     BaseEventFrequencyQueryHandler,
     slow_condition_query_handler_registry,
 )
-from sentry.workflow_engine.models import Action, DataCondition, DataConditionGroup, Workflow
+from sentry.workflow_engine.models import DataCondition, DataConditionGroup, Workflow
 from sentry.workflow_engine.models.data_condition import (
     PERCENT_CONDITIONS,
     SLOW_CONDITIONS,
@@ -45,6 +45,7 @@
 from sentry.workflow_engine.processors.detector import get_detector_by_event
 from sentry.workflow_engine.processors.workflow import (
     WORKFLOW_ENGINE_BUFFER_LIST_KEY,
+    create_workflow_fire_histories,
     evaluate_workflows_action_filters,
     log_fired_workflows,
 )
@@ -398,8 +399,8 @@ def fire_actions_for_groups(
     group_to_groupevent: dict[Group, GroupEvent],
 ) -> None:
     for group, group_event in group_to_groupevent.items():
-        job = WorkflowEventData(event=group_event)
-        detector = get_detector_by_event(job)
+        event_data = WorkflowEventData(event=group_event)
+        detector = get_detector_by_event(event_data)
 
         workflow_triggers: set[DataConditionGroup] = set()
         action_filters: set[DataConditionGroup] = set()
@@ -410,13 +411,16 @@ def fire_actions_for_groups(
                 action_filters.add(dcg)
 
         # process action filters
-        filtered_actions: list[Action] = list(
-            filter_recently_fired_workflow_actions(action_filters, group)
-        )
+        filtered_actions = filter_recently_fired_workflow_actions(action_filters, event_data)
 
         # process workflow_triggers
         workflows = set(Workflow.objects.filter(when_condition_group_id__in=workflow_triggers))
-        filtered_actions.extend(list(evaluate_workflows_action_filters(workflows, job)))
+
+        # create WorkflowFireHistory (updated in evaluate_workflows_action_filters)
+        create_workflow_fire_histories(workflows, event_data)
+        filtered_actions = filtered_actions.union(
+            evaluate_workflows_action_filters(workflows, event_data)
+        )
 
         # temporary fetching of organization, so not passing in as parameter
         organization = group.project.organization
@@ -437,8 +441,8 @@ def fire_actions_for_groups(
         ):
             log_fired_workflows(
                 log_name="workflow_engine.delayed_workflow.fired_workflow",
-                actions=filtered_actions,
-                job=job,
+                actions=list(filtered_actions),
+                event_data=event_data,
             )
 
         if features.has(
@@ -446,7 +450,7 @@ def fire_actions_for_groups(
             organization,
         ):
             for action in filtered_actions:
-                action.trigger(job, detector)
+                action.trigger(event_data, detector)
 
 
 def cleanup_redis_buffer(
diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py
index eb29319259db30..f2010f8bb208a7 100644
--- a/src/sentry/workflow_engine/processors/workflow.py
+++ b/src/sentry/workflow_engine/processors/workflow.py
@@ -18,6 +18,7 @@
     DataConditionGroup,
     Detector,
     Workflow,
+    WorkflowFireHistory,
 )
 from sentry.workflow_engine.processors.action import filter_recently_fired_workflow_actions
 from sentry.workflow_engine.processors.data_condition_group import process_data_condition_group
@@ -34,6 +35,19 @@ class WorkflowDataConditionGroupType(StrEnum):
     WORKFLOW_TRIGGER = "workflow_trigger"
 
 
+def create_workflow_fire_histories(
+    workflows: set[Workflow], event_data: WorkflowEventData
+) -> list[WorkflowFireHistory]:
+    # Create WorkflowFireHistory objects for triggered workflows
+    fire_histories = [
+        WorkflowFireHistory(
+            workflow=workflow, group=event_data.event.group, event_id=event_data.event.event_id
+        )
+        for workflow in workflows
+    ]
+    return WorkflowFireHistory.objects.bulk_create(fire_histories)
+
+
 def enqueue_workflow(
     workflow: Workflow,
     delayed_conditions: list[DataCondition],
@@ -77,6 +91,8 @@ def evaluate_workflow_triggers(
             if evaluation:
                 triggered_workflows.add(workflow)
 
+    create_workflow_fire_histories(triggered_workflows, event_data)
+
     return triggered_workflows
 
 
@@ -100,9 +116,10 @@ def evaluate_workflows_action_filters(
     for action_condition in action_conditions:
         workflow_event_data = event_data
 
+        # each DataConditionGroup here has 1 WorkflowDataConditionGroup
         workflow_data_condition_group = action_condition.workflowdataconditiongroup_set.first()
 
-        # Populate the workflow_env in the job for the action_condition evaluation
+        # Populate the workflow_env in the event_data for the action_condition evaluation
         if workflow_data_condition_group:
             workflow_event_data = replace(
                 workflow_event_data, workflow_env=workflow_data_condition_group.workflow.environment
@@ -126,10 +143,12 @@ def evaluate_workflows_action_filters(
             if evaluation:
                 filtered_action_groups.add(action_condition)
 
-    return filter_recently_fired_workflow_actions(filtered_action_groups, event_data.event.group)
+    return filter_recently_fired_workflow_actions(filtered_action_groups, event_data)
 
 
-def log_fired_workflows(log_name: str, actions: list[Action], job: WorkflowEventData) -> None:
+def log_fired_workflows(
+    log_name: str, actions: list[Action], event_data: WorkflowEventData
+) -> None:
     # go from actions to workflows
     action_ids = {action.id for action in actions}
     action_conditions = DataConditionGroup.objects.filter(
@@ -150,14 +169,14 @@ def log_fired_workflows(log_name: str, actions: list[Action], job: WorkflowEvent
             extra={
                 "workflow_id": workflow.id,
                 "rule_id": workflow_to_rule.get(workflow.id),
-                "payload": asdict(job),
-                "group_id": job.event.group_id,
-                "event_id": job.event.event_id,
+                "payload": asdict(event_data),
+                "group_id": event_data.event.group_id,
+                "event_id": event_data.event.event_id,
             },
         )
 
 
-def process_workflows(job: WorkflowEventData) -> set[Workflow]:
+def process_workflows(event_data: WorkflowEventData) -> set[Workflow]:
     """
     This method will get the detector based on the event, and then gather the associated workflows.
     Next, it will evaluate the "when" (or trigger) conditions for each workflow, if the conditions are met,
@@ -167,17 +186,21 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
     """
     # Check to see if the GroupEvent has an issue occurrence
     try:
-        detector = get_detector_by_event(job)
+        detector = get_detector_by_event(event_data)
     except Detector.DoesNotExist:
         metrics.incr("workflow_engine.process_workflows.error")
-        logger.exception("Detector not found for event", extra={"event_id": job.event.event_id})
+        logger.exception(
+            "Detector not found for event", extra={"event_id": event_data.event.event_id}
+        )
         return set()
 
     try:
-        environment = job.event.get_environment()
+        environment = event_data.event.get_environment()
     except Environment.DoesNotExist:
         metrics.incr("workflow_engine.process_workflows.error")
-        logger.exception("Missing environment for event", extra={"event_id": job.event.event_id})
+        logger.exception(
+            "Missing environment for event", extra={"event_id": event_data.event.event_id}
+        )
         return set()
 
     # TODO: remove fetching org, only used for FF check
@@ -199,9 +222,9 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
         logger.info(
             "workflow_engine.process_workflows.process_event",
             extra={
-                "payload": job,
-                "group_id": job.event.group_id,
-                "event_id": job.event.event_id,
+                "payload": event_data,
+                "group_id": event_data.event.group_id,
+                "event_id": event_data.event.event_id,
                 "event_environment_id": environment.id,
                 "workflows": [workflow.id for workflow in workflows],
             },
@@ -215,7 +238,7 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
         )
 
     with sentry_sdk.start_span(op="workflow_engine.process_workflows.evaluate_workflow_triggers"):
-        triggered_workflows = evaluate_workflow_triggers(workflows, job)
+        triggered_workflows = evaluate_workflow_triggers(workflows, event_data)
 
         if triggered_workflows:
             metrics.incr(
@@ -227,7 +250,7 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
     with sentry_sdk.start_span(
         op="workflow_engine.process_workflows.evaluate_workflows_action_filters"
     ):
-        actions = evaluate_workflows_action_filters(triggered_workflows, job)
+        actions = evaluate_workflows_action_filters(triggered_workflows, event_data)
 
         if features.has(
             "organizations:workflow-engine-process-workflows",
@@ -246,7 +269,7 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
             log_fired_workflows(
                 log_name="workflow_engine.process_workflows.fired_workflow",
                 actions=list(actions),
-                job=job,
+                event_data=event_data,
             )
 
     with sentry_sdk.start_span(op="workflow_engine.process_workflows.trigger_actions"):
@@ -255,7 +278,7 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
             organization,
         ):
             for action in actions:
-                action.trigger(job, detector)
+                action.trigger(event_data, detector)
 
     return triggered_workflows
 
diff --git a/tests/sentry/workflow_engine/processors/test_action.py b/tests/sentry/workflow_engine/processors/test_action.py
index 698594e07282e3..ce03fee36c4403 100644
--- a/tests/sentry/workflow_engine/processors/test_action.py
+++ b/tests/sentry/workflow_engine/processors/test_action.py
@@ -3,9 +3,12 @@
 from django.utils import timezone
 
 from sentry.testutils.helpers.datetime import freeze_time
-from sentry.workflow_engine.models import DataConditionGroup
+from sentry.workflow_engine.models import Action, DataConditionGroup, WorkflowFireHistory
 from sentry.workflow_engine.models.action_group_status import ActionGroupStatus
-from sentry.workflow_engine.processors.action import filter_recently_fired_workflow_actions
+from sentry.workflow_engine.processors.action import (
+    filter_recently_fired_workflow_actions,
+    update_workflow_fire_histories,
+)
 from sentry.workflow_engine.types import WorkflowEventData
 from tests.sentry.workflow_engine.test_base import BaseWorkflowTest
 
@@ -36,7 +39,7 @@ def test(self):
         status_2 = ActionGroupStatus.objects.create(action=action, group=self.group)
 
         triggered_actions = filter_recently_fired_workflow_actions(
-            set(DataConditionGroup.objects.all()), self.group
+            set(DataConditionGroup.objects.all()), self.event_data
         )
         assert set(triggered_actions) == {self.action}
 
@@ -58,10 +61,32 @@ def test_multiple_workflows(self):
         status_3.update(date_updated=timezone.now() - timedelta(days=2))
 
         triggered_actions = filter_recently_fired_workflow_actions(
-            set(DataConditionGroup.objects.all()), self.group
+            set(DataConditionGroup.objects.all()), self.event_data
         )
         assert set(triggered_actions) == {self.action, action_3}
 
         for status in [status_1, status_2, status_3]:
             status.refresh_from_db()
             assert status.date_updated == timezone.now()
+
+    def test_update_workflow_fire_histories(self):
+        WorkflowFireHistory.objects.create(
+            workflow=self.workflow,
+            group=self.group,
+            event_id=self.group_event.event_id,
+            has_fired_actions=False,
+        )
+
+        actions = Action.objects.all()
+        assert actions.count() == 1
+
+        update_workflow_fire_histories(actions, self.event_data)
+        assert (
+            WorkflowFireHistory.objects.filter(
+                workflow=self.workflow,
+                group=self.group,
+                event_id=self.group_event.event_id,
+                has_fired_actions=True,
+            ).count()
+            == 1
+        )
diff --git a/tests/sentry/workflow_engine/processors/test_delayed_workflow.py b/tests/sentry/workflow_engine/processors/test_delayed_workflow.py
index 52292043cd36cd..c128c87853a72c 100644
--- a/tests/sentry/workflow_engine/processors/test_delayed_workflow.py
+++ b/tests/sentry/workflow_engine/processors/test_delayed_workflow.py
@@ -27,6 +27,7 @@
     DataConditionGroup,
     Detector,
     Workflow,
+    WorkflowFireHistory,
 )
 from sentry.workflow_engine.models.data_condition import (
     PERCENT_CONDITIONS,
@@ -800,6 +801,41 @@ def test_fire_actions_for_groups__enqueue(self, mock_enqueue):
             WorkflowDataConditionGroupType.ACTION_FILTER,
         )
 
+    @patch("sentry.workflow_engine.processors.workflow.process_data_condition_group")
+    def test_fire_actions_for_groups__workflow_fire_history(self, mock_process):
+        mock_process.return_value = (True, []), []
+
+        self.trigger_group_to_dcg_model[DataConditionHandler.Group.WORKFLOW_TRIGGER] = {
+            self.workflow1_dcgs[0].id: self.workflow1.id,
+        }
+        self.trigger_group_to_dcg_model[DataConditionHandler.Group.ACTION_FILTER] = {
+            self.workflow2_dcgs[1].id: self.workflow2.id,
+        }
+        self.groups_to_dcgs = {
+            self.group1.id: {self.workflow1_dcgs[0]},
+            self.group2.id: {self.workflow2_dcgs[1]},
+        }
+        # WorkflowFireHistory for enqueued filter (already triggered)
+        wfh = WorkflowFireHistory.objects.create(
+            workflow=self.workflow2,
+            group_id=self.group2.id,
+            event_id=self.event2.event_id,
+        )
+
+        fire_actions_for_groups(
+            self.groups_to_dcgs, self.trigger_group_to_dcg_model, self.group_to_groupevent
+        )
+
+        wfh.refresh_from_db()
+        assert wfh.has_fired_actions is True
+
+        assert WorkflowFireHistory.objects.filter(
+            workflow=self.workflow1,
+            group_id=self.group1.id,
+            event_id=self.event1.event_id,
+            has_fired_actions=True,
+        ).exists()
+
 
 class TestCleanupRedisBuffer(TestDelayedWorkflowBase):
     def test_cleanup_redis(self):
diff --git a/tests/sentry/workflow_engine/processors/test_workflow.py b/tests/sentry/workflow_engine/processors/test_workflow.py
index 1f41a2d569f324..a9c5cb751d9507 100644
--- a/tests/sentry/workflow_engine/processors/test_workflow.py
+++ b/tests/sentry/workflow_engine/processors/test_workflow.py
@@ -21,11 +21,13 @@
     DataConditionGroup,
     DataConditionGroupAction,
     Workflow,
+    WorkflowFireHistory,
 )
 from sentry.workflow_engine.models.data_condition import Condition
 from sentry.workflow_engine.processors.workflow import (
     WORKFLOW_ENGINE_BUFFER_LIST_KEY,
     WorkflowDataConditionGroupType,
+    create_workflow_fire_histories,
     delete_workflow,
     enqueue_workflow,
     evaluate_workflow_triggers,
@@ -155,7 +157,7 @@ def test_populate_workflow_env_for_filters(self, mock_filter):
 
         process_workflows(self.event_data)
 
-        mock_filter.assert_called_with({workflow_filters}, self.group)
+        mock_filter.assert_called_with({workflow_filters}, self.event_data)
 
     def test_same_environment_only(self):
         env = self.create_environment(project=self.project)
@@ -580,6 +582,79 @@ def test_with_slow_conditions(self):
         # TODO @saponifi3d - Add a check to ensure the second condition is enqueued for later evaluation
 
 
+class TestWorkflowFireHistory(BaseWorkflowTest):
+    def setUp(self):
+        (
+            self.workflow,
+            self.detector,
+            self.detector_workflow,
+            self.workflow_triggers,
+        ) = self.create_detector_and_workflow()
+
+        self.action_group, self.action = self.create_workflow_action(workflow=self.workflow)
+
+        self.group, self.event, self.group_event = self.create_group_event(
+            occurrence=self.build_occurrence(evidence_data={"detector_id": self.detector.id})
+        )
+        self.event_data = WorkflowEventData(event=self.group_event)
+
+    def test_create_workflow_fire_histories(self):
+        create_workflow_fire_histories({self.workflow}, self.event_data)
+        assert (
+            WorkflowFireHistory.objects.filter(
+                workflow=self.workflow,
+                group=self.group,
+                event_id=self.group_event.event_id,
+                has_fired_actions=False,
+            ).count()
+            == 1
+        )
+
+    def test_evaluate_filters_updates_histories(self):
+        # only updates workflows that meet filters
+        self.create_data_condition(
+            condition_group=self.action_group,
+            type=Condition.EVENT_SEEN_COUNT,
+            comparison=1,
+            condition_result=True,
+        )
+
+        workflow = self.create_workflow()
+        action_group, _ = self.create_workflow_action(workflow=workflow)
+        self.create_data_condition(
+            condition_group=action_group,
+            type=Condition.EVENT_SEEN_COUNT,
+            comparison=5,
+            condition_result=True,
+        )  # condition is not met
+
+        create_workflow_fire_histories({self.workflow, workflow}, self.event_data)
+
+        triggered_actions = evaluate_workflows_action_filters(
+            {self.workflow, workflow}, self.event_data
+        )
+        assert set(triggered_actions) == {self.action}
+
+        assert (
+            WorkflowFireHistory.objects.filter(
+                workflow=self.workflow,
+                group=self.group,
+                event_id=self.group_event.event_id,
+                has_fired_actions=True,
+            ).count()
+            == 1
+        )
+        assert (
+            WorkflowFireHistory.objects.filter(
+                workflow=workflow,
+                group=self.group,
+                event_id=self.group_event.event_id,
+                has_fired_actions=False,
+            ).count()
+            == 1
+        )
+
+
 class TestEnqueueWorkflows(BaseWorkflowTest):
     def setUp(self):
         self.workflow = self.create_workflow()


Reviews:
user: login: saponifi3d
body: i'd recommend thinking about the code structure here a little more - how can we make these methods not have side effects? I'm a bit concerned about the increased complexity in process_workflows and the side effects in delayed processor.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T16:40:37Z
user: login: cathteng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T18:28:49Z
user: login: cathteng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T18:29:15Z
user: login: ceorourke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T22:15:48Z
user: login: ceorourke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T22:16:41Z
user: login: cathteng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T22:17:46Z
user: login: ceorourke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T22:23:34Z
user: login: saponifi3d
body: lgtm - we may want to refactor the delayed processor stuff in the future to avoid the side-effect style updates, but for now 👍 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:23:32Z

Review comments:
diff_hunk: @@ -34,6 +36,34 @@ class WorkflowDataConditionGroupType(StrEnum):
     WORKFLOW_TRIGGER = "workflow_trigger"
 
 
+def create_workflow_fire_histories(workflows: set[Workflow], event_data: WorkflowEventData) -> None:
+    # Create WorkflowFireHistory objects for triggered workflows
+    fire_histories = [
+        WorkflowFireHistory(
+            workflow=workflow, group=event_data.event.group, event_id=event_data.event.event_id
+        )
+        for workflow in workflows
+    ]
+    WorkflowFireHistory.objects.bulk_create(fire_histories)
path: src/sentry/workflow_engine/processors/workflow.py
user: login: saponifi3d
body: Since this methods job is to create them, I think we should have this return as well. If the consumer of this method doesn't want them, they don't have to assign it to a variable.
created_at: 2025-04-04T16:03:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -199,9 +242,9 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
         logger.info(
             "workflow_engine.process_workflows.process_event",
             extra={
-                "payload": job,
-                "group_id": job.event.group_id,
-                "event_id": job.event.event_id,
+                "payload": event_data,
path: src/sentry/workflow_engine/processors/workflow.py
user: login: saponifi3d
body: next time it'd be a little easier for the review if the event_data rename stuff was split into a separate PR. with this is 1 pr it means i need to try and parse what is just a rename vs the changeset for workflow triggers
created_at: 2025-04-04T16:32:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -215,7 +258,8 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
         )
 
     with sentry_sdk.start_span(op="workflow_engine.process_workflows.evaluate_workflow_triggers"):
-        triggered_workflows = evaluate_workflow_triggers(workflows, job)
+        triggered_workflows = evaluate_workflow_triggers(workflows, event_data)
+        create_workflow_fire_histories(triggered_workflows, event_data)
path: src/sentry/workflow_engine/processors/workflow.py
user: login: saponifi3d
body: 🤔 should `create_workflow_fire_histories` live in `evaluate_workflow_triggers`? that way if we evaluate a workflow and it's in a triggered state we would record that - this will only record if it's invoking process workflows
created_at: 2025-04-04T16:34:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -410,13 +412,18 @@ def fire_actions_for_groups(
                 action_filters.add(dcg)
 
         # process action filters
-        filtered_actions: list[Action] = list(
-            filter_recently_fired_workflow_actions(action_filters, group)
-        )
+        filtered_actions = filter_recently_fired_workflow_actions(action_filters, group)
+        # update WorkflowFireHistory for the filtered actions (created before enqueuing)
+        update_workflow_fire_histories(filtered_actions, event_data)
 
         # process workflow_triggers
         workflows = set(Workflow.objects.filter(when_condition_group_id__in=workflow_triggers))
-        filtered_actions.extend(list(evaluate_workflows_action_filters(workflows, job)))
+
+        # create WorkflowFireHistory (updated in evaluate_workflows_action_filters)
+        create_workflow_fire_histories(workflows, event_data)
path: src/sentry/workflow_engine/processors/delayed_workflow.py
user: login: saponifi3d
body: this feels like a side effect of `fire_actions_for_groups` is there another place we could do this? The way i'd structure the code is to evaluate the actions, return the list of actions, then have another method trigger them (no queries or anything in that firing method, just taking a list of actions and calling anything that needs to be fired)
created_at: 2025-04-04T16:37:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +36,34 @@ class WorkflowDataConditionGroupType(StrEnum):
     WORKFLOW_TRIGGER = "workflow_trigger"
 
 
+def create_workflow_fire_histories(workflows: set[Workflow], event_data: WorkflowEventData) -> None:
+    # Create WorkflowFireHistory objects for triggered workflows
+    fire_histories = [
+        WorkflowFireHistory(
+            workflow=workflow, group=event_data.event.group, event_id=event_data.event.event_id
+        )
+        for workflow in workflows
+    ]
+    WorkflowFireHistory.objects.bulk_create(fire_histories)
+
+
+def update_workflow_fire_histories(
+    actions_to_fire: BaseQuerySet[Action], event_data: WorkflowEventData
+) -> None:
+    # Update WorkflowFireHistory objects for workflows with actions to fire
+    fired_workflows = set(
+        WorkflowDataConditionGroup.objects.filter(
+            condition_group__dataconditiongroupaction__action__in=actions_to_fire
+        ).values_list("workflow_id", flat=True)
+    )
+
+    WorkflowFireHistory.objects.filter(
path: src/sentry/workflow_engine/processors/workflow.py
user: login: saponifi3d
body: we should probably return this incase something would want to use it.
created_at: 2025-04-04T16:38:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +36,34 @@ class WorkflowDataConditionGroupType(StrEnum):
     WORKFLOW_TRIGGER = "workflow_trigger"
 
 
+def create_workflow_fire_histories(workflows: set[Workflow], event_data: WorkflowEventData) -> None:
+    # Create WorkflowFireHistory objects for triggered workflows
+    fire_histories = [
+        WorkflowFireHistory(
+            workflow=workflow, group=event_data.event.group, event_id=event_data.event.event_id
+        )
+        for workflow in workflows
+    ]
+    WorkflowFireHistory.objects.bulk_create(fire_histories)
+
+
+def update_workflow_fire_histories(
+    actions_to_fire: BaseQuerySet[Action], event_data: WorkflowEventData
+) -> None:
path: src/sentry/workflow_engine/processors/workflow.py
user: login: saponifi3d
body: I feel like this method should live in `processors/action` because we are taking a list of them actions and updating a state to say that we have triggered actions. in the future, this gives us a specific hook to update for the notification platform tracking.
created_at: 2025-04-04T16:38:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -410,13 +412,18 @@ def fire_actions_for_groups(
                 action_filters.add(dcg)
 
         # process action filters
-        filtered_actions: list[Action] = list(
-            filter_recently_fired_workflow_actions(action_filters, group)
-        )
+        filtered_actions = filter_recently_fired_workflow_actions(action_filters, group)
+        # update WorkflowFireHistory for the filtered actions (created before enqueuing)
+        update_workflow_fire_histories(filtered_actions, event_data)
 
         # process workflow_triggers
         workflows = set(Workflow.objects.filter(when_condition_group_id__in=workflow_triggers))
-        filtered_actions.extend(list(evaluate_workflows_action_filters(workflows, job)))
+
+        # create WorkflowFireHistory (updated in evaluate_workflows_action_filters)
+        create_workflow_fire_histories(workflows, event_data)
path: src/sentry/workflow_engine/processors/delayed_workflow.py
user: login: cathteng
body: i agree that ideally this would not be here and preferably would be done beforehand...

logically it belongs right after this in `get_groups_to_fire` https://github.com/getsentry/sentry/blob/14f7a70fdf9cf2fa666da33f9faea83d959d2d05/src/sentry/workflow_engine/processors/delayed_workflow.py#L289

i'm not certain if it's possible to nicely slot it in `get_groups_to_fire` because:
1) `WorkflowFireHistory` requires the `event_id` from the `GroupEvent`
2) we grab the `GroupEvents` in bulk after exiting from `get_groups_to_fire` (aka after we figure out which groups we need to fire workflows for)

given this, would it be okay to leave it where it is? i've refactored away other standalone usages save for this one
created_at: 2025-04-04T18:28:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -215,7 +258,8 @@ def process_workflows(job: WorkflowEventData) -> set[Workflow]:
         )
 
     with sentry_sdk.start_span(op="workflow_engine.process_workflows.evaluate_workflow_triggers"):
-        triggered_workflows = evaluate_workflow_triggers(workflows, job)
+        triggered_workflows = evaluate_workflow_triggers(workflows, event_data)
+        create_workflow_fire_histories(triggered_workflows, event_data)
path: src/sentry/workflow_engine/processors/workflow.py
user: login: cathteng
body: yes updated
created_at: 2025-04-04T18:29:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -410,13 +412,18 @@ def fire_actions_for_groups(
                 action_filters.add(dcg)
 
         # process action filters
-        filtered_actions: list[Action] = list(
-            filter_recently_fired_workflow_actions(action_filters, group)
-        )
+        filtered_actions = filter_recently_fired_workflow_actions(action_filters, group)
+        # update WorkflowFireHistory for the filtered actions (created before enqueuing)
+        update_workflow_fire_histories(filtered_actions, event_data)
 
         # process workflow_triggers
         workflows = set(Workflow.objects.filter(when_condition_group_id__in=workflow_triggers))
-        filtered_actions.extend(list(evaluate_workflows_action_filters(workflows, job)))
+
+        # create WorkflowFireHistory (updated in evaluate_workflows_action_filters)
+        create_workflow_fire_histories(workflows, event_data)
path: src/sentry/workflow_engine/processors/delayed_workflow.py
user: login: ceorourke
body: Oof yeah this is tricky, I looked at it for a bit trying to figure out another way and I can't think of anything super clean. You _could_ store `filtered_actions` in 2 separate variables (since you tack on the results of `evaluate_workflows_action_filters` and wouldn't want that) and return both along with the corresponding event ids (or just the event ids ins separate vars?) so you can then call `create_workflow_fire_histories` in `process_delayed_workflows`, but idk if that's really much better than you have it now.
created_at: 2025-04-07T22:15:48Z
author_association: MEMBER
diff_hunk: @@ -126,10 +143,14 @@ def evaluate_workflows_action_filters(
             if evaluation:
                 filtered_action_groups.add(action_condition)
 
-    return filter_recently_fired_workflow_actions(filtered_action_groups, event_data.event.group)
+    actions_to_fire = filter_recently_fired_workflow_actions(filtered_action_groups, event_data)
 
+    return actions_to_fire
path: src/sentry/workflow_engine/processors/workflow.py
user: login: ceorourke
body: nit but why do we need this change?
created_at: 2025-04-07T22:16:40Z
author_association: MEMBER
diff_hunk: @@ -126,10 +143,14 @@ def evaluate_workflows_action_filters(
             if evaluation:
                 filtered_action_groups.add(action_condition)
 
-    return filter_recently_fired_workflow_actions(filtered_action_groups, event_data.event.group)
+    actions_to_fire = filter_recently_fired_workflow_actions(filtered_action_groups, event_data)
 
+    return actions_to_fire
path: src/sentry/workflow_engine/processors/workflow.py
user: login: cathteng
body: oh it's a leftover oops
created_at: 2025-04-07T22:17:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,10 +61,27 @@ def test_multiple_workflows(self):
         status_3.update(date_updated=timezone.now() - timedelta(days=2))
 
         triggered_actions = filter_recently_fired_workflow_actions(
-            set(DataConditionGroup.objects.all()), self.group
+            set(DataConditionGroup.objects.all()), self.event_data
         )
         assert set(triggered_actions) == {self.action, action_3}
 
         for status in [status_1, status_2, status_3]:
             status.refresh_from_db()
             assert status.date_updated == timezone.now()
+
+    def test_update_workflow_fire_histories(self):
+        WorkflowFireHistory.objects.create(
+            workflow=self.workflow, group=self.group, event_id=self.group_event.event_id
+        )
+
+        actions = Action.objects.all()
path: tests/sentry/workflow_engine/processors/test_action.py
user: login: ceorourke
body: This might read better if you explicitly pass in `has_fired_actions=False` when creating it. Even though it's the default, I had to go check the model definition to see what the difference between the created model and the one fetched after was. Also maybe add `assert actions.count() == 1` (I'm assuming that's the count?) so I can tell that there's a 1:1 with actions and workflowfirehistories with `has_fired_actions=True`
created_at: 2025-04-07T22:23:34Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-04T03:18:34Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry/pull/88775?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
Attention: Patch coverage is `98.50746%` with `1 line` in your changes missing coverage. Please review.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/getsentry/sentry/pull/88775?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Patch % | Lines |
|---|---|---|
| [src/sentry/workflow\_engine/processors/workflow.py](https://app.codecov.io/gh/getsentry/sentry/pull/88775?src=pr&el=tree&filepath=src%2Fsentry%2Fworkflow_engine%2Fprocessors%2Fworkflow.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c3JjL3NlbnRyeS93b3JrZmxvd19lbmdpbmUvcHJvY2Vzc29ycy93b3JrZmxvdy5weQ==) | 92.85% | [1 Missing :warning: ](https://app.codecov.io/gh/getsentry/sentry/pull/88775?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           master   #88775       +/-   ##
===========================================
+ Coverage   42.12%   87.69%   +45.56%     
===========================================
  Files       10062    10093       +31     
  Lines      569227   570890     +1663     
  Branches    22448    22448               
===========================================
+ Hits       239801   500646   +260845     
+ Misses     329018    69836   -259182     
  Partials      408      408               
```

</details>
author_association: NONE
user: login: sentry[bot]
created_at: 2025-04-11T20:43:07Z
body: ## Suspect Issues
This pull request was deployed and Sentry observed the following issues:

- ‼️ **Detector.DoesNotExist: Detector matching query does not exist.** `sentry.tasks.post_process.post_process_group` [View Issue](https://sentry.sentry.io/issues/6537414888/?referrer=github-pr-bot)

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: CONTRIBUTOR