Skip to content

Commit 691e18b

Browse files
mifu67ceorourke
andauthored
test(aci milestone 3): rewrite subscription processor tests for workflow engine (#95487)
Duplicate tests from `subscription_processor.py` but rewritten for workflow engine. These will be used for single processing to ensure we don't lose test coverage. This has a lot of tests but isn't comprehensive, there will be follow up PRs for: * Anomaly detection tests * Adding `assert_open_period` and `assert_no_open_period` methods and adding those to tests (waiting on #97621) * Early return scenarios * 10 minute cooldown tests --------- Co-authored-by: Colleen O'Rourke <[email protected]>
1 parent 2358fc7 commit 691e18b

File tree

4 files changed

+567
-1
lines changed

4 files changed

+567
-1
lines changed

src/sentry/incidents/subscription_processor.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,10 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> None:
514514
results = self.process_results_workflow_engine(
515515
subscription_update, aggregation_value, organization
516516
)
517+
else:
518+
# XXX: after we fully migrate to single processing we can return early here
519+
# this just preserves test functionality for now
520+
metrics.incr("incidents.alert_rules.skipping_update_invalid_aggregation_value")
517521

518522
if has_metric_issue_single_processing:
519523
# don't go through the legacy system
@@ -538,7 +542,6 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> None:
538542
return
539543

540544
if aggregation_value is None:
541-
metrics.incr("incidents.alert_rules.skipping_update_invalid_aggregation_value")
542545
return
543546

544547
fired_incident_triggers: list[IncidentTrigger] = []
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
import copy
2+
from datetime import timedelta
3+
from functools import cached_property
4+
from unittest.mock import call, patch
5+
6+
from django.utils import timezone
7+
8+
from sentry.testutils.factories import DEFAULT_EVENT_DATA
9+
from sentry.workflow_engine.models.data_condition import Condition, DataCondition
10+
from sentry.workflow_engine.types import DetectorPriorityLevel
11+
from tests.sentry.incidents.subscription_processor.test_subscription_processor_base import (
12+
ProcessUpdateBaseClass,
13+
)
14+
15+
16+
class ProcessUpdateTest(ProcessUpdateBaseClass):
17+
"""
18+
Test early return scenarios + simple cases.
19+
"""
20+
21+
# TODO: tests for early return scenarios. These will need to be added once
22+
# we've decoupled the subscription processor from the alert rule model.
23+
24+
def test_simple(self) -> None:
25+
"""
26+
Verify that an alert can trigger.
27+
"""
28+
self.send_update(self.critical_threshold + 1)
29+
assert self.get_detector_state(self.metric_detector) == DetectorPriorityLevel.HIGH
30+
31+
def test_resolve(self) -> None:
32+
detector = self.metric_detector
33+
self.send_update(self.critical_threshold + 1, timedelta(minutes=-2))
34+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
35+
36+
self.send_update(self.resolve_threshold - 1, timedelta(minutes=-1))
37+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
38+
39+
def test_resolve_percent_boundary(self) -> None:
40+
detector = self.metric_detector
41+
self.update_threshold(detector, DetectorPriorityLevel.HIGH, 0.5)
42+
self.update_threshold(detector, DetectorPriorityLevel.OK, 0.5)
43+
self.send_update(self.critical_threshold + 0.1, timedelta(minutes=-2))
44+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
45+
46+
self.send_update(self.resolve_threshold, timedelta(minutes=-1))
47+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
48+
49+
def test_reversed(self) -> None:
50+
"""
51+
Test that resolutions work when the threshold is reversed.
52+
"""
53+
detector = self.metric_detector
54+
DataCondition.objects.filter(condition_group=detector.workflow_condition_group).delete()
55+
self.set_up_data_conditions(detector, Condition.LESS, 100, None, 100)
56+
self.send_update(self.critical_threshold - 1, timedelta(minutes=-2))
57+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
58+
59+
self.send_update(self.resolve_threshold, timedelta(minutes=-1))
60+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
61+
62+
def test_multiple_triggers(self) -> None:
63+
detector = self.metric_detector
64+
DataCondition.objects.filter(condition_group=detector.workflow_condition_group).delete()
65+
self.set_up_data_conditions(detector, Condition.GREATER, 100, 50, 50)
66+
67+
self.send_update(self.warning_threshold + 1, timedelta(minutes=-5))
68+
assert self.get_detector_state(detector) == DetectorPriorityLevel.MEDIUM
69+
70+
self.send_update(self.critical_threshold + 1, timedelta(minutes=-4))
71+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
72+
73+
self.send_update(self.critical_threshold - 1, timedelta(minutes=-3))
74+
assert self.get_detector_state(detector) == DetectorPriorityLevel.MEDIUM
75+
76+
self.send_update(self.warning_threshold - 1, timedelta(minutes=-2))
77+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
78+
79+
def test_multiple_triggers_reversed(self) -> None:
80+
detector = self.metric_detector
81+
DataCondition.objects.filter(condition_group=detector.workflow_condition_group).delete()
82+
self.set_up_data_conditions(detector, Condition.LESS, 50, 100, 100)
83+
84+
self.send_update(self.warning_threshold - 1, timedelta(minutes=-5))
85+
assert self.get_detector_state(detector) == DetectorPriorityLevel.MEDIUM
86+
87+
self.send_update(self.critical_threshold - 1, timedelta(minutes=-4))
88+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
89+
90+
self.send_update(self.critical_threshold + 1, timedelta(minutes=-3))
91+
assert self.get_detector_state(detector) == DetectorPriorityLevel.MEDIUM
92+
93+
self.send_update(self.warning_threshold + 1, timedelta(minutes=-2))
94+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
95+
96+
# TODO: the subscription processor has a 10 minute cooldown period for creating new incidents
97+
# We probably need similar logic within workflow engine.
98+
99+
100+
class ProcessUpdateComparisonAlertTest(ProcessUpdateBaseClass):
101+
@cached_property
102+
def comparison_detector_above(self):
103+
detector = self.metric_detector
104+
detector.config.update({"comparison_delta": 60 * 60})
105+
detector.save()
106+
self.update_threshold(detector, DetectorPriorityLevel.HIGH, 150)
107+
self.update_threshold(detector, DetectorPriorityLevel.OK, 150)
108+
snuba_query = self.get_snuba_query(detector)
109+
snuba_query.update(time_window=60 * 60)
110+
return detector
111+
112+
@cached_property
113+
def comparison_detector_below(self):
114+
detector = self.metric_detector
115+
detector.config.update({"comparison_delta": 60 * 60})
116+
detector.save()
117+
DataCondition.objects.filter(condition_group=detector.workflow_condition_group).delete()
118+
self.set_up_data_conditions(detector, Condition.LESS, 50, None, 50)
119+
snuba_query = self.get_snuba_query(detector)
120+
snuba_query.update(time_window=60 * 60)
121+
return detector
122+
123+
@patch("sentry.incidents.utils.process_update_helpers.metrics")
124+
def test_comparison_alert_above(self, helper_metrics):
125+
detector = self.comparison_detector_above
126+
comparison_delta = timedelta(seconds=detector.config["comparison_delta"])
127+
self.send_update(self.critical_threshold + 1, timedelta(minutes=-10))
128+
129+
# Shouldn't trigger, since there should be no data in the comparison period
130+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
131+
helper_metrics.incr.assert_has_calls(
132+
[
133+
call("incidents.alert_rules.skipping_update_comparison_value_invalid"),
134+
]
135+
)
136+
self.metrics.incr.assert_has_calls(
137+
[
138+
call("incidents.alert_rules.skipping_update_invalid_aggregation_value"),
139+
]
140+
)
141+
comparison_date = timezone.now() - comparison_delta
142+
143+
for i in range(4):
144+
self.store_event(
145+
data={
146+
"timestamp": (comparison_date - timedelta(minutes=30 + i)).isoformat(),
147+
"environment": self.environment.name,
148+
},
149+
project_id=self.project.id,
150+
)
151+
self.metrics.incr.reset_mock()
152+
self.send_update(2, timedelta(minutes=-9))
153+
# Shouldn't trigger, since there are 4 events in the comparison period, and 2/4 == 50%
154+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
155+
156+
self.send_update(4, timedelta(minutes=-8))
157+
# Shouldn't trigger, since there are 4 events in the comparison period, and 4/4 == 100%
158+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
159+
160+
self.send_update(6, timedelta(minutes=-7))
161+
# Shouldn't trigger: 6/4 == 150%, but we want > 150%
162+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
163+
164+
self.send_update(7, timedelta(minutes=-6))
165+
# Should trigger: 7/4 == 175% > 150%
166+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
167+
168+
# Check that we successfully resolve
169+
self.send_update(6, timedelta(minutes=-5))
170+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
171+
172+
@patch("sentry.incidents.utils.process_update_helpers.metrics")
173+
def test_comparison_alert_below(self, helper_metrics):
174+
detector = self.comparison_detector_below
175+
comparison_delta = timedelta(seconds=detector.config["comparison_delta"])
176+
self.send_update(self.critical_threshold - 1, timedelta(minutes=-10))
177+
178+
# Shouldn't trigger, since there should be no data in the comparison period
179+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
180+
helper_metrics.incr.assert_has_calls(
181+
[
182+
call("incidents.alert_rules.skipping_update_comparison_value_invalid"),
183+
]
184+
)
185+
self.metrics.incr.assert_has_calls(
186+
[
187+
call("incidents.alert_rules.skipping_update_invalid_aggregation_value"),
188+
]
189+
)
190+
comparison_date = timezone.now() - comparison_delta
191+
192+
for i in range(4):
193+
self.store_event(
194+
data={
195+
"timestamp": (comparison_date - timedelta(minutes=30 + i)).isoformat(),
196+
"environment": self.environment.name,
197+
},
198+
project_id=self.project.id,
199+
)
200+
201+
self.metrics.incr.reset_mock()
202+
self.send_update(6, timedelta(minutes=-9))
203+
# Shouldn't trigger, since there are 4 events in the comparison period, and 6/4 == 150%
204+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
205+
206+
self.send_update(4, timedelta(minutes=-8))
207+
# Shouldn't trigger, since there are 4 events in the comparison period, and 4/4 == 100%
208+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
209+
210+
self.send_update(2, timedelta(minutes=-7))
211+
# Shouldn't trigger: 2/4 == 50%, but we want < 50%
212+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
213+
214+
self.send_update(1, timedelta(minutes=-6))
215+
# Should trigger: 1/4 == 25% < 50%
216+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
217+
218+
# Check that we successfully resolve
219+
self.send_update(2, timedelta(minutes=-5))
220+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
221+
222+
@patch("sentry.incidents.utils.process_update_helpers.metrics")
223+
def test_is_unresolved_comparison_query(self, helper_metrics):
224+
"""
225+
Test that uses the ErrorsQueryBuilder (because of the specific query)
226+
"""
227+
detector = self.comparison_detector_above
228+
comparison_delta = timedelta(seconds=detector.config["comparison_delta"])
229+
snuba_query = self.get_snuba_query(detector)
230+
snuba_query.update(query="(event.type:error) AND (is:unresolved)")
231+
232+
self.send_update(self.critical_threshold + 1, timedelta(minutes=-10), subscription=self.sub)
233+
helper_metrics.incr.assert_has_calls(
234+
[
235+
call("incidents.alert_rules.skipping_update_comparison_value_invalid"),
236+
]
237+
)
238+
self.metrics.incr.assert_has_calls(
239+
[
240+
call("incidents.alert_rules.skipping_update_invalid_aggregation_value"),
241+
]
242+
)
243+
comparison_date = timezone.now() - comparison_delta
244+
245+
for i in range(4):
246+
data = {
247+
"timestamp": (comparison_date - timedelta(minutes=30 + i)).isoformat(),
248+
"environment": self.environment.name,
249+
"stacktrace": copy.deepcopy(DEFAULT_EVENT_DATA["stacktrace"]),
250+
"fingerprint": ["group2"],
251+
"level": "error",
252+
"exception": {
253+
"values": [
254+
{
255+
"type": "IntegrationError",
256+
"value": "Identity not found.",
257+
}
258+
]
259+
},
260+
}
261+
self.store_event(
262+
data=data,
263+
project_id=self.project.id,
264+
)
265+
266+
self.metrics.incr.reset_mock()
267+
self.send_update(2, timedelta(minutes=-9))
268+
# Shouldn't trigger, since there are 4 events in the comparison period, and 2/4 == 50%
269+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
270+
271+
self.send_update(4, timedelta(minutes=-8))
272+
# Shouldn't trigger, since there are 4 events in the comparison period, and 4/4 == 100%
273+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
274+
275+
self.send_update(6, timedelta(minutes=-7))
276+
# Shouldn't trigger: 6/4 == 150%, but we want > 150%
277+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
278+
279+
self.send_update(7, timedelta(minutes=-6))
280+
# Should trigger: 7/4 == 175% > 150%
281+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
282+
283+
# Check that we successfully resolve
284+
self.send_update(6, timedelta(minutes=-5))
285+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
286+
287+
@patch("sentry.incidents.utils.process_update_helpers.metrics")
288+
def test_is_unresolved_different_aggregate(self, helper_metrics):
289+
detector = self.comparison_detector_above
290+
comparison_delta = timedelta(seconds=detector.config["comparison_delta"])
291+
snuba_query = self.get_snuba_query(detector)
292+
snuba_query.update(aggregate="count_unique(tags[sentry:user])")
293+
294+
self.send_update(self.critical_threshold + 1, timedelta(minutes=-10), subscription=self.sub)
295+
helper_metrics.incr.assert_has_calls(
296+
[
297+
call("incidents.alert_rules.skipping_update_comparison_value_invalid"),
298+
]
299+
)
300+
self.metrics.incr.assert_has_calls(
301+
[
302+
call("incidents.alert_rules.skipping_update_invalid_aggregation_value"),
303+
]
304+
)
305+
comparison_date = timezone.now() - comparison_delta
306+
307+
for i in range(4):
308+
self.store_event(
309+
data={
310+
"timestamp": (comparison_date - timedelta(minutes=30 + i)).isoformat(),
311+
"environment": self.environment.name,
312+
"tags": {"sentry:user": i},
313+
},
314+
project_id=self.project.id,
315+
)
316+
317+
self.metrics.incr.reset_mock()
318+
self.send_update(2, timedelta(minutes=-9))
319+
# Shouldn't trigger, since there are 4 events in the comparison period, and 2/4 == 50%
320+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
321+
322+
self.send_update(4, timedelta(minutes=-8))
323+
# Shouldn't trigger, since there are 4 events in the comparison period, and 4/4 == 100%
324+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
325+
326+
self.send_update(6, timedelta(minutes=-7))
327+
# Shouldn't trigger: 6/4 == 150%, but we want > 150%
328+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK
329+
330+
self.send_update(7, timedelta(minutes=-6))
331+
# Should trigger: 7/4 == 175% > 150%
332+
assert self.get_detector_state(detector) == DetectorPriorityLevel.HIGH
333+
334+
# Check that we successfully resolve
335+
self.send_update(6, timedelta(minutes=-5))
336+
assert self.get_detector_state(detector) == DetectorPriorityLevel.OK

0 commit comments

Comments
 (0)