Skip to content

Commit dce91fa

Browse files
authored
ref(aci): Don't rely on an AlertRule existing in single processing contexts (#101116)
No intended behavioral change here. The aim is to prepare for the move to not requiring AlertRule to exist when single processing by removing mandatory uses of self.alert_rule in single processing-only contexts. * Rather than using a conditionally-defined self.alert_rule, we use a `self._alert_rule: AlertRule | None`, and use a property that asserts in legacy code paths. This makes tool-based validation a bit easier, and avoids some scary hasattr. * Pull the AlertRuleDetectionType from Detector.config rather than from alert_rule; should be identical when both exist. * update_alert_rule_stats/reset_trigger_counts changed to take an AlertRule argument to clarify their requirements; as we allow absent AlertRule, we'll need to make a variant of this logic for Detector-only, but that's for a follow-up. Testing: Existing testing should be sufficient, as no behavior is expected to change here.
1 parent 15255ac commit dce91fa

File tree

2 files changed

+47
-24
lines changed

2 files changed

+47
-24
lines changed

src/sentry/incidents/subscription_processor.py

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Sequence
66
from copy import deepcopy
77
from datetime import datetime, timedelta
8-
from typing import TypeVar, cast
8+
from typing import Literal, TypedDict, TypeVar, cast
99

1010
from django.conf import settings
1111
from django.db import router, transaction
@@ -87,6 +87,15 @@
8787
T = TypeVar("T")
8888

8989

