Skip to content

Commit 732b755

Browse files
cliffordxingandrewshie-sentry
authored andcommitted
feat(Replay): Add Function to Query EAP for Replay Details (#102416)
Adding helper function to allow for query EAP for replay details and added unit test for function. This helper function is not being called yet (planning on adding a flag to gate) Relates to: REPLAY-824
1 parent 1010244 commit 732b755

File tree

4 files changed

+333
-0
lines changed

4 files changed

+333
-0
lines changed

src/sentry/replays/endpoints/organization_replay_details.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import uuid
2+
from datetime import datetime
23

34
from drf_spectacular.utils import extend_schema
45
from rest_framework.request import Request
56
from rest_framework.response import Response
7+
from snuba_sdk import Column, Condition, Entity, Function, Granularity, Op, Query
68

79
from sentry import features
810
from sentry.api.api_owners import ApiOwner
@@ -15,11 +17,98 @@
1517
from sentry.apidocs.utils import inline_sentry_response_serializer
1618
from sentry.constants import ALL_ACCESS_PROJECTS
1719
from sentry.models.organization import Organization
20+
from sentry.replays.lib.eap import read as eap_read
21+
from sentry.replays.lib.eap.snuba_transpiler import RequestMeta, Settings
1822
from sentry.replays.post_process import ReplayDetailsResponse, process_raw_response
1923
from sentry.replays.query import query_replay_instance
2024
from sentry.replays.validators import ReplayValidator
2125

2226

27+
def query_replay_instance_eap(
28+
project_ids: list[int],
29+
replay_ids: list[str],
30+
start: datetime,
31+
end: datetime,
32+
organization_id: int,
33+
request_user_id: int,
34+
referrer: str = "replays.query.details_query",
35+
):
36+
select = [
37+
Column("replay_id"),
38+
Function("min", parameters=[Column("project_id")], alias="agg_project_id"),
39+
Function("min", parameters=[Column("replay_start_timestamp")], alias="started_at"),
40+
Function("max", parameters=[Column("timestamp")], alias="finished_at"),
41+
Function("count", parameters=[Column("segment_id")], alias="count_segments"),
42+
Function("sum", parameters=[Column("count_error_events")], alias="count_errors"),
43+
Function("sum", parameters=[Column("count_warning_events")], alias="count_warnings"),
44+
Function("sum", parameters=[Column("count_info_events")], alias="count_infos"),
45+
Function(
46+
"sumIf",
47+
parameters=[
48+
Column("click_is_dead"),
49+
Function(
50+
"greaterOrEquals",
51+
[Column("timestamp"), int(datetime(year=2023, month=7, day=24).timestamp())],
52+
),
53+
],
54+
alias="count_dead_clicks",
55+
),
56+
Function(
57+
"sumIf",
58+
parameters=[
59+
Column("click_is_rage"),
60+
Function(
61+
"greaterOrEquals",
62+
[Column("timestamp"), int(datetime(year=2023, month=7, day=24).timestamp())],
63+
),
64+
],
65+
alias="count_rage_clicks",
66+
),
67+
Function("max", parameters=[Column("is_archived")], alias="is_archived"),
68+
]
69+
70+
snuba_query = Query(
71+
match=Entity("replays"),
72+
select=select,
73+
where=[
74+
Condition(Column("replay_id"), Op.IN, replay_ids),
75+
],
76+
groupby=[Column("replay_id")],
77+
granularity=Granularity(3600),
78+
)
79+
80+
settings = Settings(
81+
attribute_types={
82+
"replay_id": str,
83+
"project_id": int,
84+
"timestamp": int,
85+
"replay_start_timestamp": int,
86+
"segment_id": int,
87+
"is_archived": int,
88+
"count_error_events": int,
89+
"count_warning_events": int,
90+
"count_info_events": int,
91+
"click_is_dead": int,
92+
"click_is_rage": int,
93+
},
94+
default_limit=100,
95+
default_offset=0,
96+
)
97+
98+
request_meta = RequestMeta(
99+
cogs_category="replays",
100+
debug=False,
101+
start_datetime=start,
102+
end_datetime=end,
103+
organization_id=organization_id,
104+
project_ids=project_ids,
105+
referrer=referrer,
106+
request_id=str(uuid.uuid4().hex),
107+
trace_item_type="replay",
108+
)
109+
return eap_read.query(snuba_query, settings, request_meta, [])
110+
111+
23112
@region_silo_endpoint
24113
@extend_schema(tags=["Replays"])
25114
class OrganizationReplayDetailsEndpoint(OrganizationEndpoint):

src/sentry/replays/usecases/ingest/event_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ def as_trace_item(
366366
# Extend the attributes with the replay_id to make it queryable by replay_id after we
367367
# eventually use the trace_id in its rightful position.
368368
trace_item_context["attributes"]["replay_id"] = context["replay_id"]
369+
trace_item_context["attributes"]["segment_id"] = context["segment_id"]
369370

370371
return new_trace_item(
371372
{

src/sentry/testutils/cases.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from collections.abc import Generator, Mapping, Sequence
1010
from contextlib import contextmanager
1111
from datetime import UTC, datetime, timedelta
12+
from enum import Enum
1213
from io import BytesIO
1314
from typing import Any, Literal, TypedDict, Union
1415
from unittest import mock
@@ -3786,9 +3787,127 @@ def load_default(self) -> Event:
37863787
)
37873788

37883789

3790+
class ReplayBreadcrumbType(Enum):
3791+
CLICK = "click"
3792+
DEAD_CLICK = "dead_click"
3793+
RAGE_CLICK = "rage_click"
3794+
ERROR = "error"
3795+
WARNING = "warning"
3796+
INFO = "info"
3797+
3798+
37893799
@pytest.mark.snuba
37903800
@requires_snuba
37913801
@pytest.mark.usefixtures("reset_snuba")
3802+
class ReplayEAPTestCase(BaseTestCase):
3803+
def create_eap_replay_breadcrumb(
3804+
self,
3805+
*,
3806+
project,
3807+
replay_id,
3808+
segment_id,
3809+
breadcrumb_type,
3810+
timestamp=None,
3811+
organization=None,
3812+
trace_id=None,
3813+
retention_days=30,
3814+
**attributes,
3815+
):
3816+
"""Create single EAP replay breadcrumb TraceItem."""
3817+
from datetime import datetime, timezone
3818+
from uuid import uuid4
3819+
3820+
from google.protobuf.timestamp_pb2 import Timestamp
3821+
from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType
3822+
from sentry_protos.snuba.v1.trace_item_pb2 import TraceItem
3823+
3824+
if organization is None:
3825+
organization = self.organization
3826+
if timestamp is None:
3827+
timestamp = datetime.now(timezone.utc)
3828+
if trace_id is None:
3829+
trace_id = replay_id
3830+
3831+
if breadcrumb_type == ReplayBreadcrumbType.CLICK:
3832+
category = "ui.click"
3833+
click_is_dead = 0
3834+
click_is_rage = 0
3835+
elif breadcrumb_type == ReplayBreadcrumbType.DEAD_CLICK:
3836+
category = "ui.click"
3837+
click_is_dead = 1
3838+
click_is_rage = 0
3839+
elif breadcrumb_type == ReplayBreadcrumbType.RAGE_CLICK:
3840+
category = "ui.click"
3841+
click_is_dead = 1
3842+
click_is_rage = 1
3843+
elif breadcrumb_type == ReplayBreadcrumbType.ERROR:
3844+
category = "error"
3845+
click_is_dead = None
3846+
click_is_rage = None
3847+
elif breadcrumb_type == ReplayBreadcrumbType.WARNING:
3848+
category = "warning"
3849+
click_is_dead = None
3850+
click_is_rage = None
3851+
elif breadcrumb_type == ReplayBreadcrumbType.INFO:
3852+
category = "info"
3853+
click_is_dead = None
3854+
click_is_rage = None
3855+
else:
3856+
raise ValueError(f"Unknown breadcrumb type: {breadcrumb_type}")
3857+
3858+
breadcrumb_data = {
3859+
"replay_id": replay_id,
3860+
"segment_id": segment_id,
3861+
"project_id": project.id,
3862+
"timestamp": int(timestamp.timestamp()),
3863+
"category": category,
3864+
}
3865+
3866+
if category == "ui.click":
3867+
breadcrumb_data["click_is_dead"] = click_is_dead
3868+
breadcrumb_data["click_is_rage"] = click_is_rage
3869+
3870+
breadcrumb_data.update(attributes)
3871+
3872+
attributes_proto = {}
3873+
for k, v in breadcrumb_data.items():
3874+
if v is not None:
3875+
attributes_proto[k] = scalar_to_any_value(v)
3876+
3877+
timestamp_proto = Timestamp()
3878+
timestamp_proto.FromDatetime(timestamp)
3879+
3880+
return TraceItem(
3881+
organization_id=organization.id,
3882+
project_id=project.id,
3883+
item_type=TraceItemType.TRACE_ITEM_TYPE_REPLAY,
3884+
timestamp=timestamp_proto,
3885+
trace_id=trace_id,
3886+
item_id=uuid4().bytes,
3887+
received=timestamp_proto,
3888+
retention_days=retention_days,
3889+
attributes=attributes_proto,
3890+
client_sample_rate=1.0,
3891+
server_sample_rate=1.0,
3892+
)
3893+
3894+
def store_replays_eap(self, replays):
3895+
import requests
3896+
from django.conf import settings
3897+
3898+
files = {f"replay_{i}": replay.SerializeToString() for i, replay in enumerate(replays)}
3899+
response = requests.post(
3900+
settings.SENTRY_SNUBA + "/tests/entities/eap_items/insert_bytes",
3901+
files=files,
3902+
)
3903+
assert response.status_code == 200
3904+
3905+
for replay in replays:
3906+
# Reverse the ids here since these are stored in little endian in Clickhouse
3907+
# and end up reversed.
3908+
replay.item_id = replay.item_id[::-1]
3909+
3910+
37923911
class UptimeResultEAPTestCase(BaseTestCase):
37933912
"""Test case for creating and storing EAP uptime results."""
37943913

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import datetime
2+
from uuid import uuid4
3+
4+
from sentry.replays.endpoints.organization_replay_details import query_replay_instance_eap
5+
from sentry.testutils.cases import ReplayBreadcrumbType, ReplayEAPTestCase, TestCase
6+
7+
8+
class TestQueryReplayInstanceEAP(TestCase, ReplayEAPTestCase):
9+
def test_eap_replay_query(self) -> None:
10+
replay_id1 = uuid4().hex
11+
replay_id2 = uuid4().hex
12+
now = datetime.datetime.now(datetime.UTC)
13+
14+
replay1_breadcrumbs = [
15+
# Dead clicks
16+
self.create_eap_replay_breadcrumb(
17+
project=self.project,
18+
replay_id=replay_id1,
19+
segment_id=0,
20+
breadcrumb_type=ReplayBreadcrumbType.DEAD_CLICK,
21+
timestamp=now,
22+
),
23+
self.create_eap_replay_breadcrumb(
24+
project=self.project,
25+
replay_id=replay_id1,
26+
segment_id=0,
27+
breadcrumb_type=ReplayBreadcrumbType.DEAD_CLICK,
28+
timestamp=now,
29+
),
30+
# Rage clicks
31+
self.create_eap_replay_breadcrumb(
32+
project=self.project,
33+
replay_id=replay_id1,
34+
segment_id=0,
35+
breadcrumb_type=ReplayBreadcrumbType.RAGE_CLICK,
36+
timestamp=now,
37+
),
38+
# Regular click
39+
self.create_eap_replay_breadcrumb(
40+
project=self.project,
41+
replay_id=replay_id1,
42+
segment_id=0,
43+
breadcrumb_type=ReplayBreadcrumbType.CLICK,
44+
timestamp=now,
45+
),
46+
]
47+
48+
replay2_breadcrumbs = [
49+
# Dead clicks
50+
self.create_eap_replay_breadcrumb(
51+
project=self.project,
52+
replay_id=replay_id2,
53+
segment_id=0,
54+
breadcrumb_type=ReplayBreadcrumbType.DEAD_CLICK,
55+
timestamp=now,
56+
),
57+
# Rage clicks
58+
self.create_eap_replay_breadcrumb(
59+
project=self.project,
60+
replay_id=replay_id2,
61+
segment_id=0,
62+
breadcrumb_type=ReplayBreadcrumbType.RAGE_CLICK,
63+
timestamp=now,
64+
),
65+
self.create_eap_replay_breadcrumb(
66+
project=self.project,
67+
replay_id=replay_id2,
68+
segment_id=0,
69+
breadcrumb_type=ReplayBreadcrumbType.RAGE_CLICK,
70+
timestamp=now,
71+
),
72+
]
73+
74+
self.store_replays_eap(replay1_breadcrumbs + replay2_breadcrumbs)
75+
76+
start = now - datetime.timedelta(minutes=5)
77+
end = now + datetime.timedelta(minutes=5)
78+
organization_id = self.organization.id
79+
project_ids = [self.project.id]
80+
81+
res1 = query_replay_instance_eap(
82+
project_ids=project_ids,
83+
replay_ids=[replay_id1],
84+
start=start,
85+
end=end,
86+
request_user_id=self.user.id,
87+
organization_id=organization_id,
88+
)
89+
res2 = query_replay_instance_eap(
90+
project_ids=project_ids,
91+
replay_ids=[replay_id2],
92+
start=start,
93+
end=end,
94+
request_user_id=self.user.id,
95+
organization_id=organization_id,
96+
)
97+
98+
assert isinstance(res1, dict)
99+
assert isinstance(res2, dict)
100+
assert res1.get("data") is not None
101+
assert res2.get("data") is not None
102+
103+
assert len(res1["data"]) == 1, f"Expected 1 row for replay_id1, got {len(res1['data'])}"
104+
assert len(res2["data"]) == 1, f"Expected 1 row for replay_id2, got {len(res2['data'])}"
105+
106+
assert res1["data"][0]["replay_id"] == replay_id1
107+
assert res2["data"][0]["replay_id"] == replay_id2
108+
109+
replay1_data = res1["data"][0]
110+
assert "count_segments" in replay1_data
111+
assert "count_errors" in replay1_data
112+
assert "count_warnings" in replay1_data
113+
assert "count_dead_clicks" in replay1_data
114+
assert "count_rage_clicks" in replay1_data
115+
assert "is_archived" in replay1_data
116+
assert "started_at" in replay1_data
117+
assert "finished_at" in replay1_data
118+
119+
assert replay1_data["count_dead_clicks"] == 3, "2 DEAD_CLICK + 1 RAGE_CLICK = 3 dead"
120+
assert replay1_data["count_rage_clicks"] == 1, "1 RAGE_CLICK = 1 rage"
121+
122+
replay2_data = res2["data"][0]
123+
assert replay2_data["count_dead_clicks"] == 3, "1 DEAD_CLICK + 2 RAGE_CLICK = 3 dead"
124+
assert replay2_data["count_rage_clicks"] == 2, "2 RAGE_CLICK = 2 rage"

0 commit comments

Comments
 (0)