Skip to content

Commit 14a772b

Browse files
committed
fixes
1 parent f129281 commit 14a772b

File tree

8 files changed

+295
-46
lines changed

8 files changed

+295
-46
lines changed

src/sentry/workflow_engine/handlers/detector/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@
44
"DetectorHandler",
55
"DetectorOccurrence",
66
"DetectorStateData",
7+
"GroupedDetectorEvaluationResult",
78
"StatefulDetectorHandler",
89
]
910

10-
from .base import DataPacketEvaluationType, DataPacketType, DetectorHandler, DetectorOccurrence
11+
from .base import (
12+
DataPacketEvaluationType,
13+
DataPacketType,
14+
DetectorHandler,
15+
DetectorOccurrence,
16+
GroupedDetectorEvaluationResult,
17+
)
1118
from .stateful import DetectorStateData, StatefulDetectorHandler

src/sentry/workflow_engine/models/workflow.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from sentry.workflow_engine.models.data_condition import DataCondition, is_slow_condition
2020
from sentry.workflow_engine.models.data_condition_group import DataConditionGroup
2121
from sentry.workflow_engine.processors.data_condition_group import TriggerResult
22-
from sentry.workflow_engine.types import WorkflowEventData
22+
from sentry.workflow_engine.types import ConditionError, WorkflowEventData
2323

2424
from .json_config import JSONConfigBase
2525

@@ -117,7 +117,7 @@ def evaluate_trigger_conditions(
117117
"DataConditionGroup does not exist",
118118
extra={"id": self.when_condition_group_id},
119119
)
120-
return TriggerResult.FALSE, []
120+
return TriggerResult(False, ConditionError(msg="DataConditionGroup does not exist")), []
121121
group_evaluation, remaining_conditions = process_data_condition_group(
122122
group, workflow_event_data, when_data_conditions
123123
)

src/sentry/workflow_engine/processors/data_condition_group.py

Lines changed: 101 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import dataclasses
22
import logging
3-
from collections.abc import Iterable
3+
from collections.abc import Callable, Iterable
44
from typing import ClassVar, NoReturn, TypeVar
55

66
import sentry_sdk
@@ -17,6 +17,13 @@
1717
T = TypeVar("T")
1818

1919

