diff --git a/examples/example.c b/examples/example.c index d8618558cc..938bbfc167 100644 --- a/examples/example.c +++ b/examples/example.c @@ -1070,6 +1070,15 @@ main(int argc, char **argv) sentry_set_trace(direct_trace_id, direct_parent_span_id); } + if (has_arg(argc, argv, "replay-context")) { + // mimics an embedder (e.g. sentry-unreal) that stages replay clips in + // `/replays/` and announces the active replay on the scope + sentry_value_t replay = sentry_value_new_object(); + sentry_value_set_by_key(replay, "replay_id", + sentry_value_new_string("deadbeefdeadbeefdeadbeefdeadbeef")); + sentry_set_context("replay", replay); + } + if (has_arg(argc, argv, "attach-after-init")) { // assuming the example / test is run directly from the cmake build // directory diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 155235d624..1c077c8a0c 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3340,8 +3340,6 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) SENTRY_DEBUG("Extracting run folder from event path"); sentry_path_t *ev_path = sentry__path_from_str(event_path); sentry_path_t *run_folder = ev_path ? sentry__path_dir(ev_path) : NULL; - if (ev_path) - sentry__path_free(ev_path); // Acquire the run directory lock file so that process_old_runs() in a // new SDK run will skip this directory while the daemon is still @@ -3369,6 +3367,7 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) if (path_len < 0 || path_len >= (int)sizeof(envelope_path)) { SENTRY_WARN("Envelope path truncated or invalid"); + sentry__path_free(ev_path); if (run_folder) { sentry__path_free(run_folder); } @@ -3472,6 +3471,7 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) if (!envelope_written) { SENTRY_WARN("Failed to write envelope"); + sentry__path_free(ev_path); if (run_folder) { sentry__path_free(run_folder); } @@ -3571,6 +3571,28 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) #endif cleanup: + // Send the staged session-replay envelope same-session, enriched from the + // crash event (`/__sentry-event`) so it shares the crash's + // tags/contexts/trace. Only flush when the crash itself was delivered: + // `cleanup` is also reached via `goto` on error paths where the crash was + // never captured, and flushing there would consume (and delete) the staged + // replay for a crash that never arrived. + if (crash_captured && options && options->transport + && sentry__session_replay_has_pending(options)) { + sentry_value_t crash_event = sentry_value_new_null(); + if (ev_path) { + size_t ev_len = 0; + char *ev_json = sentry__path_read_to_buffer(ev_path, &ev_len); + if (ev_json) { + crash_event = sentry__value_from_json(ev_json, ev_len); + sentry_free(ev_json); + } + } + sentry__session_replay_flush_pending( + options, options->transport, crash_event); + sentry_value_decref(crash_event); + } + // Send all other envelopes from run folder (logs, etc.) before cleanup if (run_folder && options && options->transport && options->run) { SENTRY_DEBUG("Checking for additional envelopes in run folder"); @@ -3613,6 +3635,7 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) sentry__path_remove_all(run_folder); sentry__path_free(run_folder); } + sentry__path_free(ev_path); // Release and clean up the lock file if (run_lock) { diff --git a/src/sentry_client_report.h b/src/sentry_client_report.h index d1e6372b51..1fabb0d258 100644 --- a/src/sentry_client_report.h +++ b/src/sentry_client_report.h @@ -30,6 +30,7 @@ typedef enum { SENTRY_DATA_CATEGORY_LOG_ITEM, SENTRY_DATA_CATEGORY_FEEDBACK, SENTRY_DATA_CATEGORY_TRACE_METRIC, + SENTRY_DATA_CATEGORY_REPLAY, SENTRY_DATA_CATEGORY_MAX } sentry_data_category_t; diff --git a/src/sentry_envelope.c b/src/sentry_envelope.c index 3f0352e2a5..908bd1381a 100644 --- a/src/sentry_envelope.c +++ b/src/sentry_envelope.c @@ -136,6 +136,8 @@ envelope_item_get_ratelimiter_category(const sentry_envelope_item_t *item) return SENTRY_RL_CATEGORY_SESSION; } else if (sentry__string_eq(ty, "transaction")) { return SENTRY_RL_CATEGORY_TRANSACTION; + } else if (sentry__string_eq(ty, "replay_video")) { + return SENTRY_RL_CATEGORY_REPLAY; } else if (sentry__string_eq(ty, "client_report")) { // internal telemetry, bypass rate limiting return -1; @@ -160,6 +162,8 @@ item_type_to_data_category(const char *ty) return SENTRY_DATA_CATEGORY_FEEDBACK; } else if (sentry__string_eq(ty, "trace_metric")) { return SENTRY_DATA_CATEGORY_TRACE_METRIC; + } else if (sentry__string_eq(ty, "replay_video")) { + return SENTRY_DATA_CATEGORY_REPLAY; } return SENTRY_DATA_CATEGORY_ERROR; } @@ -1347,6 +1351,8 @@ data_category_to_string(sentry_data_category_t category) return "feedback"; case SENTRY_DATA_CATEGORY_TRACE_METRIC: return "trace_metric"; + case SENTRY_DATA_CATEGORY_REPLAY: + return "replay"; case SENTRY_DATA_CATEGORY_MAX: default: return "unknown"; diff --git a/src/sentry_ratelimiter.c b/src/sentry_ratelimiter.c index 02687ffedf..9ba843edfe 100644 --- a/src/sentry_ratelimiter.c +++ b/src/sentry_ratelimiter.c @@ -3,7 +3,7 @@ #include "sentry_slice.h" #include "sentry_utils.h" -#define MAX_RATE_LIMITS 4 +#define MAX_RATE_LIMITS 5 #define MAX_RETRY_AFTER (24 * 60 * 60) // 24h struct sentry_rate_limiter_s { @@ -28,6 +28,7 @@ sentry__rate_limiter_new(void) rl->disabled_until[SENTRY_RL_CATEGORY_ERROR] = 0; rl->disabled_until[SENTRY_RL_CATEGORY_SESSION] = 0; rl->disabled_until[SENTRY_RL_CATEGORY_TRANSACTION] = 0; + rl->disabled_until[SENTRY_RL_CATEGORY_REPLAY] = 0; } return rl; } @@ -64,6 +65,8 @@ sentry__rate_limiter_update_from_header( } else if (sentry__slice_eqs(category, "transaction")) { rl->disabled_until[SENTRY_RL_CATEGORY_TRANSACTION] = retry_after; + } else if (sentry__slice_eqs(category, "replay")) { + rl->disabled_until[SENTRY_RL_CATEGORY_REPLAY] = retry_after; } categories = sentry__slice_advance(categories, category.len); diff --git a/src/sentry_ratelimiter.h b/src/sentry_ratelimiter.h index b7d4c55759..d466e17887 100644 --- a/src/sentry_ratelimiter.h +++ b/src/sentry_ratelimiter.h @@ -7,6 +7,7 @@ #define SENTRY_RL_CATEGORY_ERROR 1 #define SENTRY_RL_CATEGORY_SESSION 2 #define SENTRY_RL_CATEGORY_TRANSACTION 3 +#define SENTRY_RL_CATEGORY_REPLAY 4 typedef struct sentry_rate_limiter_s sentry_rate_limiter_t; diff --git a/src/sentry_session_replay.h b/src/sentry_session_replay.h index e2b21a657e..337c1be3a4 100644 --- a/src/sentry_session_replay.h +++ b/src/sentry_session_replay.h @@ -24,4 +24,29 @@ bool sentry__session_replay_capture( */ sentry_path_t *sentry__session_replay_get_path(const sentry_options_t *options); +/** + * Returns whether the embedder's `/replays/` staging directory holds + * a replay clip, so callers can skip crash-event work when nothing is staged. + */ +bool sentry__session_replay_has_pending(const sentry_options_t *options); + +/** + * Build and send the session-replay envelope for the crash described by + * `scope_source`. The replay is identified by the crash event's + * `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. + * + * `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 + * window. If it is NULL or carries no `contexts.replay.replay_id`, nothing is + * flushed. + */ +void sentry__session_replay_flush_pending(const sentry_options_t *options, + sentry_transport_t *transport, sentry_value_t scope_source); + #endif diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index 3799d56f58..9c4f566a2e 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -1,7 +1,383 @@ #include "sentry_session_replay.h" +#include "sentry_core.h" +#include "sentry_database.h" +#include "sentry_envelope.h" +#include "sentry_json.h" +#include "sentry_string.h" +#include "sentry_utils.h" +#include "sentry_value.h" + +#if defined(_MSC_VER) +# pragma warning(push) +# pragma warning(disable : 4127) // conditional expression is constant +# if defined(__clang__) // clang-cl +# pragma clang diagnostic push +# pragma clang diagnostic ignored "-Wdocumentation" +# pragma clang diagnostic ignored "-Wpre-c11-compat" +# endif +#elif defined(__clang__) +# pragma clang diagnostic push +# pragma clang diagnostic ignored "-Wstatic-in-inline" +#endif + +#include "../vendor/mpack.h" + +#if defined(_MSC_VER) +# pragma warning(pop) +# ifdef __clang__ // clang-cl +# pragma clang diagnostic pop +# endif +#elif defined(__clang__) +# pragma clang diagnostic pop +#endif + +#include + sentry_path_t * sentry__session_replay_get_path(const sentry_options_t *options) { return sentry__path_join_str(options->run->run_path, "session-replay.mp4"); } + +static sentry_path_t * +session_replay_dir(const sentry_options_t *options) +{ + if (!options->run || !options->run->run_path) { + return NULL; + } + sentry_path_t *db_dir = sentry__path_dir(options->run->run_path); + if (!db_dir) { + return NULL; + } + sentry_path_t *replays = sentry__path_join_str(db_dir, "replays"); + sentry__path_free(db_dir); + return replays; +} + +// Build the replay_event from the recorder's metadata. When `scope_source` (the +// crash event) is non-null, its scope fields and trace id are copied onto the +// replay so it shares the crash's context. `error_ids` is omitted (deprecated). +static sentry_value_t +build_replay_event(sentry_value_t meta, const char *replay_id, double start_sec, + double end_sec, int32_t segment_id, sentry_value_t scope_source) +{ + const char *replay_type + = sentry_value_as_string(sentry_value_get_by_key(meta, "replayType")); + + sentry_value_t event = sentry_value_new_object(); + sentry_value_set_by_key( + event, "type", sentry_value_new_string("replay_event")); + sentry_value_set_by_key(event, "replay_type", + sentry_value_new_string( + replay_type && replay_type[0] ? replay_type : "buffer")); + sentry_value_set_by_key( + event, "segment_id", sentry_value_new_int32(segment_id)); + sentry_value_set_by_key( + event, "replay_id", sentry_value_new_string(replay_id)); + sentry_value_set_by_key( + event, "event_id", sentry_value_new_string(replay_id)); + sentry_value_set_by_key( + event, "platform", sentry_value_new_string("native")); + sentry_value_set_by_key( + event, "timestamp", sentry_value_new_double(end_sec)); + sentry_value_set_by_key( + event, "replay_start_timestamp", sentry_value_new_double(start_sec)); + sentry_value_set_by_key(event, "urls", sentry_value_new_list()); + + if (!sentry_value_is_null(scope_source)) { + static const char *const scope_keys[] = { "tags", "contexts", "release", + "environment", "dist", "user", "sdk" }; + for (size_t i = 0; i < sizeof(scope_keys) / sizeof(scope_keys[0]); + i++) { + sentry_value_t v + = sentry_value_get_by_key(scope_source, scope_keys[i]); + if (!sentry_value_is_null(v)) { + sentry_value_incref(v); + sentry_value_set_by_key(event, scope_keys[i], v); + } + } + + sentry_value_t trace_id = sentry_value_get_by_key( + sentry_value_get_by_key( + sentry_value_get_by_key(scope_source, "contexts"), "trace"), + "trace_id"); + sentry_value_t trace_ids = sentry_value_new_list(); + if (!sentry_value_is_null(trace_id)) { + sentry_value_incref(trace_id); + sentry_value_append(trace_ids, trace_id); + } + sentry_value_set_by_key(event, "trace_ids", trace_ids); + } + return event; +} + +// Build the rrweb recording list (meta event + video event) describing the +// clip. +static sentry_value_t +build_replay_recording(sentry_value_t meta, double start_sec, + int32_t segment_id, double size_bytes, double duration_ms) +{ + const int32_t width + = sentry_value_as_int32(sentry_value_get_by_key(meta, "width")); + const int32_t height + = sentry_value_as_int32(sentry_value_get_by_key(meta, "height")); + const double ts_ms = start_sec * 1000.0; + + sentry_value_t meta_data = sentry_value_new_object(); + sentry_value_set_by_key(meta_data, "href", sentry_value_new_string("")); + sentry_value_set_by_key(meta_data, "width", sentry_value_new_int32(width)); + sentry_value_set_by_key( + meta_data, "height", sentry_value_new_int32(height)); + sentry_value_t meta_event = sentry_value_new_object(); + sentry_value_set_by_key(meta_event, "type", sentry_value_new_int32(4)); + sentry_value_set_by_key( + meta_event, "timestamp", sentry_value_new_double(ts_ms)); + sentry_value_set_by_key(meta_event, "data", meta_data); + + sentry_value_t payload = sentry_value_new_object(); + sentry_value_set_by_key( + payload, "segmentId", sentry_value_new_int32(segment_id)); + sentry_value_set_by_key( + payload, "size", sentry_value_new_double(size_bytes)); + sentry_value_set_by_key( + payload, "duration", sentry_value_new_double(duration_ms)); + sentry_value_set_by_key( + payload, "encoding", sentry_value_new_string("h264")); + sentry_value_set_by_key( + payload, "container", sentry_value_new_string("mp4")); + sentry_value_set_by_key(payload, "height", sentry_value_new_int32(height)); + sentry_value_set_by_key(payload, "width", sentry_value_new_int32(width)); + sentry_value_set_by_key(payload, "left", sentry_value_new_int32(0)); + sentry_value_set_by_key(payload, "top", sentry_value_new_int32(0)); + sentry_value_set_by_key(payload, "frameCount", + sentry_value_new_int32(sentry_value_as_int32( + sentry_value_get_by_key(meta, "frameCount")))); + sentry_value_set_by_key(payload, "frameRate", + sentry_value_new_int32( + sentry_value_as_int32(sentry_value_get_by_key(meta, "frameRate")))); + sentry_value_set_by_key( + payload, "frameRateType", sentry_value_new_string("variable")); + sentry_value_t video_data = sentry_value_new_object(); + sentry_value_set_by_key( + video_data, "tag", sentry_value_new_string("video")); + sentry_value_set_by_key(video_data, "payload", payload); + sentry_value_t video_event = sentry_value_new_object(); + sentry_value_set_by_key(video_event, "type", sentry_value_new_int32(5)); + sentry_value_set_by_key( + video_event, "timestamp", sentry_value_new_double(ts_ms)); + sentry_value_set_by_key(video_event, "data", video_data); + + sentry_value_t recording = sentry_value_new_list(); + sentry_value_append(recording, meta_event); + sentry_value_append(recording, video_event); + return recording; +} + +// Build the replay_video envelope from the parsed sidecar + its mp4. `end_sec` +// is the window end (crash time); <= 0 falls back to the sidecar's. NULL on +// failure. +static sentry_envelope_t * +build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, + const sentry_path_t *mp4_path, double end_sec, sentry_value_t scope_source) +{ + if (sentry_value_is_null(meta) || !mp4_path) { + return NULL; + } + + const char *replay_id + = sentry_value_as_string(sentry_value_get_by_key(meta, "replayId")); + if (!replay_id || !replay_id[0]) { + return NULL; + } + + size_t video_len = 0; + char *video = sentry__path_read_to_buffer(mp4_path, &video_len); + if (!video || video_len == 0) { + sentry_free(video); + return NULL; + } + + const double duration_ms + = sentry_value_as_double(sentry_value_get_by_key(meta, "durationMs")); + if (end_sec <= 0.0) { + end_sec = sentry_value_as_double( + sentry_value_get_by_key(meta, "endTimestampSec")); + } + const double start_sec = end_sec - duration_ms / 1000.0; + const int32_t segment_id + = sentry_value_as_int32(sentry_value_get_by_key(meta, "segmentId")); + + sentry_value_t event = build_replay_event( + meta, replay_id, start_sec, end_sec, segment_id, scope_source); + sentry_value_t recording = build_replay_recording( + meta, start_sec, segment_id, (double)video_len, duration_ms); + + sentry_envelope_t *envelope = NULL; + + size_t event_len = 0; + char *event_json = sentry__value_to_json(event, &event_len); + size_t rrweb_len = 0; + char *rrweb_json = sentry__value_to_json(recording, &rrweb_len); + + if (event_json && rrweb_json) { + // replay_recording = `{"segment_id":N}\n` + the rrweb array. + char hdr[48]; + int hdr_len + = snprintf(hdr, sizeof(hdr), "{\"segment_id\":%d}\n", segment_id); + + sentry_stringbuilder_t rb; + sentry__stringbuilder_init(&rb); + int rb_rv = sentry__stringbuilder_append_buf(&rb, hdr, (size_t)hdr_len); + rb_rv |= sentry__stringbuilder_append_buf(&rb, rrweb_json, rrweb_len); + size_t recording_len = sentry__stringbuilder_len(&rb); + char *recording_buf = sentry__stringbuilder_into_string(&rb); + + if (rb_rv == 0) { + mpack_writer_t writer; + char *body = NULL; + size_t body_len = 0; + mpack_writer_init_growable(&writer, &body, &body_len); + mpack_start_map(&writer, 3); + mpack_write_cstr(&writer, "replay_event"); + mpack_write_bin(&writer, event_json, (uint32_t)event_len); + mpack_write_cstr(&writer, "replay_recording"); + mpack_write_bin(&writer, recording_buf, (uint32_t)recording_len); + mpack_write_cstr(&writer, "replay_video"); + mpack_write_bin(&writer, video, (uint32_t)video_len); + mpack_finish_map(&writer); + bool body_ok = mpack_writer_destroy(&writer) == mpack_ok; + + if (body_ok && body) { + envelope = sentry__envelope_new_with_dsn(options->dsn); + if (envelope) { + sentry__envelope_set_header(envelope, "event_id", + sentry_value_new_string(replay_id)); + if (!sentry__envelope_add_from_buffer( + envelope, body, body_len, "replay_video")) { + sentry_envelope_free(envelope); + envelope = NULL; + } + } + } + sentry_free(body); + } + + sentry_free(recording_buf); + } + + sentry_free(rrweb_json); + sentry_free(event_json); + sentry_value_decref(event); + sentry_value_decref(recording); + sentry_free(video); + return envelope; +} + +// Build `/replay-`, matching the embedder's staged name. +static sentry_path_t * +replay_file_path( + const sentry_path_t *dir, const char *replay_id, const char *ext) +{ + size_t len = strlen("replay-") + strlen(replay_id) + strlen(ext); + char *name = sentry_malloc(len + 1); + if (!name) { + return NULL; + } + snprintf(name, len + 1, "replay-%s%s", replay_id, ext); + sentry_path_t *path = sentry__path_join_str(dir, name); + sentry_free(name); + return path; +} + +bool +sentry__session_replay_has_pending(const sentry_options_t *options) +{ + sentry_path_t *dir = session_replay_dir(options); + if (!dir) { + return false; + } + + bool pending = false; + sentry_pathiter_t *iter = sentry__path_iter_directory(dir); + if (iter) { + const sentry_path_t *entry; + while ((entry = sentry__pathiter_next(iter)) != NULL) { + if (sentry__path_ends_with(entry, ".mp4")) { + pending = true; + break; + } + } + sentry__pathiter_free(iter); + } + sentry__path_free(dir); + return pending; +} + +void +sentry__session_replay_flush_pending(const sentry_options_t *options, + sentry_transport_t *transport, sentry_value_t scope_source) +{ + if (!options || !transport) { + return; + } + + const char *replay_id = NULL; + if (!sentry_value_is_null(scope_source)) { + sentry_value_t replay_ctx = sentry_value_get_by_key( + sentry_value_get_by_key(scope_source, "contexts"), "replay"); + const char *rid = sentry_value_as_string( + sentry_value_get_by_key(replay_ctx, "replay_id")); + if (rid && rid[0]) { + replay_id = rid; + } + } + if (!replay_id) { + return; + } + + sentry_path_t *dir = session_replay_dir(options); + if (!dir) { + return; + } + + sentry_path_t *sidecar_path = replay_file_path(dir, replay_id, ".json"); + sentry_path_t *mp4_path = replay_file_path(dir, replay_id, ".mp4"); + sentry__path_free(dir); + if (!sidecar_path || !mp4_path) { + sentry__path_free(sidecar_path); + sentry__path_free(mp4_path); + return; + } + + size_t json_len = 0; + char *json = sentry__path_read_to_buffer(sidecar_path, &json_len); + if (json) { + sentry_value_t meta = sentry__value_from_json(json, json_len); + sentry_free(json); + + // build_replay_envelope falls back to the sidecar's end timestamp if 0. + double end_sec = 0.0; + const char *ts = sentry_value_as_string( + sentry_value_get_by_key(scope_source, "timestamp")); + if (ts && ts[0]) { + uint64_t usec = sentry__iso8601_to_usec(ts); + if (usec) { + end_sec = (double)usec / 1000000.0; + } + } + + sentry_envelope_t *envelope = build_replay_envelope( + options, meta, mp4_path, end_sec, scope_source); + if (envelope) { + sentry__capture_envelope(transport, envelope, options); + } + sentry_value_decref(meta); + + sentry__path_remove(mp4_path); + sentry__path_remove(sidecar_path); + } + + sentry__path_free(mp4_path); + sentry__path_free(sidecar_path); +} diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 70884ec973..1348580c74 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -5,11 +5,14 @@ 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 ( @@ -1139,3 +1142,130 @@ def test_native_restart_on_crash(cmake, httpserver): for req in httpserver.log: envelope = Envelope.deserialize(req[0].get_data()) 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.""" + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) + + replays, video = stage_replay(tmp_path) + + # crash envelope + replay envelope + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run_crash( + tmp_path, + "sentry_example", + ["log", "stdout", "replay-context", "crash"] + SANITIZER_ARGS, + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + wait_for_daemon=True, + ) + 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 + + # the staged replay is consumed exactly once + assert not (replays / f"replay-{REPLAY_ID}.mp4").exists() + assert not (replays / f"replay-{REPLAY_ID}.json").exists() + + +def test_native_replay_orphan_not_flushed(cmake, httpserver): + """A staged replay that the crash event does not reference is neither + sent nor consumed.""" + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) + + replays, _ = stage_replay(tmp_path) + + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run_crash( + tmp_path, + "sentry_example", + ["log", "stdout", "crash"] + SANITIZER_ARGS, + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + wait_for_daemon=True, + ) + assert waiting.result + + for req, _ in httpserver.log: + assert not is_replay_envelope(Envelope.deserialize(req.get_data())) + assert (replays / f"replay-{REPLAY_ID}.mp4").exists() + assert (replays / f"replay-{REPLAY_ID}.json").exists()