From ab4b179420dad98e138073b60c482eda684b207b Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:44:09 -0700 Subject: [PATCH 1/3] feat(api): Accept project slugs in replay endpoints Allow root-cause-analysis and replay project filters to resolve project IDs or slugs while preserving legacy projectSlug precedence and ID-focused docs. Co-Authored-By: OpenCode --- ...organization_events_root_cause_analysis.py | 10 +++- .../endpoints/organization_replay_index.py | 14 ++++- .../organization_replay_selector_index.py | 14 ++++- src/sentry/replays/validators.py | 6 +- ...organization_events_root_cause_analysis.py | 59 +++++++++++++++++++ .../test_organization_replay_index.py | 25 ++++++++ 6 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 tests/sentry/api/endpoints/test_organization_events_root_cause_analysis.py diff --git a/src/sentry/api/endpoints/organization_events_root_cause_analysis.py b/src/sentry/api/endpoints/organization_events_root_cause_analysis.py index c9ecbe7aa90502..acee739d198af0 100644 --- a/src/sentry/api/endpoints/organization_events_root_cause_analysis.py +++ b/src/sentry/api/endpoints/organization_events_root_cause_analysis.py @@ -8,6 +8,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization_events import OrganizationEventsEndpointBase from sentry.api.endpoints.organization_events_spans_performance import EventID, get_span_description +from sentry.api.serializers.rest_framework.project import ProjectField from sentry.api.utils import handle_query_errors from sentry.search.events.builder.discover import DiscoverQueryBuilder from sentry.search.events.types import QueryBuilderConfig @@ -35,7 +36,7 @@ class RootCauseAnalysisQuerySerializer(serializers.Serializer): transaction = serializers.CharField(max_length=200) - project = serializers.IntegerField() + project = ProjectField(scope="project:read", id_allowed=True) breakpoint = serializers.CharField() per_page = serializers.IntegerField(min_value=1, max_value=MAX_LIMIT, default=DEFAULT_LIMIT) span_score_threshold = serializers.IntegerField( @@ -203,13 +204,16 @@ class OrganizationEventsRootCauseAnalysisEndpoint(OrganizationEventsEndpointBase } def get(self, request, organization): - serializer = RootCauseAnalysisQuerySerializer(data=request.GET) + serializer = RootCauseAnalysisQuerySerializer( + data=request.GET, + context={"access": request.access, "organization": organization}, + ) if not serializer.is_valid(): return Response(serializer.errors, status=400) validated = serializer.validated_data transaction_name = validated["transaction"] - project_id = validated["project"] + project_id = validated["project"].id regression_breakpoint = validated["breakpoint"] limit = validated["per_page"] span_score_threshold = validated["span_score_threshold"] diff --git a/src/sentry/replays/endpoints/organization_replay_index.py b/src/sentry/replays/endpoints/organization_replay_index.py index 8dea2587247187..a7a99cc62395ab 100644 --- a/src/sentry/replays/endpoints/organization_replay_index.py +++ b/src/sentry/replays/endpoints/organization_replay_index.py @@ -59,7 +59,19 @@ def get(self, request: Request, organization: Organization) -> Response[_ListRep except NoProjects: return Response({"data": []}, status=200) - result = ReplayValidator(data=request.GET) + # Preserve legacy projectSlug precedence while treating blank project filters as absent. + query_params = request.GET.copy() + project_slug_params = [slug for slug in query_params.getlist("projectSlug") if slug] + if "projectSlug" in query_params: + query_params.setlist("projectSlug", project_slug_params) + if project_slug_params: + query_params.pop("project", None) + elif "project" in query_params: + query_params.setlist( + "project", [project for project in query_params.getlist("project") if project] + ) + + result = ReplayValidator(data=query_params) if not result.is_valid(): raise ParseError(result.errors) diff --git a/src/sentry/replays/endpoints/organization_replay_selector_index.py b/src/sentry/replays/endpoints/organization_replay_selector_index.py index f60f80cb257372..27fe18a765b864 100644 --- a/src/sentry/replays/endpoints/organization_replay_selector_index.py +++ b/src/sentry/replays/endpoints/organization_replay_selector_index.py @@ -118,7 +118,19 @@ def get(self, request: Request, organization: Organization) -> Response[ReplaySe except NoProjects: return Response({"data": []}, status=200) - result = ReplaySelectorValidator(data=request.GET) + # Preserve legacy projectSlug precedence while treating blank project filters as absent. + query_params = request.GET.copy() + project_slug_params = [slug for slug in query_params.getlist("projectSlug") if slug] + if "projectSlug" in query_params: + query_params.setlist("projectSlug", project_slug_params) + if project_slug_params: + query_params.pop("project", None) + elif "project" in query_params: + query_params.setlist( + "project", [project for project in query_params.getlist("project") if project] + ) + + result = ReplaySelectorValidator(data=query_params) if not result.is_valid(): raise ParseError(result.errors) diff --git a/src/sentry/replays/validators.py b/src/sentry/replays/validators.py index d5da0cb5402e6e..b13f230ef51452 100644 --- a/src/sentry/replays/validators.py +++ b/src/sentry/replays/validators.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from sentry.api.helpers.projects import ProjectIdOrSlugField + VALID_FIELD_SET = ( "activity", "browser", @@ -67,7 +69,7 @@ class ReplayValidator(serializers.Serializer): project = serializers.ListField( required=False, help_text="The ID of the projects to filter by.", - child=serializers.IntegerField(), + child=ProjectIdOrSlugField(), ) projectSlug = serializers.ListField( required=False, @@ -116,7 +118,7 @@ class ReplaySelectorValidator(serializers.Serializer): project = serializers.ListField( required=False, help_text="The ID of the projects to filter by.", - child=serializers.IntegerField(), + child=ProjectIdOrSlugField(), ) projectSlug = serializers.ListField( required=False, diff --git a/tests/sentry/api/endpoints/test_organization_events_root_cause_analysis.py b/tests/sentry/api/endpoints/test_organization_events_root_cause_analysis.py new file mode 100644 index 00000000000000..fb62337ef737bc --- /dev/null +++ b/tests/sentry/api/endpoints/test_organization_events_root_cause_analysis.py @@ -0,0 +1,59 @@ +from typing import Any +from unittest.mock import MagicMock + +from sentry.api.endpoints.organization_events_root_cause_analysis import ( + RootCauseAnalysisQuerySerializer, +) +from sentry.testutils.cases import APITestCase + + +class RootCauseAnalysisQuerySerializerTest(APITestCase): + def setUp(self) -> None: + super().setUp() + self.project = self.create_project(organization=self.organization) + self.access = MagicMock() + self.access.has_any_project_scope.return_value = True + + def _data(self, project: str) -> dict[str, str]: + return { + "transaction": "GET /api/0/issues/", + "project": project, + "breakpoint": "2024-01-01T00:00:00Z", + } + + def _context(self) -> dict[str, Any]: + return {"access": self.access, "organization": self.organization} + + def test_accepts_project_id(self) -> None: + serializer = RootCauseAnalysisQuerySerializer( + data=self._data(str(self.project.id)), context=self._context() + ) + + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["project"] == self.project + + def test_accepts_project_slug(self) -> None: + serializer = RootCauseAnalysisQuerySerializer( + data=self._data(self.project.slug), context=self._context() + ) + + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["project"] == self.project + + def test_rejects_project_id_from_another_organization(self) -> None: + other_project = self.create_project(organization=self.create_organization()) + serializer = RootCauseAnalysisQuerySerializer( + data=self._data(str(other_project.id)), context=self._context() + ) + + assert not serializer.is_valid() + assert str(serializer.errors["project"][0]) == "Invalid project" + + def test_rejects_project_id_without_scope(self) -> None: + self.access.has_any_project_scope.return_value = False + serializer = RootCauseAnalysisQuerySerializer( + data=self._data(str(self.project.id)), context=self._context() + ) + + assert not serializer.is_valid() + assert str(serializer.errors["project"][0]) == "Insufficient access to project" diff --git a/tests/sentry/replays/endpoints/test_organization_replay_index.py b/tests/sentry/replays/endpoints/test_organization_replay_index.py index 4176b677fe34dc..e190e117182404 100644 --- a/tests/sentry/replays/endpoints/test_organization_replay_index.py +++ b/tests/sentry/replays/endpoints/test_organization_replay_index.py @@ -12,6 +12,8 @@ mock_replay_tap, mock_replay_viewed, ) +from sentry.replays.usecases.query import QueryResponse +from sentry.replays.validators import ReplaySelectorValidator, ReplayValidator from sentry.testutils.cases import APITestCase, ReplaysSnubaTestCase from sentry.utils.cursors import Cursor from sentry.utils.snuba import QueryMemoryLimitExceeded @@ -29,6 +31,29 @@ def setUp(self) -> None: def features(self) -> dict[str, bool]: return {"organizations:session-replay": True} + def test_replay_validators_accept_project_slugs(self) -> None: + replay_validator = ReplayValidator(data={"project": ["my-project"]}) + selector_validator = ReplaySelectorValidator(data={"project": ["my-project"]}) + + assert replay_validator.is_valid(), replay_validator.errors + assert selector_validator.is_valid(), selector_validator.errors + + def test_get_replays_empty_project_uses_project_slug_filter(self) -> None: + project = self.create_project(teams=[self.team], slug="replay-project") + + with self.feature(self.features): + with mock.patch( + "sentry.replays.endpoints.organization_replay_index.query_replays_collection_paginated", + return_value=QueryResponse(response=[], has_more=False, source="mock"), + ) as mock_query: + response = self.client.get( + self.url, + {"projectSlug": project.slug, "project": ""}, + ) + + assert response.status_code == 200 + assert mock_query.call_args.kwargs["project_ids"] == [project.id] + def test_feature_flag_disabled(self) -> None: """Test replays can be disabled.""" response = self.client.get(self.url) From 052a4d460c386dc346045a660ada5fcd3264e9f3 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:05:39 -0700 Subject: [PATCH 2/3] docs(api): Document replay project slug filters Document the canonical replay project query parameter as accepting project IDs or slugs while leaving the existing projectSlug docs intact for compatibility. Co-Authored-By: OpenCode --- src/sentry/replays/validators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/replays/validators.py b/src/sentry/replays/validators.py index b13f230ef51452..770a7678750355 100644 --- a/src/sentry/replays/validators.py +++ b/src/sentry/replays/validators.py @@ -68,7 +68,7 @@ class ReplayValidator(serializers.Serializer): ) project = serializers.ListField( required=False, - help_text="The ID of the projects to filter by.", + help_text="A list of project IDs or slugs to filter by.", child=ProjectIdOrSlugField(), ) projectSlug = serializers.ListField( @@ -117,7 +117,7 @@ class ReplaySelectorValidator(serializers.Serializer): ) project = serializers.ListField( required=False, - help_text="The ID of the projects to filter by.", + help_text="A list of project IDs or slugs to filter by.", child=ProjectIdOrSlugField(), ) projectSlug = serializers.ListField( From 70a72be7e9452d62e6e4f2fd8375552afd184800 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:22:41 -0700 Subject: [PATCH 3/3] ref(api): Reuse project query normalization in replays Use the shared organization helper for legacy projectSlug precedence before replay validator handling. Co-Authored-By: OpenCode --- .../replays/endpoints/organization_replay_index.py | 12 +----------- .../endpoints/organization_replay_selector_index.py | 12 +----------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/src/sentry/replays/endpoints/organization_replay_index.py b/src/sentry/replays/endpoints/organization_replay_index.py index a7a99cc62395ab..f0e767300a7b82 100644 --- a/src/sentry/replays/endpoints/organization_replay_index.py +++ b/src/sentry/replays/endpoints/organization_replay_index.py @@ -59,17 +59,7 @@ def get(self, request: Request, organization: Organization) -> Response[_ListRep except NoProjects: return Response({"data": []}, status=200) - # Preserve legacy projectSlug precedence while treating blank project filters as absent. - query_params = request.GET.copy() - project_slug_params = [slug for slug in query_params.getlist("projectSlug") if slug] - if "projectSlug" in query_params: - query_params.setlist("projectSlug", project_slug_params) - if project_slug_params: - query_params.pop("project", None) - elif "project" in query_params: - query_params.setlist( - "project", [project for project in query_params.getlist("project") if project] - ) + query_params = self.get_query_params_with_project_slug_precedence(request) result = ReplayValidator(data=query_params) if not result.is_valid(): diff --git a/src/sentry/replays/endpoints/organization_replay_selector_index.py b/src/sentry/replays/endpoints/organization_replay_selector_index.py index 27fe18a765b864..52fd9672d92087 100644 --- a/src/sentry/replays/endpoints/organization_replay_selector_index.py +++ b/src/sentry/replays/endpoints/organization_replay_selector_index.py @@ -118,17 +118,7 @@ def get(self, request: Request, organization: Organization) -> Response[ReplaySe except NoProjects: return Response({"data": []}, status=200) - # Preserve legacy projectSlug precedence while treating blank project filters as absent. - query_params = request.GET.copy() - project_slug_params = [slug for slug in query_params.getlist("projectSlug") if slug] - if "projectSlug" in query_params: - query_params.setlist("projectSlug", project_slug_params) - if project_slug_params: - query_params.pop("project", None) - elif "project" in query_params: - query_params.setlist( - "project", [project for project in query_params.getlist("project") if project] - ) + query_params = self.get_query_params_with_project_slug_precedence(request) result = ReplaySelectorValidator(data=query_params) if not result.is_valid():