Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/sentry/seer/autofix/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sentry import features, options, ratelimits
from sentry.constants import DataCategory
from sentry.issues.auto_source_code_config.code_mapping import get_sorted_code_mapping_configs
from sentry.models.group import Group
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.models.repository import Repository
Expand Down Expand Up @@ -286,6 +287,54 @@ def is_seer_scanner_rate_limited(project: Project, organization: Organization) -
return is_rate_limited


def is_issue_eligible_for_seer_automation(group: Group) -> bool:
"""Check if Seer automation is allowed for a given group based on permissions and issue type."""
from sentry import quotas
from sentry.issues.grouptype import GroupCategory

# check currently supported issue categories for Seer
if group.issue_category not in [
GroupCategory.ERROR,
GroupCategory.PERFORMANCE,
GroupCategory.MOBILE,
GroupCategory.FRONTEND,
GroupCategory.DB_QUERY,
GroupCategory.HTTP_CLIENT,
] or group.issue_category in [
GroupCategory.REPLAY,
GroupCategory.FEEDBACK,
]:
return False

if not features.has("organizations:gen-ai-features", group.organization):
return False

gen_ai_allowed = not group.organization.get_option("sentry:hide_ai_features")
if not gen_ai_allowed:
return False

project = group.project
if (
not project.get_option("sentry:seer_scanner_automation")
and not group.issue_type.always_trigger_seer_automation
):
return False

from sentry.seer.seer_setup import get_seer_org_acknowledgement
Copy link
Member

Choose a reason for hiding this comment

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

was including the import here intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it was just to keep parity with the original function. That also has it inside the function on line 1625


seer_enabled = get_seer_org_acknowledgement(group.organization)
if not seer_enabled:
return False

has_budget: bool = quotas.backend.has_available_reserved_budget(
org_id=group.organization.id, data_category=DataCategory.SEER_SCANNER
)
if not has_budget:
return False

return True


AUTOFIX_AUTOTRIGGED_RATE_LIMIT_OPTION_MULTIPLIERS = {
AutofixAutomationTuningSettings.OFF: 5,
AutofixAutomationTuningSettings.SUPER_LOW: 5,
Expand Down
61 changes: 6 additions & 55 deletions src/sentry/tasks/post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -1622,7 +1622,10 @@ def check_if_flags_sent(job: PostProcessJob) -> None:

def kick_off_seer_automation(job: PostProcessJob) -> None:
from sentry.seer.autofix.issue_summary import get_issue_summary_lock_key
from sentry.seer.seer_setup import get_seer_org_acknowledgement
from sentry.seer.autofix.utils import (
is_issue_eligible_for_seer_automation,
is_seer_scanner_rate_limited,
)
from sentry.tasks.autofix import start_seer_automation

event = job["event"]
Expand All @@ -1632,39 +1635,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None:
if group.seer_fixability_score is not None:
return

# check currently supported issue categories for Seer
if group.issue_category not in [
GroupCategory.ERROR,
GroupCategory.PERFORMANCE,
GroupCategory.MOBILE,
GroupCategory.FRONTEND,
GroupCategory.DB_QUERY,
GroupCategory.HTTP_CLIENT,
] or group.issue_category in [
GroupCategory.REPLAY,
GroupCategory.FEEDBACK,
]:
return

if not features.has("organizations:gen-ai-features", group.organization):
return

gen_ai_allowed = not group.organization.get_option("sentry:hide_ai_features")
if not gen_ai_allowed:
return

project = group.project
if (
not project.get_option("sentry:seer_scanner_automation")
and not group.issue_type.always_trigger_seer_automation
):
return

# Check if automation has already been queued or completed for this group
# seer_autofix_last_triggered is set when trigger_autofix is successfully started.
# Use cache with short TTL to hold lock for a short since it takes a few minutes to set seer_autofix_last_triggeredes
cache_key = f"seer_automation_queued:{group.id}"
if cache.get(cache_key) or group.seer_autofix_last_triggered is not None:
if is_issue_eligible_for_seer_automation(group) is False:
return

# Don't run if there's already a task in progress for this issue
Expand All @@ -1673,27 +1644,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None:
if lock.locked():
return

seer_enabled = get_seer_org_acknowledgement(group.organization)
if not seer_enabled:
return

from sentry import quotas
from sentry.constants import DataCategory

has_budget: bool = quotas.backend.has_available_reserved_budget(
org_id=group.organization.id, data_category=DataCategory.SEER_SCANNER
)
if not has_budget:
return

from sentry.seer.autofix.utils import is_seer_scanner_rate_limited

if is_seer_scanner_rate_limited(project, group.organization):
return

# cache.add uses Redis SETNX which atomically sets the key only if it doesn't exist
# Returns False if another process already set the key, ensuring only one process proceeds
if not cache.add(cache_key, True, timeout=600): # 10 minute
if is_seer_scanner_rate_limited(group.project, group.organization):
return

start_seer_automation.delay(group.id)
Expand Down
166 changes: 50 additions & 116 deletions tests/sentry/tasks/test_post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from hashlib import md5
from typing import Any
from unittest import mock
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, Mock, PropertyMock, patch

import pytest
from django.db import router
Expand Down Expand Up @@ -3053,137 +3053,70 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled(

mock_start_seer_automation.assert_not_called()

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement",
return_value=True,
)
@patch("sentry.tasks.autofix.start_seer_automation.delay")
@with_feature("organizations:gen-ai-features")
def test_kick_off_seer_automation_skips_when_seer_autofix_last_triggered_set(
self, mock_start_seer_automation, mock_get_seer_org_acknowledgement
):
"""Test that automation is skipped when group.seer_autofix_last_triggered is already set"""
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)

