Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions src/backends/sentry_backend_breakpad.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,20 @@ breakpad_backend_callback(const google_breakpad::MinidumpDescriptor &descriptor,
sentry__attachment_free(replay);
}

if (envelope && sentry__session_replay_has_pending(options)) {
sentry_value_t crash_event
= sentry_envelope_get_event(envelope);
sentry_transport_t *replay_transport
= sentry_new_disk_transport(options->run);
if (replay_transport) {
sentry__session_replay_flush_pending(
options, replay_transport, crash_event);
sentry__transport_dump_queue(
replay_transport, options->run);
sentry_transport_free(replay_transport);
}
}

if (!sentry__launch_external_crash_reporter(options, envelope)) {
// capture the envelopes with the disk transport
sentry_transport_t *disk_transport
Expand Down
15 changes: 15 additions & 0 deletions src/backends/sentry_backend_crashpad.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extern "C" {
#endif
#include "sentry_path.h"
#include "sentry_screenshot.h"
#include "sentry_session_replay.h"
#include "sentry_sync.h"
#include "sentry_transport.h"
#include "sentry_value.h"
Expand Down Expand Up @@ -385,6 +386,7 @@ crashpad_handler(int signum, siginfo_t *info, ucontext_t *user_context)
should_dump = !sentry_value_is_null(crash_event);

if (should_dump) {
sentry_value_incref(crash_event);
flush_scope_from_handler(options, crash_event);
sentry__write_crash_marker(options);

Expand All @@ -402,6 +404,19 @@ crashpad_handler(int signum, siginfo_t *info, ucontext_t *user_context)
sentry__transport_dump_queue(disk_transport, options->run);
sentry_transport_free(disk_transport);
}

if (sentry__session_replay_has_pending(options)) {
sentry_transport_t *replay_transport
= sentry_new_disk_transport(options->run);
if (replay_transport) {
sentry__session_replay_flush_pending(
options, replay_transport, crash_event);
sentry__transport_dump_queue(
replay_transport, options->run);
sentry_transport_free(replay_transport);
}
}
sentry_value_decref(crash_event);
} else {
SENTRY_DEBUG("event was discarded");
}
Expand Down
14 changes: 14 additions & 0 deletions src/backends/sentry_backend_inproc.c
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,20 @@ process_ucontext_deferred(const sentry_ucontext_t *uctx,
sentry__attachment_free(replay);
}

if (envelope && sentry__session_replay_has_pending(options)) {
sentry_value_t crash_event
= sentry_envelope_get_event(envelope);
sentry_transport_t *replay_transport
= sentry_new_disk_transport(options->run);
if (replay_transport) {
sentry__session_replay_flush_pending(
options, replay_transport, crash_event);
sentry__transport_dump_queue(
replay_transport, options->run);
sentry_transport_free(replay_transport);
}
}

if (!sentry__launch_external_crash_reporter(options, envelope)) {
// capture the envelopes with the disk transport
sentry_transport_t *disk_transport
Expand Down
11 changes: 7 additions & 4 deletions src/sentry_session_replay.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ bool sentry__session_replay_has_pending(const sentry_options_t *options);
* `contexts.replay.replay_id` and staged by the embedder as
* `<database>/replays/replay-<id>.{json,mp4}`.
*
* Native-daemon-only: called out-of-process by the crash
* daemon, so it runs only on a crash and delivers same-session. The sidecar and
* mp4 are consumed once and removed after the flush attempt, regardless of
* whether the envelope was built or sent, so a failed flush is not retried.
* Runs only on a crash. The native crash daemon calls it out-of-process with
* the live transport, delivering the replay same-session. The crashpad,
* breakpad and inproc backends call it from their crash handlers with a
* run-dir disk transport, so the replay envelope is persisted next to the
* crash envelope and delivered on the next launch. The sidecar and mp4 are
* consumed once and removed after the flush attempt, regardless of whether
* the envelope was built or sent, so a failed flush is not retried.
*
* `scope_source` is the crash event (`<run>/__sentry-event`); its scope fields
* and trace id are copied onto the replay, and its timestamp ends the replay
Expand Down
33 changes: 33 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ def adb(*args, **kwargs):

SENTRY_VERSION = "0.15.2"

# must match the fixed id set by the example's "replay-context" arg
REPLAY_ID = "deadbeefdeadbeefdeadbeefdeadbeef"

from .assertions import wait_for_daemon as _wait_for_daemon


Expand Down Expand Up @@ -76,6 +79,36 @@ def is_feedback_envelope(data):
return b'"type":"feedback"' in data


def is_replay_envelope(data):
return b'"type":"replay_video"' in data


def stage_replay(tmp_path, replay_id=REPLAY_ID):
"""Stage a dummy replay clip + sidecar in `<database>/replays/` the way an
embedder (e.g. sentry-unreal) would. The SDK treats the mp4 as opaque
bytes, so no valid video container is needed."""
replays = tmp_path / ".sentry-native" / "replays"
replays.mkdir(parents=True)
video = b"\x00\x00\x00\x1cftyp-dummy-mp4-payload-" * 64
(replays / f"replay-{replay_id}.mp4").write_bytes(video)
sidecar = {
"replayId": replay_id,
"videoFilename": f"replay-{replay_id}.mp4",
"replayType": "buffer",
"segmentId": 0,
"startTimestampSec": 1782892813.261,
"endTimestampSec": 1782892828.338,
"width": 3864,
"height": 2100,
"durationMs": 15077,
"sizeBytes": len(video),
"frameCount": 58,
"frameRate": 30,
}
(replays / f"replay-{replay_id}.json").write_text(json.dumps(sidecar))
return replays, video


def split_log_request_cond(httpserver_log, cond):
return (
(httpserver_log[0][0], httpserver_log[1][0])
Expand Down
44 changes: 43 additions & 1 deletion tests/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import msgpack

from . import SENTRY_VERSION
from . import REPLAY_ID, SENTRY_VERSION
from .conditions import is_android, is_asan, is_tsan

VERSION_RE = re.compile(r"(\d+\.\d+\.\d+)[-.]?(.*)")
Expand Down Expand Up @@ -744,3 +744,45 @@ def stdout_text():
f"Collected output:\n{stdout_text()}"
)
return stdout_text()


def assert_replay_envelope(envelope, video, replay_id=REPLAY_ID):
"""Validate a `replay_video` envelope built from the fixture staged by
`tests.stage_replay` (the sidecar values below match that fixture)."""
assert envelope.headers["event_id"] == replay_id

(item,) = envelope.items
assert item.headers["type"] == "replay_video"
body = msgpack.unpackb(item.payload.bytes)
assert set(body) == {"replay_event", "replay_recording", "replay_video"}

event = json.loads(body["replay_event"])
assert event["type"] == "replay_event"
assert event["replay_id"] == replay_id
assert event["event_id"] == replay_id
assert event["segment_id"] == 0
assert event["replay_type"] == "buffer"
assert event["platform"] == "native"

# scope fields are copied over from the crash event
assert event["tags"]["expected-tag"] == "some value"
assert isinstance(event["trace_ids"], list)

header, _, rrweb = body["replay_recording"].partition(b"\n")
assert json.loads(header) == {"segment_id": 0}
meta_event, video_event = json.loads(rrweb)
assert meta_event["type"] == 4
assert meta_event["data"]["width"] == 3864
assert meta_event["data"]["height"] == 2100
assert video_event["type"] == 5
assert video_event["data"]["tag"] == "video"
payload = video_event["data"]["payload"]
assert payload["segmentId"] == 0
assert payload["size"] == len(video)
assert payload["duration"] == 15077
assert payload["encoding"] == "h264"
assert payload["container"] == "mp4"
assert payload["frameCount"] == 58
assert payload["frameRate"] == 30

assert body["replay_video"] == video
44 changes: 44 additions & 0 deletions tests/test_integration_crashpad.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
from . import (
make_dsn,
run,
stage_replay,
Envelope,
split_log_request_cond,
extract_request,
is_session_envelope,
is_logs_envelope,
is_feedback_envelope,
is_replay_envelope,
REPLAY_ID,
)
from .conditions import has_crashpad, has_oom
from .proxy import (
Expand All @@ -29,6 +32,7 @@
assert_crashpad_upload,
assert_meta,
assert_minidump,
assert_replay_envelope,
assert_session,
assert_gzip_file_header,
assert_logs,
Expand Down Expand Up @@ -257,6 +261,46 @@ def test_crashpad_reinstall(cmake, httpserver):
assert len(httpserver.log) == 1


@pytest.mark.skipif(
sys.platform == "darwin",
reason="crashpad doesn't provide SetFirstChanceExceptionHandler on macOS",
)
def test_crashpad_replay_envelope(cmake, httpserver):
"""A staged replay referenced by the crash event's `contexts.replay` is
consumed by the first-chance handler at crash time, persisted to the run
folder as its own `replay_video` envelope, and sent on the next launch,
while the minidump uploads same-session."""
tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "crashpad"})

replays, video = stage_replay(tmp_path)

env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver))
httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data("OK")
httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK")

