From b4378532a4d2f4cabd6ba72cbc0717530356f2e4 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Thu, 18 Jun 2026 14:36:22 +0300 Subject: [PATCH 01/18] Add session replay capturing --- include/sentry.h | 11 ++ src/session_replay/sentry_session_replay.c | 137 +++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/include/sentry.h b/include/sentry.h index 28c3cde0ce..543b5e7d11 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -1699,6 +1699,17 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_attach_session_replay( SENTRY_EXPERIMENTAL_API void sentry_options_set_session_replay_duration( sentry_options_t *opts, uint32_t duration_ms); +/** + * Captures a Session Replay for a caller-recorded video. + * + * The caller is responsible for building the inputs. + * + * See https://develop.sentry.dev/sdk/telemetry/replays/ for the payload format. + */ +SENTRY_EXPERIMENTAL_API void sentry_capture_session_replay( + const char *video_path, sentry_value_t replay_event, + sentry_value_t recording); + /** * Sets the path to the crashpad handler if the crashpad backend is used. * diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index 3799d56f58..a04f1ff4fd 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -1,7 +1,144 @@ #include "sentry_session_replay.h" +#include "sentry_core.h" +#include "sentry_database.h" +#include "sentry_envelope.h" +#include "sentry_json.h" +#include "sentry_scope.h" +#include "sentry_string.h" +#include "sentry_value.h" + +#include "../vendor/mpack.h" + +#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"); } + +void +sentry_capture_session_replay( + const char *video_path, sentry_value_t replay_event, sentry_value_t recording) +{ + if (!video_path) { + sentry_value_decref(replay_event); + sentry_value_decref(recording); + return; + } + + // Read the video the embedder recorded. + sentry_path_t *vpath = sentry__path_from_str(video_path); + size_t video_len = 0; + char *video = vpath ? sentry__path_read_to_buffer(vpath, &video_len) : NULL; + sentry__path_free(vpath); + if (!video || video_len == 0) { + sentry_free(video); + sentry_value_decref(replay_event); + sentry_value_decref(recording); + return; + } + + SENTRY_WITH_OPTIONS (options) { + // The embedder owns the replay id; it lives on the event it built. + char *replay_id = NULL; + const char *rid = sentry_value_as_string( + sentry_value_get_by_key(replay_event, "replay_id")); + if (rid && rid[0]) { + replay_id = sentry__string_clone(rid); + } + if (!replay_id) { + break; // nothing to key the envelope on + } + + // Enrich the embedder-built event from the live scope: tags, contexts, + // user, release/environment, os/device/sdk, and contexts.trace. + SENTRY_WITH_SCOPE (scope) { + sentry__scope_apply_to_event( + scope, options, replay_event, SENTRY_SCOPE_NONE); + // TODO(phase 2): map scope->breadcrumbs (and logs) into rrweb + // breadcrumb events and append them to `recording` here. + } + + // trace_ids <- contexts.trace.trace_id (added by scope apply) + sentry_value_t trace_id = sentry_value_get_by_key( + sentry_value_get_by_key( + sentry_value_get_by_key(replay_event, "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(replay_event, "trace_ids", trace_ids); + + // Serialize the event and the embedder-built rrweb recording list. + size_t event_len = 0; + char *event_json = sentry__value_to_json(replay_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. + const int32_t segment_id = sentry_value_as_int32( + sentry_value_get_by_key(replay_event, "segment_id")); + char hdr[48]; + int hdr_len + = snprintf(hdr, sizeof(hdr), "{\"segment_id\":%d}\n", segment_id); + + sentry_stringbuilder_t rb; + sentry__stringbuilder_init(&rb); + sentry__stringbuilder_append_buf(&rb, hdr, (size_t)hdr_len); + 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); + + // replay_video item body: msgpack map of three raw blobs, framed with + // the vendored mpack writer. + 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; + + sentry_free(recording_buf); + + if (body_ok && body) { + sentry_envelope_t *envelope = sentry__envelope_new(); + if (envelope) { + // Relay keys the replay on the envelope event_id. + sentry__envelope_set_header(envelope, "event_id", + sentry_value_new_string(replay_id)); + const char *dsn = sentry_options_get_dsn(options); + if (dsn && dsn[0]) { + sentry__envelope_set_header( + envelope, "dsn", sentry_value_new_string(dsn)); + } + sentry__envelope_add_from_buffer( + envelope, body, body_len, "replay_video"); + if (options->run) { + sentry__run_write_envelope(options->run, envelope); + } + sentry_envelope_free(envelope); + } + } + sentry_free(body); + } + sentry_free(rrweb_json); + sentry_free(event_json); + sentry_free(replay_id); + } + + sentry_free(video); + sentry_value_decref(replay_event); + sentry_value_decref(recording); +} From 205b288cd0a4d3e70ce152af145d02e529bb59e3 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 09:27:29 +0300 Subject: [PATCH 02/18] Handle replay envelope upload internally without public interface --- include/sentry.h | 11 - src/backends/native/sentry_crash_daemon.c | 8 + src/sentry_core.c | 13 + src/sentry_session_replay.h | 17 + src/session_replay/sentry_session_replay.c | 419 +++++++++++++++------ 5 files changed, 350 insertions(+), 118 deletions(-) diff --git a/include/sentry.h b/include/sentry.h index 543b5e7d11..28c3cde0ce 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -1699,17 +1699,6 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_attach_session_replay( SENTRY_EXPERIMENTAL_API void sentry_options_set_session_replay_duration( sentry_options_t *opts, uint32_t duration_ms); -/** - * Captures a Session Replay for a caller-recorded video. - * - * The caller is responsible for building the inputs. - * - * See https://develop.sentry.dev/sdk/telemetry/replays/ for the payload format. - */ -SENTRY_EXPERIMENTAL_API void sentry_capture_session_replay( - const char *video_path, sentry_value_t replay_event, - sentry_value_t recording); - /** * Sets the path to the crashpad handler if the crashpad backend is used. * diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 155235d624..25c1892ff5 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3571,6 +3571,14 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) #endif cleanup: + // Build and send any session-replay envelope the embedder staged in + // `/replays/`, out-of-process and same-session. Sources are + // deleted on send so the next launch does not resend them. `end == 0` + // makes it read the crash time from the `last_crash` marker. + if (options && options->transport) { + sentry__session_replay_flush_pending(options, options->transport, 0.0); + } + // 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"); diff --git a/src/sentry_core.c b/src/sentry_core.c index 6fdc4116dd..171c94b271 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -10,6 +10,7 @@ #include "sentry_client_report.h" #include "sentry_core.h" #include "sentry_database.h" +#include "sentry_session_replay.h" #include "sentry_envelope.h" #include "sentry_hint.h" #include "sentry_logs.h" @@ -248,6 +249,18 @@ sentry_init(sentry_options_t *options) // and handle remaining sessions. SENTRY_DEBUG("processing and pruning old runs"); sentry__process_old_runs(options, last_crash); + + // Build and send any session-replay envelope the embedder staged in + // `/replays/`. Gating is by file presence: the embedder removes the + // staged files on a clean shutdown, so anything left here belongs to a session + // that terminated abnormally (a crash, including the WER/stack-overflow path). + // This runs in the healthy newly-started process for the crashpad/breakpad/ + // inproc backends; the daemon backend already handled its own out-of-process, + // same-session. Passing 0 lets it read the crash time from the `last_crash` + // marker, falling back to the sidecar's own end timestamp. + if (options->transport) { + sentry__session_replay_flush_pending(options, options->transport, 0.0); + } if (backend && backend->prune_database_func) { backend->prune_database_func(backend); } diff --git a/src/sentry_session_replay.h b/src/sentry_session_replay.h index e2b21a657e..be15174de9 100644 --- a/src/sentry_session_replay.h +++ b/src/sentry_session_replay.h @@ -24,4 +24,21 @@ bool sentry__session_replay_capture( */ sentry_path_t *sentry__session_replay_get_path(const sentry_options_t *options); +/** + * Build and send any pending session-replay envelopes staged by an embedder in + * `/replays/` (a `replay-.json` metadata sidecar next to its mp4). + * + * For each pending replay this parses the sidecar, reads the mp4, constructs the + * `replay_video` envelope and hands it to `transport`, then deletes the sources + * so it is not re-sent. `end_timestamp_sec` is the authoritative end-of-window + * (crash) time; when <= 0 the `/last_crash` marker is consulted, and + * failing that the sidecar's own end timestamp is used. + * + * Called out-of-process by the daemon at crash time (same-session delivery) and + * at `sentry_init` for the other backends (next-launch delivery, gated on a + * crash having occurred). + */ +void sentry__session_replay_flush_pending(const sentry_options_t *options, + sentry_transport_t *transport, double end_timestamp_sec); + #endif diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index a04f1ff4fd..fb4c3ff921 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -6,6 +6,7 @@ #include "sentry_json.h" #include "sentry_scope.h" #include "sentry_string.h" +#include "sentry_utils.h" #include "sentry_value.h" #include "../vendor/mpack.h" @@ -18,127 +19,331 @@ sentry__session_replay_get_path(const sentry_options_t *options) return sentry__path_join_str(options->run->run_path, "session-replay.mp4"); } -void -sentry_capture_session_replay( - const char *video_path, sentry_value_t replay_event, sentry_value_t recording) +// Resolve `/replays`. The daemon only populates `options->run`, not +// `options->database_path`, so derive the database dir from the run path's +// parent (works for both the daemon and a regular init). Caller owns the path. +static sentry_path_t * +session_replay_dir(const sentry_options_t *options) { - if (!video_path) { - sentry_value_decref(replay_event); - sentry_value_decref(recording); - return; + sentry_path_t *db_dir = NULL; + if (options->database_path) { + db_dir = sentry__path_clone(options->database_path); + } else if (options->run && options->run->run_path) { + 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; +} + +// Read the `/last_crash` marker (ISO8601, written by the in-process +// crash handlers) and return the crash time in seconds, or 0 if unavailable. +static double +session_replay_marker_time_sec(const sentry_options_t *options) +{ + sentry_path_t *db_dir = NULL; + if (options->database_path) { + db_dir = sentry__path_clone(options->database_path); + } else if (options->run && options->run->run_path) { + db_dir = sentry__path_dir(options->run->run_path); + } + if (!db_dir) { + return 0.0; + } + sentry_path_t *marker = sentry__path_join_str(db_dir, "last_crash"); + sentry__path_free(db_dir); + if (!marker) { + return 0.0; + } + + size_t len = 0; + char *buf = sentry__path_read_to_buffer(marker, &len); + sentry__path_free(marker); + if (!buf || len == 0) { + sentry_free(buf); + return 0.0; + } + + char iso[40]; + if (len >= sizeof(iso)) { + len = sizeof(iso) - 1; + } + memcpy(iso, buf, len); + iso[len] = '\0'; + sentry_free(buf); + + uint64_t usec = sentry__iso8601_to_usec(iso); + return usec ? (double)usec / 1000000.0 : 0.0; +} + +static int32_t +meta_int32(sentry_value_t meta, const char *key) +{ + return sentry_value_as_int32(sentry_value_get_by_key(meta, key)); +} + +static double +meta_double(sentry_value_t meta, const char *key) +{ + return sentry_value_as_double(sentry_value_get_by_key(meta, key)); +} + +// Build the replay_event payload from the recorder's metadata. The replay is +// associated with the error via the `replay` context on the event, so no +// `error_ids`/`trace_ids` are needed here (the former is 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) +{ + const char *replay_type + = sentry_value_as_string(sentry_value_get_by_key(meta, "replay_type")); + + 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()); + 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 = meta_int32(meta, "width"); + const int32_t height = meta_int32(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(meta_int32(meta, "frame_count"))); + sentry_value_set_by_key(payload, "frameRate", + sentry_value_new_int32(meta_int32(meta, "frame_rate"))); + 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 a parsed metadata sidecar + its mp4. +// `end_sec` is the authoritative end-of-window (the crash time); when <= 0 the +// sidecar's own end timestamp is used. Returns 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) +{ + if (sentry_value_is_null(meta) || !mp4_path) { + return NULL; + } + + const char *replay_id + = sentry_value_as_string(sentry_value_get_by_key(meta, "replay_id")); + if (!replay_id || !replay_id[0]) { + return NULL; } - // Read the video the embedder recorded. - sentry_path_t *vpath = sentry__path_from_str(video_path); size_t video_len = 0; - char *video = vpath ? sentry__path_read_to_buffer(vpath, &video_len) : NULL; - sentry__path_free(vpath); + char *video = sentry__path_read_to_buffer(mp4_path, &video_len); if (!video || video_len == 0) { sentry_free(video); - sentry_value_decref(replay_event); - sentry_value_decref(recording); - return; + return NULL; } - SENTRY_WITH_OPTIONS (options) { - // The embedder owns the replay id; it lives on the event it built. - char *replay_id = NULL; - const char *rid = sentry_value_as_string( - sentry_value_get_by_key(replay_event, "replay_id")); - if (rid && rid[0]) { - replay_id = sentry__string_clone(rid); - } - if (!replay_id) { - break; // nothing to key the envelope on - } + const double duration_ms = meta_double(meta, "duration_ms"); + if (end_sec <= 0.0) { + end_sec = meta_double(meta, "end_timestamp_sec"); + } + const double start_sec = end_sec - duration_ms / 1000.0; + const int32_t segment_id = meta_int32(meta, "segment_id"); - // Enrich the embedder-built event from the live scope: tags, contexts, - // user, release/environment, os/device/sdk, and contexts.trace. - SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event( - scope, options, replay_event, SENTRY_SCOPE_NONE); - // TODO(phase 2): map scope->breadcrumbs (and logs) into rrweb - // breadcrumb events and append them to `recording` here. - } + sentry_value_t event + = build_replay_event(meta, replay_id, start_sec, end_sec, segment_id); + sentry_value_t recording = build_replay_recording( + meta, start_sec, segment_id, (double)video_len, duration_ms); - // trace_ids <- contexts.trace.trace_id (added by scope apply) - sentry_value_t trace_id = sentry_value_get_by_key( - sentry_value_get_by_key( - sentry_value_get_by_key(replay_event, "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(replay_event, "trace_ids", trace_ids); - - // Serialize the event and the embedder-built rrweb recording list. - size_t event_len = 0; - char *event_json = sentry__value_to_json(replay_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. - const int32_t segment_id = sentry_value_as_int32( - sentry_value_get_by_key(replay_event, "segment_id")); - char hdr[48]; - int hdr_len - = snprintf(hdr, sizeof(hdr), "{\"segment_id\":%d}\n", segment_id); - - sentry_stringbuilder_t rb; - sentry__stringbuilder_init(&rb); - sentry__stringbuilder_append_buf(&rb, hdr, (size_t)hdr_len); - 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); - - // replay_video item body: msgpack map of three raw blobs, framed with - // the vendored mpack writer. - 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; - - sentry_free(recording_buf); - - if (body_ok && body) { - sentry_envelope_t *envelope = sentry__envelope_new(); - if (envelope) { - // Relay keys the replay on the envelope event_id. - sentry__envelope_set_header(envelope, "event_id", - sentry_value_new_string(replay_id)); - const char *dsn = sentry_options_get_dsn(options); - if (dsn && dsn[0]) { - sentry__envelope_set_header( - envelope, "dsn", sentry_value_new_string(dsn)); - } - sentry__envelope_add_from_buffer( - envelope, body, body_len, "replay_video"); - if (options->run) { - sentry__run_write_envelope(options->run, envelope); - } - sentry_envelope_free(envelope); + 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); + sentry__stringbuilder_append_buf(&rb, hdr, (size_t)hdr_len); + 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); + + // replay_video item body: msgpack map of three raw blobs. + 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; + + sentry_free(recording_buf); + + if (body_ok && body) { + envelope = sentry__envelope_new(); + if (envelope) { + // Relay keys the replay on the envelope event_id. + sentry__envelope_set_header( + envelope, "event_id", sentry_value_new_string(replay_id)); + const char *dsn = sentry_options_get_dsn(options); + if (dsn && dsn[0]) { + sentry__envelope_set_header( + envelope, "dsn", sentry_value_new_string(dsn)); } + sentry__envelope_add_from_buffer( + envelope, body, body_len, "replay_video"); } - sentry_free(body); } - sentry_free(rrweb_json); - sentry_free(event_json); - sentry_free(replay_id); + sentry_free(body); } - sentry_free(video); - sentry_value_decref(replay_event); + sentry_free(rrweb_json); + sentry_free(event_json); + sentry_value_decref(event); sentry_value_decref(recording); + sentry_free(video); + return envelope; +} + +void +sentry__session_replay_flush_pending(const sentry_options_t *options, + sentry_transport_t *transport, double end_timestamp_sec) +{ + if (!options || !transport) { + return; + } + + sentry_path_t *dir = session_replay_dir(options); + if (!dir) { + return; + } + if (!sentry__path_is_dir(dir)) { + sentry__path_free(dir); + return; + } + + // Resolve the end-of-window (crash) time once. The caller may pass it + // explicitly (e.g. crashpad's completed-report time for the WER path); + // otherwise fall back to the on-disk crash marker. + double end_sec = end_timestamp_sec; + if (end_sec <= 0.0) { + end_sec = session_replay_marker_time_sec(options); + } + + sentry_pathiter_t *iter = sentry__path_iter_directory(dir); + const sentry_path_t *file; + while (iter && (file = sentry__pathiter_next(iter)) != NULL) { + if (!sentry__path_ends_with(file, ".json")) { + continue; + } + + size_t json_len = 0; + char *json = sentry__path_read_to_buffer(file, &json_len); + if (!json) { + continue; + } + sentry_value_t meta = sentry__value_from_json(json, json_len); + sentry_free(json); + + const char *video_filename = sentry_value_as_string( + sentry_value_get_by_key(meta, "video_filename")); + sentry_path_t *mp4_path = (video_filename && video_filename[0]) + ? sentry__path_join_str(dir, video_filename) + : NULL; + + sentry_envelope_t *envelope + = build_replay_envelope(options, meta, mp4_path, end_sec); + if (envelope) { + // Hands ownership to the transport (queued for async send); the + // transport re-persists any unsent envelope to the run folder on + // shutdown, so removing the sources below is safe. + sentry__capture_envelope(transport, envelope, options); + } + + // Delete sources right after capture so the same replay is not re-sent + // on the next launch (where `last_crash` would still be set). + if (mp4_path) { + sentry__path_remove(mp4_path); + sentry__path_free(mp4_path); + } + sentry__path_remove(file); + sentry_value_decref(meta); + } + sentry__pathiter_free(iter); + sentry__path_free(dir); } From f9a6b0850d8f2288a6a70a05c5f3620279b8c4fe Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 09:55:27 +0300 Subject: [PATCH 03/18] Fix replay dir path --- src/session_replay/sentry_session_replay.c | 33 +++++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index fb4c3ff921..aa6820b69e 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -19,18 +19,28 @@ sentry__session_replay_get_path(const sentry_options_t *options) return sentry__path_join_str(options->run->run_path, "session-replay.mp4"); } -// Resolve `/replays`. The daemon only populates `options->run`, not -// `options->database_path`, so derive the database dir from the run path's -// parent (works for both the daemon and a regular init). Caller owns the path. +// Resolve the database dir. Prefer the run path's parent: it is always the +// absolute run location. `options->database_path` is unreliable here because the +// crash daemon never overrides the default relative ".sentry-native" (set in +// sentry_options_new) with the app's absolute path, so it must only be a +// fallback. Caller owns the returned path. static sentry_path_t * -session_replay_dir(const sentry_options_t *options) +session_replay_database_dir(const sentry_options_t *options) { - sentry_path_t *db_dir = NULL; + if (options->run && options->run->run_path) { + return sentry__path_dir(options->run->run_path); + } if (options->database_path) { - db_dir = sentry__path_clone(options->database_path); - } else if (options->run && options->run->run_path) { - db_dir = sentry__path_dir(options->run->run_path); + return sentry__path_clone(options->database_path); } + return NULL; +} + +// Resolve `/replays`. Caller owns the path. +static sentry_path_t * +session_replay_dir(const sentry_options_t *options) +{ + sentry_path_t *db_dir = session_replay_database_dir(options); if (!db_dir) { return NULL; } @@ -44,12 +54,7 @@ session_replay_dir(const sentry_options_t *options) static double session_replay_marker_time_sec(const sentry_options_t *options) { - sentry_path_t *db_dir = NULL; - if (options->database_path) { - db_dir = sentry__path_clone(options->database_path); - } else if (options->run && options->run->run_path) { - db_dir = sentry__path_dir(options->run->run_path); - } + sentry_path_t *db_dir = session_replay_database_dir(options); if (!db_dir) { return 0.0; } From 0e6246a7dcc8e1d67a7b66278f07e3af0a4adaaa Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 10:23:25 +0300 Subject: [PATCH 04/18] Fix json property names --- src/session_replay/sentry_session_replay.c | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index aa6820b69e..5bce7959e7 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -20,9 +20,9 @@ sentry__session_replay_get_path(const sentry_options_t *options) } // Resolve the database dir. Prefer the run path's parent: it is always the -// absolute run location. `options->database_path` is unreliable here because the -// crash daemon never overrides the default relative ".sentry-native" (set in -// sentry_options_new) with the app's absolute path, so it must only be a +// absolute run location. `options->database_path` is unreliable here because +// the crash daemon never overrides the default relative ".sentry-native" (set +// in sentry_options_new) with the app's absolute path, so it must only be a // fallback. Caller owns the returned path. static sentry_path_t * session_replay_database_dir(const sentry_options_t *options) @@ -104,7 +104,7 @@ build_replay_event(sentry_value_t meta, const char *replay_id, double start_sec, double end_sec, int32_t segment_id) { const char *replay_type - = sentry_value_as_string(sentry_value_get_by_key(meta, "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( @@ -165,9 +165,9 @@ build_replay_recording(sentry_value_t meta, double start_sec, 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(meta_int32(meta, "frame_count"))); + sentry_value_new_int32(meta_int32(meta, "frameCount"))); sentry_value_set_by_key(payload, "frameRate", - sentry_value_new_int32(meta_int32(meta, "frame_rate"))); + sentry_value_new_int32(meta_int32(meta, "frameRate"))); sentry_value_set_by_key( payload, "frameRateType", sentry_value_new_string("variable")); sentry_value_t video_data = sentry_value_new_object(); @@ -198,7 +198,7 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, } const char *replay_id - = sentry_value_as_string(sentry_value_get_by_key(meta, "replay_id")); + = sentry_value_as_string(sentry_value_get_by_key(meta, "replayId")); if (!replay_id || !replay_id[0]) { return NULL; } @@ -210,12 +210,12 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, return NULL; } - const double duration_ms = meta_double(meta, "duration_ms"); + const double duration_ms = meta_double(meta, "durationMs"); if (end_sec <= 0.0) { - end_sec = meta_double(meta, "end_timestamp_sec"); + end_sec = meta_double(meta, "endTimestampSec"); } const double start_sec = end_sec - duration_ms / 1000.0; - const int32_t segment_id = meta_int32(meta, "segment_id"); + const int32_t segment_id = meta_int32(meta, "segmentId"); sentry_value_t event = build_replay_event(meta, replay_id, start_sec, end_sec, segment_id); @@ -326,7 +326,7 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, sentry_free(json); const char *video_filename = sentry_value_as_string( - sentry_value_get_by_key(meta, "video_filename")); + sentry_value_get_by_key(meta, "videoFilename")); sentry_path_t *mp4_path = (video_filename && video_filename[0]) ? sentry__path_join_str(dir, video_filename) : NULL; From da1371b906270b808793b6bb940b0aaf2afb1d76 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 11:50:44 +0300 Subject: [PATCH 05/18] Limit replay envelope to native daemon --- src/backends/native/sentry_crash_daemon.c | 26 ++++- src/sentry_core.c | 15 +-- src/sentry_session_replay.h | 18 ++-- src/session_replay/sentry_session_replay.c | 109 +++++++++++---------- 4 files changed, 94 insertions(+), 74 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 25c1892ff5..9fa43ef085 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3572,11 +3572,29 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) cleanup: // Build and send any session-replay envelope the embedder staged in - // `/replays/`, out-of-process and same-session. Sources are - // deleted on send so the next launch does not resend them. `end == 0` - // makes it read the crash time from the `last_crash` marker. + // `/replays/`, out-of-process and same-session. Sources are deleted + // on send. The crash event (already scope-applied in-process) is read back + // from `/__sentry-event` so the replay carries the same tags/contexts/ + // trace as the crash report and its window ends at the crash time. if (options && options->transport) { - sentry__session_replay_flush_pending(options, options->transport, 0.0); + sentry_value_t crash_event = sentry_value_new_null(); + if (run_folder) { + sentry_path_t *sentry_event_path + = sentry__path_join_str(run_folder, "__sentry-event"); + if (sentry_event_path) { + size_t ev_len = 0; + char *ev_json + = sentry__path_read_to_buffer(sentry_event_path, &ev_len); + if (ev_json) { + crash_event = sentry__value_from_json(ev_json, ev_len); + sentry_free(ev_json); + } + sentry__path_free(sentry_event_path); + } + } + 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 diff --git a/src/sentry_core.c b/src/sentry_core.c index 171c94b271..88e9a4e125 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -10,7 +10,6 @@ #include "sentry_client_report.h" #include "sentry_core.h" #include "sentry_database.h" -#include "sentry_session_replay.h" #include "sentry_envelope.h" #include "sentry_hint.h" #include "sentry_logs.h" @@ -250,17 +249,9 @@ sentry_init(sentry_options_t *options) SENTRY_DEBUG("processing and pruning old runs"); sentry__process_old_runs(options, last_crash); - // Build and send any session-replay envelope the embedder staged in - // `/replays/`. Gating is by file presence: the embedder removes the - // staged files on a clean shutdown, so anything left here belongs to a session - // that terminated abnormally (a crash, including the WER/stack-overflow path). - // This runs in the healthy newly-started process for the crashpad/breakpad/ - // inproc backends; the daemon backend already handled its own out-of-process, - // same-session. Passing 0 lets it read the crash time from the `last_crash` - // marker, falling back to the sidecar's own end timestamp. - if (options->transport) { - sentry__session_replay_flush_pending(options, options->transport, 0.0); - } + // Session replay is a native-daemon-only feature: its envelope is built and + // sent out-of-process by the crash daemon, same-session, so there is no + // next-launch handling here. if (backend && backend->prune_database_func) { backend->prune_database_func(backend); } diff --git a/src/sentry_session_replay.h b/src/sentry_session_replay.h index be15174de9..1dac7e87fa 100644 --- a/src/sentry_session_replay.h +++ b/src/sentry_session_replay.h @@ -30,15 +30,19 @@ sentry_path_t *sentry__session_replay_get_path(const sentry_options_t *options); * * For each pending replay this parses the sidecar, reads the mp4, constructs the * `replay_video` envelope and hands it to `transport`, then deletes the sources - * so it is not re-sent. `end_timestamp_sec` is the authoritative end-of-window - * (crash) time; when <= 0 the `/last_crash` marker is consulted, and - * failing that the sidecar's own end timestamp is used. + * so it is not re-sent. * - * Called out-of-process by the daemon at crash time (same-session delivery) and - * at `sentry_init` for the other backends (next-launch delivery, gated on a - * crash having occurred). + * Session replay is a native-daemon-only feature: this is called out-of-process + * by the crash daemon, so it runs only when a crash occurred (delivery is + * same-session and gating is inherent — no next-launch path). + * + * `scope_source` is the crashed session's event read from `/__sentry-event`, + * already enriched in-process via `sentry__scope_apply_to_event`. Its + * tags/contexts/release/environment/user/sdk are copied onto the replay_event, + * `contexts.trace.trace_id` is lifted into `trace_ids`, and its `timestamp` marks + * the end of the replay window. Pass a null value to skip enrichment. */ void sentry__session_replay_flush_pending(const sentry_options_t *options, - sentry_transport_t *transport, double end_timestamp_sec); + 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 5bce7959e7..60e7939058 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -49,41 +49,6 @@ session_replay_dir(const sentry_options_t *options) return replays; } -// Read the `/last_crash` marker (ISO8601, written by the in-process -// crash handlers) and return the crash time in seconds, or 0 if unavailable. -static double -session_replay_marker_time_sec(const sentry_options_t *options) -{ - sentry_path_t *db_dir = session_replay_database_dir(options); - if (!db_dir) { - return 0.0; - } - sentry_path_t *marker = sentry__path_join_str(db_dir, "last_crash"); - sentry__path_free(db_dir); - if (!marker) { - return 0.0; - } - - size_t len = 0; - char *buf = sentry__path_read_to_buffer(marker, &len); - sentry__path_free(marker); - if (!buf || len == 0) { - sentry_free(buf); - return 0.0; - } - - char iso[40]; - if (len >= sizeof(iso)) { - len = sizeof(iso) - 1; - } - memcpy(iso, buf, len); - iso[len] = '\0'; - sentry_free(buf); - - uint64_t usec = sentry__iso8601_to_usec(iso); - return usec ? (double)usec / 1000000.0 : 0.0; -} - static int32_t meta_int32(sentry_value_t meta, const char *key) { @@ -96,12 +61,18 @@ meta_double(sentry_value_t meta, const char *key) return sentry_value_as_double(sentry_value_get_by_key(meta, key)); } -// Build the replay_event payload from the recorder's metadata. The replay is -// associated with the error via the `replay` context on the event, so no -// `error_ids`/`trace_ids` are needed here (the former is deprecated). +// Build the replay_event payload from the recorder's metadata, enriched with +// the crashed session's scope when `scope_source` is provided. `scope_source` +// is the crash event the daemon read from `/__sentry-event`, which the +// in-process handler already ran `sentry__scope_apply_to_event` on; we reuse +// that here so the replay carries the same +// tags/contexts/release/environment/user/sdk/trace as the crash without +// re-applying the scope. Pass a null value to skip enrichment. The replay is +// associated with the error via the `replay` context on the event, so +// `error_ids` is intentionally omitted (it is 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) + 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")); @@ -125,6 +96,34 @@ build_replay_event(sentry_value_t meta, const char *replay_id, double start_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)) { + // Copy the scope-derived fields (not exception/threads) from the crash + // event so the replay reflects the same session context. + 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); + } + } + + // trace_ids <- contexts.trace.trace_id + 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; } @@ -191,7 +190,7 @@ build_replay_recording(sentry_value_t meta, double start_sec, // sidecar's own end timestamp is used. Returns 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) + const sentry_path_t *mp4_path, double end_sec, sentry_value_t scope_source) { if (sentry_value_is_null(meta) || !mp4_path) { return NULL; @@ -217,8 +216,8 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, const double start_sec = end_sec - duration_ms / 1000.0; const int32_t segment_id = meta_int32(meta, "segmentId"); - sentry_value_t event - = build_replay_event(meta, replay_id, start_sec, end_sec, segment_id); + 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); @@ -287,7 +286,7 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, void sentry__session_replay_flush_pending(const sentry_options_t *options, - sentry_transport_t *transport, double end_timestamp_sec) + sentry_transport_t *transport, sentry_value_t scope_source) { if (!options || !transport) { return; @@ -302,12 +301,20 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, return; } - // Resolve the end-of-window (crash) time once. The caller may pass it - // explicitly (e.g. crashpad's completed-report time for the WER path); - // otherwise fall back to the on-disk crash marker. - double end_sec = end_timestamp_sec; - if (end_sec <= 0.0) { - end_sec = session_replay_marker_time_sec(options); + // End-of-window time: the crash event's timestamp, so the replay window + // ends at the crash and the error falls inside it. Falls back to the + // sidecar's own end timestamp (in build_replay_envelope) when the event is + // unavailable. + double end_sec = 0.0; + if (!sentry_value_is_null(scope_source)) { + 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_pathiter_t *iter = sentry__path_iter_directory(dir); @@ -331,8 +338,8 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, ? sentry__path_join_str(dir, video_filename) : NULL; - sentry_envelope_t *envelope - = build_replay_envelope(options, meta, mp4_path, end_sec); + sentry_envelope_t *envelope = build_replay_envelope( + options, meta, mp4_path, end_sec, scope_source); if (envelope) { // Hands ownership to the transport (queued for async send); the // transport re-persists any unsent envelope to the run folder on From 8895c834e59b765a634f2dd04063b62704677cad Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 12:04:47 +0300 Subject: [PATCH 06/18] Clean up --- src/backends/native/sentry_crash_daemon.c | 8 +-- src/sentry_core.c | 4 -- src/sentry_session_replay.h | 23 +++----- src/session_replay/sentry_session_replay.c | 66 ++++++---------------- 4 files changed, 29 insertions(+), 72 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 9fa43ef085..e2de2e3c6d 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3571,11 +3571,9 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) #endif cleanup: - // Build and send any session-replay envelope the embedder staged in - // `/replays/`, out-of-process and same-session. Sources are deleted - // on send. The crash event (already scope-applied in-process) is read back - // from `/__sentry-event` so the replay carries the same tags/contexts/ - // trace as the crash report and its window ends at the crash time. + // Send the staged session-replay envelope same-session, enriched from the + // crash event (`/__sentry-event`) so it shares the crash's + // tags/contexts/trace. if (options && options->transport) { sentry_value_t crash_event = sentry_value_new_null(); if (run_folder) { diff --git a/src/sentry_core.c b/src/sentry_core.c index 88e9a4e125..6fdc4116dd 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -248,10 +248,6 @@ sentry_init(sentry_options_t *options) // and handle remaining sessions. SENTRY_DEBUG("processing and pruning old runs"); sentry__process_old_runs(options, last_crash); - - // Session replay is a native-daemon-only feature: its envelope is built and - // sent out-of-process by the crash daemon, same-session, so there is no - // next-launch handling here. if (backend && backend->prune_database_func) { backend->prune_database_func(backend); } diff --git a/src/sentry_session_replay.h b/src/sentry_session_replay.h index 1dac7e87fa..e92a3f21e1 100644 --- a/src/sentry_session_replay.h +++ b/src/sentry_session_replay.h @@ -25,22 +25,15 @@ bool sentry__session_replay_capture( sentry_path_t *sentry__session_replay_get_path(const sentry_options_t *options); /** - * Build and send any pending session-replay envelopes staged by an embedder in - * `/replays/` (a `replay-.json` metadata sidecar next to its mp4). + * Build and send the session-replay envelope(s) the embedder staged in + * `/replays/` (a `replay-.json` sidecar next to its mp4). Native- + * daemon-only: called out-of-process by the crash daemon, so it runs only on a + * crash and delivers same-session. Sources are left on disk for the embedder to + * clear. * - * For each pending replay this parses the sidecar, reads the mp4, constructs the - * `replay_video` envelope and hands it to `transport`, then deletes the sources - * so it is not re-sent. - * - * Session replay is a native-daemon-only feature: this is called out-of-process - * by the crash daemon, so it runs only when a crash occurred (delivery is - * same-session and gating is inherent — no next-launch path). - * - * `scope_source` is the crashed session's event read from `/__sentry-event`, - * already enriched in-process via `sentry__scope_apply_to_event`. Its - * tags/contexts/release/environment/user/sdk are copied onto the replay_event, - * `contexts.trace.trace_id` is lifted into `trace_ids`, and its `timestamp` marks - * the end of the replay window. Pass a null value to skip enrichment. + * `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. Null skips enrichment. */ void sentry__session_replay_flush_pending(const sentry_options_t *options, sentry_transport_t *transport, sentry_value_t scope_source); diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index 60e7939058..310b60c3fc 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -4,7 +4,6 @@ #include "sentry_database.h" #include "sentry_envelope.h" #include "sentry_json.h" -#include "sentry_scope.h" #include "sentry_string.h" #include "sentry_utils.h" #include "sentry_value.h" @@ -19,28 +18,15 @@ sentry__session_replay_get_path(const sentry_options_t *options) return sentry__path_join_str(options->run->run_path, "session-replay.mp4"); } -// Resolve the database dir. Prefer the run path's parent: it is always the -// absolute run location. `options->database_path` is unreliable here because -// the crash daemon never overrides the default relative ".sentry-native" (set -// in sentry_options_new) with the app's absolute path, so it must only be a -// fallback. Caller owns the returned path. -static sentry_path_t * -session_replay_database_dir(const sentry_options_t *options) -{ - if (options->run && options->run->run_path) { - return sentry__path_dir(options->run->run_path); - } - if (options->database_path) { - return sentry__path_clone(options->database_path); - } - return NULL; -} - -// Resolve `/replays`. Caller owns the path. +// Resolve `/replays` from the run path's parent (the daemon's +// `options->database_path` is an unusable relative default). Caller owns it. static sentry_path_t * session_replay_dir(const sentry_options_t *options) { - sentry_path_t *db_dir = session_replay_database_dir(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; } @@ -61,15 +47,9 @@ meta_double(sentry_value_t meta, const char *key) return sentry_value_as_double(sentry_value_get_by_key(meta, key)); } -// Build the replay_event payload from the recorder's metadata, enriched with -// the crashed session's scope when `scope_source` is provided. `scope_source` -// is the crash event the daemon read from `/__sentry-event`, which the -// in-process handler already ran `sentry__scope_apply_to_event` on; we reuse -// that here so the replay carries the same -// tags/contexts/release/environment/user/sdk/trace as the crash without -// re-applying the scope. Pass a null value to skip enrichment. The replay is -// associated with the error via the `replay` context on the event, so -// `error_ids` is intentionally omitted (it is deprecated). +// 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) @@ -98,8 +78,7 @@ build_replay_event(sentry_value_t meta, const char *replay_id, double start_sec, sentry_value_set_by_key(event, "urls", sentry_value_new_list()); if (!sentry_value_is_null(scope_source)) { - // Copy the scope-derived fields (not exception/threads) from the crash - // event so the replay reflects the same session context. + // Scope fields only, not the crash's exception/threads. 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]); @@ -185,9 +164,9 @@ build_replay_recording(sentry_value_t meta, double start_sec, return recording; } -// Build the replay_video envelope from a parsed metadata sidecar + its mp4. -// `end_sec` is the authoritative end-of-window (the crash time); when <= 0 the -// sidecar's own end timestamp is used. Returns NULL on failure. +// 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) @@ -301,10 +280,8 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, return; } - // End-of-window time: the crash event's timestamp, so the replay window - // ends at the crash and the error falls inside it. Falls back to the - // sidecar's own end timestamp (in build_replay_envelope) when the event is - // unavailable. + // End the replay window at the crash time (from the crash event); falls back + // to the sidecar's own end timestamp in build_replay_envelope. double end_sec = 0.0; if (!sentry_value_is_null(scope_source)) { const char *ts = sentry_value_as_string( @@ -341,19 +318,12 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, sentry_envelope_t *envelope = build_replay_envelope( options, meta, mp4_path, end_sec, scope_source); if (envelope) { - // Hands ownership to the transport (queued for async send); the - // transport re-persists any unsent envelope to the run folder on - // shutdown, so removing the sources below is safe. sentry__capture_envelope(transport, envelope, options); } - // Delete sources right after capture so the same replay is not re-sent - // on the next launch (where `last_crash` would still be set). - if (mp4_path) { - sentry__path_remove(mp4_path); - sentry__path_free(mp4_path); - } - sentry__path_remove(file); + // Leave the sources on disk: the embedder owns `/replays` and + // clears it next launch, and the mp4 may still back a crash attachment. + sentry__path_free(mp4_path); sentry_value_decref(meta); } sentry__pathiter_free(iter); From cbab847ab969fbb035abce711dc02b0b193d5eba Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 17:33:02 +0300 Subject: [PATCH 07/18] Fix lint --- src/session_replay/sentry_session_replay.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index 310b60c3fc..2eecdab7d1 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -82,7 +82,7 @@ build_replay_event(sentry_value_t meta, const char *replay_id, double start_sec, 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++) { + i++) { sentry_value_t v = sentry_value_get_by_key(scope_source, scope_keys[i]); if (!sentry_value_is_null(v)) { @@ -280,8 +280,8 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, return; } - // End the replay window at the crash time (from the crash event); falls back - // to the sidecar's own end timestamp in build_replay_envelope. + // End the replay window at the crash time (from the crash event); falls + // back to the sidecar's own end timestamp in build_replay_envelope. double end_sec = 0.0; if (!sentry_value_is_null(scope_source)) { const char *ts = sentry_value_as_string( From 8e27cb02b9c658ca1a4eaedf7a07253956497742 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 20:52:58 +0300 Subject: [PATCH 08/18] Fix tests --- src/session_replay/sentry_session_replay.c | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index 2eecdab7d1..45d06eac4b 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -8,8 +8,30 @@ #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 * From f46122208bf90f139a289444a4ba370167c70ff4 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 21:00:47 +0300 Subject: [PATCH 09/18] Inline helper functions --- src/session_replay/sentry_session_replay.c | 33 +++++++++------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index 45d06eac4b..b6bd08dda2 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -57,18 +57,6 @@ session_replay_dir(const sentry_options_t *options) return replays; } -static int32_t -meta_int32(sentry_value_t meta, const char *key) -{ - return sentry_value_as_int32(sentry_value_get_by_key(meta, key)); -} - -static double -meta_double(sentry_value_t meta, const char *key) -{ - return sentry_value_as_double(sentry_value_get_by_key(meta, key)); -} - // 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). @@ -134,8 +122,10 @@ 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 = meta_int32(meta, "width"); - const int32_t height = meta_int32(meta, "height"); + 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(); @@ -165,9 +155,11 @@ build_replay_recording(sentry_value_t meta, double start_sec, 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(meta_int32(meta, "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(meta_int32(meta, "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(); @@ -210,12 +202,15 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, return NULL; } - const double duration_ms = meta_double(meta, "durationMs"); + const double duration_ms + = sentry_value_as_double(sentry_value_get_by_key(meta, "durationMs")); if (end_sec <= 0.0) { - end_sec = meta_double(meta, "endTimestampSec"); + 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 = meta_int32(meta, "segmentId"); + 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); From cb3fc588d5e9f79999956408a9e08860fb03cd56 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 21:09:50 +0300 Subject: [PATCH 10/18] Clean up --- src/session_replay/sentry_session_replay.c | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index b6bd08dda2..d1dcfabadc 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -40,8 +40,6 @@ sentry__session_replay_get_path(const sentry_options_t *options) return sentry__path_join_str(options->run->run_path, "session-replay.mp4"); } -// Resolve `/replays` from the run path's parent (the daemon's -// `options->database_path` is an unusable relative default). Caller owns it. static sentry_path_t * session_replay_dir(const sentry_options_t *options) { @@ -88,7 +86,6 @@ build_replay_event(sentry_value_t meta, const char *replay_id, double start_sec, sentry_value_set_by_key(event, "urls", sentry_value_new_list()); if (!sentry_value_is_null(scope_source)) { - // Scope fields only, not the crash's exception/threads. 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]); @@ -101,7 +98,6 @@ build_replay_event(sentry_value_t meta, const char *replay_id, double start_sec, } } - // trace_ids <- contexts.trace.trace_id sentry_value_t trace_id = sentry_value_get_by_key( sentry_value_get_by_key( sentry_value_get_by_key(scope_source, "contexts"), "trace"), @@ -257,7 +253,6 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, if (body_ok && body) { envelope = sentry__envelope_new(); if (envelope) { - // Relay keys the replay on the envelope event_id. sentry__envelope_set_header( envelope, "event_id", sentry_value_new_string(replay_id)); const char *dsn = sentry_options_get_dsn(options); @@ -338,8 +333,6 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, sentry__capture_envelope(transport, envelope, options); } - // Leave the sources on disk: the embedder owns `/replays` and - // clears it next launch, and the mp4 may still back a crash attachment. sentry__path_free(mp4_path); sentry_value_decref(meta); } From 772dcbb1657b0041c35647abdaca1b31de89b538 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Mon, 29 Jun 2026 21:33:13 +0300 Subject: [PATCH 11/18] Fix lint --- src/session_replay/sentry_session_replay.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index d1dcfabadc..953c7cf6ea 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -151,8 +151,8 @@ build_replay_recording(sentry_value_t meta, double start_sec, 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_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")))); From 148c0a1e1ca6770253d78b9de40ce57100738926 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Tue, 30 Jun 2026 10:51:18 +0300 Subject: [PATCH 12/18] Add replay files cleanup upon successfull envelope creation --- src/sentry_session_replay.h | 6 ++++-- src/session_replay/sentry_session_replay.c | 13 +++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/sentry_session_replay.h b/src/sentry_session_replay.h index e92a3f21e1..ccfe6c70bc 100644 --- a/src/sentry_session_replay.h +++ b/src/sentry_session_replay.h @@ -28,8 +28,10 @@ sentry_path_t *sentry__session_replay_get_path(const sentry_options_t *options); * Build and send the session-replay envelope(s) the embedder staged in * `/replays/` (a `replay-.json` sidecar next to its mp4). Native- * daemon-only: called out-of-process by the crash daemon, so it runs only on a - * crash and delivers same-session. Sources are left on disk for the embedder to - * clear. + * crash and delivers same-session. Each consumed sidecar and its mp4 are + * removed once the envelope has been captured (malformed sidecars are removed + * too), so the flush is idempotent. The SDK owns this cleanup -- the embedder + * doesn't have to clear the `replays/` folder itself. * * `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 diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index 953c7cf6ea..a265a49c2e 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -307,14 +307,14 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, } sentry_pathiter_t *iter = sentry__path_iter_directory(dir); - const sentry_path_t *file; - while (iter && (file = sentry__pathiter_next(iter)) != NULL) { - if (!sentry__path_ends_with(file, ".json")) { + const sentry_path_t *sidecar_path; + while (iter && (sidecar_path = sentry__pathiter_next(iter)) != NULL) { + if (!sentry__path_ends_with(sidecar_path, ".json")) { continue; } size_t json_len = 0; - char *json = sentry__path_read_to_buffer(file, &json_len); + char *json = sentry__path_read_to_buffer(sidecar_path, &json_len); if (!json) { continue; } @@ -333,6 +333,11 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, sentry__capture_envelope(transport, envelope, options); } + if (mp4_path) { + sentry__path_remove(mp4_path); + } + sentry__path_remove(sidecar_path); + sentry__path_free(mp4_path); sentry_value_decref(meta); } From be68d1b6f7de36e4f50bc6c0626d61202a40d609 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Tue, 30 Jun 2026 13:05:35 +0300 Subject: [PATCH 13/18] Classify `replay_video` under the replay data/rate-limit category --- src/sentry_client_report.h | 1 + src/sentry_envelope.c | 6 ++++++ src/sentry_ratelimiter.c | 5 ++++- src/sentry_ratelimiter.h | 1 + 4 files changed, 12 insertions(+), 1 deletion(-) 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; From 998e92def7fe4e5c0c4428fe0ea1796cbffa7bd8 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Jul 2026 12:21:09 +0300 Subject: [PATCH 14/18] Remove redundant videoFilename check --- src/sentry_session_replay.h | 19 +++-- src/session_replay/sentry_session_replay.c | 86 +++++++++++++--------- 2 files changed, 61 insertions(+), 44 deletions(-) diff --git a/src/sentry_session_replay.h b/src/sentry_session_replay.h index ccfe6c70bc..61c30882ef 100644 --- a/src/sentry_session_replay.h +++ b/src/sentry_session_replay.h @@ -25,17 +25,20 @@ bool sentry__session_replay_capture( sentry_path_t *sentry__session_replay_get_path(const sentry_options_t *options); /** - * Build and send the session-replay envelope(s) the embedder staged in - * `/replays/` (a `replay-.json` sidecar next to its mp4). Native- - * daemon-only: called out-of-process by the crash daemon, so it runs only on a - * crash and delivers same-session. Each consumed sidecar and its mp4 are - * removed once the envelope has been captured (malformed sidecars are removed - * too), so the flush is idempotent. The SDK owns this cleanup -- the embedder - * doesn't have to clear the `replays/` folder itself. + * 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 consumed + * sidecar and mp4 are removed once the envelope has been captured, so the flush + * is idempotent. * * `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. Null skips enrichment. + * 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); diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index a265a49c2e..f8caf3dfcf 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -275,6 +275,22 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, 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; +} + void sentry__session_replay_flush_pending(const sentry_options_t *options, sentry_transport_t *transport, sentry_value_t scope_source) @@ -283,19 +299,42 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, 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; } - if (!sentry__path_is_dir(dir)) { - sentry__path_free(dir); + + 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; } - // End the replay window at the crash time (from the crash event); falls - // back to the sidecar's own end timestamp in build_replay_envelope. - double end_sec = 0.0; - if (!sentry_value_is_null(scope_source)) { + 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]) { @@ -304,43 +343,18 @@ sentry__session_replay_flush_pending(const sentry_options_t *options, end_sec = (double)usec / 1000000.0; } } - } - - sentry_pathiter_t *iter = sentry__path_iter_directory(dir); - const sentry_path_t *sidecar_path; - while (iter && (sidecar_path = sentry__pathiter_next(iter)) != NULL) { - if (!sentry__path_ends_with(sidecar_path, ".json")) { - continue; - } - - size_t json_len = 0; - char *json = sentry__path_read_to_buffer(sidecar_path, &json_len); - if (!json) { - continue; - } - sentry_value_t meta = sentry__value_from_json(json, json_len); - sentry_free(json); - - const char *video_filename = sentry_value_as_string( - sentry_value_get_by_key(meta, "videoFilename")); - sentry_path_t *mp4_path = (video_filename && video_filename[0]) - ? sentry__path_join_str(dir, video_filename) - : NULL; 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); - if (mp4_path) { - sentry__path_remove(mp4_path); - } + sentry__path_remove(mp4_path); sentry__path_remove(sidecar_path); - - sentry__path_free(mp4_path); - sentry_value_decref(meta); } - sentry__pathiter_free(iter); - sentry__path_free(dir); + + sentry__path_free(mp4_path); + sentry__path_free(sidecar_path); } From 0106e1fd1d15178b3b47c70b3cf862e88b0f16b1 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Jul 2026 14:19:53 +0300 Subject: [PATCH 15/18] Fix comment --- src/sentry_session_replay.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sentry_session_replay.h b/src/sentry_session_replay.h index 61c30882ef..5cfe7f91d7 100644 --- a/src/sentry_session_replay.h +++ b/src/sentry_session_replay.h @@ -31,9 +31,9 @@ sentry_path_t *sentry__session_replay_get_path(const sentry_options_t *options); * `/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 consumed - * sidecar and mp4 are removed once the envelope has been captured, so the flush - * is idempotent. + * 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 From 52b905b68ae4b5d0efad1fad874dcfcdfc4e973d Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Jul 2026 14:38:55 +0300 Subject: [PATCH 16/18] Fix issue with uploading orphan replays if crash wasn't captured --- src/backends/native/sentry_crash_daemon.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index e2de2e3c6d..bae3446786 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3573,8 +3573,11 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) 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. - if (options && options->transport) { + // 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_value_t crash_event = sentry_value_new_null(); if (run_folder) { sentry_path_t *sentry_event_path From 85a3cec255dfb3df527e0974b48e9defa6c82c48 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Jul 2026 15:12:50 +0300 Subject: [PATCH 17/18] Clean up --- src/session_replay/sentry_session_replay.c | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index f8caf3dfcf..bcd7789741 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -251,15 +251,10 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, sentry_free(recording_buf); if (body_ok && body) { - envelope = sentry__envelope_new(); + envelope = sentry__envelope_new_with_dsn(options->dsn); if (envelope) { sentry__envelope_set_header( envelope, "event_id", sentry_value_new_string(replay_id)); - const char *dsn = sentry_options_get_dsn(options); - if (dsn && dsn[0]) { - sentry__envelope_set_header( - envelope, "dsn", sentry_value_new_string(dsn)); - } sentry__envelope_add_from_buffer( envelope, body, body_len, "replay_video"); } From 50526b0fc2deed5b39ced18ae25c60cffc3b0cb7 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Jul 2026 15:30:07 +0300 Subject: [PATCH 18/18] Guard replay_recording assembly against string-builder failure --- src/session_replay/sentry_session_replay.c | 55 +++++++++++----------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/session_replay/sentry_session_replay.c b/src/session_replay/sentry_session_replay.c index bcd7789741..cfffeed9c7 100644 --- a/src/session_replay/sentry_session_replay.c +++ b/src/session_replay/sentry_session_replay.c @@ -228,38 +228,39 @@ build_replay_envelope(const sentry_options_t *options, sentry_value_t meta, sentry_stringbuilder_t rb; sentry__stringbuilder_init(&rb); - sentry__stringbuilder_append_buf(&rb, hdr, (size_t)hdr_len); - sentry__stringbuilder_append_buf(&rb, rrweb_json, rrweb_len); + 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); - // replay_video item body: msgpack map of three raw blobs. - 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; - - sentry_free(recording_buf); - - 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)); - sentry__envelope_add_from_buffer( - envelope, body, body_len, "replay_video"); + 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)); + sentry__envelope_add_from_buffer( + envelope, body, body_len, "replay_video"); + } } + sentry_free(body); } - sentry_free(body); + + sentry_free(recording_buf); } sentry_free(rrweb_json);