Skip to content

Commit 30e2e32

Browse files
authored
feat(incidents): Add is_metric_subscription_allowed; use it (#112241)
There are a couple places where we check features and datasets to determine whether a subscription is allowed to be enabled. This defines that somewhere central and uses it in subscription processing. Once it lands, we'll extend it to some getsentry logic.
1 parent cbb90a8 commit 30e2e32

File tree

4 files changed

+149
-139
lines changed

4 files changed

+149
-139
lines changed

src/sentry/incidents/subscription_processor.py

Lines changed: 13 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
get_comparison_aggregation_value,
1616
get_crash_rate_alert_metrics_aggregation_value_helper,
1717
)
18+
from sentry.incidents.utils.subscription_limits import is_metric_subscription_allowed
1819
from sentry.incidents.utils.types import (
1920
DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION,
2021
AnomalyDetectionUpdate,
@@ -53,46 +54,6 @@ class MetricIssueDetectorConfig(TypedDict):
5354
detection_type: Literal["static", "percent", "dynamic"]
5455

5556

56-
def has_downgraded(dataset: str, organization: Organization) -> bool:
57-
"""
58-
Check if the organization has downgraded since the subscription was created.
59-
"""
60-
supports_metrics_issues = features.has("organizations:incidents", organization)
61-
if dataset == Dataset.Events.value and not supports_metrics_issues:
62-
metrics.incr("incidents.alert_rules.ignore_update_missing_incidents")
63-
return True
64-
65-
supports_performance_view = features.has("organizations:performance-view", organization)
66-
if dataset == Dataset.Transactions.value and not (
67-
supports_metrics_issues and supports_performance_view
68-
):
69-
metrics.incr("incidents.alert_rules.ignore_update_missing_incidents_performance")
70-
return True
71-
72-
supports_explore_view = features.has("organizations:visibility-explore-view", organization)
73-
if dataset == Dataset.EventsAnalyticsPlatform.value and not (
74-
supports_metrics_issues and supports_explore_view
75-
):
76-
metrics.incr("incidents.alert_rules.ignore_update_missing_incidents_eap")
77-
return True
78-
79-
if dataset == Dataset.PerformanceMetrics.value and not features.has(
80-
"organizations:on-demand-metrics-extraction", organization
81-
):
82-
metrics.incr("incidents.alert_rules.ignore_update_missing_on_demand")
83-
return True
84-
85-
if not supports_metrics_issues:
86-
# These should probably be downgraded, but we should know the impact first.
87-
metrics.incr(
88-
"incidents.alert_rules.no_incidents_not_downgraded",
89-
sample_rate=1.0,
90-
tags={"dataset": dataset},
91-
)
92-
93-
return False
94-
95-
9657
class SubscriptionProcessor:
9758
"""
9859
Class for processing subscription updates for workflow engine.
@@ -268,9 +229,20 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> bool:
268229
dataset = self.subscription.snuba_query.dataset
269230
organization = self.subscription.project.organization
270231

271-
if has_downgraded(dataset, organization):
232+
if not is_metric_subscription_allowed(dataset, organization):
233+
metrics.incr(
234+
"incidents.alert_rules.ignore_update_not_enabled",
235+
tags={"dataset": dataset},
236+
)
272237
return False
273238

239+
if not features.has("organizations:incidents", organization):
240+
metrics.incr(
241+
"incidents.alert_rules.no_incidents_not_downgraded",
242+
sample_rate=1.0,
243+
tags={"dataset": dataset},
244+
)
245+
274246
if subscription_update["timestamp"] <= self.last_update:
275247
metrics.incr("incidents.alert_rules.skipping_already_processed_update")
276248
return False

src/sentry/incidents/utils/subscription_limits.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
1+
from __future__ import annotations
2+
13
from django.conf import settings
24

3-
from sentry import options
5+
from sentry import features, options
46
from sentry.models.organization import Organization
7+
from sentry.snuba.dataset import Dataset
8+
9+
10+
def is_metric_subscription_allowed(dataset: str, organization: Organization) -> bool:
11+
"""
12+
Check whether the given organization is allowed to have a metric alert
13+
subscription for the given dataset.
14+
15+
Returns True if allowed, False if the organization lacks the required features
16+
(e.g. after a plan downgrade).
17+
"""
18+
has_incidents = features.has("organizations:incidents", organization)
19+
if dataset == Dataset.Events.value:
20+
return has_incidents
21+
22+
if dataset == Dataset.Transactions.value:
23+
return has_incidents and features.has("organizations:performance-view", organization)
24+
25+
if dataset == Dataset.EventsAnalyticsPlatform.value:
26+
return has_incidents and features.has("organizations:visibility-explore-view", organization)
27+
28+
if dataset == Dataset.PerformanceMetrics.value:
29+
return features.has("organizations:on-demand-metrics-extraction", organization)
30+
31+
# Other datasets (e.g. Metrics/sessions) aren't gated here but probably should be.
32+
return True
533

634

735
def get_max_metric_alert_subscriptions(organization: Organization) -> int:

tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py

Lines changed: 7 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import copy
22
import math
3-
from contextlib import contextmanager
43
from datetime import timedelta
54
from functools import cached_property
65
from unittest.mock import MagicMock, call, patch
@@ -12,9 +11,8 @@
1211
from urllib3.response import HTTPResponse
1312

1413
from sentry.constants import ObjectStatus
15-
from sentry.incidents.subscription_processor import SubscriptionProcessor, has_downgraded
14+
from sentry.incidents.subscription_processor import SubscriptionProcessor
1615
from sentry.incidents.utils.types import QuerySubscriptionUpdate
17-
from sentry.models.organization import Organization
1816
from sentry.seer.anomaly_detection.types import (
1917
AnomalyDetectionSeasonality,
2018
AnomalyDetectionSensitivity,
@@ -65,17 +63,18 @@ def test_missing_project(self, mock_metrics: MagicMock) -> None:
6563
self.sub.project.delete()
6664
assert self.send_update(self.critical_threshold + 1) is False
6765

68-
@patch("sentry.incidents.subscription_processor.has_downgraded", return_value=True)
69-
def test_process_update_returns_false_when_downgraded(
70-
self, mock_has_downgraded: MagicMock
71-
) -> None:
66+
@patch(
67+
"sentry.incidents.subscription_processor.is_metric_subscription_allowed",
68+
return_value=False,
69+
)
70+
def test_process_update_returns_false_when_downgraded(self, mock_is_enabled: MagicMock) -> None:
7271
message = self.build_subscription_update(
7372
self.sub, value=self.critical_threshold + 1, time_delta=timedelta()
7473
)
7574

7675
with self.capture_on_commit_callbacks(execute=True):
7776
assert SubscriptionProcessor.process(self.sub, message) is False
78-
mock_has_downgraded.assert_called_once()
77+
mock_is_enabled.assert_called_once()
7978

8079
@patch("sentry.incidents.subscription_processor.metrics")
8180
def test_invalid_aggregation_value(self, mock_metrics: MagicMock) -> None:
@@ -1012,92 +1011,3 @@ def test_ensure_case_when_no_metrics_index_not_found_is_handled_gracefully(
10121011
call("incidents.alert_rules.skipping_update_invalid_aggregation_value"),
10131012
]
10141013
)
1015-
1016-
1017-
@patch("sentry.incidents.subscription_processor.metrics")
1018-
class TestHasDowngraded:
1019-
org = MagicMock(spec=Organization)
1020-
1021-
@contextmanager
1022-
def fake_features(self, enabled: set[str]):
1023-
with patch("sentry.incidents.subscription_processor.features") as mock_features:
1024-
mock_features.has.side_effect = lambda name, *a, **kw: name in enabled
1025-
yield
1026-
1027-
def test_events_without_incidents_feature(self, mock_metrics: MagicMock) -> None:
1028-
with self.fake_features(set()):
1029-
assert has_downgraded(Dataset.Events.value, self.org) is True
1030-
mock_metrics.incr.assert_called_with(
1031-
"incidents.alert_rules.ignore_update_missing_incidents"
1032-
)
1033-
1034-
def test_events_with_incidents_feature(self, mock_metrics: MagicMock) -> None:
1035-
with self.fake_features({"organizations:incidents"}):
1036-
assert has_downgraded(Dataset.Events.value, self.org) is False
1037-
1038-
def test_transactions_without_any_features(self, mock_metrics: MagicMock) -> None:
1039-
with self.fake_features(set()):
1040-
assert has_downgraded(Dataset.Transactions.value, self.org) is True
1041-
mock_metrics.incr.assert_called_with(
1042-
"incidents.alert_rules.ignore_update_missing_incidents_performance"
1043-
)
1044-
1045-
def test_transactions_with_only_performance_view(self, mock_metrics: MagicMock) -> None:
1046-
with self.fake_features({"organizations:performance-view"}):
1047-
assert has_downgraded(Dataset.Transactions.value, self.org) is True
1048-
mock_metrics.incr.assert_called_with(
1049-
"incidents.alert_rules.ignore_update_missing_incidents_performance"
1050-
)
1051-
1052-
def test_transactions_with_both_features(self, mock_metrics: MagicMock) -> None:
1053-
with self.fake_features({"organizations:incidents", "organizations:performance-view"}):
1054-
assert has_downgraded(Dataset.Transactions.value, self.org) is False
1055-
1056-
def test_eap_without_features(self, mock_metrics: MagicMock) -> None:
1057-
with self.fake_features(set()):
1058-
assert has_downgraded(Dataset.EventsAnalyticsPlatform.value, self.org) is True
1059-
mock_metrics.incr.assert_called_with(
1060-
"incidents.alert_rules.ignore_update_missing_incidents_eap"
1061-
)
1062-
1063-
def test_eap_with_only_explore_view(self, mock_metrics: MagicMock) -> None:
1064-
with self.fake_features({"organizations:visibility-explore-view"}):
1065-
assert has_downgraded(Dataset.EventsAnalyticsPlatform.value, self.org) is True
1066-
mock_metrics.incr.assert_called_with(
1067-
"incidents.alert_rules.ignore_update_missing_incidents_eap"
1068-
)
1069-
1070-
def test_eap_with_both_features(self, mock_metrics: MagicMock) -> None:
1071-
with self.fake_features(
1072-
{"organizations:incidents", "organizations:visibility-explore-view"}
1073-
):
1074-
assert has_downgraded(Dataset.EventsAnalyticsPlatform.value, self.org) is False
1075-
1076-
def test_performance_metrics_without_on_demand_feature(self, mock_metrics: MagicMock) -> None:
1077-
with self.fake_features(set()):
1078-
assert has_downgraded(Dataset.PerformanceMetrics.value, self.org) is True
1079-
mock_metrics.incr.assert_called_with(
1080-
"incidents.alert_rules.ignore_update_missing_on_demand"
1081-
)
1082-
1083-
def test_performance_metrics_with_on_demand_feature(self, mock_metrics: MagicMock) -> None:
1084-
with self.fake_features({"organizations:on-demand-metrics-extraction"}):
1085-
assert has_downgraded(Dataset.PerformanceMetrics.value, self.org) is False
1086-
1087-
def test_unknown_dataset_not_downgraded(self, mock_metrics: MagicMock) -> None:
1088-
with self.fake_features(set()):
1089-
assert has_downgraded("unknown_dataset", self.org) is False
1090-
mock_metrics.incr.assert_called_with(
1091-
"incidents.alert_rules.no_incidents_not_downgraded",
1092-
sample_rate=1.0,
1093-
tags={"dataset": "unknown_dataset"},
1094-
)
1095-
1096-
def test_no_incidents_not_downgraded_emits_metric(self, mock_metrics: MagicMock) -> None:
1097-
with self.fake_features({"organizations:on-demand-metrics-extraction"}):
1098-
assert has_downgraded(Dataset.PerformanceMetrics.value, self.org) is False
1099-
mock_metrics.incr.assert_called_with(
1100-
"incidents.alert_rules.no_incidents_not_downgraded",
1101-
sample_rate=1.0,
1102-
tags={"dataset": Dataset.PerformanceMetrics.value},
1103-
)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from contextlib import contextmanager
2+
from unittest.mock import MagicMock, patch
3+
4+
from sentry.incidents.utils.subscription_limits import is_metric_subscription_allowed
5+
from sentry.models.organization import Organization
6+
from sentry.snuba.dataset import Dataset
7+
8+
9+
class TestIsMetricSubscriptionAllowed:
10+
org = MagicMock(spec=Organization)
11+
12+
@contextmanager
13+
def fake_features(self, enabled: set[str]):
14+
with patch("sentry.incidents.utils.subscription_limits.features") as mock_features:
15+
mock_features.has.side_effect = lambda name, *a, **kw: name in enabled
16+
yield
17+
18+
# -- Events: requires :incidents --
19+
20+
def test_events_without_incidents(self) -> None:
21+
with self.fake_features(set()):
22+
assert is_metric_subscription_allowed(Dataset.Events.value, self.org) is False
23+
24+
def test_events_with_incidents(self) -> None:
25+
with self.fake_features({"organizations:incidents"}):
26+
assert is_metric_subscription_allowed(Dataset.Events.value, self.org) is True
27+
28+
# -- Transactions: requires :incidents + :performance-view --
29+
30+
def test_transactions_without_any_features(self) -> None:
31+
with self.fake_features(set()):
32+
assert is_metric_subscription_allowed(Dataset.Transactions.value, self.org) is False
33+
34+
def test_transactions_with_only_performance_view(self) -> None:
35+
with self.fake_features({"organizations:performance-view"}):
36+
assert is_metric_subscription_allowed(Dataset.Transactions.value, self.org) is False
37+
38+
def test_transactions_with_only_incidents(self) -> None:
39+
with self.fake_features({"organizations:incidents"}):
40+
assert is_metric_subscription_allowed(Dataset.Transactions.value, self.org) is False
41+
42+
def test_transactions_with_both_features(self) -> None:
43+
with self.fake_features({"organizations:incidents", "organizations:performance-view"}):
44+
assert is_metric_subscription_allowed(Dataset.Transactions.value, self.org) is True
45+
46+
# -- EAP: requires :incidents + :visibility-explore-view --
47+
48+
def test_eap_without_any_features(self) -> None:
49+
with self.fake_features(set()):
50+
assert (
51+
is_metric_subscription_allowed(Dataset.EventsAnalyticsPlatform.value, self.org)
52+
is False
53+
)
54+
55+
def test_eap_with_only_explore_view(self) -> None:
56+
with self.fake_features({"organizations:visibility-explore-view"}):
57+
assert (
58+
is_metric_subscription_allowed(Dataset.EventsAnalyticsPlatform.value, self.org)
59+
is False
60+
)
61+
62+
def test_eap_with_only_incidents(self) -> None:
63+
with self.fake_features({"organizations:incidents"}):
64+
assert (
65+
is_metric_subscription_allowed(Dataset.EventsAnalyticsPlatform.value, self.org)
66+
is False
67+
)
68+
69+
def test_eap_with_both_features(self) -> None:
70+
with self.fake_features(
71+
{"organizations:incidents", "organizations:visibility-explore-view"}
72+
):
73+
assert (
74+
is_metric_subscription_allowed(Dataset.EventsAnalyticsPlatform.value, self.org)
75+
is True
76+
)
77+
78+
# -- PerformanceMetrics: requires :on-demand-metrics-extraction only --
79+
80+
def test_performance_metrics_without_on_demand(self) -> None:
81+
with self.fake_features(set()):
82+
assert (
83+
is_metric_subscription_allowed(Dataset.PerformanceMetrics.value, self.org) is False
84+
)
85+
86+
def test_performance_metrics_with_on_demand(self) -> None:
87+
with self.fake_features({"organizations:on-demand-metrics-extraction"}):
88+
assert (
89+
is_metric_subscription_allowed(Dataset.PerformanceMetrics.value, self.org) is True
90+
)
91+
92+
# -- Unknown / other datasets: always allowed --
93+
94+
def test_unknown_dataset_without_incidents(self) -> None:
95+
with self.fake_features(set()):
96+
assert is_metric_subscription_allowed("unknown_dataset", self.org) is True
97+
98+
def test_unknown_dataset_with_incidents(self) -> None:
99+
with self.fake_features({"organizations:incidents"}):
100+
assert is_metric_subscription_allowed("unknown_dataset", self.org) is True

0 commit comments

Comments
 (0)