Skip to content
Merged
51 changes: 28 additions & 23 deletions src/sentry/incidents/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,30 @@ def nullify_id(model: Model) -> None:
)


def delete_anomaly_detection_rule(snuba_query: SnubaQuery, alert_rule: AlertRule) -> None:
"""
Delete accompanying data in Seer for anomaly detection rules
"""
try:
source_id = QuerySubscription.objects.get(snuba_query_id=snuba_query.id).id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Handle Multiple Subscriptions Gracefully

QuerySubscription.objects.get(snuba_query_id=snuba_query.id) will raise MultipleObjectsReturned when an alert rule has multiple projects, since each project has its own subscription pointing to the same SnubaQuery. The function should either use .first() instead of .get(), or iterate through all subscriptions if multiple Seer deletions are needed.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no multi-project alert rules/detectors exist

success = delete_rule_in_seer(
organization=alert_rule.organization,
source_id=source_id,
)
if not success:
logger.error(
"Call to delete rule data in Seer failed",
extra={
"source_id": source_id,
},
)
except QuerySubscription.DoesNotExist:
logger.exception(
"Snuba query missing query subscription",
extra={"snuba_query_id": snuba_query.id},
)


def update_alert_rule(
alert_rule: AlertRule,
query_type: SnubaQuery.Type | None = None,
Expand Down Expand Up @@ -921,18 +945,8 @@ def update_alert_rule(
alert_rule, project, snuba_query, updated_fields, updated_query_fields
)
else:
# if this was a dynamic rule, delete the data in Seer
if alert_rule.detection_type == AlertRuleDetectionType.DYNAMIC:
success = delete_rule_in_seer(
alert_rule=alert_rule,
)
if not success:
logger.error(
"Call to delete rule data in Seer failed",
extra={
"rule_id": alert_rule.id,
},
)
delete_anomaly_detection_rule(snuba_query, alert_rule)
# if this alert was previously a dynamic alert, then we should update the rule to be ready
if alert_rule.status == AlertRuleStatus.NOT_ENOUGH_DATA.value:
alert_rule.update(status=AlertRuleStatus.PENDING.value)
Expand Down Expand Up @@ -1088,20 +1102,11 @@ def delete_alert_rule(
)
subscriptions = _unpack_snuba_query(alert_rule).subscriptions.all()

if alert_rule.detection_type == AlertRuleDetectionType.DYNAMIC:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're now deleting the rule in Seer regardless of whether an incident exists or not - previously we had it behind that condition because we were deleting it in the post delete signal and due to the order of deletions there would still be incidents after the rule was deleted so this was the only way we could make sure a dynamic alert rule with incidents sent the data to Seer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're overriding the delete() method on the detector, I don't think we need any logic to do it for the alert rule (given that every alert rule has a corresponding detector). Let's take this out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have it here for the legacy API use case - if they hit this directly, it won't hit the detector validator code path right? Worst case Seer gets 2 requests to delete the same data, but the 2nd one would just fail because it's already gone.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I see that we changed the validator and not the model itself, in which case this should stay.

delete_anomaly_detection_rule(alert_rule.snuba_query, alert_rule)

incidents = Incident.objects.filter(alert_rule=alert_rule)
if incidents.exists():
# if this was a dynamic rule, delete the data in Seer
if alert_rule.detection_type == AlertRuleDetectionType.DYNAMIC:
success = delete_rule_in_seer(
alert_rule=alert_rule,
)
if not success:
logger.error(
"Call to delete rule data in Seer failed",
extra={
"rule_id": alert_rule.id,
},
)
AlertRuleActivity.objects.create(
alert_rule=alert_rule,
user_id=user.id if user else None,
Expand Down
9 changes: 9 additions & 0 deletions src/sentry/incidents/metric_issue_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from sentry.incidents.logic import enable_disable_subscriptions
from sentry.incidents.models.alert_rule import AlertRuleDetectionType
from sentry.relay.config.metric_extraction import on_demand_metrics_feature_flags
from sentry.seer.anomaly_detection.delete_rule import delete_data_in_seer_for_detector
from sentry.seer.anomaly_detection.store_data_workflow_engine import send_new_detector_data
from sentry.snuba.dataset import Dataset
from sentry.snuba.metrics.extraction import should_use_on_demand_metrics
Expand Down Expand Up @@ -294,3 +295,11 @@ def create(self, validated_data: dict[str, Any]):

schedule_update_project_config(detector)
return detector

def delete(self):
# Let Seer know we're deleting a dynamic detector so the data can be deleted there too
assert self.instance is not None
detector: Detector = self.instance
delete_data_in_seer_for_detector(detector)

super().delete()
14 changes: 0 additions & 14 deletions src/sentry/incidents/models/alert_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
ActionService,
ActionTarget,
)
from sentry.seer.anomaly_detection.delete_rule import delete_rule_in_seer
from sentry.snuba.models import QuerySubscription
from sentry.types.actor import Actor
from sentry.utils import metrics
Expand Down Expand Up @@ -140,18 +139,6 @@ def clear_alert_rule_subscription_caches(cls, instance: AlertRule, **kwargs: Any
for sub_id in subscription_ids
)

@classmethod
def delete_data_in_seer(cls, instance: AlertRule, **kwargs: Any) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we now require the source id (the query subscription id) instead of the rule id we can't execute this in post delete because the query subscription is deleted by this point. Instead we call it in logic.py's delete_alert_rule method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The post_delete is used for cascade deletions (like when a project gets deleted), during which the code in logic.py will not be executed 😭 I don't think we can take this out of here

Copy link
Contributor Author

@mifu67 mifu67 Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it doesn't matter if Seer has a cleanup task, though EDIT: since we're overriding the detector delete method, we don't need anything for the alert rule at all, so this is good to remove.

if instance.detection_type == AlertRuleDetectionType.DYNAMIC:
success = delete_rule_in_seer(alert_rule=instance)
if not success:
logger.error(
"Call to delete rule data in Seer failed",
extra={
"rule_id": instance.id,
},
)


@region_silo_model
class AlertRuleProjects(Model):
Expand Down Expand Up @@ -624,7 +611,6 @@ class Meta:


post_delete.connect(AlertRuleManager.clear_subscription_cache, sender=QuerySubscription)
post_delete.connect(AlertRuleManager.delete_data_in_seer, sender=AlertRule)
post_save.connect(AlertRuleManager.clear_subscription_cache, sender=QuerySubscription)
post_save.connect(AlertRuleManager.clear_alert_rule_subscription_caches, sender=AlertRule)
post_delete.connect(AlertRuleManager.clear_alert_rule_subscription_caches, sender=AlertRule)
Expand Down
58 changes: 49 additions & 9 deletions src/sentry/seer/anomaly_detection/delete_rule.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from django.conf import settings
from urllib3.exceptions import MaxRetryError, TimeoutError

from sentry.conf.server import SEER_ALERT_DELETION_URL
from sentry.models.organization import Organization
from sentry.net.http import connection_from_url
from sentry.seer.anomaly_detection.types import DeleteAlertDataRequest
from sentry.seer.anomaly_detection.types import AlertInSeer, DataSourceType, DeleteAlertDataRequest
from sentry.seer.signed_seer_api import make_signed_seer_api_request
from sentry.utils import json
from sentry.utils.json import JSONDecodeError
Expand All @@ -18,21 +21,51 @@
)

