From f86fa57f3114fe26707f95af1692485358c41c25 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 13 May 2026 08:47:35 -0700 Subject: [PATCH 1/2] Record Seer events for display in the issue activity timeline --- src/sentry/features/temporary.py | 2 + src/sentry/seer/entrypoints/operator.py | 72 ++++++++++++-- src/sentry/types/activity.py | 26 +++++ .../sentry/seer/entrypoints/test_operator.py | 99 +++++++++++++++++++ 4 files changed, 190 insertions(+), 9 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 4a300b048a1e..0bde4d130d65 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -306,6 +306,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-slack-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Show Seer run ID in Slack notification footers manager.add("organizations:seer-run-id-in-slack", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable Seer activity events in the issue activity timeline + manager.add("organizations:seer-activity-timeline", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable search query attribute validation manager.add("organizations:search-query-attribute-validation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable search query builder raw search replacement diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index 5625ca3c7bdb..fd65e8df0d6c 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -5,6 +5,7 @@ from sentry import features from sentry.constants import DataCategory +from sentry.models.activity import Activity from sentry.models.group import Group from sentry.models.organization import Organization from sentry.seer.agent.client import SeerAgentClient @@ -43,17 +44,18 @@ from sentry.sentry_apps.metrics import SentryAppEventType from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import seer_tasks +from sentry.types.activity import ActivityType from sentry.users.models.user import User from sentry.users.services.user import RpcUser -SEER_OPERATOR_AUTOFIX_UPDATE_EVENTS = { - SentryAppEventType.SEER_ROOT_CAUSE_STARTED, - SentryAppEventType.SEER_ROOT_CAUSE_COMPLETED, - SentryAppEventType.SEER_SOLUTION_STARTED, - SentryAppEventType.SEER_SOLUTION_COMPLETED, - SentryAppEventType.SEER_CODING_STARTED, - SentryAppEventType.SEER_CODING_COMPLETED, - SentryAppEventType.SEER_PR_CREATED, +SEER_EVENT_TO_ACTIVITY_TYPE: dict[SentryAppEventType, ActivityType] = { + SentryAppEventType.SEER_ROOT_CAUSE_STARTED: ActivityType.SEER_RCA_STARTED, + SentryAppEventType.SEER_ROOT_CAUSE_COMPLETED: ActivityType.SEER_RCA_COMPLETED, + SentryAppEventType.SEER_SOLUTION_STARTED: ActivityType.SEER_SOLUTION_STARTED, + SentryAppEventType.SEER_SOLUTION_COMPLETED: ActivityType.SEER_SOLUTION_COMPLETED, + SentryAppEventType.SEER_CODING_STARTED: ActivityType.SEER_CODING_STARTED, + SentryAppEventType.SEER_CODING_COMPLETED: ActivityType.SEER_CODING_COMPLETED, + SentryAppEventType.SEER_PR_CREATED: ActivityType.SEER_PR_CREATED, } logger = logging.getLogger(__name__) @@ -690,6 +692,46 @@ def trigger_agent( return run_id +def _create_seer_activity( + group: Group, + event_type: SentryAppEventType, + event_payload: dict[str, Any], +) -> None: + activity_type = SEER_EVENT_TO_ACTIVITY_TYPE.get(event_type) + if not activity_type: + return + + organization = group.project.organization + if not features.has("organizations:seer-activity-timeline", organization): + return + + run_id = event_payload.get("run_id") + + activity_data: dict[str, Any] = {} + if run_id is not None: + activity_data["run_id"] = run_id + + if event_type == SentryAppEventType.SEER_ROOT_CAUSE_COMPLETED: + if "root_cause" in event_payload: + activity_data["root_cause"] = event_payload["root_cause"] + elif event_type == SentryAppEventType.SEER_SOLUTION_COMPLETED: + if "solution" in event_payload: + activity_data["solution"] = event_payload["solution"] + elif event_type == SentryAppEventType.SEER_CODING_COMPLETED: + if "changes" in event_payload: + activity_data["changes"] = event_payload["changes"] + elif event_type == SentryAppEventType.SEER_PR_CREATED: + if "pull_requests" in event_payload: + activity_data["pull_requests"] = event_payload["pull_requests"] + + Activity.objects.create_group_activity( + group, + activity_type, + data=activity_data if activity_data else None, + send_notification=False, + ) + + @instrumented_task( name="sentry.seer.entrypoints.operator.process_autofix_updates", namespace=seer_tasks, @@ -724,7 +766,7 @@ def process_autofix_updates( lifecycle.record_failure(failure_reason="missing_identifiers") return - if event_type not in SEER_OPERATOR_AUTOFIX_UPDATE_EVENTS: + if event_type not in SEER_EVENT_TO_ACTIVITY_TYPE: lifecycle.record_halt(halt_reason="skipped") return @@ -740,6 +782,18 @@ def process_autofix_updates( lifecycle.record_halt(halt_reason="no_operator_access") return + try: + _create_seer_activity(group, event_type, event_payload) + except Exception: + logger.exception( + "seer.activity_creation_failed", + extra={ + "group_id": group_id, + "run_id": run_id, + "event_type": str(event_type), + }, + ) + for entrypoint_key, entrypoint_cls in autofix_entrypoint_registry.registrations.items(): logging_ctx = { "organization_id": organization.id, diff --git a/src/sentry/types/activity.py b/src/sentry/types/activity.py index 30acad2b54ee..02253c051f50 100644 --- a/src/sentry/types/activity.py +++ b/src/sentry/types/activity.py @@ -34,6 +34,14 @@ class ActivityType(Enum): DELETED_ATTACHMENT = 27 REFERENCED_IN_COMMIT = 28 + SEER_RCA_STARTED = 29 + SEER_RCA_COMPLETED = 30 + SEER_SOLUTION_STARTED = 31 + SEER_SOLUTION_COMPLETED = 32 + SEER_CODING_STARTED = 33 + SEER_CODING_COMPLETED = 34 + SEER_PR_CREATED = 35 + # Warning: This must remain in this EXACT order. CHOICES = tuple( @@ -67,6 +75,13 @@ class ActivityType(Enum): ActivityType.SET_PRIORITY, # 26 ActivityType.DELETED_ATTACHMENT, # 27 ActivityType.REFERENCED_IN_COMMIT, # 28 + ActivityType.SEER_RCA_STARTED, # 29 + ActivityType.SEER_RCA_COMPLETED, # 30 + ActivityType.SEER_SOLUTION_STARTED, # 31 + ActivityType.SEER_SOLUTION_COMPLETED, # 32 + ActivityType.SEER_CODING_STARTED, # 33 + ActivityType.SEER_CODING_COMPLETED, # 34 + ActivityType.SEER_PR_CREATED, # 35 ] ) @@ -82,3 +97,14 @@ class ActivityType(Enum): ActivityType.SET_RESOLVED_IN_PULL_REQUEST, ActivityType.SET_ESCALATING, ) + + +SEER_ACTIVITY_TYPES = ( + ActivityType.SEER_RCA_STARTED, + ActivityType.SEER_RCA_COMPLETED, + ActivityType.SEER_SOLUTION_STARTED, + ActivityType.SEER_SOLUTION_COMPLETED, + ActivityType.SEER_CODING_STARTED, + ActivityType.SEER_CODING_COMPLETED, + ActivityType.SEER_PR_CREATED, +) diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py index ca95253f7e71..49ade0ad66de 100644 --- a/tests/sentry/seer/entrypoints/test_operator.py +++ b/tests/sentry/seer/entrypoints/test_operator.py @@ -6,6 +6,7 @@ from rest_framework.response import Response from fixtures.seer.webhooks import MOCK_RUN_ID +from sentry.models.activity import Activity from sentry.models.organization import Organization from sentry.seer.agent.client_models import ( CodingAgentState, @@ -24,6 +25,7 @@ from sentry.seer.autofix.utils import CodingAgentState as LegacyCodingAgentState from sentry.seer.entrypoints.operator import ( AUTOFIX_FALLBACK_CAUSE_ID, + SEER_EVENT_TO_ACTIVITY_TYPE, SeerAgentOperator, SeerAutofixOperator, SeerOperatorCompletionHook, @@ -41,6 +43,7 @@ from sentry.sentry_apps.metrics import SentryAppEventType from sentry.testutils.asserts import assert_failure_metric from sentry.testutils.cases import TestCase +from sentry.types.activity import SEER_ACTIVITY_TYPES, ActivityType class MockCachePayload(TypedDict): @@ -812,6 +815,102 @@ def test_can_trigger_autofix_returns_false_without_quota(self, mock_quota): ): assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is False + @patch.object(SeerAutofixOperator, "has_access", return_value=True) + def test_seer_event_creates_activity(self, _mock_has_access): + event_payload = { + "run_id": MOCK_RUN_ID, + "group_id": self.group.id, + "root_cause": { + "description": "Test root cause", + "steps": [{"title": "Step 1"}], + }, + } + + with self.feature("organizations:seer-activity-timeline"): + process_autofix_updates( + event_type=SentryAppEventType.SEER_ROOT_CAUSE_COMPLETED, + event_payload=event_payload, + organization_id=self.organization.id, + ) + + activity = Activity.objects.get( + group=self.group, type=ActivityType.SEER_RCA_COMPLETED.value + ) + assert activity.data["run_id"] == MOCK_RUN_ID + assert activity.data["root_cause"]["description"] == "Test root cause" + + @patch.object(SeerAutofixOperator, "has_access", return_value=True) + def test_create_seer_activity_all_mapped_event_types(self, _mock_has_access): + for seer_event, expected_activity_type in SEER_EVENT_TO_ACTIVITY_TYPE.items(): + event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} + with self.feature("organizations:seer-activity-timeline"): + process_autofix_updates( + event_type=seer_event, + event_payload=event_payload, + organization_id=self.organization.id, + ) + assert Activity.objects.filter( + group=self.group, type=expected_activity_type.value + ).exists(), f"Activity not created for {seer_event}" + + @patch.object(SeerAutofixOperator, "has_access", return_value=True) + def test_create_seer_activity_skips_non_seer_events(self, _mock_has_access): + event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} + + with self.feature("organizations:seer-activity-timeline"): + process_autofix_updates( + event_type=SentryAppEventType.ISSUE_CREATED, + event_payload=event_payload, + organization_id=self.organization.id, + ) + + seer_type_values = [t.value for t in SEER_ACTIVITY_TYPES] + assert not Activity.objects.filter(group=self.group, type__in=seer_type_values).exists() + + @patch.object(SeerAutofixOperator, "has_access", return_value=True) + def test_create_seer_activity_feature_flag_disabled(self, _mock_has_access): + event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} + + process_autofix_updates( + event_type=SentryAppEventType.SEER_ROOT_CAUSE_STARTED, + event_payload=event_payload, + organization_id=self.organization.id, + ) + + seer_type_values = [t.value for t in SEER_ACTIVITY_TYPES] + assert not Activity.objects.filter(group=self.group, type__in=seer_type_values).exists() + + @patch.object(SeerAutofixOperator, "has_access", return_value=True) + def test_create_seer_activity_pr_created_with_pull_requests(self, _mock_has_access): + event_payload = { + "run_id": MOCK_RUN_ID, + "group_id": self.group.id, + "pull_requests": [ + { + "pull_request": { + "pr_number": 42, + "pr_url": "https://github.com/owner/repo/pull/42", + }, + "repo_name": "owner/repo", + "provider": "github", + } + ], + } + + with self.feature("organizations:seer-activity-timeline"): + process_autofix_updates( + event_type=SentryAppEventType.SEER_PR_CREATED, + event_payload=event_payload, + organization_id=self.organization.id, + ) + + activity = Activity.objects.get(group=self.group, type=ActivityType.SEER_PR_CREATED.value) + assert activity.data["pull_requests"][0]["repo_name"] == "owner/repo" + assert ( + activity.data["pull_requests"][0]["pull_request"]["pr_url"] + == "https://github.com/owner/repo/pull/42" + ) + class TestGetAutofixExplorerStatus(TestCase): @staticmethod From 94b6e0f6c80c5ba3c243e122db2981bb3e37d712 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 13 May 2026 08:55:19 -0700 Subject: [PATCH 2/2] Remove redundant `SEER_ACTIVITY_TYPES` definition --- src/sentry/types/activity.py | 11 ----------- tests/sentry/seer/entrypoints/test_operator.py | 6 +++--- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/sentry/types/activity.py b/src/sentry/types/activity.py index 02253c051f50..901b938efc10 100644 --- a/src/sentry/types/activity.py +++ b/src/sentry/types/activity.py @@ -97,14 +97,3 @@ class ActivityType(Enum): ActivityType.SET_RESOLVED_IN_PULL_REQUEST, ActivityType.SET_ESCALATING, ) - - -SEER_ACTIVITY_TYPES = ( - ActivityType.SEER_RCA_STARTED, - ActivityType.SEER_RCA_COMPLETED, - ActivityType.SEER_SOLUTION_STARTED, - ActivityType.SEER_SOLUTION_COMPLETED, - ActivityType.SEER_CODING_STARTED, - ActivityType.SEER_CODING_COMPLETED, - ActivityType.SEER_PR_CREATED, -) diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py index 49ade0ad66de..e963c44f0b51 100644 --- a/tests/sentry/seer/entrypoints/test_operator.py +++ b/tests/sentry/seer/entrypoints/test_operator.py @@ -43,7 +43,7 @@ from sentry.sentry_apps.metrics import SentryAppEventType from sentry.testutils.asserts import assert_failure_metric from sentry.testutils.cases import TestCase -from sentry.types.activity import SEER_ACTIVITY_TYPES, ActivityType +from sentry.types.activity import ActivityType class MockCachePayload(TypedDict): @@ -864,7 +864,7 @@ def test_create_seer_activity_skips_non_seer_events(self, _mock_has_access): organization_id=self.organization.id, ) - seer_type_values = [t.value for t in SEER_ACTIVITY_TYPES] + seer_type_values = [t.value for t in SEER_EVENT_TO_ACTIVITY_TYPE.values()] assert not Activity.objects.filter(group=self.group, type__in=seer_type_values).exists() @patch.object(SeerAutofixOperator, "has_access", return_value=True) @@ -877,7 +877,7 @@ def test_create_seer_activity_feature_flag_disabled(self, _mock_has_access): organization_id=self.organization.id, ) - seer_type_values = [t.value for t in SEER_ACTIVITY_TYPES] + seer_type_values = [t.value for t in SEER_EVENT_TO_ACTIVITY_TYPE.values()] assert not Activity.objects.filter(group=self.group, type__in=seer_type_values).exists() @patch.object(SeerAutofixOperator, "has_access", return_value=True)