# Set seer_autofix_last_triggered on the group to simulate autofix already triggered
group = event.group
group.seer_autofix_last_triggered = timezone.now()
group.save()

self.call_post_process_group(
is_new=True,
is_regression=False,
is_new_group_environment=True,
event=event,
)

mock_start_seer_automation.assert_not_called()
class SeerAutomationHelperFunctionsTestMixin(BasePostProgressGroupMixin):
"""Unit tests for is_issue_eligible_for_seer_automation."""

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement",
return_value=True,
)
@patch("sentry.tasks.autofix.start_seer_automation.delay")
@with_feature("organizations:gen-ai-features")
def test_kick_off_seer_automation_skips_when_cache_key_exists(
self, mock_start_seer_automation, mock_get_seer_org_acknowledgement
@patch("sentry.quotas.backend.has_available_reserved_budget", return_value=True)
@patch("sentry.seer.seer_setup.get_seer_org_acknowledgement", return_value=True)
@patch("sentry.features.has", return_value=True)
def test_is_issue_eligible_for_seer_automation(
self, mock_features_has, mock_get_seer_org_acknowledgement, mock_has_budget
):
"""Test that automation is skipped when cache key indicates it's already queued"""
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)
"""Test permission check with various failure conditions."""
from sentry.constants import DataCategory
from sentry.issues.grouptype import GroupCategory
from sentry.seer.autofix.utils import is_issue_eligible_for_seer_automation

# Set cache key to simulate automation already queued
cache_key = f"seer_automation_queued:{event.group.id}"
cache.set(cache_key, True, timeout=600)

self.call_post_process_group(
is_new=True,
is_regression=False,
is_new_group_environment=True,
event=event,
)
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(data={"message": "testing"}, project_id=self.project.id)
group = event.group

mock_start_seer_automation.assert_not_called()
# All conditions pass
assert is_issue_eligible_for_seer_automation(group) is True

# Cleanup
cache.delete(cache_key)
# Unsupported categories (using PropertyMock to mock the property)
with patch(
"sentry.models.group.Group.issue_category", new_callable=PropertyMock
) as mock_category:
mock_category.return_value = GroupCategory.REPLAY
assert is_issue_eligible_for_seer_automation(group) is False

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement",
return_value=True,
)
@patch("sentry.tasks.autofix.start_seer_automation.delay")
@with_feature("organizations:gen-ai-features")
def test_kick_off_seer_automation_uses_atomic_cache_add(
self, mock_start_seer_automation, mock_get_seer_org_acknowledgement
):
"""Test that cache.add atomic operation prevents race conditions"""
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)
mock_category.return_value = GroupCategory.FEEDBACK
assert is_issue_eligible_for_seer_automation(group) is False

cache_key = f"seer_automation_queued:{event.group.id}"
# Missing feature flag
mock_features_has.return_value = False
assert is_issue_eligible_for_seer_automation(group) is False

with patch("sentry.tasks.post_process.cache") as mock_cache:
# Simulate cache.get returning None (not in cache)
# but cache.add returning False (another process set it)
mock_cache.get.return_value = None
mock_cache.add.return_value = False
# Hide AI features enabled
mock_features_has.return_value = True
self.organization.update_option("sentry:hide_ai_features", True)
assert is_issue_eligible_for_seer_automation(group) is False
self.organization.update_option("sentry:hide_ai_features", False)

self.call_post_process_group(
is_new=True,
is_regression=False,
is_new_group_environment=True,
event=event,
)
# Scanner disabled without always_trigger
self.project.update_option("sentry:seer_scanner_automation", False)
with patch.object(group.issue_type, "always_trigger_seer_automation", False):
assert is_issue_eligible_for_seer_automation(group) is False

# Should check cache but not call automation due to cache.add returning False
mock_cache.get.assert_called()
mock_cache.add.assert_called_once_with(cache_key, True, timeout=600)
mock_start_seer_automation.assert_not_called()
# Scanner disabled but always_trigger enabled
with patch.object(group.issue_type, "always_trigger_seer_automation", True):
assert is_issue_eligible_for_seer_automation(group) is True

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement",
return_value=True,
)
@patch("sentry.tasks.autofix.start_seer_automation.delay")
@with_feature("organizations:gen-ai-features")
def test_kick_off_seer_automation_proceeds_when_cache_add_succeeds(
self, mock_start_seer_automation, mock_get_seer_org_acknowledgement
):
"""Test that automation proceeds when cache.add succeeds (no race condition)"""
# Seer not acknowledged
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)

# Ensure seer_autofix_last_triggered is not set
assert event.group.seer_autofix_last_triggered is None
mock_get_seer_org_acknowledgement.return_value = False
assert is_issue_eligible_for_seer_automation(group) is False

self.call_post_process_group(
is_new=True,
is_regression=False,
is_new_group_environment=True,
event=event,
# No budget
mock_get_seer_org_acknowledgement.return_value = True
mock_has_budget.return_value = False
assert is_issue_eligible_for_seer_automation(group) is False
mock_has_budget.assert_called_with(
org_id=group.organization.id, data_category=DataCategory.SEER_SCANNER
)

# Should successfully queue automation
mock_start_seer_automation.assert_called_once_with(event.group.id)

# Cleanup
cache_key = f"seer_automation_queued:{event.group.id}"
cache.delete(cache_key)


class PostProcessGroupErrorTest(
TestCase,
Expand All @@ -3194,6 +3127,7 @@ class PostProcessGroupErrorTest(
InboxTestMixin,
ResourceChangeBoundsTestMixin,
KickOffSeerAutomationTestMixin,
SeerAutomationHelperFunctionsTestMixin,
RuleProcessorTestMixin,
ServiceHooksTestMixin,
SnoozeTestMixin,
Expand Down
Loading