if TYPE_CHECKING:
from sentry.incidents.models.alert_rule import AlertRule
from sentry.workflow_engine.models.detector import Detector


def delete_data_in_seer_for_detector(detector: Detector):
from sentry.incidents.models.alert_rule import AlertRuleDetectionType
from sentry.workflow_engine.models import DataSourceDetector

def delete_rule_in_seer(alert_rule: "AlertRule") -> bool:
data_source_detector = DataSourceDetector.objects.filter(detector_id=detector.id).first()
if not data_source_detector:
logger.error(
"No data source found for detector",
extra={
"detector_id": detector.id,
},
)
return

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The delete_data_in_seer_for_detector() function incompletely deletes data for detectors with multiple data sources.
Severity: CRITICAL | Confidence: 1.00

🔍 Detailed Analysis

If a detector has multiple associated data sources, the delete_data_in_seer_for_detector() function will only clean up the first one from Seer due to its use of .first(). The remaining data sources will have orphaned data in Seer, causing data bloat and inconsistent state where some detector data remains after deletion, despite the operation appearing to succeed.

💡 Suggested Fix

Modify delete_data_in_seer_for_detector() to iterate over all DataSourceDetector objects associated with the detector, rather than using .first(), to ensure all related data in Seer is properly cleaned up.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/seer/anomaly_detection/delete_rule.py#L40