90+
class MetricIssueDetectorConfig(TypedDict):
91+
"""
92+
Schema for Metric Issue Detector.config.
93+
"""
94+
95+
comparison_delta: int | None
96+
detection_type: Literal["static", "percent", "dynamic"]
97+
98+
9099
class SubscriptionProcessor:
91100
"""
92101
Class for processing subscription updates for an alert rule. Accepts a subscription
@@ -107,19 +116,20 @@ class SubscriptionProcessor:
107116

108117
def __init__(self, subscription: QuerySubscription) -> None:
109118
self.subscription = subscription
119+
self._alert_rule: AlertRule | None = None
110120
try:
111-
self.alert_rule = AlertRule.objects.get_for_subscription(subscription)
121+
self._alert_rule = AlertRule.objects.get_for_subscription(subscription)
112122
except AlertRule.DoesNotExist:
113123
return
114124

115-
self.triggers = AlertRuleTrigger.objects.get_for_alert_rule(self.alert_rule)
125+
self.triggers = AlertRuleTrigger.objects.get_for_alert_rule(self._alert_rule)
116126
self.triggers.sort(key=lambda trigger: trigger.alert_threshold)
117127

118128
(
119129
self.last_update,
120130
self.trigger_alert_counts,
121131
self.trigger_resolve_counts,
122-
) = get_alert_rule_stats(self.alert_rule, self.subscription, self.triggers)
132+
) = get_alert_rule_stats(self._alert_rule, self.subscription, self.triggers)
123133
self.orig_trigger_alert_counts = deepcopy(self.trigger_alert_counts)
124134
self.orig_trigger_resolve_counts = deepcopy(self.trigger_resolve_counts)
125135

@@ -135,6 +145,14 @@ def __init__(self, subscription: QuerySubscription) -> None:
135145
or self._has_workflow_engine_processing_only
136146
)
137147

148+
@property
149+
def alert_rule(self) -> AlertRule:
150+
"""
151+
Only use this in non-single processing contexts.
152+
"""
153+
assert self._alert_rule is not None
154+
return self._alert_rule
155+
138156
@property
139157
def active_incident(self) -> Incident | None:
140158
"""
@@ -188,15 +206,15 @@ def check_trigger_matches_status(
188206
incident_trigger = self.incident_trigger_map.get(trigger.id)
189207
return incident_trigger is not None and incident_trigger.status == status.value
190208

191-
def reset_trigger_counts(self) -> None:
209+
def reset_trigger_counts(self, alert_rule: AlertRule) -> None:
192210
"""
193211
Helper method that clears both the trigger alert and the trigger resolve counts
194212
"""
195213
for trigger_id in self.trigger_alert_counts:
196214
self.trigger_alert_counts[trigger_id] = 0
197215
for trigger_id in self.trigger_resolve_counts:
198216
self.trigger_resolve_counts[trigger_id] = 0
199-
self.update_alert_rule_stats()
217+
self.update_alert_rule_stats(alert_rule)
200218

201219
def calculate_resolve_threshold(self, trigger: AlertRuleTrigger) -> float:
202220
"""
@@ -253,8 +271,8 @@ def get_crash_rate_alert_metrics_aggregation_value(
253271
aggregation_value = get_crash_rate_alert_metrics_aggregation_value_helper(
254272
subscription_update
255273
)
256-
if aggregation_value is None:
257-
self.reset_trigger_counts()
274+
if aggregation_value is None and self._alert_rule is not None:
275+
self.reset_trigger_counts(self._alert_rule)
258276
return aggregation_value
259277

260278
def get_aggregation_value(
@@ -271,7 +289,7 @@ def get_aggregation_value(
271289
organization_id=self.subscription.project.organization.id,
272290
project_ids=[self.subscription.project_id],
273291
comparison_delta=comparison_delta,
274-
alert_rule_id=self.alert_rule.id,
292+
alert_rule_id=self._alert_rule.id if self._alert_rule else None,
275293
)
276294

277295
return aggregation_value
@@ -300,7 +318,7 @@ def handle_trigger_anomalies(
300318
is_resolved=False,
301319
)
302320
incremented = metrics_incremented or incremented
303-
incident_trigger = self.trigger_alert_threshold(trigger, aggregation_value)
321+
incident_trigger = self.trigger_alert_threshold(trigger)
304322
if incident_trigger is not None:
305323
fired_incident_triggers.append(incident_trigger)
306324
else:
@@ -332,9 +350,12 @@ def get_comparison_delta(self, detector: Detector | None) -> int | None:
332350
comparison_delta = None
333351

334352
if detector:
335-
comparison_delta = detector.config.get("comparison_delta")
353+
detector_cfg: MetricIssueDetectorConfig = detector.config
354+
comparison_delta = detector_cfg.get("comparison_delta")
336355
else:
337-
comparison_delta = self.alert_rule.comparison_delta
356+
# If we don't have a Detector, we must have an AlertRule.
357+
assert self._alert_rule is not None
358+
comparison_delta = self._alert_rule.comparison_delta
338359

339360
return comparison_delta
340361

@@ -421,7 +442,7 @@ def handle_trigger_alerts(
421442
)
422443
incremented = metrics_incremented or incremented
423444
# triggering a threshold will create an incident and set the status to active
424-
incident_trigger = self.trigger_alert_threshold(trigger, aggregation_value)
445+
incident_trigger = self.trigger_alert_threshold(trigger)
425446
if incident_trigger is not None:
426447
fired_incident_triggers.append(incident_trigger)
427448
else:
@@ -455,11 +476,13 @@ def handle_trigger_alerts(
455476

456477
def process_results_workflow_engine(
457478
self,
479+
detector: Detector,
458480
subscription_update: QuerySubscriptionUpdate,
459481
aggregation_value: float,
460482
organization: Organization,
461483
) -> list[tuple[Detector, dict[DetectorGroupKey, DetectorEvaluationResult]]]:
462-
if self.alert_rule.detection_type == AlertRuleDetectionType.DYNAMIC:
484+
detector_cfg: MetricIssueDetectorConfig = detector.config
485+
if detector_cfg["detection_type"] == AlertRuleDetectionType.DYNAMIC.value:
463486
anomaly_detection_packet = AnomalyDetectionUpdate(
464487
entity=subscription_update.get("entity", ""),
465488
subscription_id=subscription_update["subscription_id"],
@@ -499,14 +522,13 @@ def process_results_workflow_engine(
499522
"results": results,
500523
"num_results": len(results),
501524
"value": aggregation_value,
502-
"rule_id": self.alert_rule.id,
525+
"rule_id": self._alert_rule.id if self._alert_rule else None,
503526
},
504527
)
505528
return results
506529

507530
def process_legacy_metric_alerts(
508531
self,
509-
subscription_update: QuerySubscriptionUpdate,
510532
aggregation_value: float,
511533
detector: Detector | None,
512534
results: list[tuple[Detector, dict[DetectorGroupKey, DetectorEvaluationResult]]] | None,
@@ -632,7 +654,7 @@ def process_legacy_metric_alerts(
632654
# is killed here. The trade-off is that we might process an update twice. Mostly
633655
# this will have no effect, but if someone manages to close a triggered incident
634656
# before the next one then we might alert twice.
635-
self.update_alert_rule_stats()
657+
self.update_alert_rule_stats(self.alert_rule)
636658
return fired_incident_triggers
637659

638660
def has_downgraded(self, dataset: str, organization: Organization) -> bool:
@@ -677,7 +699,7 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> None:
677699
if self.has_downgraded(dataset, organization):
678700
return
679701

680-
if not hasattr(self, "alert_rule"):
702+
if self._alert_rule is None:
681703
# QuerySubscriptions must _always_ have an associated AlertRule
682704
# If the alert rule has been removed then clean up associated tables and return
683705
metrics.incr("incidents.alert_rules.no_alert_rule_for_subscription", sample_rate=1.0)
@@ -736,8 +758,9 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> None:
736758
legacy_results = None
737759

738760
if self._has_workflow_engine_processing:
761+
assert detector is not None
739762
workflow_engine_results = self.process_results_workflow_engine(
740-
subscription_update, aggregation_value, organization
763+
detector, subscription_update, aggregation_value, organization
741764
)
742765

743766
if self._has_workflow_engine_processing_only:
@@ -756,7 +779,6 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> None:
756779
workflow engine "and" metric alerts.
757780
"""
758781
legacy_results = self.process_legacy_metric_alerts(
759-
subscription_update,
760782
aggregation_value,
761783
detector,
762784
workflow_engine_results,
@@ -775,7 +797,8 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> None:
775797
)
776798

777799
def trigger_alert_threshold(
778-
self, trigger: AlertRuleTrigger, metric_value: float
800+
self,
801+
trigger: AlertRuleTrigger,
779802
) -> IncidentTrigger | None:
780803
"""
781804
Called when a subscription update exceeds the value defined in the
@@ -1019,7 +1042,7 @@ def handle_incident_severity_update(self) -> None:
10191042
status_method=IncidentStatusMethod.RULE_TRIGGERED,
10201043
)
10211044

1022-
def update_alert_rule_stats(self) -> None:
1045+
def update_alert_rule_stats(self, alert_rule: AlertRule) -> None:
10231046
"""
10241047
Updates stats about the alert rule, if they're changed.
10251048
:return:
@@ -1036,7 +1059,7 @@ def update_alert_rule_stats(self) -> None:
10361059
}
10371060

10381061
update_alert_rule_stats(
1039-
self.alert_rule,
1062+
alert_rule,
10401063
self.subscription,
10411064
self.last_update,
10421065
updated_trigger_alert_counts,

tests/sentry/incidents/subscription_processor/test_subscription_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3481,7 +3481,7 @@ def test_seer_call_null_aggregation_value(
34813481

34823482
mock_seer_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
34833483
processor = SubscriptionProcessor(self.sub)
3484-
processor.alert_rule = self.dynamic_rule
3484+
processor._alert_rule = self.dynamic_rule
34853485
result = get_anomaly_data_from_seer_legacy(
34863486
alert_rule=processor.alert_rule,
34873487
subscription=processor.subscription,

0 commit comments

Comments
 (0)