diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index f48a566b091fee..5667065e8255c6 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -571,6 +571,10 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("projects:supergroup-embeddings-explorer", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable lightweight Explorer RCA runs for supergroup quality evaluation manager.add("projects:supergroup-lightweight-rca", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable lightweight RCA clustering write path (generate embeddings on new issues) + manager.add("organizations:supergroups-lightweight-rca-clustering-write", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable lightweight RCA clustering read path (query lightweight embeddings in supergroup APIs) + manager.add("organizations:supergroups-lightweight-rca-clustering-read", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("projects:workflow-engine-performance-detectors", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 8725c6ad5c15d0..c58843c709136d 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -1385,14 +1385,6 @@ flags=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/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index 33752a24de8b8b..14967631caa763 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -1,6 +1,7 @@ import hashlib import hmac import logging +from enum import StrEnum from typing import Any, NotRequired, TypedDict from urllib.parse import urlparse @@ -369,6 +370,11 @@ class SummarizeIssueRequest(TypedDict): experiment_variant: NotRequired[str | None] +class RCASource(StrEnum): + EXPLORER = "EXPLORER" + LIGHTWEIGHT = "LIGHTWEIGHT" + + class SupergroupsEmbeddingRequest(TypedDict): organization_id: int group_id: int @@ -387,11 +393,13 @@ class LightweightRCAClusterRequest(TypedDict): class SupergroupsGetRequest(TypedDict): organization_id: int supergroup_id: int + rca_source: str class SupergroupsGetByGroupIdsRequest(TypedDict): organization_id: int group_ids: list[int] + rca_source: str class SupergroupDetailData(TypedDict): diff --git a/src/sentry/seer/supergroups/endpoints/organization_supergroup_details.py b/src/sentry/seer/supergroups/endpoints/organization_supergroup_details.py index ab03c3fe13099a..ce4746ffd032f8 100644 --- a/src/sentry/seer/supergroups/endpoints/organization_supergroup_details.py +++ b/src/sentry/seer/supergroups/endpoints/organization_supergroup_details.py @@ -12,7 +12,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.models.organization import Organization -from sentry.seer.signed_seer_api import SeerViewerContext, make_supergroups_get_request +from sentry.seer.signed_seer_api import RCASource, SeerViewerContext, make_supergroups_get_request logger = logging.getLogger(__name__) @@ -35,8 +35,20 @@ def get(self, request: Request, organization: Organization, supergroup_id: int) if not features.has("organizations:top-issues-ui", organization, actor=request.user): return Response({"detail": "Feature not available"}, status=403) + rca_source = ( + RCASource.LIGHTWEIGHT + if features.has( + "organizations:supergroups-lightweight-rca-clustering-read", organization + ) + else RCASource.EXPLORER + ) + response = make_supergroups_get_request( - {"organization_id": organization.id, "supergroup_id": supergroup_id}, + { + "organization_id": organization.id, + "supergroup_id": supergroup_id, + "rca_source": rca_source, + }, SeerViewerContext(organization_id=organization.id, user_id=request.user.id), timeout=10, ) diff --git a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py index fc2c50de7a59be..ef92c5df0c2974 100644 --- a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py +++ b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py @@ -15,6 +15,7 @@ from sentry.models.group import STATUS_QUERY_CHOICES, Group from sentry.models.organization import Organization from sentry.seer.signed_seer_api import ( + RCASource, SeerViewerContext, SupergroupsByGroupIdsResponse, make_supergroups_get_by_group_ids_request, @@ -77,8 +78,16 @@ def get(self, request: Request, organization: Organization) -> Response: status=status_codes.HTTP_404_NOT_FOUND, ) + rca_source = ( + RCASource.LIGHTWEIGHT + if features.has( + "organizations:supergroups-lightweight-rca-clustering-read", organization + ) + else RCASource.EXPLORER + ) + response = make_supergroups_get_by_group_ids_request( - {"organization_id": organization.id, "group_ids": group_ids}, + {"organization_id": organization.id, "group_ids": group_ids, "rca_source": rca_source}, SeerViewerContext(organization_id=organization.id, user_id=request.user.id), timeout=10, ) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index a7d5ac582ad224..f0efd0f7d7759a 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1593,8 +1593,9 @@ def kick_off_lightweight_rca_cluster(job: PostProcessJob) -> None: event = job["event"] group = event.group - enabled_orgs: list[int] = options.get("supergroups.lightweight-enabled-orgs") - if group.organization.id not in enabled_orgs: + if not features.has( + "organizations:supergroups-lightweight-rca-clustering-write", group.organization + ): return trigger_lightweight_rca_cluster_task.delay(group.id) diff --git a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroup_details.py b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroup_details.py new file mode 100644 index 00000000000000..d146d79126ccfa --- /dev/null +++ b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroup_details.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import orjson + +from sentry.testutils.cases import APITestCase + + +def mock_seer_response(data: dict[str, Any]) -> MagicMock: + response = MagicMock() + response.status = 200 + response.data = orjson.dumps(data) + return response + + +class OrganizationSupergroupDetailsEndpointTest(APITestCase): + endpoint = "sentry-api-0-organization-supergroup-details" + + def setUp(self): + super().setUp() + self.login_as(self.user) + + @patch( + "sentry.seer.supergroups.endpoints.organization_supergroup_details.make_supergroups_get_request" + ) + def test_get_supergroup_details(self, mock_seer): + mock_seer.return_value = mock_seer_response( + {"id": 1, "title": "NullPointerException in auth", "group_ids": [10, 20]} + ) + + with self.feature("organizations:top-issues-ui"): + response = self.get_success_response(self.organization.slug, "1") + + assert response.data["id"] == 1 + assert response.data["title"] == "NullPointerException in auth" + assert response.data["group_ids"] == [10, 20] + + @patch( + "sentry.seer.supergroups.endpoints.organization_supergroup_details.make_supergroups_get_request" + ) + def test_rca_source_defaults_to_explorer(self, mock_seer): + mock_seer.return_value = mock_seer_response({"id": 1, "title": "test"}) + + with self.feature("organizations:top-issues-ui"): + self.get_success_response(self.organization.slug, "1") + + body = mock_seer.call_args.args[0] + assert body["rca_source"] == "EXPLORER" + + @patch( + "sentry.seer.supergroups.endpoints.organization_supergroup_details.make_supergroups_get_request" + ) + def test_rca_source_lightweight_when_flag_enabled(self, mock_seer): + mock_seer.return_value = mock_seer_response({"id": 1, "title": "test"}) + + with self.feature( + { + "organizations:top-issues-ui": True, + "organizations:supergroups-lightweight-rca-clustering-read": True, + } + ): + self.get_success_response(self.organization.slug, "1") + + body = mock_seer.call_args.args[0] + assert body["rca_source"] == "LIGHTWEIGHT" diff --git a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py index 4419d824602847..1c89779ffef777 100644 --- a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py +++ b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py @@ -74,3 +74,38 @@ def test_status_filter_invalid(self): status="bogus", status_code=400, ) + + @patch( + "sentry.seer.supergroups.endpoints.organization_supergroups_by_group.make_supergroups_get_by_group_ids_request" + ) + def test_rca_source_defaults_to_explorer(self, mock_seer): + mock_seer.return_value = mock_seer_response({"data": []}) + + with self.feature("organizations:top-issues-ui"): + self.get_success_response( + self.organization.slug, + group_id=[self.unresolved_group.id], + ) + + body = mock_seer.call_args.args[0] + assert body["rca_source"] == "EXPLORER" + + @patch( + "sentry.seer.supergroups.endpoints.organization_supergroups_by_group.make_supergroups_get_by_group_ids_request" + ) + def test_rca_source_lightweight_when_flag_enabled(self, mock_seer): + mock_seer.return_value = mock_seer_response({"data": []}) + + with self.feature( + { + "organizations:top-issues-ui": True, + "organizations:supergroups-lightweight-rca-clustering-read": True, + } + ): + self.get_success_response( + self.organization.slug, + group_id=[self.unresolved_group.id], + ) + + body = mock_seer.call_args.args[0] + assert body["rca_source"] == "LIGHTWEIGHT" diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 9ee29b5d7fc7b1..7506b70d38ac07 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3078,7 +3078,7 @@ def test_kick_off_lightweight_rca_cluster_when_enabled(self, mock_task): project_id=self.project.id, ) - with self.options({"supergroups.lightweight-enabled-orgs": [self.project.organization.id]}): + with self.feature("organizations:supergroups-lightweight-rca-clustering-write"): self.call_post_process_group( is_new=True, is_regression=False, @@ -3095,13 +3095,12 @@ def test_kick_off_lightweight_rca_cluster_skips_when_not_enabled(self, mock_task 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, - ) + self.call_post_process_group( + is_new=True, + is_regression=False, + is_new_group_environment=True, + event=event, + ) mock_task.assert_not_called() @@ -3112,7 +3111,7 @@ def test_kick_off_lightweight_rca_cluster_skips_when_not_new(self, mock_task): project_id=self.project.id, ) - with self.options({"supergroups.lightweight-enabled-orgs": [self.project.organization.id]}): + with self.feature("organizations:supergroups-lightweight-rca-clustering-write"): self.call_post_process_group( is_new=False, is_regression=False,