diff --git a/CHANGELOG.md b/CHANGELOG.md index 122829877..29508206c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Features**: -- Add new offline caching options to persist envelopes locally, currently supported with the `inproc` and `breakpad` backends: `sentry_options_set_cache_keep`, `sentry_options_set_cache_max_items`, `sentry_options_set_cache_max_size`, and `sentry_options_set_cache_max_age`. ([#1490](https://github.com/getsentry/sentry-native/pull/1490)) +- Add new offline caching options to persist envelopes locally: `sentry_options_set_cache_keep`, `sentry_options_set_cache_max_items`, `sentry_options_set_cache_max_size`, and `sentry_options_set_cache_max_age`. ([#1490](https://github.com/getsentry/sentry-native/pull/1490), [#1493](https://github.com/getsentry/sentry-native/pull/1493)) **Fixes**: diff --git a/src/backends/sentry_backend_crashpad.cpp b/src/backends/sentry_backend_crashpad.cpp index 11dee66ef..95c60740d 100644 --- a/src/backends/sentry_backend_crashpad.cpp +++ b/src/backends/sentry_backend_crashpad.cpp @@ -19,6 +19,7 @@ extern "C" { #include "sentry_screenshot.h" #include "sentry_sync.h" #include "sentry_transport.h" +#include "sentry_value.h" #ifdef SENTRY_PLATFORM_LINUX # include "sentry_unix_pageallocator.h" #endif @@ -436,6 +437,167 @@ sentry__crashpad_handler(int signum, siginfo_t *info, ucontext_t *user_context) } #endif +static sentry_value_t +read_msgpack_file(const sentry_path_t *path) +{ + size_t size; + char *data = sentry__path_read_to_buffer(path, &size); + if (!data) { + return sentry_value_new_null(); + } + sentry_value_t value = sentry__value_from_msgpack(data, size); + sentry_free(data); + return value; +} + +static sentry_path_t * +report_attachments_dir(const crashpad::CrashReportDatabase::Report &report, + const sentry_options_t *options) +{ + sentry_path_t *attachments_root + = sentry__path_join_str(options->database_path, "attachments"); + if (!attachments_root) { + return nullptr; + } + + sentry_path_t *attachments_dir = sentry__path_join_str( + attachments_root, report.uuid.ToString().c_str()); + + sentry__path_free(attachments_root); + return attachments_dir; +} + +// Converts a completed crashpad report into a sentry envelope by reading the +// event, breadcrumbs, and attachments from the report's attachments directory. +static sentry_envelope_t * +report_to_envelope(const crashpad::CrashReportDatabase::Report &report, + const sentry_options_t *options) +{ +#ifdef SENTRY_PLATFORM_WINDOWS + sentry_path_t *minidump_path + = sentry__path_from_wstr(report.file_path.value().c_str()); +#else + sentry_path_t *minidump_path + = sentry__path_from_str(report.file_path.value().c_str()); +#endif + sentry_path_t *attachments_dir = report_attachments_dir(report, options); + + if (!minidump_path || !attachments_dir) { + sentry__path_free(minidump_path); + sentry__path_free(attachments_dir); + return nullptr; + } + + sentry_value_t event = sentry_value_new_null(); + sentry_value_t breadcrumbs1 = sentry_value_new_null(); + sentry_value_t breadcrumbs2 = sentry_value_new_null(); + sentry_attachment_t *attachments = nullptr; + + sentry_pathiter_t *iter = sentry__path_iter_directory(attachments_dir); + if (iter) { + const sentry_path_t *path; + while ((path = sentry__pathiter_next(iter)) != nullptr) { + const char *filename = sentry__path_filename(path); + if (strcmp(filename, "__sentry-event") == 0) { + event = read_msgpack_file(path); + } else if (strcmp(filename, "__sentry-breadcrumb1") == 0) { + breadcrumbs1 = read_msgpack_file(path); + } else if (strcmp(filename, "__sentry-breadcrumb2") == 0) { + breadcrumbs2 = read_msgpack_file(path); + } else { + sentry__attachments_add_path(&attachments, + sentry__path_clone(path), ATTACHMENT, nullptr); + } + } + sentry__pathiter_free(iter); + } + sentry__path_free(attachments_dir); + + sentry_envelope_t *envelope = nullptr; + if (!sentry_value_is_null(event)) { + envelope = sentry__envelope_new(); + if (envelope && options->dsn && options->dsn->is_valid) { + sentry__envelope_set_header(envelope, "dsn", + sentry_value_new_string(sentry_options_get_dsn(options))); + } + } + if (envelope) { + sentry_value_set_by_key(event, "breadcrumbs", + sentry__value_merge_breadcrumbs( + breadcrumbs1, breadcrumbs2, options->max_breadcrumbs)); + sentry__attachments_add_path( + &attachments, minidump_path, MINIDUMP, nullptr); + + if (sentry__envelope_add_event(envelope, event)) { + sentry__envelope_add_attachments(envelope, attachments); + } else { + sentry_value_decref(event); + sentry_envelope_free(envelope); + envelope = nullptr; + } + } else { + sentry__path_free(minidump_path); + sentry_value_decref(event); + } + + sentry_value_decref(breadcrumbs1); + sentry_value_decref(breadcrumbs2); + sentry__attachments_free(attachments); + + return envelope; +} + +// Caches completed crashpad reports as sentry envelopes and removes them from +// the crashpad database. Called during startup before the handler is started. +static void +process_completed_reports( + crashpad_state_t *state, const sentry_options_t *options) +{ + if (!state || !state->db || !options || !options->cache_keep) { + return; + } + + std::vector reports; + if (state->db->GetCompletedReports(&reports) + != crashpad::CrashReportDatabase::kNoError + || reports.empty()) { + return; + } + + SENTRY_DEBUGF("caching %zu completed reports", reports.size()); + + sentry_path_t *cache_dir + = sentry__path_join_str(options->database_path, "cache"); + if (!cache_dir || sentry__path_create_dir_all(cache_dir) != 0) { + SENTRY_WARN("failed to create cache dir"); + sentry__path_free(cache_dir); + return; + } + + for (const auto &report : reports) { + std::string filename = report.uuid.ToString() + ".envelope"; + sentry_envelope_t *envelope = report_to_envelope(report, options); + if (!envelope) { + SENTRY_WARNF("failed to convert \"%s\"", filename.c_str()); + continue; + } + sentry_path_t *out_path + = sentry__path_join_str(cache_dir, filename.c_str()); + if (!out_path + || (!sentry__path_is_file(out_path) + && sentry_envelope_write_to_path(envelope, out_path) != 0)) { + SENTRY_WARNF("failed to cache \"%s\"", filename.c_str()); + } else if (state->db->DeleteReport(report.uuid) + != crashpad::CrashReportDatabase::kNoError) { + SENTRY_WARNF("failed to delete \"%s\"", filename.c_str()); + } + sentry__path_free(out_path); + sentry_envelope_free(envelope); + } + + sentry__path_free(cache_dir); +} + static int crashpad_backend_startup( sentry_backend_t *backend, const sentry_options_t *options) @@ -549,6 +711,7 @@ crashpad_backend_startup( // Initialize database first, flushing the consent later on as part of // `sentry_init` will persist the upload flag. data->db = crashpad::CrashReportDatabase::Initialize(database).release(); + process_completed_reports(data, options); data->client = new crashpad::CrashpadClient; char *minidump_url = sentry__dsn_get_minidump_url(options->dsn, options->user_agent); diff --git a/src/sentry_envelope.c b/src/sentry_envelope.c index 3e8225284..d5ffd96da 100644 --- a/src/sentry_envelope.c +++ b/src/sentry_envelope.c @@ -169,7 +169,7 @@ sentry_envelope_free(sentry_envelope_t *envelope) sentry_free(envelope); } -static void +void sentry__envelope_set_header( sentry_envelope_t *envelope, const char *key, sentry_value_t value) { diff --git a/src/sentry_envelope.h b/src/sentry_envelope.h index 0cae74e69..56e342305 100644 --- a/src/sentry_envelope.h +++ b/src/sentry_envelope.h @@ -106,6 +106,12 @@ sentry_envelope_item_t *sentry__envelope_add_from_buffer( sentry_envelope_t *envelope, const char *buf, size_t buf_len, const char *type); +/** + * This sets an explicit header for the given envelope. + */ +void sentry__envelope_set_header( + sentry_envelope_t *envelope, const char *key, sentry_value_t value); + /** * This sets an explicit header for the given envelope item. */ diff --git a/tests/test_integration_crashpad.py b/tests/test_integration_crashpad.py index 815cd9ea6..619ed0fa5 100644 --- a/tests/test_integration_crashpad.py +++ b/tests/test_integration_crashpad.py @@ -27,6 +27,7 @@ assert_breadcrumb, assert_crashpad_upload, assert_meta, + assert_minidump, assert_session, assert_gzip_file_header, assert_logs, @@ -765,3 +766,187 @@ def test_crashpad_external_crash_reporter(cmake, httpserver, run_args): ) def test_crashpad_external_crash_reporter_wer(cmake, httpserver, run_args): test_crashpad_external_crash_reporter(cmake, httpserver, run_args) + + +@pytest.mark.parametrize("cache_keep", [True, False]) +def test_crashpad_cache_keep(cmake, httpserver, cache_keep): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "crashpad"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "crash"] + (["cache-keep"] if cache_keep else []), + expect_failure=True, + env=env, + ) + assert waiting.result + + assert not cache_dir.exists() or len(list(cache_dir.glob("*.envelope"))) == 0 + + # upload + run( + tmp_path, + "sentry_example", + ["log", "no-setup"] + (["cache-keep"] if cache_keep else []), + env=env, + ) + + # cache + run( + tmp_path, + "sentry_example", + ["log", "no-setup"] + (["cache-keep"] if cache_keep else []), + env=env, + ) + + assert cache_dir.exists() or cache_keep is False + if cache_keep: + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + with open(cache_files[0], "rb") as f: + envelope = Envelope.deserialize_from(f) + assert "dsn" in envelope.headers + assert_meta(envelope, integration="crashpad") + assert_minidump(envelope) + + +def test_crashpad_cache_max_size(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "crashpad"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # 5 x 2mb + for i in range(5): + httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data( + "OK" + ) + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "crash"], + expect_failure=True, + env=env, + ) + assert waiting.result + + # upload + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + # cache + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + if cache_dir.exists(): + for f in cache_dir.glob("*.envelope"): + with open(f, "r+b") as file: + file.truncate(2 * 1024 * 1024) + + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + # max 4mb + assert cache_dir.exists() + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) <= 2 + assert sum(f.stat().st_size for f in cache_files) <= 4 * 1024 * 1024 + + +def test_crashpad_cache_max_age(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "crashpad"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # 4 crashes that get fully cached + for i in range(4): + httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data( + "OK" + ) + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "crash"], + expect_failure=True, + env=env, + ) + assert waiting.result + + # upload + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + # cache + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + # 2,4,6,8 days old + assert cache_dir.exists() + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 4 + for i, f in enumerate(cache_files): + mtime = time.time() - ((i + 1) * 2 * 24 * 60 * 60) + os.utime(str(f), (mtime, mtime)) + + # 5th crash - only upload, not cached yet + httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "crash"], + expect_failure=True, + env=env, + ) + assert waiting.result + + # upload + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + # 0 days old (caches 5th crash + prunes old files) + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + # max 5 days + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 3 + for f in cache_files: + assert time.time() - f.stat().st_mtime <= 5 * 24 * 60 * 60