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 7614a3fe225308..787963d07148d6 100644 --- a/src/sentry/workflow_engine/handlers/condition/tagged_event_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/tagged_event_handler.py @@ -6,6 +6,9 @@ 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 +from sentry.workflow_engine.utils import log_context + +logger = log_context.get_logger(__name__) @condition_handler_registry.register(Condition.TAGGED_EVENT) @@ -90,4 +93,17 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: if k.lower() == key or tagstore.backend.get_standardized_key(k) == key ) - return match_values(group_values=tag_values, match_value=value, match_type=match) + result = match_values(group_values=tag_values, match_value=value, match_type=match) + + logger.debug( + "workflow_engine.handlers.tagged_event_handler", + extra={ + "evaluation_result": result, + "event": event, + "event_tags": event.tags, + "processed_values": tag_values, + "comparison_type": match, + }, + ) + + return result diff --git a/src/sentry/workflow_engine/models/action.py b/src/sentry/workflow_engine/models/action.py index 01f776126b4872..5831bf92faa413 100644 --- a/src/sentry/workflow_engine/models/action.py +++ b/src/sentry/workflow_engine/models/action.py @@ -3,7 +3,7 @@ import builtins import logging from enum import StrEnum -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, TypedDict from django.db import models from django.db.models import Q @@ -30,6 +30,11 @@ logger = logging.getLogger(__name__) +class ActionSnapshot(TypedDict): + id: int + type: Action.Type + + class ActionManager(BaseManager["Action"]): def get_queryset(self) -> BaseQuerySet[Action]: return ( @@ -112,6 +117,12 @@ class Meta: ), ] + def get_snapshot(self) -> ActionSnapshot: + return { + "id": self.id, + "type": Action.Type(self.type), + } + def get_handler(self) -> builtins.type[ActionHandler]: action_type = Action.Type(self.type) return action_handler_registry.get(action_type) diff --git a/src/sentry/workflow_engine/models/data_condition.py b/src/sentry/workflow_engine/models/data_condition.py index 8b1f6d4b6f8f26..84c52f55aa5aa5 100644 --- a/src/sentry/workflow_engine/models/data_condition.py +++ b/src/sentry/workflow_engine/models/data_condition.py @@ -3,7 +3,7 @@ import time from datetime import timedelta from enum import StrEnum -from typing import Any, TypeVar, cast +from typing import Any, TypedDict, TypeVar, cast from django.db import models from django.db.models.signals import pre_save @@ -111,6 +111,13 @@ class Condition(StrEnum): FAST_CONDITION_TOO_SLOW_THRESHOLD = timedelta(milliseconds=500) +class DataConditionSnapshot(TypedDict): + id: int + type: str + comparison: str + condition_result: DataConditionResult + + @region_silo_model class DataCondition(DefaultFieldsModel): """ @@ -137,7 +144,7 @@ class DataCondition(DefaultFieldsModel): on_delete=models.CASCADE, ) - def get_snapshot(self) -> dict[str, Any]: + def get_snapshot(self) -> DataConditionSnapshot: return { "id": self.id, "type": self.type, diff --git a/src/sentry/workflow_engine/models/data_condition_group.py b/src/sentry/workflow_engine/models/data_condition_group.py index bb09ed2c9695f6..dddeac54217745 100644 --- a/src/sentry/workflow_engine/models/data_condition_group.py +++ b/src/sentry/workflow_engine/models/data_condition_group.py @@ -1,11 +1,21 @@ +from __future__ import annotations + from enum import StrEnum -from typing import ClassVar, Self +from typing import ClassVar, Self, TypedDict from django.db import models from sentry.backup.scopes import RelocationScope from sentry.db.models import DefaultFieldsModel, region_silo_model, sane_repr from sentry.db.models.manager.base import BaseManager +from sentry.db.models.utils import is_model_attr_cached +from sentry.workflow_engine.models.data_condition import DataConditionSnapshot + + +class DataConditionGroupSnapshot(TypedDict): + id: int + logic_type: DataConditionGroup.Type + conditions: list[DataConditionSnapshot] @region_silo_model @@ -36,3 +46,14 @@ class Type(StrEnum): max_length=200, choices=[(t.value, t.value) for t in Type], default=Type.ANY ) organization = models.ForeignKey("sentry.Organization", on_delete=models.CASCADE) + + def get_snapshot(self) -> DataConditionGroupSnapshot: + conditions = [] + if is_model_attr_cached(self, "conditions"): + conditions = [cond.get_snapshot() for cond in self.conditions.all()] + + return { + "id": self.id, + "logic_type": DataConditionGroup.Type(self.logic_type), + "conditions": conditions, + } diff --git a/src/sentry/workflow_engine/models/detector.py b/src/sentry/workflow_engine/models/detector.py index 3ffefe92f3a033..26d993d1bb967d 100644 --- a/src/sentry/workflow_engine/models/detector.py +++ b/src/sentry/workflow_engine/models/detector.py @@ -3,7 +3,7 @@ import builtins import logging from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, TypedDict from django.conf import settings from django.db import models @@ -29,10 +29,18 @@ if TYPE_CHECKING: from sentry.workflow_engine.handlers.detector import DetectorHandler + from sentry.workflow_engine.models.data_condition_group import DataConditionGroupSnapshot logger = logging.getLogger(__name__) +class DetectorSnapshot(TypedDict): + id: int + enabled: bool + status: int + trigger_condition: DataConditionGroupSnapshot | None + + class DetectorManager(BaseManager["Detector"]): def get_queryset(self) -> BaseQuerySet[Detector]: return ( @@ -141,6 +149,18 @@ def settings(self) -> DetectorSettings: return settings + def get_snapshot(self) -> DetectorSnapshot: + trigger_condition = None + if self.workflow_condition_group: + trigger_condition = self.workflow_condition_group.get_snapshot() + + return { + "id": self.id, + "enabled": self.enabled, + "status": self.status, + "trigger_condition": trigger_condition, + } + def get_audit_log_data(self) -> dict[str, Any]: return {"name": self.name} diff --git a/src/sentry/workflow_engine/models/workflow.py b/src/sentry/workflow_engine/models/workflow.py index c55bff06579289..07cfc53202b60d 100644 --- a/src/sentry/workflow_engine/models/workflow.py +++ b/src/sentry/workflow_engine/models/workflow.py @@ -2,7 +2,7 @@ import logging from dataclasses import replace -from typing import Any, ClassVar +from typing import Any, ClassVar, TypedDict from django.conf import settings from django.db import models @@ -17,7 +17,10 @@ from sentry.db.models.manager.base_query_set import BaseQuerySet from sentry.models.owner_base import OwnerModel from sentry.workflow_engine.models.data_condition import DataCondition, is_slow_condition -from sentry.workflow_engine.models.data_condition_group import DataConditionGroup +from sentry.workflow_engine.models.data_condition_group import ( + DataConditionGroup, + DataConditionGroupSnapshot, +) from sentry.workflow_engine.processors.data_condition_group import TriggerResult from sentry.workflow_engine.types import ConditionError, WorkflowEventData @@ -26,6 +29,14 @@ logger = logging.getLogger(__name__) +class WorkflowSnapshot(TypedDict): + id: int + enabled: bool + environment_id: int | None + status: int + triggers: DataConditionGroupSnapshot | None + + class WorkflowManager(BaseManager["Workflow"]): def get_queryset(self) -> BaseQuerySet[Workflow]: return ( @@ -83,7 +94,7 @@ class Workflow(DefaultFieldsModel, OwnerModel, JSONConfigBase): "additionalProperties": False, } - __repr__ = sane_repr("name", "organization_id") + __repr__ = sane_repr("organization_id") class Meta: app_label = "workflow_engine" @@ -92,6 +103,23 @@ class Meta: def get_audit_log_data(self) -> dict[str, Any]: return {"name": self.name} + def get_snapshot(self) -> WorkflowSnapshot: + when_condition_group = None + if self.when_condition_group: + when_condition_group = self.when_condition_group.get_snapshot() + + environment_id = None + if self.environment: + environment_id = self.environment.id + + return { + "id": self.id, + "enabled": self.enabled, + "environment_id": environment_id, + "status": self.status, + "triggers": when_condition_group, + } + def evaluate_trigger_conditions( self, event_data: WorkflowEventData, when_data_conditions: list[DataCondition] | None = None ) -> tuple[TriggerResult, list[DataCondition]]: diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index 831be818cdd373..21d418a574ea02 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -117,7 +117,7 @@ def enqueue_workflows( for queue_item in items_by_workflow.values(): if not queue_item.delayed_if_group_ids and not queue_item.passing_if_group_ids: # Skip because there are no IF groups we could possibly fire actions for if - # the WHEN/IF delayed condtions are met + # the WHEN/IF delayed conditions are met continue project_id = queue_item.event.project_id items_by_project_id[project_id].append(queue_item) @@ -482,7 +482,7 @@ def process_workflows( fire_actions, ) - workflow_evaluation_data = WorkflowEvaluationData(group_event=event_data.event) + workflow_evaluation_data = WorkflowEvaluationData(event=event_data.event) try: if detector is None and isinstance(event_data.event, GroupEvent): diff --git a/src/sentry/workflow_engine/types.py b/src/sentry/workflow_engine/types.py index a3b19363a68c2f..2e151899eaf52d 100644 --- a/src/sentry/workflow_engine/types.py +++ b/src/sentry/workflow_engine/types.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from enum import IntEnum, StrEnum from logging import Logger from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict, TypeVar @@ -89,13 +89,50 @@ class WorkflowEventData: @dataclass class WorkflowEvaluationData: - group_event: GroupEvent | Activity + event: GroupEvent | Activity action_groups: set[DataConditionGroup] | None = None workflows: set[Workflow] | None = None triggered_actions: set[Action] | None = None triggered_workflows: set[Workflow] | None = None associated_detector: Detector | None = None + def get_snapshot(self) -> dict[str, Any]: + """ + This method will take the complex data structures, like models / list of models, + and turn them into the critical attributes of a model or lists of IDs. + """ + + associated_detector = None + if self.associated_detector: + associated_detector = self.associated_detector.get_snapshot() + + workflow_ids = None + if self.workflows: + workflow_ids = [workflow.id for workflow in self.workflows] + + triggered_workflows = None + if self.triggered_workflows: + triggered_workflows = [workflow.get_snapshot() for workflow in self.triggered_workflows] + + action_filter_conditions = None + if self.action_groups: + action_filter_conditions = [group.get_snapshot() for group in self.action_groups] + + triggered_actions = None + if self.triggered_actions: + triggered_actions = [action.get_snapshot() for action in self.triggered_actions] + + return { + "workflow_ids": workflow_ids, + "associated_detector": associated_detector, + "event": self.event, + "group": self.event.group, + "event_data": self.event.data, + "action_filter_conditions": action_filter_conditions, + "triggered_actions": triggered_actions, + "triggered_workflows": triggered_workflows, + } + @dataclass(frozen=True) class WorkflowEvaluation: @@ -134,7 +171,7 @@ def to_log(self, logger: Logger) -> None: else: log_str = f"{log_str}.actions.triggered" - logger.info(log_str, extra={**asdict(self.data), "debug_msg": self.msg}) + logger.info(log_str, extra={**self.data.get_snapshot(), "debug_msg": self.msg}) class ConfigTransformer(ABC): diff --git a/tests/sentry/workflow_engine/test_task.py b/tests/sentry/workflow_engine/test_task.py index c633741dcc245d..3cd89acc56d8e2 100644 --- a/tests/sentry/workflow_engine/test_task.py +++ b/tests/sentry/workflow_engine/test_task.py @@ -154,13 +154,15 @@ def test_process_workflow_activity__no_workflows(self, mock_logger) -> None: mock_logger.info.assert_called_once_with( "workflow_engine.process_workflows.evaluation.workflows.not_triggered", extra={ - "debug_msg": "No workflows are associated with the detector in the event", - "group_event": self.activity, - "action_groups": None, + "workflow_ids": None, + "associated_detector": self.detector.get_snapshot(), + "event": self.activity, + "group": self.activity.group, + "event_data": self.activity.data, + "action_filter_conditions": None, "triggered_actions": None, - "workflows": set(), "triggered_workflows": None, - "associated_detector": self.detector, + "debug_msg": "No workflows are associated with the detector in the event", }, ) @@ -199,13 +201,15 @@ def test_process_workflow_activity__workflows__no_actions( mock_logger.info.assert_called_once_with( "workflow_engine.process_workflows.evaluation.workflows.triggered", extra={ - "debug_msg": "No items were triggered or queued for slow evaluation", - "group_event": self.activity, - "action_groups": None, + "workflow_ids": [self.workflow.id], + "associated_detector": self.detector.get_snapshot(), + "event": self.activity, + "group": self.activity.group, + "event_data": self.activity.data, + "action_filter_conditions": None, "triggered_actions": None, - "workflows": {self.workflow}, - "triggered_workflows": set(), # from the mock - "associated_detector": self.detector, + "triggered_workflows": None, + "debug_msg": "No items were triggered or queued for slow evaluation", }, ) @@ -241,16 +245,51 @@ def test_process_workflow_activity( ) mock_filter_actions.assert_called_once_with({self.action_group}, expected_event_data) + + @mock.patch("sentry.workflow_engine.processors.workflow.evaluate_workflow_triggers") + @mock.patch("sentry.workflow_engine.tasks.workflows.logger") + def test_process_workflow_activity__success_logs( + self, mock_logger, mock_evaluate_workflow_triggers + ) -> None: + self.workflow = self.create_workflow(organization=self.organization) + + # Add additional data to ensure logs work as expected + self.workflow.when_condition_group = self.create_data_condition_group() + self.create_data_condition(condition_group=self.workflow.when_condition_group) + self.workflow.save() + + self.action_group = self.create_data_condition_group(logic_type="any-short") + self.action = self.create_action() + self.create_data_condition_group_action( + condition_group=self.action_group, + action=self.action, + ) + self.create_workflow_data_condition_group(self.workflow, self.action_group) + + self.create_detector_workflow( + detector=self.detector, + workflow=self.workflow, + ) + + mock_evaluate_workflow_triggers.return_value = ({self.workflow}, {}) + process_workflow_activity( + activity_id=self.activity.id, + group_id=self.group.id, + detector_id=self.detector.id, + ) + mock_logger.info.assert_called_once_with( "workflow_engine.process_workflows.evaluation.actions.triggered", extra={ + "workflow_ids": [self.workflow.id], + "associated_detector": self.detector.get_snapshot(), + "event": self.activity, + "group": self.activity.group, + "event_data": self.activity.data, + "action_filter_conditions": [self.action_group.get_snapshot()], + "triggered_actions": [self.action.get_snapshot()], + "triggered_workflows": [self.workflow.get_snapshot()], "debug_msg": None, - "group_event": self.activity, - "action_groups": {self.action_group}, - "triggered_actions": set(), - "workflows": {self.workflow}, - "triggered_workflows": {self.workflow}, - "associated_detector": self.detector, }, )