diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index fad013d5f15332..aaeae4ba1a7298 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -990,6 +990,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.workflow_engine.tasks.cleanup", "sentry.tasks.seer.explorer_index", "sentry.tasks.seer.context_engine_index", + "sentry.tasks.seer.lightweight_rca_cluster", # Used for tests "sentry.taskworker.tasks.examples", ) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index bdacbbe0554a9f..7507a49a4f1363 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -1374,6 +1374,14 @@ flags=FLAG_MODIFIABLE_RATE | FLAG_AUTOMATOR_MODIFIABLE, ) +# Supergroups / Lightweight RCA +register( + "supergroups.lightweight-enabled-orgs", + type=Sequence, + default=[], + flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, +) + # ## sentry.killswitches # # The following options are documented in sentry.killswitches in more detail diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 08361e53317b55..677e3ce7e273b9 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -45,7 +45,7 @@ make_signed_seer_api_request, make_summarize_issue_request, ) -from sentry.seer.supergroups.lightweight_rca import trigger_lightweight_rca +from sentry.seer.supergroups.explorer_lightweight_rca import trigger_explorer_lightweight_rca from sentry.services import eventstore from sentry.services.eventstore.models import Event, GroupEvent from sentry.tasks.base import instrumented_task @@ -226,10 +226,10 @@ def _trigger_autofix_task( stopping_point=stopping_point, ) try: - trigger_lightweight_rca(group) + trigger_explorer_lightweight_rca(group) except Exception: logger.exception( - "lightweight_rca.trigger_error_in_trigger_autofix_task", + "explorer_lightweight_rca.trigger_error_in_trigger_autofix_task", extra={"group_id": group_id}, ) else: diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index f69c3bb11a0d61..4550a71ca37bd3 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -376,6 +376,14 @@ class SupergroupsEmbeddingRequest(TypedDict): artifact_data: dict[str, Any] +class LightweightRCAClusterRequest(TypedDict): + group_id: int + issue: dict[str, Any] + organization_slug: str + organization_id: int + project_id: int + + class SupergroupsGetRequest(TypedDict): organization_id: int supergroup_id: int @@ -485,6 +493,20 @@ def make_supergroups_embedding_request( ) +def make_lightweight_rca_cluster_request( + body: LightweightRCAClusterRequest, + timeout: int | float | None = None, + viewer_context: SeerViewerContext | None = None, +) -> BaseHTTPResponse: + return make_signed_seer_api_request( + seer_autofix_default_connection_pool, + "/v0/issues/supergroups/cluster-lightweight", + body=orjson.dumps(body, option=orjson.OPT_NON_STR_KEYS), + timeout=timeout, + viewer_context=viewer_context, + ) + + def make_supergroups_get_request( body: SupergroupsGetRequest, viewer_context: SeerViewerContext, diff --git a/src/sentry/seer/supergroups/lightweight_rca.py b/src/sentry/seer/supergroups/explorer_lightweight_rca.py similarity index 91% rename from src/sentry/seer/supergroups/lightweight_rca.py rename to src/sentry/seer/supergroups/explorer_lightweight_rca.py index 26cf86b9715aa7..b7459db4394d6e 100644 --- a/src/sentry/seer/supergroups/lightweight_rca.py +++ b/src/sentry/seer/supergroups/explorer_lightweight_rca.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -def trigger_lightweight_rca(group: Group) -> int | None: +def trigger_explorer_lightweight_rca(group: Group) -> int | None: """ Trigger a lightweight Explorer RCA run for the given group. @@ -26,7 +26,7 @@ def trigger_lightweight_rca(group: Group) -> int | None: """ has_feature = features.has("projects:supergroup-lightweight-rca", group.project) logger.info( - "lightweight_rca.feature_flag_check", + "explorer_lightweight_rca.feature_flag_check", extra={ "group_id": group.id, "project_id": group.project.id, @@ -66,7 +66,7 @@ def trigger_lightweight_rca(group: Group) -> int | None: ) logger.info( - "lightweight_rca.starting_run", + "explorer_lightweight_rca.starting_run", extra={ "group_id": group.id, "project_id": group.project.id, @@ -83,7 +83,7 @@ def trigger_lightweight_rca(group: Group) -> int | None: ) logger.info( - "lightweight_rca.run_started", + "explorer_lightweight_rca.run_started", extra={ "group_id": group.id, "project_id": group.project.id, @@ -94,7 +94,7 @@ def trigger_lightweight_rca(group: Group) -> int | None: return run_id except Exception: logger.exception( - "lightweight_rca.trigger_failed", + "explorer_lightweight_rca.trigger_failed", extra={ "group_id": group.id, "organization_id": group.organization.id, diff --git a/src/sentry/seer/supergroups/lightweight_rca_cluster.py b/src/sentry/seer/supergroups/lightweight_rca_cluster.py new file mode 100644 index 00000000000000..e8920463d9985b --- /dev/null +++ b/src/sentry/seer/supergroups/lightweight_rca_cluster.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging + +from sentry.api.serializers import EventSerializer, serialize +from sentry.eventstore import backend as eventstore +from sentry.models.group import Group +from sentry.seer.models import SeerApiError +from sentry.seer.signed_seer_api import ( + LightweightRCAClusterRequest, + SeerViewerContext, + make_lightweight_rca_cluster_request, +) + +logger = logging.getLogger(__name__) + + +def trigger_lightweight_rca_cluster(group: Group) -> None: + """ + Call Seer's lightweight RCA clustering endpoint for the given group. + + Sends issue event data to Seer, which generates a lightweight root cause analysis + and clusters the issue into supergroups based on embedding similarity. + """ + event = group.get_latest_event() + if not event: + logger.info( + "lightweight_rca_cluster.no_event", + extra={"group_id": group.id}, + ) + return + + ready_event = eventstore.get_event_by_id(group.project.id, event.event_id, group_id=group.id) + if not ready_event: + logger.info( + "lightweight_rca_cluster.event_not_ready", + extra={"group_id": group.id, "event_id": event.event_id}, + ) + return + + serialized_event = serialize(ready_event, None, EventSerializer()) + + body = LightweightRCAClusterRequest( + group_id=group.id, + issue={ + "id": group.id, + "title": group.title, + "short_id": group.qualified_short_id, + "events": [serialized_event], + }, + organization_slug=group.organization.slug, + organization_id=group.organization.id, + project_id=group.project.id, + ) + viewer_context = SeerViewerContext(organization_id=group.organization.id) + + response = make_lightweight_rca_cluster_request(body, timeout=30, viewer_context=viewer_context) + if response.status >= 400: + raise SeerApiError("Lightweight RCA cluster request failed", response.status) + + logger.info( + "lightweight_rca_cluster.success", + extra={ + "group_id": group.id, + "project_id": group.project.id, + "organization_id": group.organization.id, + }, + ) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index f4d8390e65c6be..a7d5ac582ad224 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1584,6 +1584,22 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: ) +def kick_off_lightweight_rca_cluster(job: PostProcessJob) -> None: + from sentry.tasks.seer.lightweight_rca_cluster import trigger_lightweight_rca_cluster_task + + if not job["group_state"]["is_new"]: + return + + event = job["event"] + group = event.group + + enabled_orgs: list[int] = options.get("supergroups.lightweight-enabled-orgs") + if group.organization.id not in enabled_orgs: + return + + trigger_lightweight_rca_cluster_task.delay(group.id) + + GROUP_CATEGORY_POST_PROCESS_PIPELINE: dict[ GroupCategory, list[Callable[[PostProcessJob], None]] ] = { @@ -1596,6 +1612,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: handle_owner_assignment, handle_auto_assignment, kick_off_seer_automation, + kick_off_lightweight_rca_cluster, process_workflow_engine_issue_alerts, process_resource_change_bounds, process_data_forwarding, diff --git a/src/sentry/tasks/seer/lightweight_rca_cluster.py b/src/sentry/tasks/seer/lightweight_rca_cluster.py new file mode 100644 index 00000000000000..9d8abcbb4dc75a --- /dev/null +++ b/src/sentry/tasks/seer/lightweight_rca_cluster.py @@ -0,0 +1,32 @@ +import logging + +from sentry.models.group import Group +from sentry.seer.supergroups.lightweight_rca_cluster import trigger_lightweight_rca_cluster +from sentry.tasks.base import instrumented_task +from sentry.taskworker.namespaces import ingest_errors_tasks + +logger = logging.getLogger(__name__) + + +@instrumented_task( + name="sentry.tasks.seer.lightweight_rca_cluster.trigger_lightweight_rca_cluster_task", + namespace=ingest_errors_tasks, +) +def trigger_lightweight_rca_cluster_task(group_id: int, **kwargs) -> None: + try: + group = Group.objects.get(id=group_id) + except Group.DoesNotExist: + logger.info( + "lightweight_rca_cluster_task.group_not_found", + extra={"group_id": group_id}, + ) + return + + try: + trigger_lightweight_rca_cluster(group) + except Exception: + logger.exception( + "lightweight_rca_cluster_task.failed", + extra={"group_id": group_id}, + ) + raise diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index 05ea097c4e69a7..548b83ebe7c106 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -960,14 +960,14 @@ def setUp(self) -> None: event_data = load_data("python") self.event = self.store_event(data=event_data, project_id=self.project.id) - @patch("sentry.seer.autofix.issue_summary.trigger_lightweight_rca") + @patch("sentry.seer.autofix.issue_summary.trigger_explorer_lightweight_rca") @patch("sentry.seer.autofix.issue_summary.trigger_autofix_explorer", return_value=42) def test_lightweight_rca_called_on_explorer_path( self, mock_explorer, - mock_lightweight_rca, + mock_explorer_lightweight_rca, ): - """trigger_lightweight_rca is called when the explorer path is taken""" + """trigger_explorer_lightweight_rca is called when the explorer path is taken""" _trigger_autofix_task( group_id=self.group.id, event_id=self.event.event_id, @@ -976,18 +976,18 @@ def test_lightweight_rca_called_on_explorer_path( ) mock_explorer.assert_called_once() - mock_lightweight_rca.assert_called_once_with(self.group) + mock_explorer_lightweight_rca.assert_called_once_with(self.group) - @patch("sentry.seer.autofix.issue_summary.trigger_lightweight_rca") + @patch("sentry.seer.autofix.issue_summary.trigger_explorer_lightweight_rca") @patch( "sentry.seer.autofix.issue_summary.trigger_autofix", return_value=Mock(data={"run_id": 42}) ) def test_lightweight_rca_not_called_on_legacy_path( self, mock_autofix, - mock_lightweight_rca, + mock_explorer_lightweight_rca, ): - """trigger_lightweight_rca is NOT called on the legacy autofix path""" + """trigger_explorer_lightweight_rca is NOT called on the legacy autofix path""" with self.feature( { "organizations:seer-explorer": False, @@ -1002,17 +1002,17 @@ def test_lightweight_rca_not_called_on_legacy_path( ) mock_autofix.assert_called_once() - mock_lightweight_rca.assert_not_called() + mock_explorer_lightweight_rca.assert_not_called() - @patch("sentry.seer.autofix.issue_summary.trigger_lightweight_rca") + @patch("sentry.seer.autofix.issue_summary.trigger_explorer_lightweight_rca") @patch("sentry.seer.autofix.issue_summary.trigger_autofix_explorer", return_value=42) def test_lightweight_rca_failure_does_not_block_explorer( self, mock_explorer, - mock_lightweight_rca, + mock_explorer_lightweight_rca, ): - """Failure in trigger_lightweight_rca doesn't prevent the explorer autofix from completing""" - mock_lightweight_rca.side_effect = Exception("lightweight RCA failed") + """Failure in trigger_explorer_lightweight_rca doesn't prevent the explorer autofix from completing""" + mock_explorer_lightweight_rca.side_effect = Exception("lightweight RCA failed") _trigger_autofix_task( group_id=self.group.id, @@ -1022,7 +1022,7 @@ def test_lightweight_rca_failure_does_not_block_explorer( ) mock_explorer.assert_called_once() - mock_lightweight_rca.assert_called_once_with(self.group) + mock_explorer_lightweight_rca.assert_called_once_with(self.group) class TestFetchUserPreference: diff --git a/tests/sentry/seer/supergroups/test_lightweight_rca.py b/tests/sentry/seer/supergroups/test_explorer_lightweight_rca.py similarity index 76% rename from tests/sentry/seer/supergroups/test_lightweight_rca.py rename to tests/sentry/seer/supergroups/test_explorer_lightweight_rca.py index 9d5033ae8ea960..e3c0544684062d 100644 --- a/tests/sentry/seer/supergroups/test_lightweight_rca.py +++ b/tests/sentry/seer/supergroups/test_explorer_lightweight_rca.py @@ -1,10 +1,10 @@ from unittest.mock import MagicMock, patch -from sentry.seer.supergroups.lightweight_rca import trigger_lightweight_rca +from sentry.seer.supergroups.explorer_lightweight_rca import trigger_explorer_lightweight_rca from sentry.testutils.cases import TestCase -class TestTriggerLightweightRca(TestCase): +class TestTriggerExplorerLightweightRca(TestCase): def setUp(self) -> None: super().setUp() self.user = self.create_user() @@ -13,18 +13,18 @@ def setUp(self) -> None: self.group = self.create_group(project=self.project) def test_returns_none_when_feature_flag_off(self) -> None: - run_id = trigger_lightweight_rca(self.group) + run_id = trigger_explorer_lightweight_rca(self.group) assert run_id is None - @patch("sentry.seer.supergroups.lightweight_rca.SeerExplorerClient") + @patch("sentry.seer.supergroups.explorer_lightweight_rca.SeerExplorerClient") def test_creates_client_with_correct_params(self, mock_client_cls): mock_client = MagicMock() mock_client.start_run.return_value = 42 mock_client_cls.return_value = mock_client with self.feature("projects:supergroup-lightweight-rca"): - run_id = trigger_lightweight_rca(self.group) + run_id = trigger_explorer_lightweight_rca(self.group) assert run_id == 42 mock_client_cls.assert_called_once() @@ -38,14 +38,14 @@ def test_creates_client_with_correct_params(self, mock_client_cls): assert kwargs["category_key"] == "lightweight_rca" assert kwargs["category_value"] == str(self.group.id) - @patch("sentry.seer.supergroups.lightweight_rca.SeerExplorerClient") + @patch("sentry.seer.supergroups.explorer_lightweight_rca.SeerExplorerClient") def test_start_run_called_with_correct_params(self, mock_client_cls): mock_client = MagicMock() mock_client.start_run.return_value = 42 mock_client_cls.return_value = mock_client with self.feature("projects:supergroup-lightweight-rca"): - trigger_lightweight_rca(self.group) + trigger_explorer_lightweight_rca(self.group) mock_client.start_run.assert_called_once() kwargs = mock_client.start_run.call_args[1] @@ -54,11 +54,11 @@ def test_start_run_called_with_correct_params(self, mock_client_cls): assert kwargs["metadata"] == {"group_id": self.group.id} assert "root cause" in kwargs["prompt"].lower() - @patch("sentry.seer.supergroups.lightweight_rca.SeerExplorerClient") + @patch("sentry.seer.supergroups.explorer_lightweight_rca.SeerExplorerClient") def test_returns_none_on_error(self, mock_client_cls): mock_client_cls.side_effect = Exception("connection failed") with self.feature("projects:supergroup-lightweight-rca"): - run_id = trigger_lightweight_rca(self.group) + run_id = trigger_explorer_lightweight_rca(self.group) assert run_id is None diff --git a/tests/sentry/seer/supergroups/test_lightweight_rca_cluster.py b/tests/sentry/seer/supergroups/test_lightweight_rca_cluster.py new file mode 100644 index 00000000000000..956c3df75ff7ac --- /dev/null +++ b/tests/sentry/seer/supergroups/test_lightweight_rca_cluster.py @@ -0,0 +1,40 @@ +from unittest.mock import patch + +import pytest + +from sentry.seer.supergroups.lightweight_rca_cluster import trigger_lightweight_rca_cluster +from sentry.testutils.cases import TestCase + + +class TriggerLightweightRCAClusterTest(TestCase): + def setUp(self): + super().setUp() + self.event = self.store_event( + data={"message": "test error", "level": "error"}, + project_id=self.project.id, + ) + self.group = self.event.group + + @patch("sentry.seer.supergroups.lightweight_rca_cluster.make_lightweight_rca_cluster_request") + def test_calls_seer_with_correct_payload(self, mock_request): + mock_request.return_value.status = 200 + + trigger_lightweight_rca_cluster(self.group) + + mock_request.assert_called_once() + body = mock_request.call_args.args[0] + assert body["group_id"] == self.group.id + assert body["organization_id"] == self.group.organization.id + assert body["organization_slug"] == self.group.organization.slug + assert body["project_id"] == self.group.project.id + assert body["issue"]["id"] == self.group.id + assert body["issue"]["title"] == self.group.title + assert "events" in body["issue"] + assert len(body["issue"]["events"]) == 1 + + @patch("sentry.seer.supergroups.lightweight_rca_cluster.make_lightweight_rca_cluster_request") + def test_raises_on_seer_error(self, mock_request): + mock_request.return_value.status = 500 + + with pytest.raises(Exception): + trigger_lightweight_rca_cluster(self.group) diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index ef745e87d12cae..9ee29b5d7fc7b1 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3070,6 +3070,59 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled( mock_generate_summary_and_run_automation.assert_not_called() +class KickOffLightweightRCAClusterTestMixin(BasePostProcessGroupMixin): + @patch("sentry.tasks.seer.lightweight_rca_cluster.trigger_lightweight_rca_cluster_task.delay") + def test_kick_off_lightweight_rca_cluster_when_enabled(self, mock_task): + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + with self.options({"supergroups.lightweight-enabled-orgs": [self.project.organization.id]}): + self.call_post_process_group( + is_new=True, + is_regression=False, + is_new_group_environment=True, + event=event, + ) + + mock_task.assert_called_once_with(event.group.id) + + @patch("sentry.tasks.seer.lightweight_rca_cluster.trigger_lightweight_rca_cluster_task.delay") + def test_kick_off_lightweight_rca_cluster_skips_when_not_enabled(self, mock_task): + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + with self.options({"supergroups.lightweight-enabled-orgs": []}): + self.call_post_process_group( + is_new=True, + is_regression=False, + is_new_group_environment=True, + event=event, + ) + + mock_task.assert_not_called() + + @patch("sentry.tasks.seer.lightweight_rca_cluster.trigger_lightweight_rca_cluster_task.delay") + def test_kick_off_lightweight_rca_cluster_skips_when_not_new(self, mock_task): + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + with self.options({"supergroups.lightweight-enabled-orgs": [self.project.organization.id]}): + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + mock_task.assert_not_called() + + @patch("sentry.seer.autofix.utils.is_seer_seat_based_tier_enabled", return_value=True) class TriageSignalsV0TestMixin(BasePostProcessGroupMixin): """Tests for the triage signals V0 flow.""" @@ -3497,6 +3550,7 @@ class PostProcessGroupErrorTest( InboxTestMixin, ResourceChangeBoundsTestMixin, KickOffSeerAutomationTestMixin, + KickOffLightweightRCAClusterTestMixin, TriageSignalsV0TestMixin, SeerAutomationHelperFunctionsTestMixin, WorkflowEngineTestMixin,