From f1b5af52738f6b9a326e5777b5d51723b18c4641 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Fri, 28 Nov 2025 10:34:24 -0500 Subject: [PATCH] feat(alerts): Allow bumping max snuba subscription limit --- .../organization_alert_rule_index.py | 6 +-- .../incidents/serializers/alert_rule.py | 8 ++-- .../incidents/utils/subscription_limits.py | 13 +++++++ src/sentry/options/defaults.py | 5 +++ src/sentry/snuba/models.py | 4 +- .../test_organization_alert_rule_index.py | 39 +++++++++++++++++++ .../incidents/endpoints/test_serializers.py | 33 ++++++++++++++++ tests/sentry/snuba/test_models.py | 12 ++++++ tests/sentry/snuba/test_validators.py | 24 ++++++++++++ 9 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/sentry/incidents/utils/subscription_limits.py diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_index.py b/src/sentry/incidents/endpoints/organization_alert_rule_index.py index 431b0e48c1c353..3d47a18fcd9b1c 100644 --- a/src/sentry/incidents/endpoints/organization_alert_rule_index.py +++ b/src/sentry/incidents/endpoints/organization_alert_rule_index.py @@ -2,7 +2,6 @@ from copy import deepcopy from datetime import UTC, datetime -from django.conf import settings from django.db.models import Case, DateTimeField, IntegerField, OuterRef, Q, Subquery, Value, When from django.db.models.functions import Coalesce from django.http.response import HttpResponseBase @@ -48,6 +47,7 @@ from sentry.incidents.models.incident import Incident, IncidentStatus from sentry.incidents.serializers import AlertRuleSerializer as DrfAlertRuleSerializer from sentry.incidents.utils.sentry_apps import trigger_sentry_app_action_creators_for_incidents +from sentry.incidents.utils.subscription_limits import get_max_metric_alert_subscriptions from sentry.integrations.slack.tasks.find_channel_id_for_alert_rule import ( find_channel_id_for_alert_rule, ) @@ -201,7 +201,7 @@ def fetch_metric_alert( count_hits=True, ) response[ALERT_RULES_COUNT_HEADER] = len(alert_rules) - response[MAX_QUERY_SUBSCRIPTIONS_HEADER] = settings.MAX_QUERY_SUBSCRIPTIONS_PER_ORG + response[MAX_QUERY_SUBSCRIPTIONS_HEADER] = get_max_metric_alert_subscriptions(organization) return response @@ -474,7 +474,7 @@ def has_type(type: str) -> bool: cursor_cls=StringCursor if case_insensitive else Cursor, case_insensitive=case_insensitive, ) - response[MAX_QUERY_SUBSCRIPTIONS_HEADER] = settings.MAX_QUERY_SUBSCRIPTIONS_PER_ORG + response[MAX_QUERY_SUBSCRIPTIONS_HEADER] = get_max_metric_alert_subscriptions(organization) return response diff --git a/src/sentry/incidents/serializers/alert_rule.py b/src/sentry/incidents/serializers/alert_rule.py index 2609bacd67a7a7..d2ad502dec2ea1 100644 --- a/src/sentry/incidents/serializers/alert_rule.py +++ b/src/sentry/incidents/serializers/alert_rule.py @@ -4,7 +4,6 @@ import sentry_sdk from django import forms -from django.conf import settings from django.db import router, transaction from parsimonious.exceptions import ParseError from rest_framework import serializers @@ -30,6 +29,7 @@ AlertRuleThresholdType, AlertRuleTrigger, ) +from sentry.incidents.utils.subscription_limits import get_max_metric_alert_subscriptions from sentry.snuba.dataset import Dataset from sentry.snuba.models import QuerySubscription from sentry.snuba.snuba_query_validator import SnubaQueryValidator @@ -264,9 +264,11 @@ def create(self, validated_data): ), ).count() - if org_subscription_count >= settings.MAX_QUERY_SUBSCRIPTIONS_PER_ORG: + organization = self.context["organization"] + max_subscriptions = get_max_metric_alert_subscriptions(organization) + if org_subscription_count >= max_subscriptions: raise serializers.ValidationError( - f"You may not exceed {settings.MAX_QUERY_SUBSCRIPTIONS_PER_ORG} metric alerts per organization" + f"You may not exceed {max_subscriptions} metric alerts per organization" ) with transaction.atomic(router.db_for_write(AlertRule)): triggers = validated_data.pop("triggers") diff --git a/src/sentry/incidents/utils/subscription_limits.py b/src/sentry/incidents/utils/subscription_limits.py new file mode 100644 index 00000000000000..92332aa5d6ab17 --- /dev/null +++ b/src/sentry/incidents/utils/subscription_limits.py @@ -0,0 +1,13 @@ +from django.conf import settings + +from sentry import options +from sentry.models.organization import Organization + + +def get_max_metric_alert_subscriptions(organization: Organization) -> int: + if organization.id in options.get("metric_alerts.extended_max_subscriptions_orgs") and ( + extended_max_specs := options.get("metric_alerts.extended_max_subscriptions") + ): + return extended_max_specs + + return settings.MAX_QUERY_SUBSCRIPTIONS_PER_ORG diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index b96ab687d87f1e..c52efacab735e7 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2503,6 +2503,11 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) +register("metric_alerts.extended_max_subscriptions", default=1250, flags=FLAG_AUTOMATOR_MODIFIABLE) +register( + "metric_alerts.extended_max_subscriptions_orgs", default=[], flags=FLAG_AUTOMATOR_MODIFIABLE +) + # SDK Crash Detection # # The project ID belongs to the sentry organization: https://sentry.sentry.io/projects/cocoa-sdk-crashes/?project=4505469596663808. diff --git a/src/sentry/snuba/models.py b/src/sentry/snuba/models.py index 7a35dfec100658..ac75638897e13c 100644 --- a/src/sentry/snuba/models.py +++ b/src/sentry/snuba/models.py @@ -5,7 +5,6 @@ from enum import Enum from typing import TYPE_CHECKING, ClassVar, Self, override -from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils import timezone @@ -16,6 +15,7 @@ from sentry.db.models import FlexibleForeignKey, Model, region_silo_model from sentry.db.models.manager.base import BaseManager from sentry.deletions.base import ModelRelation +from sentry.incidents.utils.subscription_limits import get_max_metric_alert_subscriptions from sentry.incidents.utils.types import DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION from sentry.models.team import Team from sentry.users.models.user import User @@ -219,7 +219,7 @@ def related_model(instance) -> list[ModelRelation]: @override @staticmethod def get_instance_limit(org: Organization) -> int | None: - return settings.MAX_QUERY_SUBSCRIPTIONS_PER_ORG + return get_max_metric_alert_subscriptions(org) @override @staticmethod diff --git a/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py b/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py index 24b40d04cd2b17..1674d57a11df13 100644 --- a/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py +++ b/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py @@ -874,6 +874,45 @@ def test_enforce_max_subscriptions(self) -> None: resp = self.get_error_response(self.organization.slug, **self.alert_rule_dict) assert resp.data[0] == "You may not exceed 1 metric alerts per organization" + @override_settings(MAX_QUERY_SUBSCRIPTIONS_PER_ORG=1) + def test_enforce_max_subscriptions_with_override(self) -> None: + with self.options( + { + "metric_alerts.extended_max_subscriptions_orgs": [self.organization.id], + "metric_alerts.extended_max_subscriptions": 3, + } + ): + with self.feature("organizations:incidents"): + resp = self.get_success_response( + self.organization.slug, status_code=201, **self.alert_rule_dict + ) + alert_rule = AlertRule.objects.get(id=resp.data["id"]) + assert resp.data == serialize(alert_rule, self.user) + + alert_rule_dict_2 = self.alert_rule_dict.copy() + alert_rule_dict_2["name"] = "Test Rule 2" + with self.feature("organizations:incidents"): + resp = self.get_success_response( + self.organization.slug, status_code=201, **alert_rule_dict_2 + ) + alert_rule_2 = AlertRule.objects.get(id=resp.data["id"]) + assert resp.data == serialize(alert_rule_2, self.user) + + alert_rule_dict_3 = self.alert_rule_dict.copy() + alert_rule_dict_3["name"] = "Test Rule 3" + with self.feature("organizations:incidents"): + resp = self.get_success_response( + self.organization.slug, status_code=201, **alert_rule_dict_3 + ) + alert_rule_3 = AlertRule.objects.get(id=resp.data["id"]) + assert resp.data == serialize(alert_rule_3, self.user) + + alert_rule_dict_4 = self.alert_rule_dict.copy() + alert_rule_dict_4["name"] = "Test Rule 4" + with self.feature("organizations:incidents"): + resp = self.get_error_response(self.organization.slug, **alert_rule_dict_4) + assert resp.data[0] == "You may not exceed 3 metric alerts per organization" + def test_sentry_app(self) -> None: other_org = self.create_organization(owner=self.user) sentry_app = self.create_sentry_app( diff --git a/tests/sentry/incidents/endpoints/test_serializers.py b/tests/sentry/incidents/endpoints/test_serializers.py index 7a67adfae6cfd5..229eaa2ffc460a 100644 --- a/tests/sentry/incidents/endpoints/test_serializers.py +++ b/tests/sentry/incidents/endpoints/test_serializers.py @@ -809,6 +809,39 @@ def test_enforce_max_subscriptions(self) -> None: assert isinstance(excinfo.value.detail, list) assert excinfo.value.detail[0] == "You may not exceed 1 metric alerts per organization" + @override_settings(MAX_QUERY_SUBSCRIPTIONS_PER_ORG=1) + def test_enforce_max_subscriptions_with_override(self) -> None: + with self.options( + { + "metric_alerts.extended_max_subscriptions_orgs": [self.organization.id], + "metric_alerts.extended_max_subscriptions": 3, + } + ): + serializer = AlertRuleSerializer(context=self.context, data=self.valid_params) + assert serializer.is_valid(), serializer.errors + serializer.save() + + params_2 = self.valid_params.copy() + params_2["name"] = "Test Rule 2" + serializer = AlertRuleSerializer(context=self.context, data=params_2) + assert serializer.is_valid(), serializer.errors + serializer.save() + + params_3 = self.valid_params.copy() + params_3["name"] = "Test Rule 3" + serializer = AlertRuleSerializer(context=self.context, data=params_3) + assert serializer.is_valid(), serializer.errors + serializer.save() + + params_4 = self.valid_params.copy() + params_4["name"] = "Test Rule 4" + serializer = AlertRuleSerializer(context=self.context, data=params_4) + assert serializer.is_valid(), serializer.errors + with pytest.raises(serializers.ValidationError) as excinfo: + serializer.save() + assert isinstance(excinfo.value.detail, list) + assert excinfo.value.detail[0] == "You may not exceed 3 metric alerts per organization" + def test_error_issue_status(self) -> None: params = self.valid_params.copy() params["query"] = "status:abcd" diff --git a/tests/sentry/snuba/test_models.py b/tests/sentry/snuba/test_models.py index 8b8f2a12ea1a24..c5c880fa7731e7 100644 --- a/tests/sentry/snuba/test_models.py +++ b/tests/sentry/snuba/test_models.py @@ -82,6 +82,18 @@ def test_get_instance_limit(self) -> None: with self.settings(MAX_QUERY_SUBSCRIPTIONS_PER_ORG=42): assert QuerySubscriptionDataSourceHandler.get_instance_limit(self.organization) == 42 + def test_get_instance_limit_with_override(self) -> None: + with self.settings(MAX_QUERY_SUBSCRIPTIONS_PER_ORG=42): + with self.options( + { + "metric_alerts.extended_max_subscriptions_orgs": [self.organization.id], + "metric_alerts.extended_max_subscriptions": 100, + } + ): + assert ( + QuerySubscriptionDataSourceHandler.get_instance_limit(self.organization) == 100 + ) + def test_get_current_instance_count(self) -> None: new_org = self.create_organization() new_project = self.create_project(organization=new_org) diff --git a/tests/sentry/snuba/test_validators.py b/tests/sentry/snuba/test_validators.py index 4324da6cd68605..427340932fcc3d 100644 --- a/tests/sentry/snuba/test_validators.py +++ b/tests/sentry/snuba/test_validators.py @@ -76,6 +76,30 @@ def test_validated_create_source_limits(self) -> None: ) ] + def test_validated_create_source_limits_with_override(self) -> None: + with self.settings(MAX_QUERY_SUBSCRIPTIONS_PER_ORG=2): + with self.options( + { + "metric_alerts.extended_max_subscriptions_orgs": [self.organization.id], + "metric_alerts.extended_max_subscriptions": 4, + } + ): + validator = SnubaQueryValidator(data=self.valid_data, context=self.context) + assert validator.is_valid() + validator.validated_create_source(validator.validated_data) + validator.validated_create_source(validator.validated_data) + validator.validated_create_source(validator.validated_data) + validator.validated_create_source(validator.validated_data) + + with pytest.raises(serializers.ValidationError) as e: + validator.validated_create_source(validator.validated_data) + assert e.value.detail == [ + ErrorDetail( + string="You may not exceed 4 data sources of this type.", + code="invalid", + ) + ] + @with_feature("organizations:workflow-engine-metric-alert-group-by-creation") def test_valid_group_by(self) -> None: """Test that valid group_by data is accepted."""