Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(replay): add viewed-by endpoint + abstract kafka utils for replay events #67972

Merged
merged 67 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
ff23806
feat(replay): add viewed-by endpoint
aliu39 Mar 29, 2024
24bcdb8
Merge branch 'master' into aliu/viewed-replay-endpoint
aliu39 Apr 1, 2024
dd6624f
Merge branch 'master' of github.com:getsentry/sentry into aliu/viewed…
aliu39 Apr 1, 2024
2377b0c
draft has_viewed query
aliu39 Apr 1, 2024
345f690
rename to has_viewed
aliu39 Apr 2, 2024
e7eb233
add viewed-by to urls
aliu39 Apr 2, 2024
0c2bdcd
Merge branch 'master' into aliu/viewed-replay-endpoint
aliu39 Apr 2, 2024
cd5f1da
add has_viewed to replay details responses
aliu39 Apr 4, 2024
286a1f7
add publish_replay_viewed
aliu39 Apr 4, 2024
f5efd56
viewed-by: post and get drafts
aliu39 Apr 4, 2024
92573fc
update GET with serializer (2 potential approaches)
aliu39 Apr 4, 2024
bf3887f
rename to actor
aliu39 Apr 4, 2024
2068757
update POST with project id query
aliu39 Apr 4, 2024
5849cd6
Merge branch 'master' of github.com:getsentry/sentry into aliu/viewed…
aliu39 Apr 5, 2024
abc0764
change to project endpoint
aliu39 Apr 5, 2024
e275954
doc
aliu39 Apr 5, 2024
500b34a
add has_viewed to details and index
aliu39 Apr 5, 2024
d898ccb
Merge branches 'aliu/viewed-replay-endpoint' and 'master' of github.c…
aliu39 Apr 6, 2024
7a905c6
Merge branch 'aliu/replay-details-has-viewed' into aliu/viewed-replay…
aliu39 Apr 6, 2024
719fb2a
update get
aliu39 Apr 6, 2024
768e0cf
publish replay_viewed synchronously
aliu39 Apr 6, 2024
529020f
Merge branch 'master' into aliu/viewed-replay-endpoint
cmanallen Apr 8, 2024
d6156aa
Abstract message publishing into its own module
cmanallen Apr 8, 2024
5a07986
Add coverage
cmanallen Apr 8, 2024
939f35f
Merge branch 'master' of github.com:getsentry/sentry into aliu/viewed…
aliu39 Apr 8, 2024
db4e36a
fix typing and tweak tests
aliu39 Apr 8, 2024
c4f56c1
Return structured content
cmanallen Apr 8, 2024
17404f4
Correct test case
cmanallen Apr 8, 2024
9374bf1
update post test and work on get test
aliu39 Apr 8, 2024
b0621b2
update mock replay viewed
aliu39 Apr 8, 2024
1fce5ff
Merge branch 'aliu/viewed-replay-endpoint' of github.com:getsentry/se…
aliu39 Apr 8, 2024
6886128
fix get test
aliu39 Apr 8, 2024
bb6e7f6
update tests
aliu39 Apr 8, 2024
3dc16f8
add user fields test
aliu39 Apr 9, 2024
889468b
tweak response type
aliu39 Apr 9, 2024
32f85e9
rm todos
aliu39 Apr 9, 2024
fbd634f
Merge branch 'master' into aliu/viewed-replay-endpoint
aliu39 Apr 9, 2024
f2f146b
fix publish_replay_event and use in archive_replay
aliu39 Apr 9, 2024
319be1a
Merge branch 'aliu/viewed-replay-endpoint' of github.com:getsentry/se…
aliu39 Apr 9, 2024
4dbcde0
rm id field from response and update query_replay_viewed_by_ids
aliu39 Apr 9, 2024
72ca4bb
docstrings for openapi
aliu39 Apr 9, 2024
a56de54
Remove assignment
cmanallen Apr 9, 2024
f18f1fb
Return empty response without calling
cmanallen Apr 9, 2024
4011e4a
Revert "fix publish_replay_event and use in archive_replay"
cmanallen Apr 9, 2024
08e356e
Specify channel
cmanallen Apr 9, 2024
677d8df
Move user serialization out of post_process and into endpoint
cmanallen Apr 9, 2024
6477546
Remove comment
cmanallen Apr 9, 2024
15c8315
Assert replay_id returned
cmanallen Apr 9, 2024
eb53d38
Add message generator coverage
cmanallen Apr 9, 2024
26c2126
Match user_service response output to the blueprint
cmanallen Apr 9, 2024
1f459c6
Remove id field, fix response type, and small comment
aliu39 Apr 9, 2024
ca740ad
Update comment
aliu39 Apr 9, 2024
f99c4bf
fix(crons): Support timezone in MockTimelineVisualization (#68477)
evanpurkhiser Apr 9, 2024
47ba160
fix(new-trace-url) Updated node path encoding. (#68458)
Abdkhan14 Apr 9, 2024
a5f3230
Simplify Laravel Metrics onboarding (#68513)
cleptric Apr 9, 2024
f8d7966
fix(proguard): Filter empty stacktraces in A/B test (#68445)
loewenheim Apr 9, 2024
ac650c8
fix(proguard): Correctly merge symbolicated and unsymbolicated excep…
loewenheim Apr 9, 2024
3a4d5e2
fix(area-chart): Revert accidental commit (#68521)
ArthurKnaus Apr 9, 2024
dbb64f2
Add Laravel & Symfony to metrics opt-in modal (#68522)
cleptric Apr 9, 2024
e413bb2
feat(spans): Extraction sample rate (#68527)
jjbayer Apr 9, 2024
ed87869
ref(ddm): Implement simpler querying structure and improve code (#68436)
iambriccardo Apr 9, 2024
bee50ad
Hide "Processing Issues" setting if it was never activated (#68173)
Swatinem Apr 9, 2024
8e6afb3
chore: handle null segment_id since it's optional (#68460)
shruthilayaj Apr 9, 2024
5e4ae40
feat(perf): Synchronize HTTP sample chart and table (#68454)
gggritso Apr 9, 2024
56ee481
Update docstrings
cmanallen Apr 9, 2024
c445a17
Fix docs
cmanallen Apr 9, 2024
9dbd660
Merge branch 'master' into aliu/viewed-replay-endpoint
cmanallen Apr 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
ProjectReplayRecordingSegmentIndexEndpoint,
)
from sentry.replays.endpoints.project_replay_video_details import ProjectReplayVideoDetailsEndpoint
from sentry.replays.endpoints.project_replay_viewed_by import ProjectReplayViewedByEndpoint
from sentry.rules.history.endpoints.project_rule_group_history import (
ProjectRuleGroupHistoryIndexEndpoint,
)
Expand Down Expand Up @@ -2400,6 +2401,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
ProjectReplayDetailsEndpoint.as_view(),
name="sentry-api-0-project-replay-details",
),
re_path(
r"^(?P<organization_slug>[^/]+)/(?P<project_slug>[^\/]+)/replays/(?P<replay_id>[\w-]+)/viewed-by/$",
ProjectReplayViewedByEndpoint.as_view(),
name="sentry-api-0-project-replay-viewed-by",
),
re_path(
r"^(?P<organization_slug>[^/]+)/(?P<project_slug>[^\/]+)/replays/(?P<replay_id>[\w-]+)/accessibility-issues/$",
ProjectReplayAccessibilityIssuesEndpoint.as_view(),
Expand Down
44 changes: 44 additions & 0 deletions src/sentry/apidocs/examples/replay_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,47 @@ class ReplayExamples:
response_only=True,
)
]

GET_REPLAY_VIEWED_BY = [
OpenApiExample(
"Get list of users who have viewed a replay",
value={
"data": {
"viewed_by": [
{
"id": "884411",
"name": "[email protected]",
"username": "d93522a35cb64c13991104bd73d44519",
"email": "[email protected]",
"avatarUrl": "https://gravatar.com/avatar/d93522a35cb64c13991104bd73d44519d93522a35cb64c13991104bd73d44519?s=32&d=mm",
"isActive": True,
"hasPasswordAuth": False,
"isManaged": False,
"dateJoined": "2022-07-25T23:36:29.593212Z",
"lastLogin": "2024-03-14T18:11:28.740309Z",
"has2fa": True,
"lastActive": "2024-03-15T22:22:06.925934Z",
"isSuperuser": True,
"isStaff": False,
"experiments": {},
"emails": [
{
"id": "2231333",
"email": "[email protected]",
"is_verified": True,
}
],
"avatar": {
"avatarType": "upload",
"avatarUuid": "499dcd0764da42a589654a2224086e67",
"avatarUrl": "https://sentry.io/avatar/499dcd0764da42a589654a2224086e67/",
},
"type": "user",
}
],
}
},
status_codes=[200],
response_only=True,
)
]
147 changes: 147 additions & 0 deletions src/sentry/replays/endpoints/project_replay_viewed_by.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import uuid
from typing import Any, TypedDict

from drf_spectacular.utils import extend_schema
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND
from sentry.apidocs.examples.replay_examples import ReplayExamples
from sentry.apidocs.parameters import GlobalParams, ReplayParams
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.models.project import Project
from sentry.replays.query import query_replay_viewed_by_ids
from sentry.replays.usecases.events import publish_replay_event, viewed_event
from sentry.services.hybrid_cloud.user.serial import serialize_generic_user
from sentry.services.hybrid_cloud.user.service import user_service


class ReplayViewedByResponsePayload(TypedDict):
viewed_by: list[dict[str, Any]]


class ReplayViewedByResponse(TypedDict):
data: ReplayViewedByResponsePayload


@region_silo_endpoint
@extend_schema(tags=["Replays"])
class ProjectReplayViewedByEndpoint(ProjectEndpoint):
owner = ApiOwner.REPLAY
publish_status = {"GET": ApiPublishStatus.PUBLIC, "POST": ApiPublishStatus.PRIVATE}

@extend_schema(
operation_id="Get list of user who have viewed a replay",
parameters=[
GlobalParams.ORG_SLUG,
GlobalParams.PROJECT_SLUG,
ReplayParams.REPLAY_ID,
],
responses={
200: inline_sentry_response_serializer("GetReplayViewedBy", ReplayViewedByResponse),
400: RESPONSE_BAD_REQUEST,
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
examples=ReplayExamples.GET_REPLAY_VIEWED_BY,
)
def get(self, request: Request, project: Project, replay_id: str) -> Response:
"""Return a list of users who have viewed a replay."""
if not features.has(
"organizations:session-replay", project.organization, actor=request.user
):
return Response(status=404)

try:
uuid.UUID(replay_id)
except ValueError:
return Response(status=404)

# query for user ids who viewed the replay
filter_params = self.get_filter_params(request, project, date_filter_optional=False)

# If no rows were found then the replay does not exist and a 404 is returned.
viewed_by_ids_response: list[dict[str, Any]] = query_replay_viewed_by_ids(
project_id=project.id,
replay_id=replay_id,
start=filter_params["start"],
end=filter_params["end"],
request_user_id=request.user.id,
organization=project.organization,
)
if not viewed_by_ids_response:
return Response(status=404)
aliu39 marked this conversation as resolved.
Show resolved Hide resolved

viewed_by_ids = viewed_by_ids_response[0]["viewed_by_ids"]
if viewed_by_ids == []:
return Response({"data": {"viewed_by": []}}, status=200)

# Note: in the rare/error case where Snuba returns non-existent user ids, this fx will filter them out.
serialized_users = user_service.serialize_many(
filter=dict(user_ids=viewed_by_ids),
as_user=serialize_generic_user(request.user),
)
serialized_users = [_normalize_user(user) for user in serialized_users]

return Response({"data": {"viewed_by": serialized_users}}, status=200)

def post(self, request: Request, project: Project, replay_id: str) -> Response:
"""Create a replay-viewed event."""
if not features.has(
"organizations:session-replay", project.organization, actor=request.user
):
return Response(status=404)

try:
replay_id = str(uuid.UUID(replay_id))
except ValueError:
return Response(status=404)

message = viewed_event(project.id, replay_id, request.user.id)
publish_replay_event(message, is_async=False)

return Response(status=204)


def _normalize_user(user: dict[str, Any]) -> dict[str, Any]:
"""Return a normalized user dictionary.

The viewed-by resource is expected to return a subset of the user_service's
response output.
"""
return {
cmanallen marked this conversation as resolved.
Show resolved Hide resolved
"avatar": {
"avatarType": user["avatar"]["avatarType"],
"avatarUuid": user["avatar"]["avatarUuid"],
"avatarUrl": user["avatar"]["avatarUrl"],
},
"avatarUrl": user["avatarUrl"],
"dateJoined": user["dateJoined"],
"email": user["email"],
"emails": [
{
"id": email["id"],
"email": email["email"],
"is_verified": email["is_verified"],
}
for email in user["emails"]
],
"experiments": user["experiments"],
"has2fa": user["has2fa"],
"hasPasswordAuth": user["hasPasswordAuth"],
"id": user["id"],
"isActive": user["isActive"],
"isManaged": user["isManaged"],
"isStaff": user["isStaff"],
"isSuperuser": user["isSuperuser"],
"lastActive": user["lastActive"],
"lastLogin": user["lastLogin"],
"name": user["name"],
"type": "user",
"username": user["username"],
}
42 changes: 30 additions & 12 deletions src/sentry/replays/lib/kafka.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,40 @@
from sentry.utils.kafka_config import get_kafka_producer_cluster_options, get_topic_definition
from sentry.utils.pubsub import KafkaPublisher

replay_publisher: KafkaPublisher | None = None
# We keep a synchronous and asynchronous singleton because a shared singleton could lead
# to synchronous publishing when asynchronous publishing was desired and vice-versa.
sync_publisher: KafkaPublisher | None = None
async_publisher: KafkaPublisher | None = None


def initialize_replays_publisher(is_async=False) -> KafkaPublisher:
global replay_publisher
def initialize_replays_publisher(is_async: bool = False) -> KafkaPublisher:
if is_async:
global async_publisher

if replay_publisher is None:
config = get_topic_definition(Topic.INGEST_REPLAY_EVENTS)
replay_publisher = KafkaPublisher(
get_kafka_producer_cluster_options(config["cluster"]),
asynchronous=is_async,
)
if async_publisher is None:
async_publisher = _init_replay_publisher(is_async=True)

return replay_publisher
return async_publisher
else:
global sync_publisher

if sync_publisher is None:
sync_publisher = _init_replay_publisher(is_async=False)

return sync_publisher


def _init_replay_publisher(is_async: bool) -> KafkaPublisher:
config = get_topic_definition(Topic.INGEST_REPLAY_EVENTS)
return KafkaPublisher(
get_kafka_producer_cluster_options(config["cluster"]),
asynchronous=is_async,
)


def clear_replay_publisher() -> None:
global replay_publisher
replay_publisher = None
global sync_publisher
global async_publisher

sync_publisher = None
async_publisher = None
2 changes: 1 addition & 1 deletion src/sentry/replays/post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def _strip_dashes(field: str) -> str:


def generate_normalized_output(
response: list[dict[str, Any]],
response: list[dict[str, Any]]
) -> Generator[ReplayDetailsResponse, None, None]:
"""For each payload in the response strip "agg_" prefixes."""
for item in response:
Expand Down
39 changes: 38 additions & 1 deletion src/sentry/replays/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,34 @@ def query_replay_instance(
)["data"]


def query_replay_viewed_by_ids(
project_id: int | list[int],
replay_id: str,
start: datetime,
end: datetime,
request_user_id: int | None,
organization: Organization | None = None,
) -> list[dict[str, Any]]:
"""Query unique user ids who viewed a given replay."""
if isinstance(project_id, list):
project_ids = project_id
else:
project_ids = [project_id]

return execute_query(
query=make_full_aggregation_query(
fields=["viewed_by_ids"],
replay_ids=[replay_id],
project_ids=project_ids,
period_start=start,
period_end=end,
request_user_id=request_user_id,
),
tenant_id={"organization_id": organization.id} if organization else {},
referrer="replays.query.viewed_by_query",
)["data"]


def query_replays_count(
project_ids: list[int],
start: datetime,
Expand Down Expand Up @@ -559,7 +587,8 @@ def _empty_uuids_lambda():
"info_ids": ["info_ids"],
"count_warnings": ["count_warnings"],
"count_infos": ["count_infos"],
"has_viewed": ["has_viewed"],
"viewed_by_ids": ["viewed_by_ids"],
"has_viewed": ["viewed_by_ids"],
}


Expand Down Expand Up @@ -707,6 +736,14 @@ def _empty_uuids_lambda():
parameters=[Column("count_info_events")],
alias="count_infos",
),
"viewed_by_ids": Function(
"groupUniqArrayIf",
parameters=[
Column("viewed_by_id"),
Function("greater", parameters=[Column("viewed_by_id"), 0]),
],
alias="viewed_by_ids",
),
}


Expand Down
35 changes: 6 additions & 29 deletions src/sentry/replays/tasks.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
from __future__ import annotations

import concurrent.futures as cf
import time
import uuid
from typing import Any

from sentry.replays.lib.kafka import initialize_replays_publisher
from sentry.replays.lib.storage import filestore, storage
from sentry.replays.models import ReplayRecordingSegment
from sentry.replays.usecases.events import archive_event
from sentry.replays.usecases.reader import fetch_segments_metadata
from sentry.silo import SiloMode
from sentry.tasks.base import instrumented_task
from sentry.utils import json, metrics
from sentry.utils import metrics
from sentry.utils.pubsub import KafkaPublisher


Expand Down Expand Up @@ -77,30 +76,8 @@ def delete_replay_recording(project_id: int, replay_id: str) -> None:

def archive_replay(publisher: KafkaPublisher, project_id: int, replay_id: str) -> None:
"""Archive a Replay instance. The Replay is not deleted."""
replay_payload: dict[str, Any] = {
"type": "replay_event",
"replay_id": replay_id,
"event_id": uuid.uuid4().hex,
"segment_id": None,
"trace_ids": [],
"error_ids": [],
"urls": [],
"timestamp": time.time(),
"is_archived": True,
"platform": "",
}
message = archive_event(project_id, replay_id)

publisher.publish(
"ingest-replay-events",
json.dumps(
{
"type": "replay_event",
"start_time": int(time.time()),
"replay_id": replay_id,
"project_id": project_id,
"segment_id": None,
"retention_days": 30,
"payload": list(bytes(json.dumps(replay_payload).encode())),
}
),
)
# We publish manually here because we sometimes provide a managed Kafka
# publisher interface which has its own setup and teardown behavior.
publisher.publish("ingest-replay-events", message)
Loading
Loading