20+
def _find_error(
21+
items: list["TriggerResult"], predicate: Callable[["TriggerResult"], bool]
22+
) -> ConditionError | None:
23+
"""Helper to find an error from items matching the predicate."""
24+
return next((item.error for item in items if predicate(item)), None)
25+
26+
2027
@dataclasses.dataclass(frozen=True)
2128
class TriggerResult:
2229
"""
@@ -39,36 +46,107 @@ class TriggerResult:
3946
TRUE: ClassVar["TriggerResult"]
4047
FALSE: ClassVar["TriggerResult"]
4148

49+
def is_tainted(self) -> bool:
50+
"""
51+
Returns True if this result is less trustworthy due to an error during
52+
evaluation.
53+
"""
54+
return self.error is not None
55+
4256
@staticmethod
4357
def any(items: Iterable["TriggerResult"]) -> "TriggerResult":
58+
"""
59+
Like `any()`, but for TriggerResult. If any inputs had errors that could
60+
impact the result, the result will contain an error from one of them.
61+
"""
4462
items_list = list(items)
45-
for _ in (item for item in items_list if item.triggered and item.error is None):
46-
return TriggerResult(triggered=True, error=None)
47-
# If we didn't have any untained Trues, the result is tainted.
48-
some_error = next((item.error for item in items_list if item.error is not None), None)
49-
return TriggerResult(triggered=any(item.triggered for item in items_list), error=some_error)
63+
result = any(item.triggered for item in items_list)
64+
65+
if result:
66+
# Result is True. If we have any untainted True, the result is clean.
67+
# Only tainted if all Trues are tainted.
68+
if any(item.triggered and not item.is_tainted() for item in items_list):
69+
return TriggerResult(triggered=True, error=None)
70+
# All Trues are tainted
71+
return TriggerResult(
72+
triggered=True, error=_find_error(items_list, lambda x: x.triggered)
73+
)
74+
else:
75+
# Result is False. Any tainted item could have changed the result.
76+
return TriggerResult(
77+
triggered=False,
78+
error=_find_error(items_list, lambda x: x.is_tainted()),
79+
)
5080

5181
@staticmethod
5282
def all(items: Iterable["TriggerResult"]) -> "TriggerResult":
83+
"""
84+
Like `all()`, but for TriggerResult. If any inputs had errors that could
85+
impact the result, the result will contain an error from one of them.
86+
"""
87+
items_list = list(items)
88+
result = all(item.triggered for item in items_list)
89+
90+
if result:
91+
# Result is True. Any tainted item could have changed the result.
92+
return TriggerResult(
93+
triggered=True,
94+
error=_find_error(items_list, lambda x: x.is_tainted()),
95+
)
96+
else:
97+
# Result is False. If we have any untainted False, the result is clean.
98+
# Only tainted if all Falses are tainted.
99+
if any(not item.triggered and not item.is_tainted() for item in items_list):
100+
return TriggerResult(triggered=False, error=None)
101+
# All Falses are tainted
102+
return TriggerResult(
103+
triggered=False,
104+
error=_find_error(items_list, lambda x: not x.triggered),
105+
)
106+
107+
@staticmethod
108+
def none(items: Iterable["TriggerResult"]) -> "TriggerResult":
109+
"""
110+
Like `not any()`, but for TriggerResult. If any inputs had errors that could
111+
impact the result, the result will contain an error from one of them.
112+
"""
53113
items_list = list(items)
54-
some_error = next((item.error for item in items_list if item.error is not None), None)
55-
# if anything was tainted, the result is tainted.
56-
return TriggerResult(
57-
triggered=all(item.triggered for item in items_list),
58-
error=some_error,
59-
)
114+
115+
# No items is guaranteed True, no possible error.
116+
if not items_list:
117+
return TriggerResult(triggered=True, error=None)
118+
119+
result = all(not item.triggered for item in items_list)
120+
121+
if result:
122+
# Result is True (no conditions triggered)
123+
# Any tainted item could have changed the result
124+
return TriggerResult(
125+
triggered=True,
126+
error=_find_error(items_list, lambda x: x.is_tainted()),
127+
)
128+
else:
129+
# Result is False (at least one condition triggered)
130+
# If we have any untainted True, the result is clean
131+
if any(item.triggered and not item.is_tainted() for item in items_list):
132+
return TriggerResult(triggered=False, error=None)
133+
# All triggered items are tainted
134+
return TriggerResult(
135+
triggered=False,
136+
error=_find_error(items_list, lambda x: x.triggered),
137+
)
60138

61139
def __or__(self, other: "TriggerResult") -> "TriggerResult":
62-
return TriggerResult(
63-
triggered=self.triggered or other.triggered,
64-
error=self.error or other.error,
65-
)
140+
"""
141+
OR operation, equivalent to TriggerResult.any([self, other]).
142+
"""
143+
return TriggerResult.any([self, other])
66144

67145
def __and__(self, other: "TriggerResult") -> "TriggerResult":
68-
return TriggerResult(
69-
triggered=self.triggered and other.triggered,
70-
error=self.error or other.error,
71-
)
146+
"""
147+
AND operation, equivalent to TriggerResult.all([self, other]).
148+
"""
149+
return TriggerResult.all([self, other])
72150

73151
def __bool__(self) -> NoReturn:
74152
raise AssertionError("TriggerResult cannot be used as a boolean")
@@ -146,7 +224,9 @@ def evaluate_condition_group_results(
146224
if logic_type == DataConditionGroup.Type.NONE:
147225
# if we get to this point, no conditions were met
148226
# because we would have short-circuited
149-
logic_result = TriggerResult.TRUE
227+
logic_result = TriggerResult.none(
228+
condition_result.logic_result for condition_result in condition_results
229+
)
150230

151231
elif logic_type == DataConditionGroup.Type.ANY:
152232
logic_result = TriggerResult.any(

tests/sentry/workflow_engine/endpoints/test_organization_detector_types.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
from sentry.testutils.cases import APITestCase
1313
from sentry.testutils.silo import region_silo_test
1414
from sentry.uptime.grouptype import UptimeDomainCheckFailure
15-
from sentry.workflow_engine.handlers.detector import DetectorHandler, DetectorOccurrence
15+
from sentry.workflow_engine.handlers.detector import (
16+
DetectorHandler,
17+
DetectorOccurrence,
18+
GroupedDetectorEvaluationResult,
19+
)
1620
from sentry.workflow_engine.handlers.detector.base import EventData
1721
from sentry.workflow_engine.models import DataPacket
1822
from sentry.workflow_engine.processors.data_condition_group import ProcessedDataConditionGroup
1923
from sentry.workflow_engine.types import (
2024
DetectorEvaluationResult,
21-
DetectorGroupKey,
2225
DetectorPriorityLevel,
2326
DetectorSettings,
2427
)
@@ -39,10 +42,13 @@ def setUp(self) -> None:
3942
self.registry_patcher.start()
4043

4144
class MockDetectorHandler(DetectorHandler[dict, bool]):
42-
def evaluate(
45+
def evaluate_impl(
4346
self, data_packet: DataPacket[dict]
44-
) -> dict[DetectorGroupKey, DetectorEvaluationResult]:
45-
return {None: DetectorEvaluationResult(None, True, DetectorPriorityLevel.HIGH)}
47+
) -> GroupedDetectorEvaluationResult:
48+
return GroupedDetectorEvaluationResult(
49+
result={None: DetectorEvaluationResult(None, True, DetectorPriorityLevel.HIGH)},
50+
tainted=False,
51+
)
4652

4753
def extract_value(self, data_packet: DataPacket[dict]) -> bool:
4854
return True

tests/sentry/workflow_engine/handlers/detector/test_base.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
DataPacketEvaluationType,
1111
DetectorHandler,
1212
DetectorOccurrence,
13+
GroupedDetectorEvaluationResult,
1314
StatefulDetectorHandler,
1415
)
1516
from sentry.workflow_engine.handlers.detector.stateful import DetectorCounters
@@ -99,10 +100,13 @@ class NoHandlerGroupType(GroupType):
99100
category_v2 = GroupCategory.METRIC_ALERT.value
100101

101102
class MockDetectorHandler(DetectorHandler[dict, int]):
102-
def evaluate(
103+
def evaluate_impl(
103104
self, data_packet: DataPacket[dict]
104-
) -> dict[DetectorGroupKey, DetectorEvaluationResult]:
105-
return {None: DetectorEvaluationResult(None, True, DetectorPriorityLevel.HIGH)}
105+
) -> GroupedDetectorEvaluationResult:
106+
return GroupedDetectorEvaluationResult(
107+
result={None: DetectorEvaluationResult(None, True, DetectorPriorityLevel.HIGH)},
108+
tainted=False,
109+
)
106110

107111
def extract_value(self, data_packet: DataPacket[dict]) -> int:
108112
return data_packet.packet.get("value", 0)
@@ -120,21 +124,24 @@ def extract_dedupe_value(self, data_packet: DataPacket[dict]) -> int:
120124
return data_packet.packet.get("dedupe", 0)
121125

122126
class MockDetectorWithUpdateHandler(DetectorHandler[dict, int]):
123-
def evaluate(
127+
def evaluate_impl(
124128
self, data_packet: DataPacket[dict]
125-
) -> dict[DetectorGroupKey, DetectorEvaluationResult]:
129+
) -> GroupedDetectorEvaluationResult:
126130
status_change = StatusChangeMessage(
127131
"test_update",
128132
project_id,
129133
DetectorPriorityLevel.HIGH,
130134
None,
131135
)
132136

133-
return {
134-
None: DetectorEvaluationResult(
135-
None, True, DetectorPriorityLevel.HIGH, status_change
136-
)
137-
}
137+
return GroupedDetectorEvaluationResult(
138+
result={
139+
None: DetectorEvaluationResult(
140+
None, True, DetectorPriorityLevel.HIGH, status_change
141+
)
142+
},
143+
tainted=False,
144+
)
138145

139146
def create_occurrence(
140147
self,

tests/sentry/workflow_engine/models/test_workflow.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,21 @@ def test_queryset(self) -> None:
3232

3333
def test_evaluate_trigger_conditions__condition_new_event__True(self) -> None:
3434
evaluation, _ = self.workflow.evaluate_trigger_conditions(self.event_data)
35-
assert evaluation is True
35+
assert evaluation.triggered is True
3636

3737
def test_evaluate_trigger_conditions__condition_new_event__False(self) -> None:
3838
# Update event to have been seen before
3939
self.group_event.group.times_seen = 5
4040

4141
evaluation, _ = self.workflow.evaluate_trigger_conditions(self.event_data)
42-
assert evaluation is False
42+
assert evaluation.triggered is False
4343

4444
def test_evaluate_trigger_conditions__no_conditions(self) -> None:
4545
self.workflow.when_condition_group = None
4646
self.workflow.save()
4747

4848
evaluation, _ = self.workflow.evaluate_trigger_conditions(self.event_data)
49-
assert evaluation is True
49+
assert evaluation.triggered is True
5050

5151
def test_evaluate_trigger_conditions__slow_condition(self) -> None:
5252
# Update group to _all_, since the fast condition is met
@@ -60,7 +60,7 @@ def test_evaluate_trigger_conditions__slow_condition(self) -> None:
6060
self.event_data
6161
)
6262

63-
assert evaluation is True
63+
assert evaluation.triggered is True
6464
assert remaining_conditions == [slow_condition]
6565

6666
def test_full_clean__success(self) -> None:

0 commit comments

Comments
 (0)