Skip to content
3 changes: 3 additions & 0 deletions src/sentry/api/serializers/models/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class BaseGroupSerializerResponse(BaseGroupResponseOptional):
priorityLockedAt: datetime | None
seerFixabilityScore: float | None
seerAutofixLastTriggered: datetime | None
seerExplorerAutofixLastTriggered: datetime | None
project: GroupProjectResponse
type: str
issueType: str
Expand Down Expand Up @@ -363,6 +364,7 @@ def serialize(
share_id = attrs["share_id"]
priority_label = PriorityLevel(obj.priority).to_str() if obj.priority else None
issue_category = obj.issue_category_v2.name.lower()

group_dict: BaseGroupSerializerResponse = {
"id": str(obj.id),
"shareId": share_id,
Expand Down Expand Up @@ -393,6 +395,7 @@ def serialize(
"priorityLockedAt": obj.priority_locked_at,
"seerFixabilityScore": obj.seer_fixability_score,
"seerAutofixLastTriggered": obj.seer_autofix_last_triggered,
"seerExplorerAutofixLastTriggered": obj.seer_explorer_autofix_last_triggered,
}

# This attribute is currently feature gated
Expand Down
1 change: 1 addition & 0 deletions src/sentry/api/serializers/models/group_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ class StreamGroupSerializerSnubaResponse(TypedDict):
priorityLockedAt: NotRequired[datetime | None]
seerFixabilityScore: NotRequired[float | None]
seerAutofixLastTriggered: NotRequired[datetime | None]
seerExplorerAutofixLastTriggered: NotRequired[datetime | None]
project: NotRequired[GroupProjectResponse]
type: NotRequired[str]
issueType: NotRequired[str]
Expand Down
1 change: 1 addition & 0 deletions src/sentry/apidocs/examples/issue_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"lastSeen": datetime.fromisoformat("2018-12-06T21:19:55Z"),
},
"seerAutofixLastTriggered": None,
"seerExplorerAutofixLastTriggered": None,
"seerFixabilityScore": None,
"status": "ignored",
"substatus": "archived_until_condition_met",
Expand Down
1 change: 1 addition & 0 deletions src/sentry/issues/endpoints/organization_shortid.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class ShortIdLookupEndpoint(GroupEndpoint):
"priorityLockedAt": None,
"seerFixabilityScore": 0.5,
"seerAutofixLastTriggered": None,
"seerExplorerAutofixLastTriggered": None,
"substatus": "ongoing",
},
"groupId": "1",
Expand Down
12 changes: 9 additions & 3 deletions src/sentry/search/snuba/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from django.utils import timezone
from django.utils.functional import SimpleLazyObject

from sentry import quotas
from sentry import features, quotas
from sentry.api.event_search import SearchFilter
from sentry.db.models.manager.base_query_set import BaseQuerySet
from sentry.exceptions import InvalidSearchQuery
Expand Down Expand Up @@ -555,6 +555,8 @@ def _get_queryset_conditions(
environments: Sequence[Environment] | None,
search_filters: Sequence[SearchFilter],
) -> Mapping[str, Condition]:
organization = projects[0].organization

