From 3c6fd49203bd5da3206f55cd83550f82f85c7773 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 29 May 2026 16:39:40 +0200 Subject: [PATCH 01/12] refactor --- src/backends/native/sentry_crash_daemon.c | 26 ++-- src/backends/sentry_backend_native.c | 147 +++++++++------------- 2 files changed, 74 insertions(+), 99 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index be660d4326..ff42856b09 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2119,14 +2119,21 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) } /** - * Build native crash event with exception, mechanism, and debug_meta + * Build a native event from the scope-complete base event, adding the + * caller-specified framing (level, mechanism) plus threads and debug_meta. + * The base event (contexts, tags, user, breadcrumbs, ...) is identical + * regardless of event type; the caller states what this event is. * * @param ctx Crash context - * @param event_file_path Path to event file from parent process + * @param event_file_path Path to base event file from parent process + * @param level Event level (e.g. "fatal") + * @param mechanism_type Exception mechanism type (e.g. "signalhandler") + * @param handled Whether the mechanism was handled */ static sentry_value_t -build_native_crash_event( - const sentry_crash_context_t *ctx, const char *event_file_path) +build_native_event(const sentry_crash_context_t *ctx, + const char *event_file_path, const char *level, + const char *mechanism_type, bool handled) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); @@ -2152,8 +2159,7 @@ build_native_crash_event( sentry_value_set_by_key( event, "platform", sentry_value_new_string("native")); - // Set level to fatal - sentry_value_set_by_key(event, "level", sentry_value_new_string("fatal")); + sentry_value_set_by_key(event, "level", sentry_value_new_string(level)); // Build exception const char *signal_name = "UNKNOWN"; @@ -2175,10 +2181,11 @@ build_native_crash_event( // Add mechanism sentry_value_t mechanism = sentry_value_new_object(); sentry_value_set_by_key( - mechanism, "type", sentry_value_new_string("signalhandler")); + mechanism, "type", sentry_value_new_string(mechanism_type)); sentry_value_set_by_key( mechanism, "synthetic", sentry_value_new_bool(true)); - sentry_value_set_by_key(mechanism, "handled", sentry_value_new_bool(false)); + sentry_value_set_by_key( + mechanism, "handled", sentry_value_new_bool(handled)); // Add signal metadata sentry_value_t meta = sentry_value_new_object(); @@ -2477,7 +2484,8 @@ write_envelope_with_native_stacktrace(const sentry_options_t *options, // Build native crash event (always include threads with names) SENTRY_DEBUGF("write_envelope_with_native_stacktrace: minidump_path=%s", minidump_path ? minidump_path : "(null)"); - sentry_value_t event = build_native_crash_event(ctx, event_file_path); + sentry_value_t event = build_native_event( + ctx, event_file_path, "fatal", "signalhandler", false); // Serialize event to JSON size_t event_size = 0; diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 3c3e508434..bea2986b1a 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -767,9 +767,61 @@ native_backend_write_attachments(const sentry_path_t *event_path) } } +#if defined(SENTRY_PLATFORM_WINDOWS) +// Sentry's symbolicator needs `contexts.device.arch` to process PE modules. If +// the scope already carries a device context with arch (host SDKs like Unity +// provide one), leave it; otherwise synthesize a minimal one so native-only +// consumers still symbolicate. +static void +native_backend_ensure_device_arch(sentry_value_t event) +{ + sentry_value_t contexts = sentry_value_get_by_key(event, "contexts"); + if (sentry_value_is_null(contexts)) { + contexts = sentry_value_new_object(); + sentry_value_set_by_key(event, "contexts", contexts); + } + sentry_value_t device = sentry_value_get_by_key(contexts, "device"); + if (sentry_value_is_null(device)) { + device = sentry_value_new_object(); + sentry_value_set_by_key( + device, "type", sentry_value_new_string("device")); + sentry_value_set_by_key(contexts, "device", device); + } + if (!sentry_value_is_null(sentry_value_get_by_key(device, "arch"))) { + return; + } +# if defined(_M_AMD64) + sentry_value_set_by_key(device, "arch", sentry_value_new_string("x86_64")); +# elif defined(_M_IX86) + sentry_value_set_by_key(device, "arch", sentry_value_new_string("x86")); +# elif defined(_M_ARM64) + sentry_value_set_by_key(device, "arch", sentry_value_new_string("arm64")); +# endif +} +#endif + +// Applies the full scope to `event`: contexts (os, device, gpu, app, runtime, +// plus SDK-specific entries such as the Unity context), user, tags, extra, +// fingerprint, release/dist/env, sdk metadata, and breadcrumbs - plus the +// Windows device.arch fallback. Single source of truth for the base event the +// daemon reads, shared by the continuous scope flush and the crash handler so +// both write an identical base regardless of which one wins the race. +static void +native_backend_apply_scope( + sentry_value_t event, const sentry_options_t *options) +{ + SENTRY_WITH_SCOPE (scope) { + sentry__scope_apply_to_event( + scope, options, event, SENTRY_SCOPE_BREADCRUMBS); + } +#if defined(SENTRY_PLATFORM_WINDOWS) + native_backend_ensure_device_arch(event); +#endif +} + static void native_backend_flush_scope( - sentry_backend_t *backend, const sentry_options_t *UNUSED(options)) + sentry_backend_t *backend, const sentry_options_t *options) { native_backend_state_t *state = (native_backend_state_t *)backend->data; if (!state || !state->event_path) { @@ -784,65 +836,11 @@ native_backend_flush_scope( return; } - // Create event with current scope + // Keep the on-disk base event complete and current, so the daemon has the + // full scope even if a crash beats the in-process handler to the file. sentry_value_t event = sentry_value_new_object(); - sentry_value_set_by_key( - event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - - // Apply scope with contexts (includes OS, device info from Sentry) - SENTRY_WITH_SCOPE (scope) { - // Get contexts from scope (includes OS info) - sentry_value_t os_context - = sentry_value_get_by_key(scope->contexts, "os"); - if (!sentry_value_is_null(os_context)) { - sentry_value_t event_contexts = sentry_value_new_object(); - sentry_value_set_by_key(event_contexts, "os", os_context); - sentry_value_incref(os_context); + native_backend_apply_scope(event, options); -#if defined(SENTRY_PLATFORM_WINDOWS) - // Add device context with arch for Windows native events - // This is required for Sentry's symbolicator to process PE modules - sentry_value_t device_context = sentry_value_new_object(); - sentry_value_set_by_key( - device_context, "type", sentry_value_new_string("device")); -# if defined(_M_AMD64) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("x86_64")); -# elif defined(_M_IX86) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("x86")); -# elif defined(_M_ARM64) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("arm64")); -# endif - sentry_value_set_by_key(event_contexts, "device", device_context); -#endif - - sentry_value_set_by_key(event, "contexts", event_contexts); - } - - // Also copy other scope data (user, tags, extra, etc.) - sentry_value_t user = scope->user; - if (sentry_value_get_type(user) == SENTRY_VALUE_TYPE_OBJECT - && sentry_value_get_length(user) > 0) { - sentry_value_set_by_key(event, "user", user); - sentry_value_incref(user); - } - - sentry_value_t tags = scope->tags; - if (!sentry_value_is_null(tags)) { - sentry_value_set_by_key(event, "tags", tags); - sentry_value_incref(tags); - } - - sentry_value_t extra = scope->extra; - if (!sentry_value_is_null(extra)) { - sentry_value_set_by_key(event, "extra", extra); - sentry_value_incref(extra); - } - } - - // Serialize to JSON (so it can be deserialized on next start) size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); sentry_value_decref(event); @@ -1024,38 +1022,7 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - // Apply scope to event including breadcrumbs - SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event( - scope, options, event, SENTRY_SCOPE_BREADCRUMBS); - } - -#if defined(SENTRY_PLATFORM_WINDOWS) - // Add device context with arch for Windows native events - // This is required for Sentry's symbolicator to process PE - // modules - sentry_value_t contexts - = sentry_value_get_by_key(event, "contexts"); - if (sentry_value_is_null(contexts)) { - contexts = sentry_value_new_object(); - sentry_value_set_by_key(event, "contexts", contexts); - } - sentry_value_t device_context = sentry_value_new_object(); - sentry_value_set_by_key( - device_context, "type", sentry_value_new_string("device")); -# if defined(_M_AMD64) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("x86_64")); -# elif defined(_M_IX86) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("x86")); -# elif defined(_M_ARM64) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("arm64")); -# endif - sentry_value_set_by_key(contexts, "device", device_context); - -#endif + native_backend_apply_scope(event, options); #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From a26c756e6e76aa1eed23344d6f1b783440b86114 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 10:11:04 +0200 Subject: [PATCH 02/12] linter --- src/backends/native/sentry_crash_daemon.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index ff42856b09..7be96c4685 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2132,8 +2132,8 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) */ static sentry_value_t build_native_event(const sentry_crash_context_t *ctx, - const char *event_file_path, const char *level, - const char *mechanism_type, bool handled) + const char *event_file_path, const char *level, const char *mechanism_type, + bool handled) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); From 0668a9345174b35f896fe3024ec0f54a188e0a6d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 14:10:42 +0200 Subject: [PATCH 03/12] restored default fatal behaviour --- src/backends/sentry_backend_native.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index bea2986b1a..d0e4d15c21 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -839,6 +839,9 @@ native_backend_flush_scope( // Keep the on-disk base event complete and current, so the daemon has the // full scope even if a crash beats the in-process handler to the file. sentry_value_t event = sentry_value_new_object(); + // Default to `FATAL` for all paths, i.e. minidump mode. + sentry_value_set_by_key( + event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); native_backend_apply_scope(event, options); size_t json_len = 0; From caeb8fbe142f03269756cb17478b327020766b22 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 14:13:16 +0200 Subject: [PATCH 04/12] fix naming --- src/backends/sentry_backend_native.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index d0e4d15c21..3c5bb62c6a 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -773,7 +773,7 @@ native_backend_write_attachments(const sentry_path_t *event_path) // provide one), leave it; otherwise synthesize a minimal one so native-only // consumers still symbolicate. static void -native_backend_ensure_device_arch(sentry_value_t event) +ensure_device_arch(sentry_value_t event) { sentry_value_t contexts = sentry_value_get_by_key(event, "contexts"); if (sentry_value_is_null(contexts)) { @@ -807,7 +807,7 @@ native_backend_ensure_device_arch(sentry_value_t event) // daemon reads, shared by the continuous scope flush and the crash handler so // both write an identical base regardless of which one wins the race. static void -native_backend_apply_scope( +apply_scope( sentry_value_t event, const sentry_options_t *options) { SENTRY_WITH_SCOPE (scope) { @@ -815,7 +815,7 @@ native_backend_apply_scope( scope, options, event, SENTRY_SCOPE_BREADCRUMBS); } #if defined(SENTRY_PLATFORM_WINDOWS) - native_backend_ensure_device_arch(event); + ensure_device_arch(event); #endif } @@ -842,7 +842,7 @@ native_backend_flush_scope( // Default to `FATAL` for all paths, i.e. minidump mode. sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - native_backend_apply_scope(event, options); + apply_scope(event, options); size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); @@ -1025,7 +1025,7 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - native_backend_apply_scope(event, options); + apply_scope(event, options); #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From 0a3a6466ad0f063b2bf87dffb2616a9db0b426ab Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 16:40:45 +0200 Subject: [PATCH 05/12] perf(native): keep breadcrumbs off the per-mutation scope flush native_backend_flush_scope runs on every scope mutation (set_tag, set_context, set_user, ...). Folding breadcrumbs into the flushed base event re-serialized the entire breadcrumb ring on each of those calls - prohibitive on a hot path such as a 60fps game main thread. Give apply_scope a scope-mode argument: the continuous flush now passes SENTRY_SCOPE_NONE, while the crash handler still passes SENTRY_SCOPE_BREADCRUMBS to capture them at crash time (the process's last chance to record them). This matches the pre-existing behavior before breadcrumbs were added to the shared flush path. Co-Authored-By: Claude --- src/backends/sentry_backend_native.c | 39 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 3c5bb62c6a..f2b391002d 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -800,19 +800,24 @@ ensure_device_arch(sentry_value_t event) } #endif -// Applies the full scope to `event`: contexts (os, device, gpu, app, runtime, -// plus SDK-specific entries such as the Unity context), user, tags, extra, -// fingerprint, release/dist/env, sdk metadata, and breadcrumbs - plus the -// Windows device.arch fallback. Single source of truth for the base event the -// daemon reads, shared by the continuous scope flush and the crash handler so -// both write an identical base regardless of which one wins the race. +// Applies the scope to `event`: contexts (os, device, gpu, app, runtime, plus +// SDK-specific entries such as the Unity context), user, tags, extra, +// fingerprint, release/dist/env, sdk metadata - plus the Windows device.arch +// fallback. Shared by the continuous scope flush and the crash handler so both +// write an identical base regardless of which one wins the race. +// +// `mode` controls the expensive, list-shaped parts. The crash handler passes +// SENTRY_SCOPE_BREADCRUMBS to capture them at crash time, but the continuous +// flush passes SENTRY_SCOPE_NONE: it runs on *every* scope mutation, so folding +// the breadcrumb buffer in there would re-serialize the whole ring on every +// set_tag/set_context/... - prohibitive on a hot path such as a 60fps main +// thread. static void -apply_scope( - sentry_value_t event, const sentry_options_t *options) +apply_scope(sentry_value_t event, const sentry_options_t *options, + sentry_scope_mode_t mode) { SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event( - scope, options, event, SENTRY_SCOPE_BREADCRUMBS); + sentry__scope_apply_to_event(scope, options, event, mode); } #if defined(SENTRY_PLATFORM_WINDOWS) ensure_device_arch(event); @@ -836,13 +841,17 @@ native_backend_flush_scope( return; } - // Keep the on-disk base event complete and current, so the daemon has the - // full scope even if a crash beats the in-process handler to the file. + // Keep the on-disk base event current, so the daemon has the full scope + // even if a crash beats the in-process handler to the file. Breadcrumbs are + // deliberately excluded here (SENTRY_SCOPE_NONE): they are flushed + // incrementally to the breadcrumb ring files and the crash handler captures + // them at crash time. This keeps the per-mutation flush off the breadcrumb + // serialization cost. sentry_value_t event = sentry_value_new_object(); // Default to `FATAL` for all paths, i.e. minidump mode. sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - apply_scope(event, options); + apply_scope(event, options, SENTRY_SCOPE_NONE); size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); @@ -1025,7 +1034,9 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - apply_scope(event, options); + // At crash time we capture breadcrumbs (unlike the continuous + // flush) - this is the process's last chance to record them. + apply_scope(event, options, SENTRY_SCOPE_BREADCRUMBS); #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From 57af0047faaffbca54d064ed6eb6f0d84742403a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 17:57:02 +0200 Subject: [PATCH 06/12] read breadcrumbs from ring file --- src/backends/native/sentry_crash_context.h | 2 + src/backends/native/sentry_crash_daemon.c | 98 +++++++++++++++++++--- src/backends/sentry_backend_native.c | 50 +++++------ 3 files changed, 116 insertions(+), 34 deletions(-) diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index e5d0bd63e7..e2dcd9a9b0 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -289,6 +289,8 @@ typedef struct { uint64_t shutdown_timeout; uint64_t transfer_timeout; bool system_crash_reporter_enabled; + uint32_t max_breadcrumbs; // Breadcrumb cap, so the daemon merges the ring + // files with the same limit the app enforced // Atomic user consent (sentry_user_consent_t), updated whenever user // consent changes so the daemon can honor it at crash time. diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 7be96c4685..af31d35663 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2118,22 +2118,89 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) return build_stacktrace_for_thread(ctx, SIZE_MAX); } +/** + * Reads one breadcrumb ring file the crashing process appended on its hot path + * (concatenated msgpack values) into a breadcrumb list. Returns null if the + * file is absent or empty. + */ +static sentry_value_t +read_breadcrumb_ring_file(const sentry_path_t *run_folder, const char *name) +{ + if (!run_folder) { + return sentry_value_new_null(); + } + sentry_path_t *path = sentry__path_join_str(run_folder, name); + if (!path) { + return sentry_value_new_null(); + } + size_t size = 0; + char *buf = sentry__path_read_to_buffer(path, &size); + sentry__path_free(path); + if (!buf || size == 0) { + sentry_free(buf); + return sentry_value_new_null(); + } + sentry_value_t list = sentry__value_from_msgpack(buf, size); + sentry_free(buf); + // `sentry__value_from_msgpack` only builds a list when the file holds 2+ + // concatenated values; a file with a single breadcrumb decodes to a bare + // object. Wrap it so the merge step (which ignores non-lists) keeps it. + if (sentry_value_get_type(list) == SENTRY_VALUE_TYPE_OBJECT) { + sentry_value_t wrapper = sentry_value_new_list(); + sentry_value_append(wrapper, list); + return wrapper; + } + return list; +} + +/** + * Assembles the crash event's breadcrumbs from the two ring files the crashing + * process appended one-at-a-time, merges them in timestamp order, keeps the + * newest `max_breadcrumbs`, and attaches them to `event`. This is what keeps + * breadcrumb persistence off the per-mutation scope-flush path - the app only + * ever appends a single breadcrumb, and the daemon does the assembly here. + * Mirrors the crashpad backend's `report_to_envelope`. + */ +static void +apply_breadcrumbs_from_ring_files(sentry_value_t event, + const sentry_path_t *run_folder, const sentry_crash_context_t *ctx) +{ + sentry_value_t b1 + = read_breadcrumb_ring_file(run_folder, "__sentry-breadcrumb1"); + sentry_value_t b2 + = read_breadcrumb_ring_file(run_folder, "__sentry-breadcrumb2"); + size_t max = ctx && ctx->max_breadcrumbs ? ctx->max_breadcrumbs + : SENTRY_BREADCRUMBS_MAX; + sentry_value_t merged = sentry__value_merge_breadcrumbs(b1, b2, max); + sentry_value_decref(b1); + sentry_value_decref(b2); + // Overwrite any breadcrumbs the base event may carry: the ring files are the + // single source of truth, so this is idempotent and never duplicates. + if (sentry_value_get_type(merged) == SENTRY_VALUE_TYPE_LIST) { + sentry_value_set_by_key(event, "breadcrumbs", merged); + } else { + sentry_value_decref(merged); + } +} + /** * Build a native event from the scope-complete base event, adding the - * caller-specified framing (level, mechanism) plus threads and debug_meta. - * The base event (contexts, tags, user, breadcrumbs, ...) is identical - * regardless of event type; the caller states what this event is. + * caller-specified framing (level, mechanism) plus threads, breadcrumbs (read + * from the ring files), and debug_meta. The base event (contexts, tags, user, + * ...) is identical regardless of event type; the caller states what this + * event is. * * @param ctx Crash context * @param event_file_path Path to base event file from parent process + * @param run_folder Run directory holding the breadcrumb ring files * @param level Event level (e.g. "fatal") * @param mechanism_type Exception mechanism type (e.g. "signalhandler") * @param handled Whether the mechanism was handled */ static sentry_value_t build_native_event(const sentry_crash_context_t *ctx, - const char *event_file_path, const char *level, const char *mechanism_type, - bool handled) + const char *event_file_path, const sentry_path_t *run_folder, + const char *level, const char *mechanism_type, bool handled) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); @@ -2155,6 +2222,10 @@ build_native_event(const sentry_crash_context_t *ctx, event = sentry_value_new_event(); } + // Assemble breadcrumbs from the ring files (the base event carries none - + // the app keeps them off the scope-flush hot path). + apply_breadcrumbs_from_ring_files(event, run_folder, ctx); + // Set platform to native sentry_value_set_by_key( event, "platform", sentry_value_new_string("native")); @@ -2485,7 +2556,7 @@ write_envelope_with_native_stacktrace(const sentry_options_t *options, SENTRY_DEBUGF("write_envelope_with_native_stacktrace: minidump_path=%s", minidump_path ? minidump_path : "(null)"); sentry_value_t event = build_native_event( - ctx, event_file_path, "fatal", "signalhandler", false); + ctx, event_file_path, run_folder, "fatal", "signalhandler", false); // Serialize event to JSON size_t event_size = 0; @@ -2734,21 +2805,28 @@ write_envelope_with_minidump(const sentry_options_t *options, const char *event_msgpack_path, const char *minidump_path, sentry_path_t *run_folder) { - // Read event JSON data + // Read the base event, merge in the breadcrumbs from the ring files (the + // base event carries none), and re-serialize. Unlike the native-stacktrace + // path this mode otherwise streams the event verbatim, so we have to + // round-trip through a value to attach breadcrumbs. size_t event_size = 0; char *event_json = NULL; char *event_id = NULL; sentry_path_t *ev_path = sentry__path_from_str(event_msgpack_path); if (ev_path) { - event_json = sentry__path_read_to_buffer(ev_path, &event_size); + size_t base_size = 0; + char *base_json = sentry__path_read_to_buffer(ev_path, &base_size); sentry__path_free(ev_path); - if (event_json && event_size > 0) { + if (base_json && base_size > 0) { sentry_value_t event - = sentry__value_from_json(event_json, event_size); + = sentry__value_from_json(base_json, base_size); + apply_breadcrumbs_from_ring_files(event, run_folder, ctx); event_id = sentry__string_clone(sentry_value_as_string( sentry_value_get_by_key(event, "event_id"))); + event_json = sentry__value_to_json(event, &event_size); sentry_value_decref(event); } + sentry_free(base_json); } // Open envelope file for writing diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index f2b391002d..e4d080dda2 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -309,6 +309,7 @@ native_backend_startup( ctx->http_retry = options->http_retry; ctx->shutdown_timeout = options->shutdown_timeout; ctx->transfer_timeout = options->transfer_timeout; + ctx->max_breadcrumbs = (uint32_t)options->max_breadcrumbs; sentry__atomic_store( &ctx->user_consent, sentry__atomic_fetch(&options->run->user_consent)); @@ -806,18 +807,19 @@ ensure_device_arch(sentry_value_t event) // fallback. Shared by the continuous scope flush and the crash handler so both // write an identical base regardless of which one wins the race. // -// `mode` controls the expensive, list-shaped parts. The crash handler passes -// SENTRY_SCOPE_BREADCRUMBS to capture them at crash time, but the continuous -// flush passes SENTRY_SCOPE_NONE: it runs on *every* scope mutation, so folding -// the breadcrumb buffer in there would re-serialize the whole ring on every -// set_tag/set_context/... - prohibitive on a hot path such as a 60fps main -// thread. +// Breadcrumbs are deliberately excluded (SENTRY_SCOPE_NONE): they are persisted +// incrementally to the breadcrumb ring files via `add_breadcrumb_func` and +// assembled by the daemon at crash time (see the daemon's +// `apply_breadcrumbs_from_ring_files`). Folding them in here would re-serialize +// the whole breadcrumb buffer on every scope mutation - prohibitive on a hot +// path such as a 60fps main thread. This mirrors the crashpad backend's +// `flush_scope_to_event`. static void -apply_scope(sentry_value_t event, const sentry_options_t *options, - sentry_scope_mode_t mode) +apply_scope(sentry_value_t event, const sentry_options_t *options) { SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event(scope, options, event, mode); + sentry__scope_apply_to_event( + scope, options, event, SENTRY_SCOPE_NONE); } #if defined(SENTRY_PLATFORM_WINDOWS) ensure_device_arch(event); @@ -843,15 +845,13 @@ native_backend_flush_scope( // Keep the on-disk base event current, so the daemon has the full scope // even if a crash beats the in-process handler to the file. Breadcrumbs are - // deliberately excluded here (SENTRY_SCOPE_NONE): they are flushed - // incrementally to the breadcrumb ring files and the crash handler captures - // them at crash time. This keeps the per-mutation flush off the breadcrumb - // serialization cost. + // not part of this (see apply_scope) - the daemon merges them from the ring + // files at crash time. sentry_value_t event = sentry_value_new_object(); // Default to `FATAL` for all paths, i.e. minidump mode. sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - apply_scope(event, options, SENTRY_SCOPE_NONE); + apply_scope(event, options); size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); @@ -890,18 +890,22 @@ native_backend_add_breadcrumb(sentry_backend_t *backend, return; } - // Serialize to JSON (so it can be deserialized on next start) - size_t json_len = 0; - char *json_str = sentry__value_to_json(breadcrumb, &json_len); - if (!json_str) { + // Append as msgpack, matching the crashpad backend. msgpack values are + // self-delimiting, so the daemon can read the concatenated ring file back + // into a list via `sentry__value_from_msgpack`. This is the only breadcrumb + // persistence on the hot path: one serialize + one append per breadcrumb, + // never a full scope re-serialization. + size_t mpack_size = 0; + char *mpack = sentry_value_to_msgpack(breadcrumb, &mpack_size); + if (!mpack) { return; } int rv = first_breadcrumb - ? sentry__path_write_buffer(breadcrumb_file, json_str, json_len) - : sentry__path_append_buffer(breadcrumb_file, json_str, json_len); + ? sentry__path_write_buffer(breadcrumb_file, mpack, mpack_size) + : sentry__path_append_buffer(breadcrumb_file, mpack, mpack_size); - sentry_free(json_str); + sentry_free(mpack); if (rv != 0) { SENTRY_WARN("failed to write breadcrumb"); @@ -1034,9 +1038,7 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - // At crash time we capture breadcrumbs (unlike the continuous - // flush) - this is the process's last chance to record them. - apply_scope(event, options, SENTRY_SCOPE_BREADCRUMBS); + apply_scope(event, options); #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From 0a57db69d5e63db67b4e543bafae24b5d0dd7ed4 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 18:31:44 +0200 Subject: [PATCH 07/12] minified change --- src/backends/native/sentry_crash_daemon.c | 5 +-- src/backends/sentry_backend_native.c | 52 ++++++++--------------- 2 files changed, 18 insertions(+), 39 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 7be96c4685..a2e03f7d33 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2119,10 +2119,7 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) } /** - * Build a native event from the scope-complete base event, adding the - * caller-specified framing (level, mechanism) plus threads and debug_meta. - * The base event (contexts, tags, user, breadcrumbs, ...) is identical - * regardless of event type; the caller states what this event is. + * Build a native event and set the level, mechanism, and handled state * * @param ctx Crash context * @param event_file_path Path to base event file from parent process diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index f2b391002d..7768fe0304 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -800,30 +800,6 @@ ensure_device_arch(sentry_value_t event) } #endif -// Applies the scope to `event`: contexts (os, device, gpu, app, runtime, plus -// SDK-specific entries such as the Unity context), user, tags, extra, -// fingerprint, release/dist/env, sdk metadata - plus the Windows device.arch -// fallback. Shared by the continuous scope flush and the crash handler so both -// write an identical base regardless of which one wins the race. -// -// `mode` controls the expensive, list-shaped parts. The crash handler passes -// SENTRY_SCOPE_BREADCRUMBS to capture them at crash time, but the continuous -// flush passes SENTRY_SCOPE_NONE: it runs on *every* scope mutation, so folding -// the breadcrumb buffer in there would re-serialize the whole ring on every -// set_tag/set_context/... - prohibitive on a hot path such as a 60fps main -// thread. -static void -apply_scope(sentry_value_t event, const sentry_options_t *options, - sentry_scope_mode_t mode) -{ - SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event(scope, options, event, mode); - } -#if defined(SENTRY_PLATFORM_WINDOWS) - ensure_device_arch(event); -#endif -} - static void native_backend_flush_scope( sentry_backend_t *backend, const sentry_options_t *options) @@ -841,17 +817,18 @@ native_backend_flush_scope( return; } - // Keep the on-disk base event current, so the daemon has the full scope - // even if a crash beats the in-process handler to the file. Breadcrumbs are - // deliberately excluded here (SENTRY_SCOPE_NONE): they are flushed - // incrementally to the breadcrumb ring files and the crash handler captures - // them at crash time. This keeps the per-mutation flush off the breadcrumb - // serialization cost. + // Create event with current scope sentry_value_t event = sentry_value_new_object(); - // Default to `FATAL` for all paths, i.e. minidump mode. sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - apply_scope(event, options, SENTRY_SCOPE_NONE); + + // Apply scope with contexts + SENTRY_WITH_SCOPE (scope) { + sentry__scope_apply_to_event(scope, options, event, SENTRY_SCOPE_NONE); + } +#if defined(SENTRY_PLATFORM_WINDOWS) + ensure_device_arch(event); +#endif size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); @@ -1034,9 +1011,14 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - // At crash time we capture breadcrumbs (unlike the continuous - // flush) - this is the process's last chance to record them. - apply_scope(event, options, SENTRY_SCOPE_BREADCRUMBS); + // Apply scope to event including breadcrumbs + SENTRY_WITH_SCOPE (scope) { + sentry__scope_apply_to_event( + scope, options, event, SENTRY_SCOPE_BREADCRUMBS); + } +#if defined(SENTRY_PLATFORM_WINDOWS) + ensure_device_arch(event); +#endif #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From b157d91f9308f7b62c0237d2129d7f670e149d02 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 18:41:47 +0200 Subject: [PATCH 08/12] minified changes here too --- src/backends/native/sentry_crash_context.h | 3 +-- src/backends/native/sentry_crash_daemon.c | 20 ++++---------------- src/backends/sentry_backend_native.c | 4 +--- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index e2dcd9a9b0..b6c985cd3d 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -289,8 +289,7 @@ typedef struct { uint64_t shutdown_timeout; uint64_t transfer_timeout; bool system_crash_reporter_enabled; - uint32_t max_breadcrumbs; // Breadcrumb cap, so the daemon merges the ring - // files with the same limit the app enforced + uint32_t max_breadcrumbs; // Atomic user consent (sentry_user_consent_t), updated whenever user // consent changes so the daemon can honor it at crash time. diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index af0d0b3514..1873ed73ae 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2120,8 +2120,7 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) /** * Reads one breadcrumb ring file the crashing process appended on its hot path - * (concatenated msgpack values) into a breadcrumb list. Returns null if the - * file is absent or empty. + * into a breadcrumb list. Returns null if the file is absent or empty. */ static sentry_value_t read_breadcrumb_ring_file(const sentry_path_t *run_folder, const char *name) @@ -2156,9 +2155,7 @@ read_breadcrumb_ring_file(const sentry_path_t *run_folder, const char *name) /** * Assembles the crash event's breadcrumbs from the two ring files the crashing * process appended one-at-a-time, merges them in timestamp order, keeps the - * newest `max_breadcrumbs`, and attaches them to `event`. This is what keeps - * breadcrumb persistence off the per-mutation scope-flush path - the app only - * ever appends a single breadcrumb, and the daemon does the assembly here. + * newest `max_breadcrumbs`, and attaches them to `event`. * Mirrors the crashpad backend's `report_to_envelope`. */ static void @@ -2184,11 +2181,6 @@ apply_breadcrumbs_from_ring_files(sentry_value_t event, } /** - * Build a native event from the scope-complete base event, adding the - * caller-specified framing (level, mechanism) plus threads, breadcrumbs (read - * from the ring files), and debug_meta. The base event (contexts, tags, user, - * ...) is identical regardless of event type; the caller states what this - * event is. * Build a native event and set the level, mechanism, and handled state * * @param ctx Crash context @@ -2223,8 +2215,6 @@ build_native_event(const sentry_crash_context_t *ctx, event = sentry_value_new_event(); } - // Assemble breadcrumbs from the ring files (the base event carries none - - // the app keeps them off the scope-flush hot path). apply_breadcrumbs_from_ring_files(event, run_folder, ctx); // Set platform to native @@ -2806,10 +2796,8 @@ write_envelope_with_minidump(const sentry_options_t *options, const char *event_msgpack_path, const char *minidump_path, sentry_path_t *run_folder) { - // Read the base event, merge in the breadcrumbs from the ring files (the - // base event carries none), and re-serialize. Unlike the native-stacktrace - // path this mode otherwise streams the event verbatim, so we have to - // round-trip through a value to attach breadcrumbs. + // Read the base event, merge in the breadcrumbs from the ring files, + // re-serialize. size_t event_size = 0; char *event_json = NULL; char *event_id = NULL; diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 682bdbbab4..df87cad8ed 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -870,9 +870,7 @@ native_backend_add_breadcrumb(sentry_backend_t *backend, // Append as msgpack, matching the crashpad backend. msgpack values are // self-delimiting, so the daemon can read the concatenated ring file back - // into a list via `sentry__value_from_msgpack`. This is the only breadcrumb - // persistence on the hot path: one serialize + one append per breadcrumb, - // never a full scope re-serialization. + // into a list via `sentry__value_from_msgpack`. size_t mpack_size = 0; char *mpack = sentry_value_to_msgpack(breadcrumb, &mpack_size); if (!mpack) { From 1326bc5307126d2e3c02f2dc67453b53dd2f396a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 3 Jun 2026 16:42:41 +0200 Subject: [PATCH 09/12] bail with breadcrumbs disabled --- src/backends/native/sentry_crash_daemon.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 1873ed73ae..28fbda11dd 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2162,6 +2162,10 @@ static void apply_breadcrumbs_from_ring_files(sentry_value_t event, const sentry_path_t *run_folder, const sentry_crash_context_t *ctx) { + if (ctx && ctx->max_breadcrumbs == 0) { + return; + } + sentry_value_t b1 = read_breadcrumb_ring_file(run_folder, "__sentry-breadcrumb1"); sentry_value_t b2 From 4fb6895eda4bea329a980c4253d995a1d5f0fa81 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 3 Jun 2026 17:26:31 +0200 Subject: [PATCH 10/12] fixed serialization regression --- src/backends/native/sentry_crash_daemon.c | 27 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 28fbda11dd..497a26889a 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2813,11 +2813,28 @@ write_envelope_with_minidump(const sentry_options_t *options, if (base_json && base_size > 0) { sentry_value_t event = sentry__value_from_json(base_json, base_size); - apply_breadcrumbs_from_ring_files(event, run_folder, ctx); - event_id = sentry__string_clone(sentry_value_as_string( - sentry_value_get_by_key(event, "event_id"))); - event_json = sentry__value_to_json(event, &event_size); - sentry_value_decref(event); + if (sentry_value_is_null(event)) { + // Parsing the base event failed (e.g. truncated buffer or + // OOM). Don't serialize the null into "null" and ship an + // invalid payload - fall back to streaming the raw event + // bytes verbatim so the crash report is preserved. + sentry_value_decref(event); + event_json = sentry__string_clone_n(base_json, base_size); + event_size = event_json ? base_size : 0; + } else { + apply_breadcrumbs_from_ring_files(event, run_folder, ctx); + event_id = sentry__string_clone(sentry_value_as_string( + sentry_value_get_by_key(event, "event_id"))); + event_json = sentry__value_to_json(event, &event_size); + sentry_value_decref(event); + if (!event_json) { + // Re-serialization failed (e.g. OOM). Fall back to the raw + // event bytes so the crash report is preserved, losing only + // the merged breadcrumbs rather than the whole event. + event_json = sentry__string_clone_n(base_json, base_size); + event_size = event_json ? base_size : 0; + } + } } sentry_free(base_json); } From 8a5a6beabd956714bd2c4c5caebde24de8521e81 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 3 Jun 2026 17:37:21 +0200 Subject: [PATCH 11/12] fixed scope sync limit --- src/backends/sentry_backend_native.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index df87cad8ed..dec427d798 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -1014,10 +1014,11 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - // Apply scope to event including breadcrumbs + // Apply scope to the event. The daemon assembles breadcrumbs + // from the ring files SENTRY_WITH_SCOPE (scope) { sentry__scope_apply_to_event( - scope, options, event, SENTRY_SCOPE_BREADCRUMBS); + scope, options, event, SENTRY_SCOPE_NONE); } #if defined(SENTRY_PLATFORM_WINDOWS) ensure_device_arch(event); From 1533aa01373548beb26e45ce4f25529caf095211 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 10:36:58 +0200 Subject: [PATCH 12/12] added tests --- tests/test_integration_native.py | 61 +++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index a694d1848d..ede68d73ac 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -167,18 +167,33 @@ def test_native_capture_minidump_generated(cmake, httpserver): assert version != 0, "Minidump should have non-zero version" -def test_native_breadcrumbs(cmake, httpserver): - """Test that breadcrumbs are captured before crash""" +# Both daemon envelope writers merge the breadcrumb ring files: the native +# stacktrace writer builds the event from scratch, while the minidump-only +# writer re-parses the parent's event JSON, merges, and re-serializes. Exercise +# both so the minidump path's extra parse/serialize roundtrip is covered too. +BREADCRUMB_CRASH_MODES = ["native", "minidump"] + + +@pytest.mark.parametrize("crash_mode", BREADCRUMB_CRASH_MODES) +def test_native_breadcrumbs(cmake, httpserver, crash_mode): + """Test that breadcrumbs survive the daemon's ring-file merge. + + The crashing process appends breadcrumbs as msgpack to the ring files; the + daemon reads, merges, and attaches them to the event. Asserting on the + default `debug crumb` verifies that whole roundtrip, not just that an event + arrived. + """ tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") - # Add breadcrumbs then crash (use stdout for initialization delay under sanitizers) + # The default setup block adds the `debug crumb`; crash so the daemon emits + # the event (use stdout for initialization delay under sanitizers). with httpserver.wait(timeout=10) as waiting: run_crash( tmp_path, "sentry_example", - ["log", "stdout", "breadcrumb-log", "crash"], + ["log", "stdout", "crash-mode", crash_mode, "crash"], env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), ) assert waiting.result @@ -186,7 +201,43 @@ def test_native_breadcrumbs(cmake, httpserver): # Verify breadcrumbs in envelope assert len(httpserver.log) >= 1 envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) - assert envelope.get_event() + assert_breadcrumb(envelope) + + +@pytest.mark.parametrize("crash_mode", BREADCRUMB_CRASH_MODES) +def test_native_overflow_breadcrumbs(cmake, httpserver, crash_mode): + """Test that the daemon caps merged breadcrumbs at max_breadcrumbs. + + The example adds 3 default crumbs plus 101 numbered crumbs ("0".."100"). + With the default max_breadcrumbs (100), the daemon keeps the newest 100, + so the count is capped and the most-recent crumb ("100") is retained. + """ + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) + + 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", + "overflow-breadcrumbs", + "crash-mode", + crash_mode, + "crash", + ], + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + ) + assert waiting.result + + assert len(httpserver.log) >= 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + breadcrumbs = envelope.get_event()["breadcrumbs"] + + assert len(breadcrumbs) == 100 + assert any(b.get("message") == "100" for b in breadcrumbs) def test_native_session_tracking(cmake, httpserver):