-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
feat(replays): Add Breadcrumb AI Summaries #93256
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
Changes from all commits
c5276f8
ec2ba36
a50e58b
9286364
6fbf97d
d9b2e4b
830a60e
4768b69
b78861a
5c26f00
109c8f2
fb87790
036d7d3
edb94f6
16388ed
8e3a282
4690322
4838537
7c87fc3
20e1f3c
4392578
f566c83
5978561
bc3807c
75c8e95
c9b3352
1680fd4
6b4dc70
00a7edb
2399edd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -583,3 +583,34 @@ A POST request is issued with no body. The URL and authorization context is used | |||||
Cookie: \_ga=GA1.2.17576183... | ||||||
|
||||||
- Response 204 | ||||||
|
||||||
## Replay Summarize Breadcrumb [/projects/<organization_id_or_slug>/<project_id_or_slug>/replays/<replay_id>/summarize/breadcrumbs/] | ||||||
|
||||||
### Fetch Replay Breadcrumb Summary [GET] | ||||||
|
||||||
| Column | Type | Description | | ||||||
| ------------------------ | --------------- | --------------------------------------------------------------------------------------------- | | ||||||
| title | str | The main title of the user journey summary. | | ||||||
| summary | str | A concise summary featuring the highlights of the user's journey while using the application. | | ||||||
| time_ranges | list[TimeRange] | A list of TimeRange objects. | | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| time_ranges.period_start | number | The start time (UNIX timestamp) of the analysis window. | | ||||||
| time_ranges.period_end | number | The end time (UNIX timestamp) of the analysis window. | | ||||||
| time_ranges.period_title | str | A concise summary utilizing 6 words or fewer describing what happened during the time range. | | ||||||
|
||||||
- Response 200 | ||||||
|
||||||
```json | ||||||
{ | ||||||
"data": { | ||||||
"title": "Something Happened", | ||||||
"summary": "The application broke", | ||||||
"time_ranges": [ | ||||||
{ | ||||||
"period_start": 1749584581.5356228, | ||||||
"period_end": 1749584992.912, | ||||||
"period_title": "Second Replay Load Failure" | ||||||
} | ||||||
] | ||||||
billyvg marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could add a second example to show the ordering and relationship of start/end (will all ranges be disjoint? no gaps in between?) |
||||||
} | ||||||
} | ||||||
``` |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,96 @@ | ||||||
import functools | ||||||
from collections.abc import Generator, Iterator | ||||||
from typing import Any | ||||||
|
||||||
import requests | ||||||
import sentry_sdk | ||||||
from django.conf import settings | ||||||
from drf_spectacular.utils import extend_schema | ||||||
from rest_framework.exceptions import ParseError | ||||||
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.api.paginator import GenericOffsetPaginator | ||||||
from sentry.replays.lib.storage import RecordingSegmentStorageMeta, storage | ||||||
from sentry.replays.usecases.ingest.event_parser import as_log_message | ||||||
from sentry.replays.usecases.reader import fetch_segments_metadata, iter_segment_data | ||||||
from sentry.seer.signed_seer_api import sign_with_seer_secret | ||||||
from sentry.utils import json | ||||||
|
||||||
|
||||||
@region_silo_endpoint | ||||||
@extend_schema(tags=["Replays"]) | ||||||
class ProjectReplaySummarizeBreadcrumbsEndpoint(ProjectEndpoint): | ||||||
owner = ApiOwner.REPLAY | ||||||
publish_status = { | ||||||
"GET": ApiPublishStatus.EXPERIMENTAL, | ||||||
} | ||||||
|
||||||
def __init__(self, **options) -> None: | ||||||
storage.initialize_client() | ||||||
super().__init__(**options) | ||||||
|
||||||
def get(self, request: Request, project, replay_id: str) -> Response: | ||||||
"""Return a collection of replay recording segments.""" | ||||||
if ( | ||||||
not features.has( | ||||||
"organizations:session-replay", project.organization, actor=request.user | ||||||
) | ||||||
or not features.has( | ||||||
"organizations:replay-ai-summaries", project.organization, actor=request.user | ||||||
) | ||||||
or not features.has( | ||||||
"organizations:gen-ai-features", project.organization, actor=request.user | ||||||
) | ||||||
): | ||||||
return self.respond(status=404) | ||||||
|
||||||
return self.paginate( | ||||||
request=request, | ||||||
paginator_cls=GenericOffsetPaginator, | ||||||
data_fn=functools.partial(fetch_segments_metadata, project.id, replay_id), | ||||||
on_results=analyze_recording_segments, | ||||||
) | ||||||
|
||||||
|
||||||
@sentry_sdk.trace | ||||||
def analyze_recording_segments(segments: list[RecordingSegmentStorageMeta]) -> dict[str, Any]: | ||||||
request_data = json.dumps({"logs": get_request_data(iter_segment_data(segments))}) | ||||||
|
||||||
# XXX: I have to deserialize this request so it can be "automatically" reserialized by the | ||||||
# paginate method. This is less than ideal. | ||||||
return json.loads(make_seer_request(request_data).decode("utf-8")) | ||||||
|
||||||
|
||||||
def make_seer_request(request_data: str) -> bytes: | ||||||
# XXX: Request isn't streaming. Limitation of Seer authentication. Would be much faster if we | ||||||
# could stream the request data since the GCS download will (likely) dominate latency. | ||||||
response = requests.post( | ||||||
f"{settings.SEER_AUTOFIX_URL}/v1/automation/summarize/replay/breadcrumbs", | ||||||
data=request_data, | ||||||
headers={ | ||||||
"content-type": "application/json;charset=utf-8", | ||||||
**sign_with_seer_secret(request_data.encode()), | ||||||
}, | ||||||
) | ||||||
if response.status_code != 200: | ||||||
raise ParseError("A summary could not be produced at this time.") | ||||||
|
||||||
return response.content | ||||||
cmanallen marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
|
||||||
def get_request_data(iterator: Iterator[tuple[int, memoryview]]) -> list[str]: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
return list(gen_request_data(map(lambda r: r[1], iterator))) | ||||||
|
||||||
|
||||||
def gen_request_data(segments: Iterator[memoryview]) -> Generator[str]: | ||||||
for segment in segments: | ||||||
for event in json.loads(segment.tobytes().decode("utf-8")): | ||||||
message = as_log_message(event) | ||||||
if message: | ||||||
yield message |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -2,7 +2,9 @@ | |||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
import random | ||||||||||||||||||||||||||||||||||
from dataclasses import dataclass | ||||||||||||||||||||||||||||||||||
from enum import Enum | ||||||||||||||||||||||||||||||||||
from typing import Any | ||||||||||||||||||||||||||||||||||
from urllib.parse import urlparse | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
import sentry_sdk | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
|
@@ -228,3 +230,138 @@ def _get_breadcrumb_event( | |||||||||||||||||||||||||||||||||
return MutationEvent(payload) | ||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||
return None | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
class EventType(Enum): | ||||||||||||||||||||||||||||||||||
CLICK = 0 | ||||||||||||||||||||||||||||||||||
DEAD_CLICK = 1 | ||||||||||||||||||||||||||||||||||
RAGE_CLICK = 2 | ||||||||||||||||||||||||||||||||||
NAVIGATION = 3 | ||||||||||||||||||||||||||||||||||
CONSOLE = 4 | ||||||||||||||||||||||||||||||||||
UI_BLUR = 5 | ||||||||||||||||||||||||||||||||||
UI_FOCUS = 6 | ||||||||||||||||||||||||||||||||||
RESOURCE_FETCH = 7 | ||||||||||||||||||||||||||||||||||
RESOURCE_XHR = 8 | ||||||||||||||||||||||||||||||||||
LCP = 9 | ||||||||||||||||||||||||||||||||||
FCP = 10 | ||||||||||||||||||||||||||||||||||
HYDRATION_ERROR = 11 | ||||||||||||||||||||||||||||||||||
MUTATIONS = 12 | ||||||||||||||||||||||||||||||||||
UNKNOWN = 13 | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we add a user feedback crumb? cc @billyvg There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cmanallen i think the breadcrumb already exists coming from the SDK! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. so can we just add the FEEDBACK type here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah. What does the crumb look like? Can you link an example replay? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or type definition in the SDK There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is what the raw breadcrumb looks like from my understanding:
where the
the hydrated version looks like: sentry/static/app/utils/replays/types.tsx Lines 347 to 362 in 86b2a5d
@billyvg please correct me if i'm wrong or if there's a better spot to reference for the types There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also here's an example replay with the feedback breadcrumb! at 41 seconds There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added. I'd like to see an example though before I do anything crazier than acknowledge its existence. If I can safely pull in the feedback message that would be great context for the model. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. unfortunately we don't have access to the message in the breadcrumb right now :( there's a ticket for that in the backlog though, i also wanted that feature |
||||||||||||||||||||||||||||||||||
FEEDBACK = 14 | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
def which(event: dict[str, Any]) -> EventType: | ||||||||||||||||||||||||||||||||||
"""Identify the passed event. | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
This function helpfully hides the dirty data munging necessary to identify an event type and | ||||||||||||||||||||||||||||||||||
helpfully reduces the number of operations required by reusing context from previous | ||||||||||||||||||||||||||||||||||
branches. | ||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||
if event.get("type") == 5: | ||||||||||||||||||||||||||||||||||
if event["data"]["tag"] == "breadcrumb": | ||||||||||||||||||||||||||||||||||
payload = event["data"]["payload"] | ||||||||||||||||||||||||||||||||||
category = payload["category"] | ||||||||||||||||||||||||||||||||||
if category == "ui.click": | ||||||||||||||||||||||||||||||||||
return EventType.CLICK | ||||||||||||||||||||||||||||||||||
elif category == "ui.slowClickDetected": | ||||||||||||||||||||||||||||||||||
is_timeout_reason = payload["data"].get("endReason") == "timeout" | ||||||||||||||||||||||||||||||||||
is_target_tagname = payload["data"].get("node", {}).get("tagName") in ( | ||||||||||||||||||||||||||||||||||
"a", | ||||||||||||||||||||||||||||||||||
"button", | ||||||||||||||||||||||||||||||||||
"input", | ||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||
timeout = payload["data"].get("timeAfterClickMs", 0) or payload["data"].get( | ||||||||||||||||||||||||||||||||||
"timeafterclickms", 0 | ||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
if is_timeout_reason and is_target_tagname and timeout >= 7000: | ||||||||||||||||||||||||||||||||||
is_rage = ( | ||||||||||||||||||||||||||||||||||
payload["data"].get("clickCount", 0) or payload["data"].get("clickcount", 0) | ||||||||||||||||||||||||||||||||||
) >= 5 | ||||||||||||||||||||||||||||||||||
cmanallen marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
if is_rage: | ||||||||||||||||||||||||||||||||||
return EventType.RAGE_CLICK | ||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||
return EventType.DEAD_CLICK | ||||||||||||||||||||||||||||||||||
elif category == "navigation": | ||||||||||||||||||||||||||||||||||
return EventType.NAVIGATION | ||||||||||||||||||||||||||||||||||
elif category == "console": | ||||||||||||||||||||||||||||||||||
return EventType.CONSOLE | ||||||||||||||||||||||||||||||||||
elif category == "ui.blur": | ||||||||||||||||||||||||||||||||||
return EventType.UI_BLUR | ||||||||||||||||||||||||||||||||||
elif category == "ui.focus": | ||||||||||||||||||||||||||||||||||
return EventType.UI_FOCUS | ||||||||||||||||||||||||||||||||||
elif category == "replay.hydrate-error": | ||||||||||||||||||||||||||||||||||
return EventType.HYDRATION_ERROR | ||||||||||||||||||||||||||||||||||
elif category == "replay.mutations": | ||||||||||||||||||||||||||||||||||
return EventType.MUTATIONS | ||||||||||||||||||||||||||||||||||
elif category == "sentry.feedback": | ||||||||||||||||||||||||||||||||||
return EventType.FEEDBACK | ||||||||||||||||||||||||||||||||||
elif event["data"]["tag"] == "performanceSpan": | ||||||||||||||||||||||||||||||||||
payload = event["data"]["payload"] | ||||||||||||||||||||||||||||||||||
op = payload["op"] | ||||||||||||||||||||||||||||||||||
if op == "resource.fetch": | ||||||||||||||||||||||||||||||||||
return EventType.RESOURCE_FETCH | ||||||||||||||||||||||||||||||||||
elif op == "resource.xhr": | ||||||||||||||||||||||||||||||||||
return EventType.RESOURCE_XHR | ||||||||||||||||||||||||||||||||||
elif op == "web-vital": | ||||||||||||||||||||||||||||||||||
if payload["description"] == "largest-contentful-paint": | ||||||||||||||||||||||||||||||||||
return EventType.LCP | ||||||||||||||||||||||||||||||||||
elif payload["description"] == "first-contentful-paint": | ||||||||||||||||||||||||||||||||||
return EventType.FCP | ||||||||||||||||||||||||||||||||||
return EventType.UNKNOWN | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
def as_log_message(event: dict[str, Any]) -> str | None: | ||||||||||||||||||||||||||||||||||
"""Return an event as a log message. | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
Useful in AI contexts where the event's structure is an impediment to the AI's understanding | ||||||||||||||||||||||||||||||||||
of the interaction log. Not every event produces a log message. This function is overly coupled | ||||||||||||||||||||||||||||||||||
to the AI use case. In later iterations, if more or all log messages are desired, this function | ||||||||||||||||||||||||||||||||||
should be forked. | ||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||
event_type = which(event) | ||||||||||||||||||||||||||||||||||
timestamp = event.get("timestamp", 0.0) | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
match event_type: | ||||||||||||||||||||||||||||||||||
case EventType.CLICK: | ||||||||||||||||||||||||||||||||||
return f"User clicked on {event["data"]["payload"]["message"]} at {timestamp}" | ||||||||||||||||||||||||||||||||||
case EventType.DEAD_CLICK: | ||||||||||||||||||||||||||||||||||
return f"User clicked on {event["data"]["payload"]["message"]} but the triggered action was slow to complete at {timestamp}" | ||||||||||||||||||||||||||||||||||
case EventType.RAGE_CLICK: | ||||||||||||||||||||||||||||||||||
return f"User rage clicked on {event["data"]["payload"]["message"]} but the triggered action was slow to complete at {timestamp}" | ||||||||||||||||||||||||||||||||||
case EventType.NAVIGATION: | ||||||||||||||||||||||||||||||||||
return f"User navigated to: {event["data"]["payload"]["data"]["to"]} at {timestamp}" | ||||||||||||||||||||||||||||||||||
case EventType.CONSOLE: | ||||||||||||||||||||||||||||||||||
return f"Logged: {event["data"]["payload"]["message"]} at {timestamp}" | ||||||||||||||||||||||||||||||||||
case EventType.UI_BLUR: | ||||||||||||||||||||||||||||||||||
return f"User looked away from the tab at {timestamp}." | ||||||||||||||||||||||||||||||||||
case EventType.UI_FOCUS: | ||||||||||||||||||||||||||||||||||
return f"User returned to tab at {timestamp}." | ||||||||||||||||||||||||||||||||||
case EventType.RESOURCE_FETCH: | ||||||||||||||||||||||||||||||||||
payload = event["data"]["payload"] | ||||||||||||||||||||||||||||||||||
parsed_url = urlparse(payload["description"]) | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
path = f"{parsed_url.path}?{parsed_url.query}" | ||||||||||||||||||||||||||||||||||
size = payload["data"]["response"]["size"] | ||||||||||||||||||||||||||||||||||
status_code = payload["data"]["statusCode"] | ||||||||||||||||||||||||||||||||||
duration = payload["endTimestamp"] - payload["startTimestamp"] | ||||||||||||||||||||||||||||||||||
method = payload["data"]["method"] | ||||||||||||||||||||||||||||||||||
return f'Application initiated request: "{method} {path} HTTP/2.0" {status_code} {size}; took {duration} milliseconds at {timestamp}' | ||||||||||||||||||||||||||||||||||
case EventType.RESOURCE_XHR: | ||||||||||||||||||||||||||||||||||
return None | ||||||||||||||||||||||||||||||||||
case EventType.LCP: | ||||||||||||||||||||||||||||||||||
duration = event["data"]["payload"]["data"]["size"] | ||||||||||||||||||||||||||||||||||
rating = event["data"]["payload"]["data"]["rating"] | ||||||||||||||||||||||||||||||||||
return f"Application largest contentful paint: {duration} ms and has a {rating} rating" | ||||||||||||||||||||||||||||||||||
case EventType.FCP: | ||||||||||||||||||||||||||||||||||
duration = event["data"]["payload"]["data"]["size"] | ||||||||||||||||||||||||||||||||||
rating = event["data"]["payload"]["data"]["rating"] | ||||||||||||||||||||||||||||||||||
return f"Application first contentful paint: {duration} ms and has a {rating} rating" | ||||||||||||||||||||||||||||||||||
case EventType.HYDRATION_ERROR: | ||||||||||||||||||||||||||||||||||
return f"There was a hydration error on the page at {timestamp}." | ||||||||||||||||||||||||||||||||||
case EventType.MUTATIONS: | ||||||||||||||||||||||||||||||||||
return None | ||||||||||||||||||||||||||||||||||
case EventType.UNKNOWN: | ||||||||||||||||||||||||||||||||||
return None | ||||||||||||||||||||||||||||||||||
case EventType.FEEDBACK: | ||||||||||||||||||||||||||||||||||
return "The user filled out a feedback form describing their experience using the application." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what's the difference between title and summary? do we need both?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Its a condensed summary. We could drop it if its not useful. I want to add more fields before we take away though (at least during the early periods while we're experimenting).