Potential issue: If a detector has multiple associated data sources, the
`delete_data_in_seer_for_detector()` function will only clean up the first one from Seer
due to its use of `.first()`. The remaining data sources will have orphaned data in
Seer, causing data bloat and inconsistent state where some detector data remains after
deletion, despite the operation appearing to succeed.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no detectors w/ multiple data sources

organization = detector.project.organization

if detector.config.get("detection_type") == AlertRuleDetectionType.DYNAMIC:
success = delete_rule_in_seer(
source_id=int(data_source_detector.data_source.source_id), organization=organization
)
if not success:
logger.error(
"Call to delete rule data in Seer failed",
extra={
"detector_id": detector.id,
},
)


def delete_rule_in_seer(source_id: int, organization: Organization) -> bool:
"""
Send a request to delete an alert rule from Seer. Returns True if the request was successful.
"""
body = DeleteAlertDataRequest(
organization_id=alert_rule.organization.id,
alert={"id": alert_rule.id},
organization_id=organization.id,
alert=AlertInSeer(
id=None, source_id=source_id, source_type=DataSourceType.SNUBA_QUERY_SUBSCRIPTION
),
)
extra_data = {
"rule_id": alert_rule.id,
"source_id": source_id,
}

try:
response = make_signed_seer_api_request(
connection_pool=seer_anomaly_detection_connection_pool,
Expand All @@ -46,7 +79,7 @@ def delete_rule_in_seer(alert_rule: "AlertRule") -> bool:
)
return False

if response.status > 400:
if response.status >= 400:
logger.error(
"Error when hitting Seer delete rule data endpoint",
extra={
Expand Down Expand Up @@ -75,7 +108,14 @@ def delete_rule_in_seer(alert_rule: "AlertRule") -> bool:
return False

status = results.get("success")
if status is None or status is not True:
if status is None:
logger.error(
"Request to delete alert rule from Seer was unsuccessful",
extra=extra_data,
)
return False
elif status is not True:
extra_data["message"] = results.get("message")
logger.error(
"Request to delete alert rule from Seer was unsuccessful",
extra=extra_data,
Expand Down
10 changes: 1 addition & 9 deletions tests/sentry/deletions/test_alert_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,14 @@ def test_simple(self) -> None:
assert not AlertRuleWorkflow.objects.filter(alert_rule_id=alert_rule.id).exists()

@with_feature("organizations:anomaly-detection-alerts")
@patch(
"sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
)
@patch(
"sentry.seer.anomaly_detection.store_data.seer_anomaly_detection_connection_pool.urlopen"
)
def test_dynamic_alert_rule(
self, mock_store_request: MagicMock, mock_delete_request: MagicMock
) -> None:
def test_dynamic_alert_rule(self, mock_store_request: MagicMock) -> None:
organization = self.create_organization()

seer_return_value = {"success": True}
mock_store_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
mock_delete_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)

alert_rule = self.create_alert_rule(
sensitivity=AlertRuleSensitivity.HIGH,
Expand Down Expand Up @@ -120,5 +114,3 @@ def test_dynamic_alert_rule(
assert not AlertRule.objects.filter(id=alert_rule.id).exists()
assert not AlertRuleTrigger.objects.filter(id=alert_rule_trigger.id).exists()
assert not Incident.objects.filter(id=incident.id).exists()

assert mock_delete_request.call_count == 1
Loading
Loading