Skip to content

Commit 371e7b2

Browse files
jpnurmiclaude
andcommitted
feat(cache): cache consent-revoked envelopes
When user consent is required and currently revoked, route envelopes through the cache instead of discarding them, so they can be sent once consent is given. Caching is gated on cache_keep || http_retry — with neither, envelopes are still discarded (existing behavior). When http_retry is enabled, the retry poller stays alive while consent is revoked, and giving consent triggers an immediate retry pass so cached envelopes flush without waiting for the next poll. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6c7f08b commit 371e7b2

10 files changed

Lines changed: 257 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
**Features**:
6+
7+
- Cache consent-revoked envelopes to disk when `cache_keep` or `http_retry` is enabled, instead of discarding them. With `http_retry`, the cached envelopes are sent automatically once consent is given. ([#1542](https://github.com/getsentry/sentry-native/pull/1542))
8+
59
**Fixes**:
610

711
- Linux: handle `ENOSYS` in `read_safely` to fix empty module list in seccomp-restricted environments. ([#1655](https://github.com/getsentry/sentry-native/pull/1655))

examples/example.c

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,9 @@ main(int argc, char **argv)
694694
if (has_arg(argc, argv, "log-attributes")) {
695695
sentry_options_set_logs_with_attributes(options, true);
696696
}
697+
if (has_arg(argc, argv, "require-user-consent")) {
698+
sentry_options_set_require_user_consent(options, true);
699+
}
697700
if (has_arg(argc, argv, "cache-keep")) {
698701
sentry_options_set_cache_keep(options, true);
699702
sentry_options_set_cache_max_size(options, 4 * 1024 * 1024); // 4 MB
@@ -758,6 +761,10 @@ main(int argc, char **argv)
758761
return EXIT_FAILURE;
759762
}
760763

764+
if (has_arg(argc, argv, "user-consent-revoke")) {
765+
sentry_user_consent_revoke();
766+
}
767+
761768
if (has_arg(argc, argv, "set-global-attribute")) {
762769
sentry_set_attribute("global.attribute.bool",
763770
sentry_value_new_attribute(sentry_value_new_bool(true), NULL));
@@ -1049,6 +1056,10 @@ main(int argc, char **argv)
10491056
}
10501057
sentry_capture_event(event);
10511058
}
1059+
if (has_arg(argc, argv, "user-consent-give")) {
1060+
sentry_user_consent_give();
1061+
sentry_flush(10000);
1062+
}
10521063
if (has_arg(argc, argv, "capture-exception")) {
10531064
sentry_value_t exc = sentry_value_new_exception(
10541065
"ParseIntError", "invalid digit found in string");

include/sentry.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,6 +1476,11 @@ SENTRY_API int sentry_options_get_auto_session_tracking(
14761476
* This disables uploads until the user has given the consent to the SDK.
14771477
* Consent itself is given with `sentry_user_consent_give` and
14781478
* `sentry_user_consent_revoke`.
1479+
*
1480+
* When combined with `cache_keep` or `http_retry`, envelopes captured
1481+
* while consent is revoked are written to the cache directory instead
1482+
* of being discarded. With `http_retry` enabled, cached envelopes are
1483+
* sent automatically once consent is given.
14791484
*/
14801485
SENTRY_API void sentry_options_set_require_user_consent(
14811486
sentry_options_t *opts, int val);
@@ -1511,6 +1516,10 @@ SENTRY_API int sentry_options_get_symbolize_stacktraces(
15111516
* subdirectory within the database directory. The cache is cleared on startup
15121517
* based on the cache_max_items, cache_max_size, and cache_max_age options.
15131518
*
1519+
* When combined with `sentry_options_set_require_user_consent`, envelopes
1520+
* captured while consent is revoked are also written to the cache. With
1521+
* `http_retry` enabled, they are sent once consent is given.
1522+
*
15141523
* Only applicable for HTTP transports.
15151524
*
15161525
* Disabled by default.
@@ -1960,6 +1969,9 @@ SENTRY_EXPERIMENTAL_API int sentry_reinstall_backend(void);
19601969

19611970
/**
19621971
* Gives user consent.
1972+
*
1973+
* Schedules a retry of any envelopes cached while consent was revoked,
1974+
* provided that `http_retry` is enabled.
19631975
*/
19641976
SENTRY_API void sentry_user_consent_give(void);
19651977

src/sentry_core.c

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,8 @@ set_user_consent(sentry_user_consent_t new_val)
427427
switch (new_val) {
428428
case SENTRY_USER_CONSENT_GIVEN:
429429
sentry__path_write_buffer(consent_path, "1\n", 2);
430+
// flush any envelopes cached while consent was revoked
431+
sentry_transport_retry(options->transport);
430432
break;
431433
case SENTRY_USER_CONSENT_REVOKED:
432434
sentry__path_write_buffer(consent_path, "0\n", 2);
@@ -483,13 +485,24 @@ void
483485
sentry__capture_envelope(
484486
sentry_transport_t *transport, sentry_envelope_t *envelope)
485487
{
486-
bool has_consent = !sentry__should_skip_upload();
487-
if (!has_consent) {
488-
SENTRY_INFO("discarding envelope due to missing user consent");
489-
sentry_envelope_free(envelope);
488+
if (!sentry__should_skip_upload()) {
489+
sentry__transport_send_envelope(transport, envelope);
490490
return;
491491
}
492-
sentry__transport_send_envelope(transport, envelope);
492+
bool cached = false;
493+
SENTRY_WITH_OPTIONS (options) {
494+
if (options->cache_keep || options->http_retry) {
495+
cached = sentry__run_write_cache(options->run, envelope, 0);
496+
if (cached && !sentry__run_should_skip_upload(options->run)) {
497+
// consent given meanwhile -> trigger retry to avoid waiting
498+
// until the next retry poll
499+
sentry_transport_retry(options->transport);
500+
}
501+
}
502+
}
503+
SENTRY_INFO(cached ? "caching envelope due to missing user consent"
504+
: "discarding envelope due to missing user consent");
505+
sentry_envelope_free(envelope);
493506
}
494507

495508
void

src/sentry_database.c

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,9 @@ sentry__process_old_runs(const sentry_options_t *options, uint64_t last_crash)
458458
}
459459
} else if (sentry__path_ends_with(file, ".envelope")) {
460460
sentry_envelope_t *envelope = sentry__envelope_from_path(file);
461-
sentry__capture_envelope(options->transport, envelope);
461+
if (envelope) {
462+
sentry__capture_envelope(options->transport, envelope);
463+
}
462464
}
463465

464466
sentry__path_remove(file);
@@ -470,7 +472,9 @@ sentry__process_old_runs(const sentry_options_t *options, uint64_t last_crash)
470472
}
471473
sentry__pathiter_free(db_iter);
472474

473-
sentry__capture_envelope(options->transport, session_envelope);
475+
if (session_envelope) {
476+
sentry__capture_envelope(options->transport, session_envelope);
477+
}
474478
}
475479

476480
// Cache Pruning below is based on prune_crash_reports.cc from Crashpad

src/sentry_retry.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ size_t
141141
sentry__retry_send(sentry_retry_t *retry, uint64_t before,
142142
sentry_retry_send_func_t send_cb, void *data)
143143
{
144+
if (sentry__run_should_skip_upload(retry->run)) {
145+
return 1; // keep the poll alive until consent is given
146+
}
147+
144148
sentry_pathiter_t *piter
145149
= sentry__path_iter_directory(retry->run->cache_path);
146150
if (!piter) {

tests/test_integration_cache.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,78 @@ def test_cache_max_items_with_retry(cmake, backend, unreachable_dsn):
263263
assert cache_dir.exists()
264264
cache_files = list(cache_dir.glob("*.envelope"))
265265
assert len(cache_files) <= 5
266+
267+
268+
def test_cache_consent_revoke(cmake, unreachable_dsn):
269+
"""With consent revoked and cache_keep, envelopes are cached to disk."""
270+
tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"})
271+
cache_dir = tmp_path.joinpath(".sentry-native/cache")
272+
env = dict(os.environ, SENTRY_DSN=unreachable_dsn)
273+
274+
run(
275+
tmp_path,
276+
"sentry_example",
277+
[
278+
"log",
279+
"cache-keep",
280+
"require-user-consent",
281+
"user-consent-revoke",
282+
"capture-event",
283+
"flush",
284+
],
285+
env=env,
286+
)
287+
288+
assert cache_dir.exists()
289+
cache_files = list(cache_dir.glob("*.envelope"))
290+
assert len(cache_files) == 1
291+
292+
293+
def test_cache_consent_discard(cmake, unreachable_dsn):
294+
"""With consent revoked but no cache_keep, envelopes are discarded."""
295+
tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"})
296+
cache_dir = tmp_path.joinpath(".sentry-native/cache")
297+
env = dict(os.environ, SENTRY_DSN=unreachable_dsn)
298+
299+
run(
300+
tmp_path,
301+
"sentry_example",
302+
[
303+
"log",
304+
"require-user-consent",
305+
"user-consent-revoke",
306+
"capture-event",
307+
"flush",
308+
],
309+
env=env,
310+
)
311+
312+
assert not cache_dir.exists() or len(list(cache_dir.glob("*.envelope"))) == 0
313+
314+
315+
def test_cache_consent_flush(cmake, httpserver):
316+
"""Giving consent after capturing flushes cached envelopes immediately."""
317+
from . import make_dsn
318+
319+
tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"})
320+
cache_dir = tmp_path.joinpath(".sentry-native/cache")
321+
env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver))
322+
323+
httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK")
324+
325+
run(
326+
tmp_path,
327+
"sentry_example",
328+
[
329+
"log",
330+
"http-retry",
331+
"require-user-consent",
332+
"user-consent-revoke",
333+
"capture-event",
334+
"user-consent-give",
335+
],
336+
env=env,
337+
)
338+
339+
assert len(httpserver.log) >= 1
340+
assert not cache_dir.exists() or len(list(cache_dir.glob("*.envelope"))) == 0

tests/unit/test_cache.c

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include "sentry_envelope.h"
44
#include "sentry_options.h"
55
#include "sentry_path.h"
6+
#include "sentry_retry.h"
67
#include "sentry_string.h"
78
#include "sentry_testsupport.h"
89
#include "sentry_uuid.h"
@@ -310,6 +311,83 @@ SENTRY_TEST(cache_max_items_with_retry)
310311
sentry_close();
311312
}
312313

314+
SENTRY_TEST(cache_consent_revoked)
315+
{
316+
#if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS)
317+
SKIP_TEST();
318+
#endif
319+
SENTRY_TEST_OPTIONS_NEW(options);
320+
sentry_options_set_dsn(options, "https://foo@sentry.invalid/42");
321+
sentry_options_set_cache_keep(options, true);
322+
sentry_options_set_require_user_consent(options, true);
323+
sentry_init(options);
324+
sentry_user_consent_revoke();
325+
326+
sentry_path_t *cache_path
327+
= sentry__path_join_str(options->database_path, "cache");
328+
TEST_ASSERT(!!cache_path);
329+
sentry__path_remove_all(cache_path);
330+
331+
sentry_capture_event(
332+
sentry_value_new_message_event(SENTRY_LEVEL_INFO, "test", "revoked"));
333+
334+
int count = 0;
335+
bool is_retry_format = false;
336+
sentry_pathiter_t *iter = sentry__path_iter_directory(cache_path);
337+
const sentry_path_t *entry;
338+
while (iter && (entry = sentry__pathiter_next(iter)) != NULL) {
339+
if (sentry__path_ends_with(entry, ".envelope")) {
340+
count++;
341+
uint64_t ts;
342+
int attempt;
343+
const char *uuid;
344+
is_retry_format = sentry__parse_cache_filename(
345+
sentry__path_filename(entry), &ts, &attempt, &uuid);
346+
}
347+
}
348+
sentry__pathiter_free(iter);
349+
TEST_CHECK_INT_EQUAL(count, 1);
350+
TEST_CHECK(is_retry_format);
351+
352+
sentry__path_free(cache_path);
353+
sentry_close();
354+
}
355+
356+
SENTRY_TEST(cache_consent_revoked_nocache)
357+
{
358+
#if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS)
359+
SKIP_TEST();
360+
#endif
361+
SENTRY_TEST_OPTIONS_NEW(options);
362+
sentry_options_set_dsn(options, "https://foo@sentry.invalid/42");
363+
sentry_options_set_cache_keep(options, false);
364+
sentry_options_set_require_user_consent(options, true);
365+
sentry_init(options);
366+
sentry_user_consent_revoke();
367+
368+
sentry_path_t *cache_path
369+
= sentry__path_join_str(options->database_path, "cache");
370+
TEST_ASSERT(!!cache_path);
371+
sentry__path_remove_all(cache_path);
372+
373+
sentry_capture_event(
374+
sentry_value_new_message_event(SENTRY_LEVEL_INFO, "test", "revoked"));
375+
376+
int count = 0;
377+
sentry_pathiter_t *iter = sentry__path_iter_directory(cache_path);
378+
const sentry_path_t *entry;
379+
while (iter && (entry = sentry__pathiter_next(iter)) != NULL) {
380+
if (sentry__path_ends_with(entry, ".envelope")) {
381+
count++;
382+
}
383+
}
384+
sentry__pathiter_free(iter);
385+
TEST_CHECK_INT_EQUAL(count, 0);
386+
387+
sentry__path_free(cache_path);
388+
sentry_close();
389+
}
390+
313391
SENTRY_TEST(cache_max_size_and_age)
314392
{
315393
#if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS)

tests/unit/test_retry.c

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,49 @@ SENTRY_TEST(retry_trigger)
489489
sentry__retry_free(retry);
490490
sentry_close();
491491
}
492+
493+
SENTRY_TEST(retry_consent)
494+
{
495+
#if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS)
496+
SKIP_TEST();
497+
#endif
498+
SENTRY_TEST_OPTIONS_NEW(options);
499+
sentry_options_set_dsn(options, "https://foo@sentry.invalid/42");
500+
sentry_options_set_http_retry(options, false);
501+
sentry_options_set_require_user_consent(options, true);
502+
sentry_init(options);
503+
sentry_user_consent_revoke();
504+
505+
sentry_retry_t *retry = sentry__retry_new(options);
506+
TEST_ASSERT(!!retry);
507+
508+
const sentry_path_t *cache_path = options->run->cache_path;
509+
sentry__path_remove_all(cache_path);
510+
sentry__path_create_dir_all(cache_path);
511+
512+
uint64_t old_ts
513+
= sentry__usec_time() / 1000 - 10 * sentry__retry_backoff(0);
514+
sentry_uuid_t event_id = sentry_uuid_new_v4();
515+
write_retry_file(options->run, old_ts, 0, &event_id);
516+
517+
TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 1);
518+
519+
// consent revoked: retry_send skips the round without calling send_cb,
520+
// but returns non-zero to keep the poll alive until consent is given
521+
retry_test_ctx_t ctx = { 200, 0 };
522+
size_t remaining = sentry__retry_send(retry, 0, test_send_cb, &ctx);
523+
TEST_CHECK_INT_EQUAL(ctx.count, 0);
524+
TEST_CHECK(remaining != 0);
525+
TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 1);
526+
527+
// give consent: retry_send sends and removes the file
528+
sentry_user_consent_give();
529+
ctx = (retry_test_ctx_t) { 200, 0 };
530+
remaining = sentry__retry_send(retry, UINT64_MAX, test_send_cb, &ctx);
531+
TEST_CHECK_INT_EQUAL(ctx.count, 1);
532+
TEST_CHECK_INT_EQUAL(remaining, 0);
533+
TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 0);
534+
535+
sentry__retry_free(retry);
536+
sentry_close();
537+
}

tests/unit/tests.inc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ XX(bgworker_flush)
4040
XX(bgworker_task_delay)
4141
XX(breadcrumb_without_type_or_message_still_valid)
4242
XX(build_id_parser)
43+
XX(cache_consent_revoked)
44+
XX(cache_consent_revoked_nocache)
4345
XX(cache_keep)
4446
XX(cache_max_age)
4547
XX(cache_max_items)
@@ -208,6 +210,7 @@ XX(read_write_envelope_to_invalid_path)
208210
XX(recursive_paths)
209211
XX(retry_backoff)
210212
XX(retry_cache)
213+
XX(retry_consent)
211214
XX(retry_filename)
212215
XX(retry_make_cache_path)
213216
XX(retry_result)

0 commit comments

Comments
 (0)