Files
sentinel/tests/sentry/replays/unit/test_ingest_dom_index.py
2024-02-15 17:01:50 +00:00

704 lines
24 KiB
Python

from __future__ import annotations
import uuid
from typing import Any
from unittest import mock
import pytest
from sentry.replays.usecases.ingest.dom_index import (
_get_testid,
_parse_classes,
encode_as_uuid,
get_user_actions,
log_canvas_size,
parse_replay_actions,
)
from sentry.testutils.helpers.features import Feature
from sentry.testutils.pytest.fixtures import django_db_all
from sentry.utils import json
@pytest.fixture(autouse=True)
def patch_rage_click_issue():
with mock.patch("sentry.replays.usecases.ingest.dom_index.report_rage_click_issue") as m:
yield m
def test_get_user_actions():
"""Test "get_user_actions" function."""
events = [
{
"type": 5,
"timestamp": 1674298825,
"data": {
"tag": "breadcrumb",
"payload": {
"timestamp": 1674298825.403,
"type": "default",
"category": "ui.click",
"message": "div#hello.hello.world",
"data": {
"nodeId": 1,
"node": {
"id": 1,
"tagName": "div",
"attributes": {
"id": "hello",
"class": "hello world",
"aria-label": "test",
"role": "button",
"alt": "1",
"data-testid": "2",
"title": "3",
"data-sentry-component": "SignUpForm",
},
"textContent": "Hello, world!",
},
},
},
},
}
]
user_actions = get_user_actions(1, uuid.uuid4().hex, events)
assert len(user_actions) == 1
assert user_actions[0]["node_id"] == 1
assert user_actions[0]["tag"] == "div"
assert user_actions[0]["id"] == "hello"
assert user_actions[0]["class"] == ["hello", "world"]
assert user_actions[0]["text"] == "Hello, world!"
assert user_actions[0]["role"] == "button"
assert user_actions[0]["alt"] == "1"
assert user_actions[0]["testid"] == "2"
assert user_actions[0]["aria_label"] == "test"
assert user_actions[0]["title"] == "3"
assert user_actions[0]["component_name"] == "SignUpForm"
assert user_actions[0]["is_dead"] == 0
assert user_actions[0]["is_rage"] == 0
assert user_actions[0]["timestamp"] == 1674298825
assert len(user_actions[0]["event_hash"]) == 36
def test_get_user_actions_missing_node():
"""Test "get_user_actions" function."""
events = [
{
"type": 5,
"timestamp": 1674298825,
"data": {
"tag": "breadcrumb",
"payload": {
"timestamp": 1674298825.403,
"type": "default",
"category": "ui.click",
"message": "div#hello.hello.world",
},
},
}
]
user_actions = get_user_actions(1, uuid.uuid4().hex, events)
assert len(user_actions) == 0
def test_parse_replay_actions():
events = [
{
"type": 5,
"timestamp": 1674291701348,
"data": {
"tag": "breadcrumb",
"payload": {
"timestamp": 1.1,
"type": "default",
"category": "ui.click",
"message": "div.container > div#root > div > ul > div",
"data": {
"nodeId": 59,
"url": "https://www.sentry.io",
"node": {
"id": 59,
"tagName": "div",
"attributes": {
"id": "id",
"class": "class1 class2",
"role": "button",
"aria-label": "test",
"alt": "1",
"data-testid": "2",
"title": "3",
"data-sentry-component": "SignUpForm",
},
"textContent": "text",
},
},
},
},
}
]
replay_actions = parse_replay_actions(1, "1", 30, events)
assert replay_actions is not None
assert replay_actions["type"] == "replay_event"
assert isinstance(replay_actions["start_time"], float)
assert replay_actions["replay_id"] == "1"
assert replay_actions["project_id"] == 1
assert replay_actions["retention_days"] == 30
assert isinstance(replay_actions["payload"], list)
payload = json.loads(bytes(replay_actions["payload"]))
assert payload["type"] == "replay_actions"
assert payload["replay_id"] == "1"
assert len(payload["clicks"]) == 1
action = payload["clicks"][0]
assert action["node_id"] == 59
assert action["tag"] == "div"
assert action["id"] == "id"
assert action["class"] == ["class1", "class2"]
assert action["text"] == "text"
assert action["aria_label"] == "test"
assert action["role"] == "button"
assert action["alt"] == "1"
assert action["testid"] == "2"
assert action["title"] == "3"
assert action["component_name"] == "SignUpForm"
assert action["is_dead"] == 0
assert action["is_rage"] == 0
assert action["timestamp"] == 1
assert len(action["event_hash"]) == 36
@django_db_all
def test_parse_replay_dead_click_actions(patch_rage_click_issue, default_project):
events = [
{
"type": 5,
"timestamp": 1674291701348,
"data": {
"tag": "breadcrumb",
"payload": {
"timestamp": 1.1,
"type": "default",
"category": "ui.slowClickDetected",
"message": "div.container > div#root > div > ul > div",
"data": {
"endReason": "timeout",
"timeafterclickms": 7000.0,
"nodeId": 59,
"url": "https://www.sentry.io",
"node": {
"id": 59,
"tagName": "a",
"attributes": {
"id": "id",
"class": "class1 class2",
"role": "button",
"aria-label": "test",
"alt": "1",
"data-testid": "2",
"title": "3",
"data-sentry-component": "SignUpForm",
},
"textContent": "text",
},
},
},
},
},
{
"type": 5,
"timestamp": 1674291701348,
"data": {
"tag": "breadcrumb",
"payload": {
"timestamp": 1.1,
"type": "default",
"category": "ui.slowClickDetected",
"message": "div.container > div#root > div > ul > div",
"data": {
"clickcount": 5,
"endReason": "timeout",
"timeafterclickms": 7000.0,
"nodeId": 59,
"url": "https://www.sentry.io",
"node": {
"id": 59,
"tagName": "a",
"attributes": {
"id": "id",
"class": "class1 class2",
"role": "button",
"aria-label": "test",
"alt": "1",
"data-testid": "2",
"title": "3",
"data-sentry-component": "SignUpForm",
},
"textContent": "text",
},
},
},
},
},
# New style slowClickDetected payload.
{
"type": 5,
"timestamp": 1674291701348,
"data": {
"tag": "breadcrumb",
"payload": {
"timestamp": 1.1,
"type": "default",
"category": "ui.slowClickDetected",
"message": "div.container > div#root > div > ul > div",
"data": {
"url": "https://www.sentry.io",
"clickCount": 5,
"endReason": "timeout",
"timeAfterClickMs": 7000.0,
"nodeId": 59,
"node": {
"id": 59,
"tagName": "a",
"attributes": {
"id": "id",
"class": "class1 class2",
"role": "button",
"aria-label": "test",
"alt": "1",
"data-testid": "2",
"title": "3",
"data-sentry-component": "SignUpForm",
},
"textContent": "text",
},
},
},
},
},
]
with Feature(
{
"organizations:session-replay-rage-click-issue-creation": True,
}
):
default_project.update_option("sentry:replay_rage_click_issues", True)
replay_actions = parse_replay_actions(default_project.id, "1", 30, events)
assert patch_rage_click_issue.delay.call_count == 2
assert replay_actions is not None
assert replay_actions["type"] == "replay_event"
assert isinstance(replay_actions["start_time"], float)
assert replay_actions["replay_id"] == "1"
assert replay_actions["project_id"] == default_project.id
assert replay_actions["retention_days"] == 30
assert isinstance(replay_actions["payload"], list)
payload = json.loads(bytes(replay_actions["payload"]))
assert payload["type"] == "replay_actions"
assert payload["replay_id"] == "1"
assert len(payload["clicks"]) == 3
action = payload["clicks"][0]
assert action["node_id"] == 59
assert action["tag"] == "a"
assert action["id"] == "id"
assert action["class"] == ["class1", "class2"]
assert action["text"] == "text"
assert action["aria_label"] == "test"
assert action["role"] == "button"
assert action["alt"] == "1"
assert action["testid"] == "2"
assert action["title"] == "3"
assert action["component_name"] == "SignUpForm"
assert action["is_dead"] == 1
assert action["is_rage"] == 0
assert action["timestamp"] == 1
assert len(action["event_hash"]) == 36
# Second slow click had more than 2 clicks which makes it a rage+dead combo.
action = payload["clicks"][1]
assert action["is_dead"] == 1
assert action["is_rage"] == 1
# Third slow click had more than 2 clicks which makes it a rage+dead combo.
action = payload["clicks"][2]
assert action["is_dead"] == 1
assert action["is_rage"] == 1
@django_db_all
def test_parse_replay_rage_click_actions(default_project):
events = [
{
"type": 5,
"timestamp": 1674291701348,
"data": {
"tag": "breadcrumb",
"payload": {
"timestamp": 1.1,
"type": "default",
"category": "ui.slowClickDetected",
"message": "div.container > div#root > div > ul > div",
"data": {
"endReason": "timeout",
"timeafterclickms": 7000.0,
"clickcount": 5,
"nodeId": 59,
"url": "https://www.sentry.io",
"node": {
"id": 59,
"tagName": "a",
"attributes": {
"id": "id",
"class": "class1 class2",
"role": "button",
"aria-label": "test",
"alt": "1",
"data-testid": "2",
"title": "3",
"data-sentry-component": "SignUpForm",
},
"textContent": "text",
},
},
},
},
}
]
replay_actions = parse_replay_actions(default_project.id, "1", 30, events)
assert replay_actions is not None
assert replay_actions["type"] == "replay_event"
assert isinstance(replay_actions["start_time"], float)
assert replay_actions["replay_id"] == "1"
assert replay_actions["project_id"] == default_project.id
assert replay_actions["retention_days"] == 30
assert isinstance(replay_actions["payload"], list)
payload = json.loads(bytes(replay_actions["payload"]))
assert payload["type"] == "replay_actions"
assert payload["replay_id"] == "1"
assert len(payload["clicks"]) == 1
action = payload["clicks"][0]
assert action["node_id"] == 59
assert action["tag"] == "a"
assert action["id"] == "id"
assert action["class"] == ["class1", "class2"]
assert action["text"] == "text"
assert action["aria_label"] == "test"
assert action["role"] == "button"
assert action["alt"] == "1"
assert action["testid"] == "2"
assert action["title"] == "3"
assert action["component_name"] == "SignUpForm"
assert action["is_dead"] == 1
assert action["is_rage"] == 1
assert action["timestamp"] == 1
assert len(action["event_hash"]) == 36
def test_encode_as_uuid():
a = encode_as_uuid("hello,world!")
b = encode_as_uuid("hello,world!")
assert a == b
assert isinstance(uuid.UUID(a), uuid.UUID)
def test_parse_request_response_latest():
events = [
{
"type": 5,
"timestamp": 1680009712.507,
"data": {
"tag": "performanceSpan",
"payload": {
"op": "resource.fetch",
"description": "https://api2.amplitude.com/2/httpapi",
"startTimestamp": 1680009712.507,
"endTimestamp": 1680009712.671,
"data": {
"url": "https://www.sentry.io",
"method": "POST",
"statusCode": 200,
"request": {
"size": 2949,
"body": {
"api_key": "foobar",
"events": "[...]",
"options": {"min_id_length": 1},
},
},
"response": {
"size": 94,
"body": {
"code": 200,
"server_upload_time": 1680009712652,
"payload_size_bytes": 2949,
"events_ingested": 5,
},
},
},
},
},
}
]
with mock.patch("sentry.utils.metrics.distribution") as timing:
parse_replay_actions(1, "1", 30, events)
assert timing.call_args_list == [
mock.call("replays.usecases.ingest.request_body_size", 2949, unit="byte"),
mock.call("replays.usecases.ingest.response_body_size", 94, unit="byte"),
]
def test_parse_request_response_no_info():
events = [
{
"type": 5,
"timestamp": 1680009712.507,
"data": {
"tag": "performanceSpan",
"payload": {
"op": "resource.fetch",
"description": "https://api2.amplitude.com/2/httpapi",
"startTimestamp": 1680009712.507,
"endTimestamp": 1680009712.671,
"data": {
"url": "https://www.sentry.io",
"method": "POST",
"statusCode": 200,
},
},
},
},
]
parse_replay_actions(1, "1", 30, events)
# just make sure we don't raise
def test_parse_request_response_old_format_request_only():
events = [
{
"type": 5,
"timestamp": 1680009712.507,
"data": {
"tag": "performanceSpan",
"payload": {
"op": "resource.fetch",
"description": "https://api2.amplitude.com/2/httpapi",
"startTimestamp": 1680009712.507,
"endTimestamp": 1680009712.671,
"data": {
"url": "https://www.sentry.io",
"method": "POST",
"statusCode": 200,
"requestBodySize": 1002,
},
},
},
},
]
with mock.patch("sentry.utils.metrics.distribution") as timing:
parse_replay_actions(1, "1", 30, events)
assert timing.call_args_list == [
mock.call("replays.usecases.ingest.request_body_size", 1002, unit="byte"),
]
def test_parse_request_response_old_format_response_only():
events = [
{
"type": 5,
"timestamp": 1680009712.507,
"data": {
"tag": "performanceSpan",
"payload": {
"op": "resource.fetch",
"description": "https://api2.amplitude.com/2/httpapi",
"startTimestamp": 1680009712.507,
"endTimestamp": 1680009712.671,
"data": {
"method": "POST",
"statusCode": 200,
"responseBodySize": 1002,
},
},
},
},
]
with mock.patch("sentry.utils.metrics.distribution") as timing:
parse_replay_actions(1, "1", 30, events)
assert timing.call_args_list == [
mock.call("replays.usecases.ingest.response_body_size", 1002, unit="byte"),
]
def test_parse_request_response_old_format_request_and_response():
events = [
{
"type": 5,
"timestamp": 1680009712.507,
"data": {
"tag": "performanceSpan",
"payload": {
"op": "resource.xhr",
"description": "https://api2.amplitude.com/2/httpapi",
"startTimestamp": 1680009712.507,
"endTimestamp": 1680009712.671,
"data": {
"method": "POST",
"statusCode": 200,
"requestBodySize": 1002,
"responseBodySize": 8001,
},
},
},
},
]
with mock.patch("sentry.utils.metrics.distribution") as timing:
parse_replay_actions(1, "1", 30, events)
assert timing.call_args_list == [
mock.call("replays.usecases.ingest.request_body_size", 1002, unit="byte"),
mock.call("replays.usecases.ingest.response_body_size", 8001, unit="byte"),
]
def test_log_sdk_options():
events: list[dict[str, Any]] = [
{
"data": {
"payload": {
"blockAllMedia": True,
"errorSampleRate": 0,
"maskAllInputs": True,
"maskAllText": True,
"networkCaptureBodies": True,
"networkDetailHasUrls": False,
"networkRequestHasHeaders": True,
"networkResponseHasHeaders": True,
"sessionSampleRate": 1,
"useCompression": False,
"useCompressionOption": True,
},
"tag": "options",
},
"timestamp": 1680009712.507,
"type": 5,
}
]
log = events[0]["data"]["payload"].copy()
log["project_id"] = 1
log["replay_id"] = "1"
with mock.patch("sentry.replays.usecases.ingest.dom_index.logger") as logger, mock.patch(
"random.randint"
) as randint:
randint.return_value = 0
parse_replay_actions(1, "1", 30, events)
assert logger.info.call_args_list == [mock.call("SDK Options:", extra=log)]
def test_log_large_dom_mutations():
events: list[dict[str, Any]] = [
{
"type": 5,
"timestamp": 1684218178.308,
"data": {
"tag": "breadcrumb",
"payload": {
"timestamp": 1684218178.308,
"type": "default",
"category": "replay.mutations",
"data": {"count": 1738},
},
},
}
]
log = events[0]["data"]["payload"].copy()
log["project_id"] = 1
log["replay_id"] = "1"
with mock.patch("sentry.replays.usecases.ingest.dom_index.logger") as logger, mock.patch(
"random.randint"
) as randint:
randint.return_value = 0
parse_replay_actions(1, "1", 30, events)
assert logger.info.call_args_list == [mock.call("Large DOM Mutations List:", extra=log)]
def test_get_testid():
# Assert each test-id permutation is extracted.
assert _get_testid({"testId": "123"}) == "123"
assert _get_testid({"data-testid": "123"}) == "123"
assert _get_testid({"data-test-id": "123"}) == "123"
# Assert no test-id is parsed as empty string
assert _get_testid({}) == ""
# testId takes precedence.
assert _get_testid({"testId": "123", "data-testid": "456", "data-test-id": "456"}) == "123"
# data-testid takes precedence.
assert _get_testid({"data-testid": "123", "data-test-id": ""}) == "123"
# data-test-id is the fallback case.
assert _get_testid({"testId": "", "data-testid": "", "data-test-id": "456"}) == "456"
assert _get_testid({"data-testid": "", "data-test-id": "456"}) == "456"
assert _get_testid({"data-test-id": "456"}) == "456"
# Defaults to empty string.
assert _get_testid({}) == ""
def test_parse_classes():
assert _parse_classes("") == []
assert _parse_classes(" ") == []
assert _parse_classes(" a b ") == ["a", "b"]
assert _parse_classes("a ") == ["a"]
assert _parse_classes(" a") == ["a"]
def test_log_canvas_size():
event = {
"type": 3,
"data": {
"source": 9,
"id": 2440,
"type": 0,
"commands": [
{"property": "clearRect", "args": [0, 0, 1342, 60]},
{
"property": "drawImage",
"args": [
{
"rr_type": "ImageBitmap",
"args": [
{
"rr_type": "Blob",
"data": [{"rr_type": "ArrayBuffer", "base64": "..."}],
"type": "image/png",
}
],
},
0,
0,
],
},
],
},
"timestamp": 1704225903264,
}
# Valid event.
log_canvas_size(1, 1, "a", [event])
# Invalid event.
log_canvas_size(1, 1, "a", [{}])
# No events.
log_canvas_size(1, 1, "a", [])