Skip to content

feat(workflow_engine): Add support for Issue Status Changes #93489

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
11 changes: 10 additions & 1 deletion src/sentry/models/activity.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"]
Expand Down
35 changes: 33 additions & 2 deletions src/sentry/workflow_engine/processors/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
37 changes: 35 additions & 2 deletions src/sentry/workflow_engine/processors/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@

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.types.activity import ActivityType
from sentry.types.group import GroupSubStatus
from sentry.utils import json
from sentry.workflow_engine.models import (
Action,
Expand Down Expand Up @@ -98,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,
Expand All @@ -122,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],
Expand Down Expand Up @@ -311,3 +319,28 @@ def process_workflows(event_data: WorkflowEventData) -> set[Workflow]:
metrics_incr("process_workflows.fired_actions")

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 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=has_reappeared,
has_escalated=has_escalated,
)

process_workflows(workflow_event_data)
Loading
Loading