Skip to content
Merged
1 change: 1 addition & 0 deletions src/backends/native/sentry_crash_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ typedef struct {
uint64_t shutdown_timeout;
uint64_t transfer_timeout;
bool system_crash_reporter_enabled;
uint32_t max_breadcrumbs;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's the daemon now constructing the breadcrumbs from files it needs the max_breadcrumbs.


// Atomic user consent (sentry_user_consent_t), updated whenever user
// consent changes so the daemon can honor it at crash time.
Expand Down
111 changes: 101 additions & 10 deletions src/backends/native/sentry_crash_daemon.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
bitsandfoxes marked this conversation as resolved.
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();
Expand All @@ -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"));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Comment thread
bitsandfoxes marked this conversation as resolved.
}
}
sentry_free(base_json);
}

// Open envelope file for writing
Expand Down
22 changes: 13 additions & 9 deletions src/backends/sentry_backend_native.c
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -867,18 +868,20 @@ native_backend_add_breadcrumb(sentry_backend_t *backend,
return;
}

// Serialize to JSON (so it can be deserialized on next start)

@bitsandfoxes bitsandfoxes Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That comment is misleading, could not find who or what was reading the JSON on the 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");
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 56 additions & 5 deletions tests/test_integration_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,26 +174,77 @@ 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

# 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):
Expand Down
Loading