Skip to content

Commit 1cd76a7

Browse files
leedongweiandrewshie-sentry
authored andcommitted
feat(aci): Enforce quota limits when creating metric detectors (#97445)
1 parent 7a6c9ee commit 1cd76a7

File tree

10 files changed

+292
-4
lines changed

10 files changed

+292
-4
lines changed

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,8 @@ def register_temporary_features(manager: FeatureManager):
535535
manager.add("organizations:workflow-engine-rule-serializers", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
536536
# Enable single processing of metric issues
537537
manager.add("organizations:workflow-engine-single-process-metric-issues", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
538+
# Enable metric detector limits by plan type
539+
manager.add("organizations:workflow-engine-metric-detector-limit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
538540
# Enable EventUniqueUserFrequencyConditionWithConditions special alert condition
539541
manager.add("organizations:event-unique-user-frequency-condition-with-conditions", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
540542
# Use spans instead of transactions for dynamic sampling calculations. This will become the new default.

src/sentry/incidents/endpoints/organization_alert_rule_index.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from rest_framework.request import Request
1313
from rest_framework.response import Response
1414

15-
from sentry import features, options
15+
from sentry import features, options, quotas
1616
from sentry.api.api_owners import ApiOwner
1717
from sentry.api.api_publish_status import ApiPublishStatus
1818
from sentry.api.base import Endpoint, region_silo_endpoint
@@ -562,6 +562,19 @@ def check_can_create_alert(self, request: Request, organization: Organization) -
562562
permission, then we must verify that the user is a team admin with "alerts:write" access to the project(s)
563563
in their request.
564564
"""
565+
if features.has(
566+
"organizations:workflow-engine-metric-detector-limit", organization, actor=request.user
567+
):
568+
alert_limit = quotas.backend.get_metric_detector_limit(organization.id)
569+
alert_count = AlertRule.objects.fetch_for_organization(
570+
organization=organization
571+
).count()
572+
573+
if alert_limit >= 0 and alert_count >= alert_limit:
574+
raise ValidationError(
575+
f"You may not exceed {alert_limit} metric alerts on your current plan."
576+
)
577+
565578
# if the requesting user has any of these org-level permissions, then they can create an alert
566579
if (
567580
request.access.has_scope("alerts:write")

src/sentry/incidents/metric_issue_detector.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33

44
from rest_framework import serializers
55

6+
from sentry import features, quotas
7+
from sentry.constants import ObjectStatus
68
from sentry.incidents.logic import enable_disable_subscriptions
79
from sentry.snuba.models import QuerySubscription, SnubaQuery, SnubaQueryEventType
810
from sentry.snuba.snuba_query_validator import SnubaQueryValidator
911
from sentry.snuba.subscriptions import update_snuba_query
1012
from sentry.workflow_engine.endpoints.validators.base import (
1113
BaseDataConditionGroupValidator,
1214
BaseDetectorTypeValidator,
15+
DetectorQuota,
1316
)
1417
from sentry.workflow_engine.endpoints.validators.base.data_condition import (
1518
BaseDataConditionValidator,
@@ -88,6 +91,32 @@ def validate(self, attrs):
8891

8992
return attrs
9093

94+
def get_quota(self) -> DetectorQuota:
95+
organization = self.context.get("organization")
96+
request = self.context.get("request")
97+
if organization is None or request is None:
98+
raise serializers.ValidationError("Missing organization/request context")
99+
100+
detector_limit = quotas.backend.get_metric_detector_limit(organization.id)
101+
if (
102+
not features.has(
103+
"organizations:workflow-engine-metric-detector-limit",
104+
organization,
105+
actor=request.user,
106+
)
107+
or detector_limit == -1
108+
):
109+
return DetectorQuota(has_exceeded=False, limit=-1, count=-1)
110+
111+
detector_count = Detector.objects.filter(
112+
project__organization=organization,
113+
type="metric_issue", # Avoided circular import. TODO: move magic strings to constant file
114+
status=ObjectStatus.ACTIVE,
115+
).count()
116+
has_exceeded = detector_count >= detector_limit
117+
118+
return DetectorQuota(has_exceeded=has_exceeded, limit=detector_limit, count=detector_count)
119+
91120
def update_data_source(self, instance: Detector, data_source: SnubaQueryDataSourceType):
92121
try:
93122
source_instance = DataSource.objects.get(detector=instance)

src/sentry/quotas/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,3 +755,9 @@ def get_dashboard_limit(self, org_id: int) -> int:
755755
Returns the maximum number of dashboards allowed for the organization's plan type.
756756
"""
757757
return -1
758+
759+
def get_metric_detector_limit(self, org_id: int) -> int:
760+
"""
761+
Returns the maximum number of detectors allowed for the organization's plan type.
762+
"""
763+
return -1

src/sentry/workflow_engine/endpoints/organization_detector_index.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ def post(self, request: Request, organization: Organization) -> Response:
314314
if not detector_type:
315315
raise ValidationError({"type": ["This field is required."]})
316316

317-
# restrict creating metric issue detectors by plan type
317+
# Restrict creating metric issue detectors by plan type
318318
if detector_type == MetricIssue.slug and not features.has(
319319
"organizations:incidents", organization, actor=request.user
320320
):

src/sentry/workflow_engine/endpoints/validators/base/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
"BaseDataSourceValidator",
77
"BaseDetectorTypeValidator",
88
"DataSourceCreator",
9+
"DetectorQuota",
910
]
1011

1112
from .action import BaseActionValidator
1213
from .data_condition import AbstractDataConditionValidator, BaseDataConditionValidator
1314
from .data_condition_group import BaseDataConditionGroupValidator
1415
from .data_source import BaseDataSourceValidator, DataSourceCreator
15-
from .detector import BaseDetectorTypeValidator
16+
from .detector import BaseDetectorTypeValidator, DetectorQuota

src/sentry/workflow_engine/endpoints/validators/base/detector.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import builtins
2+
from dataclasses import dataclass
23
from typing import Any
34

45
from django.db import router, transaction
@@ -29,6 +30,13 @@
2930
from sentry.workflow_engine.types import DataConditionType
3031

3132

33+
@dataclass(frozen=True)
34+
class DetectorQuota:
35+
has_exceeded: bool
36+
limit: int
37+
count: int
38+
39+
3240
class BaseDetectorTypeValidator(CamelSnakeSerializer):
3341
name = serializers.CharField(
3442
required=True,
@@ -64,6 +72,22 @@ def data_source(self) -> BaseDataSourceValidator:
6472
def data_conditions(self) -> BaseDataConditionValidator:
6573
raise NotImplementedError
6674

75+
def get_quota(self) -> DetectorQuota:
76+
return DetectorQuota(has_exceeded=False, limit=-1, count=-1)
77+
78+
def enforce_quota(self, validated_data) -> None:
79+
"""
80+
Enforce quota limits for detector creation.
81+
Raise ValidationError if quota limits are exceeded.
82+
83+
Only Metric Issues Detector has quota limits.
84+
"""
85+
detector_quota = self.get_quota()
86+
if detector_quota.has_exceeded:
87+
raise serializers.ValidationError(
88+
f"Used {detector_quota.count}/{detector_quota.limit} of allowed {validated_data["type"].slug} monitors."
89+
)
90+
6791
def update(self, instance: Detector, validated_data: dict[str, Any]):
6892
with transaction.atomic(router.db_for_write(Detector)):
6993
if "name" in validated_data:
@@ -110,6 +134,10 @@ def update(self, instance: Detector, validated_data: dict[str, Any]):
110134
return instance
111135

112136
def create(self, validated_data):
137+
# If quotas are exceeded, we will prevent creation of new detectors.
138+
# Do not disable or prevent the users from updating existing detectors.
139+
self.enforce_quota(validated_data)
140+
113141
with transaction.atomic(router.db_for_write(Detector)):
114142
condition_group = DataConditionGroup.objects.create(
115143
logic_type=DataConditionGroup.Type.ANY,

tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
AlertRuleDetectionType,
3131
AlertRuleSeasonality,
3232
AlertRuleSensitivity,
33+
AlertRuleStatus,
3334
AlertRuleThresholdType,
3435
AlertRuleTrigger,
3536
AlertRuleTriggerAction,
@@ -1640,6 +1641,58 @@ def test_performance_alert(self, record_analytics: MagicMock) -> None:
16401641
resp = self.get_response(self.organization.slug, **valid_alert_rule)
16411642
assert resp.status_code == 201
16421643

1644+
@with_feature("organizations:incidents")
1645+
@with_feature("organizations:workflow-engine-metric-detector-limit")
1646+
@patch("sentry.quotas.backend.get_metric_detector_limit")
1647+
def test_metric_alert_limit(self, mock_get_limit: MagicMock) -> None:
1648+
# Set limit to 2 alert rules
1649+
mock_get_limit.return_value = 2
1650+
1651+
# Create 2 existing metric alert rules (1 active, 1 to be deleted)
1652+
alert_rule = self.create_alert_rule(organization=self.organization)
1653+
alert_rule.status = AlertRuleStatus.PENDING.value
1654+
alert_rule.save()
1655+
1656+
alert_rule = self.create_alert_rule(organization=self.organization)
1657+
alert_rule.status = AlertRuleStatus.SNAPSHOT.value
1658+
alert_rule.save()
1659+
1660+
# Create another alert rule, it should succeed
1661+
data = deepcopy(self.alert_rule_dict)
1662+
with outbox_runner():
1663+
resp = self.get_success_response(
1664+
self.organization.slug,
1665+
status_code=201,
1666+
**data,
1667+
)
1668+
alert_rule = AlertRule.objects.get(id=resp.data["id"])
1669+
assert alert_rule.name == "JustAValidTestRule"
1670+
1671+
# Create another alert rule, it should fail
1672+
data = deepcopy(self.alert_rule_dict)
1673+
resp = self.get_error_response(
1674+
self.organization.slug,
1675+
status_code=400,
1676+
**data,
1677+
)
1678+
1679+
@with_feature("organizations:incidents")
1680+
@with_feature("organizations:workflow-engine-metric-detector-limit")
1681+
def test_metric_alert_limit_unlimited_plan(self) -> None:
1682+
# Create many alert rules
1683+
for _ in range(5):
1684+
self.create_alert_rule(organization=self.organization)
1685+
1686+
# Creating another alert rule, it should succeed
1687+
with outbox_runner():
1688+
resp = self.get_success_response(
1689+
self.organization.slug,
1690+
status_code=201,
1691+
**self.alert_rule_dict,
1692+
)
1693+
alert_rule = AlertRule.objects.get(id=resp.data["id"])
1694+
assert alert_rule.name == "JustAValidTestRule"
1695+
16431696

16441697
@freeze_time()
16451698
class AlertRuleCreateEndpointTestCrashRateAlert(AlertRuleIndexBase):

tests/sentry/incidents/endpoints/validators/test_validators.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from unittest import mock
22

3-
from rest_framework.exceptions import ErrorDetail
3+
from rest_framework.exceptions import ErrorDetail, ValidationError
44

55
from sentry import audit_log
6+
from sentry.constants import ObjectStatus
67
from sentry.incidents.grouptype import MetricIssue
78
from sentry.incidents.metric_issue_detector import (
89
MetricIssueComparisonConditionValidator,
@@ -23,6 +24,7 @@
2324
SnubaQuery,
2425
SnubaQueryEventType,
2526
)
27+
from sentry.testutils.helpers.features import with_feature
2628
from sentry.workflow_engine.endpoints.validators.utils import get_unknown_detector_type_error
2729
from sentry.workflow_engine.models import DataCondition, DataConditionGroup, DataSource, Detector
2830
from sentry.workflow_engine.models.data_condition import Condition
@@ -360,3 +362,48 @@ def test_too_many_conditions(self) -> None:
360362
assert validator.errors.get("nonFieldErrors") == [
361363
ErrorDetail(string="Too many conditions", code="invalid")
362364
]
365+
366+
@mock.patch("sentry.quotas.backend.get_metric_detector_limit")
367+
def test_enforce_quota_feature_disabled(self, mock_get_limit: mock.MagicMock) -> None:
368+
mock_get_limit.return_value = 0
369+
validator = MetricIssueDetectorValidator(data=self.valid_data, context=self.context)
370+
371+
assert validator.is_valid()
372+
assert validator.save()
373+
374+
@mock.patch("sentry.quotas.backend.get_metric_detector_limit")
375+
@with_feature("organizations:workflow-engine-metric-detector-limit")
376+
def test_enforce_quota_within_limit(self, mock_get_limit: mock.MagicMock) -> None:
377+
mock_get_limit.return_value = 1
378+
379+
# Create a not-metric detector
380+
self.create_detector(
381+
project_id=self.project.id,
382+
name="Error Detector",
383+
status=ObjectStatus.ACTIVE,
384+
)
385+
# Create 3 inactive detectors
386+
for status in [
387+
ObjectStatus.DISABLED,
388+
ObjectStatus.PENDING_DELETION,
389+
ObjectStatus.DELETION_IN_PROGRESS,
390+
]:
391+
self.create_detector(
392+
project_id=self.project.id,
393+
name=f"Inactive Detector {status}",
394+
type=MetricIssue.slug,
395+
status=status,
396+
)
397+
398+
validator = MetricIssueDetectorValidator(data=self.valid_data, context=self.context)
399+
assert validator.is_valid()
400+
assert validator.save()
401+
mock_get_limit.assert_called_once_with(self.project.organization.id)
402+
403+
validator = MetricIssueDetectorValidator(data=self.valid_data, context=self.context)
404+
validator.is_valid()
405+
with self.assertRaisesMessage(
406+
ValidationError,
407+
expected_message="Used 1/1 of allowed metric_issue monitors.",
408+
):
409+
validator.save()

0 commit comments

Comments
 (0)