diff --git a/src/sentry/integrations/utils/issue_summary_for_alerts.py b/src/sentry/integrations/utils/issue_summary_for_alerts.py index e7ef10174177..d235f470c14e 100644 --- a/src/sentry/integrations/utils/issue_summary_for_alerts.py +++ b/src/sentry/integrations/utils/issue_summary_for_alerts.py @@ -3,12 +3,13 @@ from typing import Any import sentry_sdk +from django.contrib.auth.models import AnonymousUser from sentry import features, options from sentry.issues.grouptype import GroupCategory from sentry.models.group import Group from sentry.seer.autofix.constants import SeerAutomationSource -from sentry.seer.autofix.issue_summary import get_issue_summary +from sentry.seer.autofix.issue_summary import get_issue_summary, run_automation from sentry.seer.autofix.utils import is_seer_scanner_rate_limited from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor @@ -52,9 +53,19 @@ def fetch_issue_summary(group: Group) -> dict[str, Any] | None: future = executor.submit( get_issue_summary, group, source=SeerAutomationSource.ALERT ) - summary_result, status_code = future.result(timeout=timeout) + summary_result, status_code, event = future.result(timeout=timeout) if status_code == 200: + if event is not None: + try: + run_automation( + group, AnonymousUser(), event, SeerAutomationSource.ALERT + ) + except Exception: + logger.exception( + "seer.automation.run_automation_failed", + extra={"group_id": group.id}, + ) return summary_result return None except concurrent.futures.TimeoutError: diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 367131d81def..f7ca09398dac 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -520,15 +520,13 @@ def _generate_summary( group: Group, user: User | RpcUser | AnonymousUser, force_event_id: str | None, - source: SeerAutomationSource, cache_key: str, - should_run_automation: bool = True, -) -> tuple[dict[str, Any], int]: +) -> tuple[dict[str, Any], int, GroupEvent | None]: """Core logic to generate and cache the issue summary.""" serialized_event, event = _get_event(group, user, provided_event_id=force_event_id) if not serialized_event or not event: - return {"detail": "Could not find an event for the issue"}, 400 + return {"detail": "Could not find an event for the issue"}, 400, None trace_tree = None if event: @@ -571,15 +569,7 @@ def _generate_summary( summary_dict["event_id"] = event.event_id cache.set(cache_key, summary_dict, timeout=int(timedelta(days=7).total_seconds())) - if should_run_automation: - try: - run_automation(group, user, event, source) - except Exception: - logger.exception( - "Error auto-triggering autofix from issue summary", extra={"group_id": group.id} - ) - - return summary_dict, 200 + return summary_dict, 200, event def _log_seer_scanner_billing_event(group: Group, source: SeerAutomationSource): @@ -604,8 +594,7 @@ def get_issue_summary( user: User | RpcUser | AnonymousUser | None = None, force_event_id: str | None = None, source: SeerAutomationSource = SeerAutomationSource.ISSUE_DETAILS, - should_run_automation: bool = True, -) -> tuple[dict[str, Any], int]: +) -> tuple[dict[str, Any], int, GroupEvent | None]: """ Generate an AI summary for an issue. @@ -614,18 +603,17 @@ def get_issue_summary( user: The user requesting the summary force_event_id: Optional event ID to force summarizing a specific event source: The source triggering the summary generation - should_run_automation: Whether to trigger automation after generating summary Returns: - A tuple containing (summary_data, status_code) + A tuple containing (summary_data, status_code, event) """ if user is None: user = AnonymousUser() if not features.has("organizations:gen-ai-features", group.organization, actor=user): - return {"detail": "Feature flag not enabled"}, 400 + return {"detail": "Feature flag not enabled"}, 400, None if group.organization.get_option("sentry:hide_ai_features"): - return {"detail": "AI features are disabled for this organization."}, 403 + return {"detail": "AI features are disabled for this organization."}, 403, None cache_key = get_issue_summary_cache_key(group.id) lock_key, lock_name = get_issue_summary_lock_key(group.id) @@ -634,15 +622,13 @@ def get_issue_summary( # if force_event_id is set, we always generate a new summary if force_event_id: - summary_dict, status_code = _generate_summary( - group, user, force_event_id, source, cache_key, should_run_automation - ) + summary_dict, status_code, event = _generate_summary(group, user, force_event_id, cache_key) _log_seer_scanner_billing_event(group, source) - return convert_dict_key_case(summary_dict, snake_to_camel_case), status_code + return convert_dict_key_case(summary_dict, snake_to_camel_case), status_code, event # 1. Check cache first if cached_summary := cache.get(cache_key): - return convert_dict_key_case(cached_summary, snake_to_camel_case), 200 + return convert_dict_key_case(cached_summary, snake_to_camel_case), 200, None # 2. Try to acquire lock try: @@ -653,17 +639,17 @@ def get_issue_summary( # Re-check cache after acquiring lock, in case another process finished # while we were waiting for the lock. if cached_summary := cache.get(cache_key): - return convert_dict_key_case(cached_summary, snake_to_camel_case), 200 + return convert_dict_key_case(cached_summary, snake_to_camel_case), 200, None # Lock acquired and cache is still empty, proceed with generation - summary_dict, status_code = _generate_summary( - group, user, force_event_id, source, cache_key, should_run_automation + summary_dict, status_code, event = _generate_summary( + group, user, force_event_id, cache_key ) _log_seer_scanner_billing_event(group, source) - return convert_dict_key_case(summary_dict, snake_to_camel_case), status_code + return convert_dict_key_case(summary_dict, snake_to_camel_case), status_code, event except UnableToAcquireLock: # Failed to acquire lock within timeout. Check cache one last time. if cached_summary := cache.get(cache_key): - return convert_dict_key_case(cached_summary, snake_to_camel_case), 200 - return {"detail": "Timeout waiting for summary generation lock"}, 503 + return convert_dict_key_case(cached_summary, snake_to_camel_case), 200, None + return {"detail": "Timeout waiting for summary generation lock"}, 503, None diff --git a/src/sentry/seer/endpoints/group_ai_summary.py b/src/sentry/seer/endpoints/group_ai_summary.py index ae10563cb67b..0a041f262fbd 100644 --- a/src/sentry/seer/endpoints/group_ai_summary.py +++ b/src/sentry/seer/endpoints/group_ai_summary.py @@ -43,7 +43,7 @@ def post(self, request: Request, group: Group) -> Response: data = orjson.loads(request.body) if request.body else {} force_event_id = data.get("event_id", None) - summary_data, status_code = get_issue_summary( + summary_data, status_code, _ = get_issue_summary( group=group, user=request.user, force_event_id=force_event_id, diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index d9076e4616d6..9be800b0f58e 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1529,7 +1529,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: is_seer_seat_based_tier_enabled, ) from sentry.tasks.seer.autofix import ( - generate_issue_summary_only, + generate_summary_and_fixability_score, generate_summary_and_run_automation, run_automation_only_task, ) @@ -1555,7 +1555,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: "seer.automation.filtered", tags={"reason": skip_reason, "tier": "seat_based"} ) if skip_reason == "below_occurrence_threshold": - generate_issue_summary_only.delay(group.id) + generate_summary_and_fixability_score.delay(group.id) return # Check if summary exists in cache diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 1d4ac97d8abc..bb0033d4099b 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -74,7 +74,9 @@ def check_autofix_status(run_id: int, organization_id: int) -> None: retry=Retry(times=1), ) def generate_summary_and_run_automation(group_id: int, **kwargs) -> None: - from sentry.seer.autofix.issue_summary import get_issue_summary + from django.contrib.auth.models import AnonymousUser + + from sentry.seer.autofix.issue_summary import get_issue_summary, run_automation trigger_path = kwargs.get("trigger_path", "unknown") sentry_sdk.set_tag("trigger_path", trigger_path) @@ -98,7 +100,12 @@ def generate_summary_and_run_automation(group_id: int, **kwargs) -> None: ) ) - get_issue_summary(group=group, source=SeerAutomationSource.POST_PROCESS) + _, status_code, event = get_issue_summary(group=group, source=SeerAutomationSource.POST_PROCESS) + if status_code == 200 and event is not None: + try: + run_automation(group, AnonymousUser(), event, SeerAutomationSource.POST_PROCESS) + except Exception: + logger.exception("seer.automation.run_automation_failed", extra={"group_id": group.id}) @instrumented_task( @@ -107,7 +114,7 @@ def generate_summary_and_run_automation(group_id: int, **kwargs) -> None: processing_deadline_duration=35, retry=Retry(times=3, delay=3, on=(Exception,)), ) -def generate_issue_summary_only(group_id: int) -> None: +def generate_summary_and_fixability_score(group_id: int) -> None: """ Generate issue summary WITHOUT triggering automation. Used for triage signals flow when event count < AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD or when summary doesn't exist yet. @@ -137,9 +144,7 @@ def generate_issue_summary_only(group_id: int) -> None: ) # Generate and cache the summary - get_issue_summary( - group=group, source=SeerAutomationSource.POST_PROCESS, should_run_automation=False - ) + get_issue_summary(group=group, source=SeerAutomationSource.POST_PROCESS) get_and_update_group_fixability_score(group, force_generate=True) diff --git a/tests/sentry/integrations/slack/test_message_builder.py b/tests/sentry/integrations/slack/test_message_builder.py index 7b5df9bd549f..6e0ef0902aaf 100644 --- a/tests/sentry/integrations/slack/test_message_builder.py +++ b/tests/sentry/integrations/slack/test_message_builder.py @@ -946,7 +946,7 @@ def test_build_group_block_with_ai_summary(self) -> None: patch(patch_path) as mock_get_summary, patch(serializer_path, serializer_mock), ): - mock_get_summary.return_value = (mock_summary, 200) + mock_get_summary.return_value = (mock_summary, 200, None) blocks = SlackIssuesMessageBuilder(group).build() @@ -1037,7 +1037,7 @@ def test_build_group_block_with_ai_summary_text_truncation(self) -> None: patch(patch_path) as mock_get_summary, patch(serializer_path, serializer_mock), ): - mock_get_summary.return_value = (mock_summary, 200) + mock_get_summary.return_value = (mock_summary, 200, None) blocks = SlackIssuesMessageBuilder(group1, event1.for_group(group1)).build() title_text = blocks["blocks"][0]["text"]["text"] @@ -1049,7 +1049,7 @@ def test_build_group_block_with_ai_summary_text_truncation(self) -> None: patch(patch_path) as mock_get_summary, patch(serializer_path, serializer_mock), ): - mock_get_summary.return_value = (mock_summary, 200) + mock_get_summary.return_value = (mock_summary, 200, None) blocks = SlackIssuesMessageBuilder(group2, event2.for_group(group2)).build() title_text = blocks["blocks"][0]["text"]["text"] @@ -1092,7 +1092,7 @@ def test_build_group_block_with_ai_summary_text_truncation(self) -> None: patch(patch_path) as mock_get_summary, patch(serializer_path, serializer_mock), ): - mock_get_summary.return_value = (mock_summary, 200) + mock_get_summary.return_value = (mock_summary, 200, None) blocks = SlackIssuesMessageBuilder(group_lb, event_lb.for_group(group_lb)).build() title_block = blocks["blocks"][0]["text"]["text"] assert f": {expected_headline_part}*>" in title_block, f"Failed for {name}" @@ -1184,7 +1184,7 @@ def test_compact_alerts_with_ai_summary(self) -> None: patch(patch_path) as mock_get_summary, patch(serializer_path, serializer_mock), ): - mock_get_summary.return_value = (mock_summary, 200) + mock_get_summary.return_value = (mock_summary, 200, None) blocks = SlackIssuesMessageBuilder(group).build() diff --git a/tests/sentry/integrations/utils/test_issue_summary_for_alerts.py b/tests/sentry/integrations/utils/test_issue_summary_for_alerts.py index ab023d66ee78..dc9d74946aaa 100644 --- a/tests/sentry/integrations/utils/test_issue_summary_for_alerts.py +++ b/tests/sentry/integrations/utils/test_issue_summary_for_alerts.py @@ -67,7 +67,7 @@ def test_fetch_issue_summary_with_hide_ai_features_disabled( "whatsWrong": "Something went wrong", "possibleCause": "Test cause", } - mock_get_issue_summary.return_value = (mock_summary, 200) + mock_get_issue_summary.return_value = (mock_summary, 200, None) result = fetch_issue_summary(self.group) @@ -174,7 +174,7 @@ def test_fetch_issue_summary_api_error( mock_has_budget.return_value = True # Mock error response - mock_get_issue_summary.return_value = (None, 500) + mock_get_issue_summary.return_value = (None, 500, None) result = fetch_issue_summary(self.group) diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index 05ea097c4e69..52030bf3532f 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -69,7 +69,7 @@ def test_get_issue_summary_with_existing_summary(self, mock_call_seer): f"ai-group-summary-v2:{self.group.id}", existing_summary, timeout=60 * 60 * 24 * 7 ) - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) assert status_code == 200 assert summary_data == convert_dict_key_case(existing_summary, snake_to_camel_case) @@ -79,7 +79,7 @@ def test_get_issue_summary_with_existing_summary(self, mock_call_seer): def test_get_issue_summary_without_event(self, mock_get_event: MagicMock) -> None: mock_get_event.return_value = [None, None] - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) assert status_code == 400 assert summary_data == {"detail": "Could not find an event for the issue"} @@ -116,7 +116,7 @@ def test_get_issue_summary_without_existing_summary( expected_response_summary = mock_summary.dict() expected_response_summary["event_id"] = event.event_id - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) assert status_code == 200 assert summary_data == convert_dict_key_case(expected_response_summary, snake_to_camel_case) @@ -164,7 +164,7 @@ def test_call_seer_integration( expected_response_summary = mock_response.json.return_value expected_response_summary["event_id"] = event.event_id - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) assert status_code == 200 assert summary_data == convert_dict_key_case(expected_response_summary, snake_to_camel_case) @@ -214,7 +214,7 @@ def test_get_issue_summary_cache_write_read(self, mock_get_issue_summary): patch("sentry.seer.autofix.issue_summary._get_event") as mock_get_event, patch("sentry.seer.autofix.issue_summary._call_seer") as mock_call_seer, ): - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) assert status_code == 200 assert summary_data == convert_dict_key_case( @@ -239,7 +239,7 @@ def side_effect_generate(*args, **kwargs): time.sleep(0.3) # Write to cache before returning (simulates behavior after lock release) cache.set(cache_key, generated_summary, timeout=60) - return generated_summary, 200 + return generated_summary, 200, None mock_generate_summary.side_effect = side_effect_generate @@ -248,7 +248,7 @@ def side_effect_generate(*args, **kwargs): def target(req_id): try: - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) results[req_id] = (summary_data, status_code) except Exception as e: exceptions[req_id] = e @@ -292,7 +292,7 @@ def test_get_issue_summary_concurrent_force_event_id_bypasses_lock(self, mock_ge """Test that force_event_id bypasses lock waiting.""" # Mock summary generation forced_summary = {"headline": "Forced Summary", "event_id": "force_event"} - mock_generate_summary.return_value = (forced_summary, 200) + mock_generate_summary.return_value = (forced_summary, 200, None) # Ensure cache is empty and lock *could* be acquired if attempted cache_key = f"ai-group-summary-v2:{self.group.id}" @@ -302,7 +302,7 @@ def test_get_issue_summary_concurrent_force_event_id_bypasses_lock(self, mock_ge locks.get(lock_key, duration=1).release() # Ensure lock isn't held # Call with force_event_id=True - summary_data, status_code = get_issue_summary( + summary_data, status_code, _event = get_issue_summary( self.group, self.user, force_event_id="some_event" ) @@ -357,7 +357,7 @@ def test_get_issue_summary_lock_timeout( # Simulate cache miss even after timeout mock_cache_get.return_value = None - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) assert status_code == 503 assert summary_data == {"detail": "Timeout waiting for summary generation lock"} @@ -475,9 +475,6 @@ def test_get_event_provided( ) mock_serialize.assert_called_once() - @patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay") - @patch("sentry.seer.autofix.issue_summary.get_autofix_state") - @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") @patch("sentry.quotas.backend.record_seer_run") @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") @patch("sentry.seer.autofix.issue_summary._call_seer") @@ -488,23 +485,7 @@ def test_get_issue_summary_with_web_vitals_issue( mock_call_seer, mock_get_trace_tree, mock_record_seer_run, - mock_generate_fixability_score, - mock_get_autofix_state, - mock_trigger_autofix_task, ): - mock_get_autofix_state.return_value = None - mock_fixability_response = SummarizeIssueResponse( - group_id=str(self.group.id), - headline="some headline", - whats_wrong="some whats wrong", - trace="some trace", - possible_cause="some possible cause", - scores=SummarizeIssueScores( - fixability_score=0.5, - is_fixable=True, - ), - ) - mock_generate_fixability_score.return_value = mock_fixability_response event = Mock( event_id="test_event_id", data="test_event_data", @@ -550,55 +531,12 @@ def test_get_issue_summary_with_web_vitals_issue( assert group_info is not None self.group = group_info.group - summary_data, status_code = get_issue_summary( + summary_data, status_code, _event = get_issue_summary( self.group, self.user, source=SeerAutomationSource.POST_PROCESS ) assert status_code == 200 mock_record_seer_run.assert_called_once() - mock_trigger_autofix_task.assert_called_once() - - @patch("sentry.seer.autofix.issue_summary.run_automation") - @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") - @patch("sentry.seer.autofix.issue_summary._call_seer") - @patch("sentry.seer.autofix.issue_summary._get_event") - def test_get_issue_summary_continues_when_automation_fails( - self, - mock_get_event, - mock_call_seer, - mock_get_trace_tree, - mock_run_automation, - ): - """Test that issue summary is still returned when run_automation throws an exception.""" - # Set up event and seer response - event = Mock(event_id="test_event_id", datetime=datetime.datetime.now()) - serialized_event = {"event_id": "test_event_id", "data": "test_event_data"} - mock_get_event.return_value = [serialized_event, event] - mock_get_trace_tree.return_value = None - - mock_summary = SummarizeIssueResponse( - group_id=str(self.group.id), - headline="Test headline", - whats_wrong="Test whats wrong", - trace="Test trace", - possible_cause="Test possible cause", - ) - mock_call_seer.return_value = mock_summary - - # Make run_automation raise an exception - mock_run_automation.side_effect = Exception("Automation failed") - - # Call get_issue_summary and verify it still returns successfully - summary_data, status_code = get_issue_summary(self.group, self.user) - - assert status_code == 200 - expected_response = mock_summary.dict() - expected_response["event_id"] = event.event_id - assert summary_data == convert_dict_key_case(expected_response, snake_to_camel_case) - - # Verify run_automation was called and failed - mock_run_automation.assert_called_once() - mock_call_seer.assert_called_once() @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") def test_get_issue_summary_handles_trace_tree_errors( @@ -627,69 +565,13 @@ def test_get_issue_summary_handles_trace_tree_errors( ) as mock_call_seer, patch("sentry.seer.autofix.issue_summary.run_automation"), ): - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) assert status_code == 200 mock_call_seer.assert_called_once_with( self.group, serialized_event, None, experiment_variant=None ) - @patch("sentry.seer.autofix.issue_summary.run_automation") - @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") - @patch("sentry.seer.autofix.issue_summary._call_seer") - @patch("sentry.seer.autofix.issue_summary._get_event") - def test_get_issue_summary_with_should_run_automation_false( - self, - mock_get_event, - mock_call_seer, - mock_get_trace_tree, - mock_run_automation, - ): - """Test that should_run_automation=False prevents run_automation from being called.""" - event = Mock( - event_id="test_event_id", - data="test_event_data", - trace_id="test_trace", - datetime=datetime.datetime.now(), - ) - serialized_event = {"event_id": "test_event_id", "data": "test_event_data"} - mock_get_event.return_value = [serialized_event, event] - mock_summary = SummarizeIssueResponse( - group_id=str(self.group.id), - headline="Test headline", - whats_wrong="Test whats wrong", - trace="Test trace", - possible_cause="Test possible cause", - scores=SummarizeIssueScores( - possible_cause_confidence=0.0, - possible_cause_novelty=0.0, - ), - ) - mock_call_seer.return_value = mock_summary - mock_get_trace_tree.return_value = {"trace": "tree"} - - expected_response_summary = mock_summary.dict() - expected_response_summary["event_id"] = event.event_id - - summary_data, status_code = get_issue_summary( - self.group, self.user, should_run_automation=False - ) - - assert status_code == 200 - assert summary_data == convert_dict_key_case(expected_response_summary, snake_to_camel_case) - mock_get_event.assert_called_once_with(self.group, self.user, provided_event_id=None) - mock_get_trace_tree.assert_called_once() - mock_call_seer.assert_called_once_with( - self.group, serialized_event, {"trace": "tree"}, experiment_variant=None - ) - - # Verify that run_automation was NOT called - mock_run_automation.assert_not_called() - - # Check if the cache was set correctly - cached_summary = cache.get(f"ai-group-summary-v2:{self.group.id}") - assert cached_summary == expected_response_summary - @patch("sentry.seer.autofix.issue_summary.run_automation") @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") @patch("sentry.seer.autofix.issue_summary._call_seer") @@ -819,7 +701,7 @@ def test_experiment_failure_does_not_affect_regular_flow( mock_get_trace_tree.return_value = {"trace": "tree"} with self.feature("organizations:issue-summary-experimental"): - summary_data, status_code = get_issue_summary(self.group, self.user) + summary_data, status_code, _event = get_issue_summary(self.group, self.user) assert status_code == 200 assert summary_data["headline"] == "Test headline" diff --git a/tests/sentry/seer/endpoints/test_group_ai_summary.py b/tests/sentry/seer/endpoints/test_group_ai_summary.py index 7dcb07ba1862..d29f8ef145da 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_summary.py +++ b/tests/sentry/seer/endpoints/test_group_ai_summary.py @@ -22,7 +22,7 @@ def _get_url(self, group_id: int) -> str: @patch("sentry.seer.endpoints.group_ai_summary.get_issue_summary") def test_endpoint_calls_get_issue_summary(self, mock_get_issue_summary: MagicMock) -> None: mock_summary_data = {"headline": "Test headline"} - mock_get_issue_summary.return_value = (mock_summary_data, 200) + mock_get_issue_summary.return_value = (mock_summary_data, 200, None) response = self.client.post(self.url, data={"event_id": "test_event_id"}, format="json") @@ -38,7 +38,7 @@ def test_endpoint_calls_get_issue_summary(self, mock_get_issue_summary: MagicMoc @patch("sentry.seer.endpoints.group_ai_summary.get_issue_summary") def test_endpoint_without_event_id(self, mock_get_issue_summary: MagicMock) -> None: mock_summary_data = {"headline": "Test headline"} - mock_get_issue_summary.return_value = (mock_summary_data, 200) + mock_get_issue_summary.return_value = (mock_summary_data, 200, None) response = self.client.post(self.url, format="json") @@ -54,7 +54,7 @@ def test_endpoint_without_event_id(self, mock_get_issue_summary: MagicMock) -> N @patch("sentry.seer.endpoints.group_ai_summary.get_issue_summary") def test_endpoint_with_error_response(self, mock_get_issue_summary: MagicMock) -> None: error_data = {"detail": "An error occurred"} - mock_get_issue_summary.return_value = (error_data, 400) + mock_get_issue_summary.return_value = (error_data, 400, None) response = self.client.post(self.url, format="json") diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index ed92f065fb2c..f24d667d016d 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from unittest import mock from unittest.mock import MagicMock, patch import pytest @@ -13,7 +14,8 @@ from sentry.tasks.seer.autofix import ( check_autofix_status, configure_seer_for_existing_org, - generate_issue_summary_only, + generate_summary_and_fixability_score, + generate_summary_and_run_automation, ) from sentry.testutils.cases import TestCase as SentryTestCase from sentry.utils.cache import cache @@ -127,6 +129,7 @@ def test_generates_fixability_score_after_summary( "possibleCause": "Test cause", }, 200, + None, ) mock_generate_fixability.return_value = SummarizeIssueResponse( group_id=str(group.id), @@ -137,10 +140,10 @@ def test_generates_fixability_score_after_summary( scores=SummarizeIssueScores(fixability_score=0.75), ) - generate_issue_summary_only(group.id) + generate_summary_and_fixability_score(group.id) mock_get_issue_summary.assert_called_once_with( - group=group, source=SeerAutomationSource.POST_PROCESS, should_run_automation=False + group=group, source=SeerAutomationSource.POST_PROCESS ) mock_generate_fixability.assert_called_once() @@ -148,6 +151,50 @@ def test_generates_fixability_score_after_summary( assert group.seer_fixability_score == 0.75 +class TestGenerateSummaryAndRunAutomation(SentryTestCase): + @patch("sentry.seer.autofix.issue_summary.run_automation") + @patch("sentry.seer.autofix.issue_summary.get_issue_summary") + def test_calls_run_automation_after_summary( + self, mock_get_issue_summary: MagicMock, mock_run_automation: MagicMock + ) -> None: + group = self.create_group(project=self.project) + mock_event = MagicMock() + mock_get_issue_summary.return_value = ({"headline": "Test"}, 200, mock_event) + + generate_summary_and_run_automation(group.id) + + mock_get_issue_summary.assert_called_once_with( + group=group, source=SeerAutomationSource.POST_PROCESS + ) + mock_run_automation.assert_called_once_with( + group, mock.ANY, mock_event, SeerAutomationSource.POST_PROCESS + ) + + @patch("sentry.seer.autofix.issue_summary.run_automation") + @patch("sentry.seer.autofix.issue_summary.get_issue_summary") + def test_skips_run_automation_when_summary_fails( + self, mock_get_issue_summary: MagicMock, mock_run_automation: MagicMock + ) -> None: + group = self.create_group(project=self.project) + mock_get_issue_summary.return_value = ({"detail": "error"}, 400, None) + + generate_summary_and_run_automation(group.id) + + mock_run_automation.assert_not_called() + + @patch("sentry.seer.autofix.issue_summary.run_automation") + @patch("sentry.seer.autofix.issue_summary.get_issue_summary") + def test_skips_run_automation_when_cache_hit( + self, mock_get_issue_summary: MagicMock, mock_run_automation: MagicMock + ) -> None: + group = self.create_group(project=self.project) + mock_get_issue_summary.return_value = ({"headline": "Cached"}, 200, None) + + generate_summary_and_run_automation(group.id) + + mock_run_automation.assert_not_called() + + class TestConfigureSeerForExistingOrg(SentryTestCase): @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index ef745e87d12c..b172c0093b2b 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3074,7 +3074,7 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled( class TriageSignalsV0TestMixin(BasePostProcessGroupMixin): """Tests for the triage signals V0 flow.""" - @patch("sentry.tasks.seer.autofix.generate_issue_summary_only.delay") + @patch("sentry.tasks.seer.autofix.generate_summary_and_fixability_score.delay") @with_feature({"organizations:gen-ai-features": True}) def test_triage_signals_event_count_less_than_10_no_cache( self, mock_generate_summary_only, mock_seat_based_tier @@ -3099,10 +3099,10 @@ def test_triage_signals_event_count_less_than_10_no_cache( event=event, ) - # Should call generate_issue_summary_only (not generate_summary_and_run_automation) + # Should call generate_summary_and_fixability_score (not generate_summary_and_run_automation) mock_generate_summary_only.assert_called_once_with(group.id) - @patch("sentry.tasks.seer.autofix.generate_issue_summary_only.delay") + @patch("sentry.tasks.seer.autofix.generate_summary_and_fixability_score.delay") @with_feature({"organizations:gen-ai-features": True}) def test_triage_signals_event_count_less_than_10_with_cache( self, mock_generate_summary_only, mock_seat_based_tier