From 739bb5eb73f99d5769ea30022c8d0d5452680789 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Mon, 6 Jul 2026 15:59:06 -0400 Subject: [PATCH] feat(replays): support querying segment_names --- src/sentry/replays/post_process.py | 3 ++ src/sentry/replays/query.py | 6 ++++ src/sentry/replays/testutils.py | 3 ++ .../usecases/query/configs/aggregate.py | 1 + .../replays/usecases/query/configs/scalar.py | 1 + src/sentry/replays/validators.py | 1 + .../test_organization_replay_index.py | 36 +++++++++++++++++++ 7 files changed, 51 insertions(+) diff --git a/src/sentry/replays/post_process.py b/src/sentry/replays/post_process.py index a595450c3fc6..9e3082acf940 100644 --- a/src/sentry/replays/post_process.py +++ b/src/sentry/replays/post_process.py @@ -69,6 +69,7 @@ class ReplayDetailsResponse(TypedDict, total=False): ota_updates: OTAUpdatesResponseType is_archived: bool | None urls: list[str] | None + segment_names: list[str] | None clicks: list[dict[str, Any]] count_dead_clicks: int | None count_rage_clicks: int | None @@ -179,6 +180,7 @@ def generate_normalized_output(response: list[dict[str, Any]]) -> Generator[Repl item.pop("agg_urls", None) ret_item["urls"] = item.pop("urls_sorted", None) + ret_item["segment_names"] = item.pop("segment_names", None) ret_item["is_archived"] = bool(item.pop("isArchived", 0)) @@ -260,6 +262,7 @@ def _archived_row(replay_id: str, project_id: int) -> ReplayDetailsResponse: "device": {"name": None, "brand": None, "model": None, "family": None}, "ota_updates": {"channel": None, "runtime_version": None, "update_id": None}, "urls": None, + "segment_names": None, "activity": None, "count_dead_clicks": None, "count_rage_clicks": None, diff --git a/src/sentry/replays/query.py b/src/sentry/replays/query.py index ed3ff4101242..03f84ce47e26 100644 --- a/src/sentry/replays/query.py +++ b/src/sentry/replays/query.py @@ -635,6 +635,7 @@ def _empty_uuids_lambda(): "duration": ["duration", "started_at", "finished_at"], "urls": ["urls_sorted", "agg_urls"], "url": ["urls_sorted", "agg_urls"], + "segment_names": ["segment_names"], "count_errors": ["count_errors"], "count_urls": ["count_urls"], "count_segments": ["count_segments"], @@ -770,6 +771,11 @@ def _empty_uuids_lambda(): parameters=[Function("tuple", parameters=[Column("segment_id"), Column("urls")])], alias="agg_urls", ), + "segment_names": Function( + "groupUniqArrayArray", + parameters=[Column("segment_names")], + alias="segment_names", + ), "count_segments": Function("count", parameters=[Column("segment_id")], alias="count_segments"), "count_urls": Function( "sum", diff --git a/src/sentry/replays/testutils.py b/src/sentry/replays/testutils.py index 20ea3052f4cd..642817ac37bf 100644 --- a/src/sentry/replays/testutils.py +++ b/src/sentry/replays/testutils.py @@ -130,6 +130,7 @@ def mock_expected_response( "subdivision": kwargs.pop("user_geo_subdivision", "California"), }, }, + "segment_names": kwargs.pop("segment_names", []), "tags": kwargs.pop("tags", {}), "activity": kwargs.pop("activity", 0), "is_archived": kwargs.pop("is_archived", False), @@ -166,6 +167,7 @@ def mock_replay( "segment_id": kwargs.pop("segment_id", 0), "tags": tags, "urls": kwargs.pop("urls", []), + "segment_names": kwargs.pop("segment_names", []), "is_archived": kwargs.pop("is_archived", None), "error_ids": kwargs.pop("error_ids", ["a3a62ef6-ac86-415b-83c2-416fc2f76db1"]), "trace_ids": kwargs.pop("trace_ids", ["44916572-43ba-4dbe-bd2f-6bd62b733080"]), @@ -524,6 +526,7 @@ def mock_replay_event(replay_id="b58a67446c914f44a4e329763420047b", **kwargs): "segment_id": kwargs.pop("segment_id", 0), "tags": tags, "urls": kwargs.pop("urls", []), + "segment_names": kwargs.pop("segment_names", []), "is_archived": kwargs.pop("is_archived", None), "error_ids": kwargs.pop("error_ids", ["a3a62ef6-ac86-415b-83c2-416fc2f76db1"]), "trace_ids": kwargs.pop("trace_ids", ["44916572-43ba-4dbe-bd2f-6bd62b733080"]), diff --git a/src/sentry/replays/usecases/query/configs/aggregate.py b/src/sentry/replays/usecases/query/configs/aggregate.py index 936f6d9e789c..192afdae8ca1 100644 --- a/src/sentry/replays/usecases/query/configs/aggregate.py +++ b/src/sentry/replays/usecases/query/configs/aggregate.py @@ -133,6 +133,7 @@ def array_string_field(column_name: str) -> StringColumnField: "tap.view_class": string_field("tap_view_class"), "tap.view_id": string_field("tap_view_id"), "trace_ids": UUIDColumnField("trace_ids", parse_uuid, SumOfUUIDArray), + "segment_names": array_string_field("segment_names"), "urls": array_string_field("urls"), "user.email": string_field("user_email"), "user.id": string_field("user_id"), diff --git a/src/sentry/replays/usecases/query/configs/scalar.py b/src/sentry/replays/usecases/query/configs/scalar.py index 8712e2c34c0b..c2708f468208 100644 --- a/src/sentry/replays/usecases/query/configs/scalar.py +++ b/src/sentry/replays/usecases/query/configs/scalar.py @@ -70,6 +70,7 @@ def string_field(column_name: str) -> StringColumnField: varying_search_config: dict[str, FieldProtocol] = { "error_ids": ComputedField(parse_uuid, ErrorIdScalar), "trace_ids": UUIDColumnField("trace_ids", parse_uuid, UUIDArray), + "segment_names": StringColumnField("segment_names", parse_str, StringArray), "urls": StringColumnField("urls", parse_str, StringArray), } diff --git a/src/sentry/replays/validators.py b/src/sentry/replays/validators.py index 770a76787503..6379bc3057d9 100644 --- a/src/sentry/replays/validators.py +++ b/src/sentry/replays/validators.py @@ -26,6 +26,7 @@ "started_at", "tags", "trace_ids", + "segment_names", "urls", "user", "clicks", diff --git a/tests/sentry/replays/endpoints/test_organization_replay_index.py b/tests/sentry/replays/endpoints/test_organization_replay_index.py index e190e1171824..5f055961942f 100644 --- a/tests/sentry/replays/endpoints/test_organization_replay_index.py +++ b/tests/sentry/replays/endpoints/test_organization_replay_index.py @@ -623,6 +623,7 @@ def test_get_replays_user_filters(self) -> None: device_model="10", tags={"a": "m", "b": "q", "c": "test"}, urls=["example.com"], + segment_names=["/api/checkout"], segment_id=0, ) ) @@ -783,6 +784,7 @@ def test_get_replays_user_filters(self) -> None: "url:example.com", "screens:example.com", "screen:example.com", + "segment_names:/api/checkout", "activity:8", "activity:>2", "count_warnings:1", @@ -853,6 +855,8 @@ def test_get_replays_user_filters(self) -> None: "release:[a,b]", "c:*zz", "!c:*st", + "segment_names:/api/orders", + "!segment_names:/api/checkout", "!activity:8", "activity:<2", f"viewed_by_id:{self.user.id + 1}", @@ -1808,6 +1812,38 @@ def test_query_branches_array_of_string_conditions(self) -> None: response_data = response.json() assert len(response_data["data"]) == 1, query + def test_query_branches_segment_names_conditions(self) -> None: + project = self.create_project(teams=[self.team]) + + replay1_id = uuid.uuid4().hex + seq1_timestamp = datetime.datetime.now() - datetime.timedelta(seconds=22) + seq2_timestamp = datetime.datetime.now() - datetime.timedelta(seconds=5) + + self.store_replays( + mock_replay( + seq1_timestamp, + project.id, + replay1_id, + segment_names=["/api/checkout"], + ) + ) + self.store_replays(mock_replay(seq2_timestamp, project.id, replay1_id, segment_names=[])) + + with self.feature(self.features): + queries = [ + "segment_names:/api/checkout", + "!segment_names:/api/orders", + "segment_names:[/api/checkout,/api/orders]", + "!segment_names:[/api/orders,/api/users]", + "segment_names:/api/*", + "!segment_names:/web/*", + ] + for query in queries: + response = self.client.get(self.url + f"?field=id&query={query}") + assert response.status_code == 200 + response_data = response.json() + assert len(response_data["data"]) == 1, query + def test_query_branches_integer_conditions(self) -> None: project = self.create_project(teams=[self.team])