queryset_conditions: dict[str, Condition] = {
"status": QCallbackCondition(lambda statuses: Q(status__in=statuses)),
"substatus": QCallbackCondition(lambda substatuses: Q(substatus__in=substatuses)),
Expand Down Expand Up @@ -590,7 +592,11 @@ def _get_queryset_conditions(
"issue.type": QCallbackCondition(lambda types: Q(type__in=types)),
"issue.priority": QCallbackCondition(lambda priorities: Q(priority__in=priorities)),
"issue.seer_actionability": QCallbackCondition(seer_actionability_filter),
"issue.seer_last_run": ScalarCondition("seer_autofix_last_triggered"),
"issue.seer_last_run": ScalarCondition(
"seer_explorer_autofix_last_triggered"
if features.has("organizations:autofix-on-explorer", organization)
else "seer_autofix_last_triggered"
),
"issue.id": QCallbackCondition(
lambda ids: Q(id__in=[int(v) for v in (ids if isinstance(ids, list) else [ids])])
),
Expand All @@ -605,7 +611,7 @@ def _get_queryset_conditions(
# if environment(s) are selected, we just filter on the group
# environment's first_release attribute.
id__in=GroupEnvironment.objects.filter(
first_release__organization_id=projects[0].organization_id,
first_release__organization_id=organization.id,
first_release__version__in=versions,
environment_id__in=environment_ids,
).values_list("group_id"),
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/seer/autofix/autofix_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ def trigger_autofix_explorer(
artifact_schema=artifact_schema,
)

group.update(seer_autofix_last_triggered=timezone.now())
group.update(seer_explorer_autofix_last_triggered=timezone.now())

payload = {
"run_id": run_id,
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/seer/autofix/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
SeerAutomationSkipReason = Literal[
"already_has_fixability_score",
"already_triggered",
"already_triggered_explorer",
"automation_already_dispatched",
"fixability_too_low",
"issue_too_old",
Expand Down Expand Up @@ -98,6 +99,9 @@ def get_seat_based_seer_automation_skip_reason(
if group.seer_autofix_last_triggered is not None:
return "already_triggered"

if group.seer_explorer_autofix_last_triggered is not None:
return "already_triggered_explorer"

# Don't run automation on old issues
if group.first_seen < (timezone.now() - timedelta(days=14)):
return "issue_too_old"
Expand Down
1 change: 1 addition & 0 deletions src/sentry/tasks/seer/autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ def run_automation_only_task(group_id: int) -> None:

# Track issue age when running automation
issue_age_days = int((timezone.now() - group.first_seen).total_seconds() / (60 * 60 * 24))

metrics.distribution(
"seer.automation.issue_age_since_first_seen", issue_age_days, unit="day", sample_rate=1.0
)
Expand Down
24 changes: 24 additions & 0 deletions tests/sentry/api/serializers/test_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,30 @@ def test_perf_issue(self) -> None:
assert serialized["issueCategory"] == "db_query"
assert serialized["issueType"] == "performance_n_plus_one_db_queries"

def test_seer_autofix_last_triggered_without_explorer(self) -> None:
user = self.create_user()
group = self.create_group()
now = timezone.now()
group.update(seer_autofix_last_triggered=now)

result = serialize(group, user)
assert result["seerAutofixLastTriggered"] == now
assert result["seerExplorerAutofixLastTriggered"] is None

def test_seer_autofix_last_triggered_with_explorer(self) -> None:
user = self.create_user()
group = self.create_group()
old_time = timezone.now() - timedelta(hours=1)
new_time = timezone.now()
group.update(
seer_autofix_last_triggered=old_time,
seer_explorer_autofix_last_triggered=new_time,
)

result = serialize(group, user)
assert result["seerAutofixLastTriggered"] == old_time
assert result["seerExplorerAutofixLastTriggered"] == new_time


class SimpleGroupSerializerTest(TestCase):
def test_simple_group_serializer(self) -> None:
Expand Down
41 changes: 33 additions & 8 deletions tests/sentry/issues/endpoints/test_organization_group_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,32 +532,57 @@ def test_has_seer_last_run(self) -> None:
"""Test filtering issues by whether they have seer_autofix_last_triggered set."""
event1 = self.store_event(
data={
"fingerprint": ["seer-group"],
"fingerprint": ["no-seer-group"],
"timestamp": before_now(seconds=1).isoformat(),
},
project_id=self.project.id,
)
group_with_seer = event1.group
group_with_seer.update(seer_autofix_last_triggered=timezone.now())
group_without_seer = event1.group

event2 = self.store_event(
data={
"fingerprint": ["no-seer-group"],
"fingerprint": ["legacy-seer-group"],
"timestamp": before_now(seconds=1).isoformat(),
},
project_id=self.project.id,
)
group_without_seer = event2.group
group_with_legacy_seer = event2.group
group_with_legacy_seer.update(seer_autofix_last_triggered=timezone.now())

event3 = self.store_event(
data={
"fingerprint": ["explorer-seer-group"],
"timestamp": before_now(seconds=1).isoformat(),
},
project_id=self.project.id,
)
group_with_explorer_seer = event3.group
group_with_explorer_seer.update(seer_explorer_autofix_last_triggered=timezone.now())

self.login_as(user=self.user)

# Query for issues that have seer_autofix_last_triggered set
response = self.get_success_response(query="has:issue.seer_last_run")
assert len(response.data) == 1
assert response.data[0]["id"] == str(group_with_seer.id)
assert response.data[0]["id"] == str(group_with_legacy_seer.id)

# Query for issues that do NOT have seer_autofix_last_triggered set
response = self.get_success_response(query="!has:issue.seer_last_run")
assert len(response.data) == 1
assert response.data[0]["id"] == str(group_without_seer.id)
assert len(response.data) == 2
assert response.data[0]["id"] == str(group_with_explorer_seer.id)
assert response.data[1]["id"] == str(group_without_seer.id)

# Query for issues that have seer_explorer_autofix_last_triggered set
with self.feature("organizations:autofix-on-explorer"):
response = self.get_success_response(query="has:issue.seer_last_run")
assert len(response.data) == 1
assert response.data[0]["id"] == str(group_with_explorer_seer.id)

# Query for issues that do NOT have seer_explorer_autofix_last_triggered set
response = self.get_success_response(query="!has:issue.seer_last_run")
assert len(response.data) == 2
assert response.data[0]["id"] == str(group_with_legacy_seer.id)
assert response.data[1]["id"] == str(group_without_seer.id)

def test_lookup_by_event_id(self) -> None:
project = self.project
Expand Down
24 changes: 24 additions & 0 deletions tests/sentry/seer/autofix/test_autofix_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,30 @@ def test_trigger_autofix_explorer_passes_group_id_in_metadata(
call_kwargs = mock_client.start_run.call_args.kwargs
assert call_kwargs["metadata"] == {"group_id": self.group.id, "referrer": "unknown"}

@patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay")
@patch("sentry.seer.autofix.autofix_agent.SeerExplorerClient")
def test_trigger_autofix_explorer_updates_explorer_last_triggered(
self, mock_client_class, mock_broadcast
):
"""trigger_autofix_explorer sets seer_explorer_autofix_last_triggered on the group."""
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.start_run.return_value = 123

assert self.group.seer_explorer_autofix_last_triggered is None

trigger_autofix_explorer(
group=self.group,
step=AutofixStep.ROOT_CAUSE,
referrer=AutofixReferrer.UNKNOWN,
run_id=None,
)

self.group.refresh_from_db()

assert self.group.seer_autofix_last_triggered is None
assert self.group.seer_explorer_autofix_last_triggered is not None


class TestTriggerCodingAgentHandoff(TestCase):
"""Tests for trigger_coding_agent_handoff function."""
Expand Down
42 changes: 42 additions & 0 deletions tests/sentry/tasks/test_post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
from sentry.models.userreport import UserReport
from sentry.replays.lib import kafka as replays_kafka
from sentry.replays.lib.kafka import clear_replay_publisher
from sentry.seer.autofix.constants import (
AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD,
FixabilityScoreThresholds,
)
from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key
from sentry.services.eventstore.models import Event
from sentry.services.eventstore.processing import event_processing_store
from sentry.silo.base import SiloMode
Expand Down Expand Up @@ -3306,6 +3311,43 @@ def mock_buffer_get(model, columns, filters):
# Should not call automation since seer_autofix_last_triggered is set
mock_run_automation.assert_not_called()

@patch("sentry.tasks.seer.autofix.run_automation_only_task.delay")
@with_feature({"organizations:gen-ai-features": True})
def test_triage_signals_skips_with_explorer_last_triggered(
self, mock_run_automation, mock_seat_based_tier
):
"""Test that with event count >= 10 and seer_explorer_autofix_last_triggered set + feature flag, we skip automation."""
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)

# Update group times_seen and seer_explorer_autofix_last_triggered
group = event.group
group.times_seen = AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD
group.seer_explorer_autofix_last_triggered = timezone.now()
group.seer_fixability_score = FixabilityScoreThresholds.MEDIUM.value
group.save()

def mock_buffer_get(model, columns, filters):
return {"times_seen": 9}

with patch.object(buffer.backend, "get", side_effect=mock_buffer_get):
cache_key = get_issue_summary_cache_key(group.id)
cache.set(cache_key, {"summary": "test summary"}, 3600)
cache.set(f"seer-project-has-repos:{self.organization.id}:{self.project.id}", True)

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

# Should not call automation since seer_explorer_autofix_last_triggered is set
mock_run_automation.assert_not_called()

@patch("sentry.tasks.seer.autofix.run_automation_only_task.delay")
@with_feature({"organizations:gen-ai-features": True})
def test_triage_signals_event_count_gte_10_skips_with_existing_fixability_score(
Expand Down
Loading