with httpserver.wait(timeout=10) as waiting:
run(
tmp_path,
"sentry_example",
["log", "replay-context", "crash"],
expect_failure=True,
env=env,
)
assert waiting.result

# the staged replay is consumed at crash time by the first-chance handler
assert not (replays / f"replay-{REPLAY_ID}.mp4").exists()
assert not (replays / f"replay-{REPLAY_ID}.json").exists()

run(tmp_path, "sentry_example", ["log", "no-setup"], env=env)

# minidump upload + replay envelope
assert len(httpserver.log) == 2
replay_request, _ = extract_request(httpserver.log, is_replay_envelope)
assert replay_request is not None, "expected a replay_video envelope"
assert_replay_envelope(Envelope.deserialize(replay_request.get_data()), video)


import psutil
import time

Expand Down
58 changes: 58 additions & 0 deletions tests/test_integration_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
from . import (
make_dsn,
run,
stage_replay,
Envelope,
split_log_request_cond,
extract_request,
is_feedback_envelope,
is_replay_envelope,
REPLAY_ID,
SENTRY_VERSION,
)
from .assertions import (
Expand All @@ -23,6 +27,7 @@
assert_event,
assert_exception,
assert_inproc_crash,
assert_replay_envelope,
assert_session,
assert_user_feedback,
assert_user_report,
Expand Down Expand Up @@ -600,6 +605,59 @@ def test_breakpad_crash_http(cmake, httpserver, build_args):
assert_minidump(envelope)


@pytest.mark.parametrize(
"backend",
[
"inproc",
pytest.param(
"breakpad",
marks=pytest.mark.skipif(
not has_breakpad or is_qemu, reason="test needs breakpad backend"
),
),
],
)
def test_crash_replay_envelope_http(cmake, httpserver, backend):
"""A staged replay referenced by the crash event's `contexts.replay` is
consumed at crash time, persisted to the run folder as its own
`replay_video` envelope, and sent on the next launch."""
tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": backend})

replays, video = stage_replay(tmp_path)

httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK")
env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver))

run(
tmp_path,
"sentry_example",
["log", "replay-context", "crash"],
expect_failure=True,
env=env,
)

# the staged replay is consumed at crash time
assert not (replays / f"replay-{REPLAY_ID}.mp4").exists()
assert not (replays / f"replay-{REPLAY_ID}.json").exists()

run(
tmp_path,
"sentry_example",
["log", "no-setup"],
env=env,
)

# crash envelope + replay envelope
assert len(httpserver.log) == 2
replay_request, others = extract_request(httpserver.log, is_replay_envelope)
assert replay_request is not None, "expected a replay_video envelope"
assert_replay_envelope(Envelope.deserialize(replay_request.get_data()), video)

crash_envelope = Envelope.deserialize(others[0][0].get_data())
event = crash_envelope.get_event()
assert event["contexts"]["replay"]["replay_id"] == REPLAY_ID


@pytest.mark.skipif(not has_breakpad or is_qemu, reason="test needs breakpad backend")
def test_breakpad_reinstall(cmake, httpserver):
tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "breakpad"})
Expand Down
Loading
Loading