diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index 40f43b4f18..754dd64978 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -290,6 +290,7 @@ typedef struct { uint64_t shutdown_timeout; uint64_t transfer_timeout; bool system_crash_reporter_enabled; + 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 16f91ba8eb..8e940d473e 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2223,19 +2223,86 @@ 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 + * 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`. + * 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) +{ + if (ctx && ctx->max_breadcrumbs == 0) { + return; + } + + 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 and set the level, mechanism, and handled state * * @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(); @@ -2257,6 +2324,8 @@ build_native_event(const sentry_crash_context_t *ctx, event = sentry_value_new_event(); } + 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")); @@ -2594,7 +2663,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; @@ -2843,21 +2912,43 @@ 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, + // re-serialize. 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); - event_id = sentry__string_clone(sentry_value_as_string( - sentry_value_get_by_key(event, "event_id"))); - sentry_value_decref(event); + = sentry__value_from_json(base_json, base_size); + 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); } // Open envelope file for writing diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 7768fe0304..dec427d798 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)); @@ -867,18 +868,20 @@ 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`. + 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"); @@ -1011,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); diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 54180014b7..7c81390dd9 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -174,18 +174,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 @@ -193,7 +208,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):