From 6257ac20f8b927f6a5fef0a6c84b130211aaca77 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 6 Jul 2026 13:37:48 +0300 Subject: [PATCH 1/2] Add session replay envelope construction support for crashpad/breakpad/inproc --- src/backends/sentry_backend_breakpad.cpp | 14 ++++++++++++++ src/backends/sentry_backend_crashpad.cpp | 15 +++++++++++++++ src/backends/sentry_backend_inproc.c | 14 ++++++++++++++ src/sentry_session_replay.h | 11 +++++++---- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/backends/sentry_backend_breakpad.cpp b/src/backends/sentry_backend_breakpad.cpp index 730db01563..9d397b0428 100644 --- a/src/backends/sentry_backend_breakpad.cpp +++ b/src/backends/sentry_backend_breakpad.cpp @@ -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 diff --git a/src/backends/sentry_backend_crashpad.cpp b/src/backends/sentry_backend_crashpad.cpp index bdfd50a9c7..e6a38b479f 100644 --- a/src/backends/sentry_backend_crashpad.cpp +++ b/src/backends/sentry_backend_crashpad.cpp @@ -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" @@ -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); @@ -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"); } diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index c71a8a7756..985115f400 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -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 diff --git a/src/sentry_session_replay.h b/src/sentry_session_replay.h index 337c1be3a4..666e58a60e 100644 --- a/src/sentry_session_replay.h +++ b/src/sentry_session_replay.h @@ -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 * `/replays/replay-.{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 (`/__sentry-event`); its scope fields * and trace id are copied onto the replay, and its timestamp ends the replay From 128338bc8210b0bf19feefb56878e6c1b250e316 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 6 Jul 2026 13:39:54 +0300 Subject: [PATCH 2/2] Add/refactor integration tests for replay capturing --- tests/__init__.py | 33 +++++++++++ tests/assertions.py | 44 ++++++++++++++- tests/test_integration_crashpad.py | 44 +++++++++++++++ tests/test_integration_http.py | 58 +++++++++++++++++++ tests/test_integration_native.py | 89 ++++-------------------------- 5 files changed, 189 insertions(+), 79 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 6177b0f8f0..93cfa3928c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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 @@ -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 `/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]) diff --git a/tests/assertions.py b/tests/assertions.py index adacce9af2..7f55aed35b 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -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+)[-.]?(.*)") @@ -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 diff --git a/tests/test_integration_crashpad.py b/tests/test_integration_crashpad.py index b73f5e73e7..9a804f1e75 100644 --- a/tests/test_integration_crashpad.py +++ b/tests/test_integration_crashpad.py @@ -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 ( @@ -29,6 +32,7 @@ assert_crashpad_upload, assert_meta, assert_minidump, + assert_replay_envelope, assert_session, assert_gzip_file_header, assert_logs, @@ -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 diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index b42dec0d5d..2aab221318 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -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 ( @@ -23,6 +27,7 @@ assert_event, assert_exception, assert_inproc_crash, + assert_replay_envelope, assert_session, assert_user_feedback, assert_user_report, @@ -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"}) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index e85e898bfc..ae00b5b2cc 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -5,28 +5,30 @@ multi-thread capture, and FPU/SIMD register capture on all platforms. """ -import json import os import subprocess import sys import time import struct -import msgpack import pytest from . import ( is_feedback_envelope, + is_replay_envelope, make_dsn, run, + stage_replay, Envelope, split_log_request_cond, + REPLAY_ID, ) from .assertions import ( assert_breadcrumb, assert_debug_meta_images_do_not_overlap, assert_meta, assert_native_crash, + assert_replay_envelope, assert_session, is_valid_hex, wait_for_file, @@ -1148,40 +1150,6 @@ def test_native_restart_on_crash(cmake, httpserver): assert_native_crash(envelope) -# must match the fixed id set by the example's "replay-context" arg -REPLAY_ID = "deadbeefdeadbeefdeadbeefdeadbeef" - - -def stage_replay(tmp_path, replay_id=REPLAY_ID): - """Stage a dummy replay clip + sidecar in `/replays/` the way an - embedder (e.g. sentry-unreal) would. The daemon 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 is_replay_envelope(envelope): - return any(item.headers.get("type") == "replay_video" for item in envelope.items) - - def test_native_replay_envelope(cmake, httpserver): """A staged replay referenced by the crash event's `contexts.replay` is sent as a correctly structured `replay_video` envelope by the daemon.""" @@ -1203,47 +1171,12 @@ def test_native_replay_envelope(cmake, httpserver): ) assert waiting.result - envelopes = [Envelope.deserialize(req.get_data()) for req, _ in httpserver.log] - replay_envelopes = [e for e in envelopes if is_replay_envelope(e)] - assert len(replay_envelopes) == 1, "expected exactly one replay_video envelope" - envelope = replay_envelopes[0] - 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 + replay_requests = [ + req for req, _ in httpserver.log if is_replay_envelope(req.get_data()) + ] + assert len(replay_requests) == 1, "expected exactly one replay_video envelope" + envelope = Envelope.deserialize(replay_requests[0].get_data()) + assert_replay_envelope(envelope, video) # the staged replay is consumed exactly once assert not (replays / f"replay-{REPLAY_ID}.mp4").exists() @@ -1270,6 +1203,6 @@ def test_native_replay_orphan_not_flushed(cmake, httpserver): assert waiting.result for req, _ in httpserver.log: - assert not is_replay_envelope(Envelope.deserialize(req.get_data())) + assert not is_replay_envelope(req.get_data()) assert (replays / f"replay-{REPLAY_ID}.mp4").exists() assert (replays / f"replay-{REPLAY_ID}.json").exists()