From 7bc89b0fb9dfcb952287bfc2bbdcb3d1bf3dd9f3 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 2 Mar 2026 12:43:00 +0100 Subject: [PATCH 1/6] feat: client reports Track and report discarded events per the client reports spec: - sample_rate: events dropped by sample rate - before_send: events, transactions, logs, and metrics dropped by before_send hooks - ratelimit_backoff: envelope items dropped by rate limiter - network_error: envelope items lost on send failure - send_error: envelope items rejected by the server (4xx/5xx) - queue_overflow: logs and metrics dropped when batcher is full Reports are opportunistically attached to outgoing envelopes. Opt-out via sentry_options_set_send_client_reports(). See https://develop.sentry.dev/sdk/telemetry/client-reports/ --- CHANGELOG.md | 1 + include/sentry.h | 21 ++ src/CMakeLists.txt | 2 + src/sentry_batcher.c | 8 +- src/sentry_batcher.h | 5 +- src/sentry_client_report.c | 197 +++++++++++++++ src/sentry_client_report.h | 72 ++++++ src/sentry_core.c | 9 + src/sentry_envelope.c | 70 +++++- src/sentry_envelope.h | 17 +- src/sentry_logs.c | 5 +- src/sentry_metrics.c | 7 +- src/sentry_options.c | 13 + src/sentry_options.h | 1 + src/transports/sentry_http_transport.c | 39 ++- tests/__init__.py | 1 + tests/assertions.py | 71 ++++++ tests/test_integration_client_reports.py | 257 ++++++++++++++++++++ tests/test_integration_http.py | 57 +++++ tests/unit/CMakeLists.txt | 1 + tests/unit/test_client_report.c | 296 +++++++++++++++++++++++ tests/unit/test_envelopes.c | 50 ++++ tests/unit/tests.inc | 6 + 23 files changed, 1184 insertions(+), 22 deletions(-) create mode 100644 src/sentry_client_report.c create mode 100644 src/sentry_client_report.h create mode 100644 tests/test_integration_client_reports.py create mode 100644 tests/unit/test_client_report.c diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a21b4fe..07ab76bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Add HTTP retry with exponential backoff, opt-in via `sentry_options_set_http_retry`. ([#1520](https://github.com/getsentry/sentry-native/pull/1520)) - Store minidump attachments as separate `.dmp` files in the offline cache for direct debugger access. ([#1607](https://github.com/getsentry/sentry-native/pull/1607)) - Enable metrics by default; metrics are now opt-out via `sentry_options_set_enable_metrics(options, false)`. ([#1609](https://github.com/getsentry/sentry-native/pull/1609)) +- Track discarded events via client reports. ([#1549](https://github.com/getsentry/sentry-native/pull/1549)) **Fixes**: diff --git a/include/sentry.h b/include/sentry.h index 4454d34ee..348d34815 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2304,6 +2304,27 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_logs_with_attributes( SENTRY_EXPERIMENTAL_API int sentry_options_get_logs_with_attributes( const sentry_options_t *opts); +/** + * Enables or disables client reports. + * + * Client reports allow the SDK to track and report why events were discarded + * before being sent to Sentry (e.g., due to sampling, hooks, rate limiting, + * network or send errors, queue overflow, etc.). + * + * When enabled (the default), client reports are opportunistically attached to + * outgoing envelopes to minimize HTTP requests. + * + * See https://develop.sentry.dev/sdk/telemetry/client-reports/ for details. + */ +SENTRY_API void sentry_options_set_send_client_reports( + sentry_options_t *opts, int val); + +/** + * Returns true if client reports are enabled. + */ +SENTRY_API int sentry_options_get_send_client_reports( + const sentry_options_t *opts); + /** * The potential returns of calling any of the sentry_log_X functions * - Success means a log was enqueued diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 326fd3ceb..9c8b2aeb6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -8,6 +8,8 @@ sentry_target_sources_cwd(sentry sentry_batcher.c sentry_batcher.h sentry_boot.h + sentry_client_report.c + sentry_client_report.h sentry_core.c sentry_core.h sentry_cpu_relax.h diff --git a/src/sentry_batcher.c b/src/sentry_batcher.c index 98806d703..e99161334 100644 --- a/src/sentry_batcher.c +++ b/src/sentry_batcher.c @@ -1,5 +1,6 @@ #include "sentry_batcher.h" #include "sentry_alloc.h" +#include "sentry_client_report.h" #include "sentry_cpu_relax.h" #include "sentry_options.h" #include "sentry_utils.h" @@ -21,7 +22,8 @@ #endif sentry_batcher_t * -sentry__batcher_new(sentry_batch_func_t batch_func) +sentry__batcher_new( + sentry_batch_func_t batch_func, sentry_data_category_t data_category) { sentry_batcher_t *batcher = SENTRY_MAKE(sentry_batcher_t); if (!batcher) { @@ -30,6 +32,7 @@ sentry__batcher_new(sentry_batch_func_t batch_func) memset(batcher, 0, sizeof(sentry_batcher_t)); batcher->refcount = 1; batcher->batch_func = batch_func; + batcher->data_category = data_category; batcher->thread_state = (long)SENTRY_BATCHER_THREAD_STOPPED; sentry__waitable_flag_init(&batcher->request_flush); sentry__thread_init(&batcher->batching_thread); @@ -301,7 +304,8 @@ sentry__batcher_enqueue(sentry_batcher_t *batcher, sentry_value_t item) // Buffer is already full, roll back our increments and retry or drop. sentry__atomic_fetch_and_add(&active->adding, -1); if (attempt == ENQUEUE_MAX_RETRIES) { - // TODO report this (e.g. client reports) + sentry__client_report_discard(SENTRY_DISCARD_REASON_QUEUE_OVERFLOW, + batcher->data_category, 1); return false; } } diff --git a/src/sentry_batcher.h b/src/sentry_batcher.h index 2812fd95d..29563bf74 100644 --- a/src/sentry_batcher.h +++ b/src/sentry_batcher.h @@ -2,6 +2,7 @@ #define SENTRY_BATCHER_H_INCLUDED #include "sentry_boot.h" +#include "sentry_client_report.h" #include "sentry_database.h" #include "sentry_envelope.h" #include "sentry_sync.h" @@ -44,6 +45,7 @@ typedef struct { sentry_waitable_flag_t request_flush; // level-triggered flush flag sentry_threadid_t batching_thread; // the batching thread sentry_batch_func_t batch_func; // function to add items to envelope + sentry_data_category_t data_category; // for client report discard tracking sentry_dsn_t *dsn; sentry_transport_t *transport; sentry_run_t *run; @@ -57,7 +59,8 @@ typedef struct { #define SENTRY_BATCHER_REF_INIT { NULL, 0 } -sentry_batcher_t *sentry__batcher_new(sentry_batch_func_t batch_func); +sentry_batcher_t *sentry__batcher_new( + sentry_batch_func_t batch_func, sentry_data_category_t data_category); /** * Acquires a reference to the batcher behind `ref`, atomically incrementing diff --git a/src/sentry_client_report.c b/src/sentry_client_report.c new file mode 100644 index 000000000..e38a0d2f0 --- /dev/null +++ b/src/sentry_client_report.c @@ -0,0 +1,197 @@ +#include "sentry_client_report.h" +#include "sentry_alloc.h" +#include "sentry_envelope.h" +#include "sentry_json.h" +#include "sentry_ratelimiter.h" +#include "sentry_string.h" +#include "sentry_sync.h" +#include "sentry_utils.h" +#include "sentry_value.h" + +// Counters for discarded events, indexed by [reason][category] +static volatile long g_discard_counts[SENTRY_DISCARD_REASON_MAX] + [SENTRY_DATA_CATEGORY_MAX] + = { { 0 } }; + +static const char * +discard_reason_to_string(sentry_discard_reason_t reason) +{ + switch (reason) { + case SENTRY_DISCARD_REASON_QUEUE_OVERFLOW: + return "queue_overflow"; + case SENTRY_DISCARD_REASON_RATELIMIT_BACKOFF: + return "ratelimit_backoff"; + case SENTRY_DISCARD_REASON_NETWORK_ERROR: + return "network_error"; + case SENTRY_DISCARD_REASON_SAMPLE_RATE: + return "sample_rate"; + case SENTRY_DISCARD_REASON_BEFORE_SEND: + return "before_send"; + case SENTRY_DISCARD_REASON_EVENT_PROCESSOR: + return "event_processor"; + case SENTRY_DISCARD_REASON_SEND_ERROR: + return "send_error"; + case SENTRY_DISCARD_REASON_MAX: + default: + return "unknown"; + } +} + +static const char * +data_category_to_string(sentry_data_category_t category) +{ + switch (category) { + case SENTRY_DATA_CATEGORY_ERROR: + return "error"; + case SENTRY_DATA_CATEGORY_SESSION: + return "session"; + case SENTRY_DATA_CATEGORY_TRANSACTION: + return "transaction"; + case SENTRY_DATA_CATEGORY_ATTACHMENT: + return "attachment"; + case SENTRY_DATA_CATEGORY_LOG_ITEM: + return "log_item"; + case SENTRY_DATA_CATEGORY_FEEDBACK: + return "feedback"; + case SENTRY_DATA_CATEGORY_TRACE_METRIC: + return "trace_metric"; + case SENTRY_DATA_CATEGORY_MAX: + default: + return "unknown"; + } +} + +void +sentry__client_report_discard(sentry_discard_reason_t reason, + sentry_data_category_t category, long quantity) +{ + if (reason >= SENTRY_DISCARD_REASON_MAX + || category >= SENTRY_DATA_CATEGORY_MAX || quantity <= 0) { + return; + } + + sentry__atomic_fetch_and_add( + (long *)&g_discard_counts[reason][category], quantity); +} + +bool +sentry__client_report_has_pending(void) +{ + for (int r = 0; r < SENTRY_DISCARD_REASON_MAX; r++) { + for (int c = 0; c < SENTRY_DATA_CATEGORY_MAX; c++) { + if (sentry__atomic_fetch((long *)&g_discard_counts[r][c]) > 0) { + return true; + } + } + } + return false; +} + +sentry_envelope_item_t * +sentry__client_report_into_envelope(sentry_envelope_t *envelope) +{ + if (!envelope || !sentry__client_report_has_pending()) { + return NULL; + } + + sentry_jsonwriter_t *jw = sentry__jsonwriter_new_sb(NULL); + if (!jw) { + return NULL; + } + + sentry__jsonwriter_write_object_start(jw); + sentry__jsonwriter_write_key(jw, "timestamp"); + char *timestamp = sentry__usec_time_to_iso8601(sentry__usec_time()); + sentry__jsonwriter_write_str(jw, timestamp); + sentry_free(timestamp); + + sentry__jsonwriter_write_key(jw, "discarded_events"); + sentry__jsonwriter_write_list_start(jw); + for (int r = 0; r < SENTRY_DISCARD_REASON_MAX; r++) { + for (int c = 0; c < SENTRY_DATA_CATEGORY_MAX; c++) { + long count = sentry__atomic_fetch((long *)&g_discard_counts[r][c]); + if (count > 0) { + sentry__jsonwriter_write_object_start(jw); + sentry__jsonwriter_write_key(jw, "reason"); + sentry__jsonwriter_write_str(jw, discard_reason_to_string(r)); + sentry__jsonwriter_write_key(jw, "category"); + sentry__jsonwriter_write_str(jw, data_category_to_string(c)); + sentry__jsonwriter_write_key(jw, "quantity"); + sentry__jsonwriter_write_int32(jw, (int32_t)count); + sentry__jsonwriter_write_object_end(jw); + } + } + } + sentry__jsonwriter_write_list_end(jw); + sentry__jsonwriter_write_object_end(jw); + + size_t payload_len = 0; + char *payload = sentry__jsonwriter_into_string(jw, &payload_len); + if (!payload) { + return NULL; + } + + sentry_envelope_item_t *item = sentry__envelope_add_from_buffer( + envelope, payload, payload_len, "client_report"); + sentry_free(payload); + + if (!item) { + return NULL; + } + + sentry__client_report_reset(); + return item; +} + +static sentry_data_category_t +item_type_to_data_category(const char *ty) +{ + if (sentry__string_eq(ty, "session")) { + return SENTRY_DATA_CATEGORY_SESSION; + } else if (sentry__string_eq(ty, "transaction")) { + return SENTRY_DATA_CATEGORY_TRANSACTION; + } else if (sentry__string_eq(ty, "attachment")) { + return SENTRY_DATA_CATEGORY_ATTACHMENT; + } else if (sentry__string_eq(ty, "log")) { + return SENTRY_DATA_CATEGORY_LOG_ITEM; + } else if (sentry__string_eq(ty, "feedback")) { + return SENTRY_DATA_CATEGORY_FEEDBACK; + } else if (sentry__string_eq(ty, "trace_metric")) { + return SENTRY_DATA_CATEGORY_TRACE_METRIC; + } + return SENTRY_DATA_CATEGORY_ERROR; +} + +void +sentry__client_report_discard_envelope(const sentry_envelope_t *envelope, + sentry_discard_reason_t reason, const sentry_rate_limiter_t *rl) +{ + size_t count = sentry__envelope_get_item_count(envelope); + for (size_t i = 0; i < count; i++) { + const sentry_envelope_item_t *item + = sentry__envelope_get_item(envelope, i); + const char *ty = sentry_value_as_string( + sentry__envelope_item_get_header(item, "type")); + int rl_category = sentry__envelope_item_type_to_rl_category(ty); + // internal items (e.g. client_report) bypass rate limiting + if (rl_category < 0) { + continue; + } + // already recorded as ratelimit_backoff + if (rl && sentry__rate_limiter_is_disabled(rl, rl_category)) { + continue; + } + sentry__client_report_discard( + reason, item_type_to_data_category(ty), 1); + } +} + +void +sentry__client_report_reset(void) +{ + for (int r = 0; r < SENTRY_DISCARD_REASON_MAX; r++) { + for (int c = 0; c < SENTRY_DATA_CATEGORY_MAX; c++) { + sentry__atomic_store((long *)&g_discard_counts[r][c], 0); + } + } +} diff --git a/src/sentry_client_report.h b/src/sentry_client_report.h new file mode 100644 index 000000000..0ba229b95 --- /dev/null +++ b/src/sentry_client_report.h @@ -0,0 +1,72 @@ +#ifndef SENTRY_CLIENT_REPORT_H_INCLUDED +#define SENTRY_CLIENT_REPORT_H_INCLUDED + +#include "sentry_boot.h" +#include "sentry_ratelimiter.h" + +/** + * Discard reasons as specified in the Sentry SDK telemetry documentation. + * https://develop.sentry.dev/sdk/telemetry/client-reports/ + */ +typedef enum { + SENTRY_DISCARD_REASON_QUEUE_OVERFLOW, + SENTRY_DISCARD_REASON_RATELIMIT_BACKOFF, + SENTRY_DISCARD_REASON_NETWORK_ERROR, + SENTRY_DISCARD_REASON_SAMPLE_RATE, + SENTRY_DISCARD_REASON_BEFORE_SEND, + SENTRY_DISCARD_REASON_EVENT_PROCESSOR, + SENTRY_DISCARD_REASON_SEND_ERROR, + SENTRY_DISCARD_REASON_MAX +} sentry_discard_reason_t; + +/** + * Data categories for tracking discarded events. + * These match the rate limiting categories defined at: + * https://develop.sentry.dev/sdk/expected-features/rate-limiting/#definitions + */ +typedef enum { + SENTRY_DATA_CATEGORY_ERROR, + SENTRY_DATA_CATEGORY_SESSION, + SENTRY_DATA_CATEGORY_TRANSACTION, + SENTRY_DATA_CATEGORY_ATTACHMENT, + SENTRY_DATA_CATEGORY_LOG_ITEM, + SENTRY_DATA_CATEGORY_FEEDBACK, + SENTRY_DATA_CATEGORY_TRACE_METRIC, + SENTRY_DATA_CATEGORY_MAX +} sentry_data_category_t; + +/** + * Record a discarded event with the given reason and category. + * This function is thread-safe using atomic operations. + */ +void sentry__client_report_discard(sentry_discard_reason_t reason, + sentry_data_category_t category, long quantity); + +/** + * Check if there are any pending discards to report. + * Returns true if there are discards, false otherwise. + */ +bool sentry__client_report_has_pending(void); + +/** + * Create a client report envelope item and add it to the given envelope. + * This atomically flushes all pending discard counters. + * Returns the envelope item if added successfully, NULL otherwise. + */ +struct sentry_envelope_item_s *sentry__client_report_into_envelope( + sentry_envelope_t *envelope); + +/** + * Record discards for all non-internal items in the envelope. + * Skips client_report items. Each item is mapped to its data category. + */ +void sentry__client_report_discard_envelope(const sentry_envelope_t *envelope, + sentry_discard_reason_t reason, const sentry_rate_limiter_t *rl); + +/** + * Reset all client report counters to zero. + * Called during SDK initialization to ensure a clean state. + */ +void sentry__client_report_reset(void); + +#endif diff --git a/src/sentry_core.c b/src/sentry_core.c index d53eaeb84..93945c2f5 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -5,6 +5,7 @@ #include "sentry_attachment.h" #include "sentry_backend.h" +#include "sentry_client_report.h" #include "sentry_core.h" #include "sentry_database.h" #include "sentry_envelope.h" @@ -246,6 +247,8 @@ sentry_init(sentry_options_t *options) g_last_crash = sentry__has_crash_marker(options); g_options = options; + sentry__client_report_reset(); + // *after* setting the global options, trigger a scope and consent flush, // since at least crashpad needs that. At this point we also freeze the // `client_sdk` in the `scope` because some downstream SDKs want to override @@ -623,6 +626,8 @@ sentry__capture_event(sentry_value_t event, sentry_scope_t *local_scope) bool should_skip = !sentry__roll_dice(options->sample_rate); if (should_skip) { SENTRY_INFO("throwing away event due to sample rate"); + sentry__client_report_discard(SENTRY_DISCARD_REASON_SAMPLE_RATE, + SENTRY_DATA_CATEGORY_ERROR, 1); sentry_envelope_free(envelope); } else { sentry__capture_envelope(options->transport, envelope); @@ -716,6 +721,8 @@ sentry__prepare_event(const sentry_options_t *options, sentry_value_t event, = options->before_send_func(event, NULL, options->before_send_data); if (sentry_value_is_null(event)) { SENTRY_DEBUG("event was discarded by the `before_send` hook"); + sentry__client_report_discard(SENTRY_DISCARD_REASON_BEFORE_SEND, + SENTRY_DATA_CATEGORY_ERROR, 1); return NULL; } } @@ -767,6 +774,8 @@ sentry__prepare_transaction(const sentry_options_t *options, if (sentry_value_is_null(transaction)) { SENTRY_DEBUG( "transaction was discarded by the `before_transaction` hook"); + sentry__client_report_discard(SENTRY_DISCARD_REASON_BEFORE_SEND, + SENTRY_DATA_CATEGORY_TRANSACTION, 1); return NULL; } } diff --git a/src/sentry_envelope.c b/src/sentry_envelope.c index bcb5576b3..fcc006ba8 100644 --- a/src/sentry_envelope.c +++ b/src/sentry_envelope.c @@ -1,5 +1,6 @@ #include "sentry_envelope.h" #include "sentry_alloc.h" +#include "sentry_client_report.h" #include "sentry_core.h" #include "sentry_json.h" #include "sentry_options.h" @@ -107,21 +108,67 @@ sentry__envelope_item_set_header( sentry_value_set_by_key(item->headers, key, value); } -static int -envelope_item_get_ratelimiter_category(const sentry_envelope_item_t *item) +/** + * Returns the rate limiter category for an envelope item type, or -1 if the + * item should bypass rate limiting (e.g., client_report items are internal + * telemetry and should always be sent). + */ +int +sentry__envelope_item_type_to_rl_category(const char *ty) { - const char *ty = sentry_value_as_string( - sentry_value_get_by_key(item->headers, "type")); if (sentry__string_eq(ty, "session")) { return SENTRY_RL_CATEGORY_SESSION; } else if (sentry__string_eq(ty, "transaction")) { return SENTRY_RL_CATEGORY_TRANSACTION; + } else if (sentry__string_eq(ty, "client_report")) { + // internal telemetry, bypass rate limiting + return -1; } // NOTE: the `type` here can be `event` or `attachment`. // Ideally, attachments should have their own RL_CATEGORY. return SENTRY_RL_CATEGORY_ERROR; } +bool +sentry__envelope_is_rate_limited( + const sentry_envelope_t *envelope, const sentry_rate_limiter_t *rl) +{ + if (envelope->is_raw) { + return false; + } + bool has_items = false; + for (const sentry_envelope_item_t *item + = envelope->contents.items.first_item; + item; item = item->next) { + const char *ty = sentry_value_as_string( + sentry_value_get_by_key(item->headers, "type")); + int rl_category = sentry__envelope_item_type_to_rl_category(ty); + // internal items (e.g. client_report) bypass rate limiting + if (rl_category < 0) { + continue; + } + has_items = true; + if (!rl || !sentry__rate_limiter_is_disabled(rl, rl_category)) { + return false; + } + } + return has_items; +} + +static sentry_data_category_t +ratelimiter_category_to_data_category(int rl_category) +{ + switch (rl_category) { + case SENTRY_RL_CATEGORY_SESSION: + return SENTRY_DATA_CATEGORY_SESSION; + case SENTRY_RL_CATEGORY_TRANSACTION: + return SENTRY_DATA_CATEGORY_TRANSACTION; + case SENTRY_RL_CATEGORY_ERROR: + default: + return SENTRY_DATA_CATEGORY_ERROR; + } +} + static sentry_envelope_item_t * envelope_add_from_owned_buffer( sentry_envelope_t *envelope, char *buf, size_t buf_len, const char *type) @@ -746,8 +793,16 @@ sentry_envelope_serialize_ratelimited(const sentry_envelope_t *envelope, = envelope->contents.items.first_item; item; item = item->next) { if (rl) { - int category = envelope_item_get_ratelimiter_category(item); - if (sentry__rate_limiter_is_disabled(rl, category)) { + const char *ty = sentry_value_as_string( + sentry_value_get_by_key(item->headers, "type")); + int rl_category = sentry__envelope_item_type_to_rl_category(ty); + // rl_category < 0 means the item should bypass rate limiting + if (rl_category >= 0 + && sentry__rate_limiter_is_disabled(rl, rl_category)) { + sentry_data_category_t data_category + = ratelimiter_category_to_data_category(rl_category); + sentry__client_report_discard( + SENTRY_DISCARD_REASON_RATELIMIT_BACKOFF, data_category, 1); continue; } } @@ -1137,8 +1192,6 @@ sentry__envelope_write_to_cache( } return rv; } - -#ifdef SENTRY_UNITTEST size_t sentry__envelope_get_item_count(const sentry_envelope_t *envelope) { @@ -1173,6 +1226,7 @@ sentry__envelope_item_get_header( return sentry_value_get_by_key(item->headers, key); } +#ifdef SENTRY_UNITTEST const char * sentry__envelope_item_get_payload( const sentry_envelope_item_t *item, size_t *payload_len_out) diff --git a/src/sentry_envelope.h b/src/sentry_envelope.h index 3639aaece..7efa059a1 100644 --- a/src/sentry_envelope.h +++ b/src/sentry_envelope.h @@ -124,6 +124,18 @@ void sentry__envelope_set_header( void sentry__envelope_item_set_header( sentry_envelope_item_t *item, const char *key, sentry_value_t value); +/** + * Returns the rate limiter category for an envelope item type, or -1 if the + * item should bypass rate limiting. + */ +int sentry__envelope_item_type_to_rl_category(const char *ty); + +/** + * Returns true if all non-internal items in the envelope are rate-limited. + */ +bool sentry__envelope_is_rate_limited( + const sentry_envelope_t *envelope, const sentry_rate_limiter_t *rl); + /** * Serialize the envelope while applying the rate limits from `rl`. * Returns `NULL` when all items have been rate-limited, and might return a @@ -155,13 +167,14 @@ MUST_USE int sentry_envelope_write_to_path( int sentry__envelope_write_to_cache( const sentry_envelope_t *envelope, const sentry_path_t *cache_dir); -// these for now are only needed for tests -#ifdef SENTRY_UNITTEST size_t sentry__envelope_get_item_count(const sentry_envelope_t *envelope); const sentry_envelope_item_t *sentry__envelope_get_item( const sentry_envelope_t *envelope, size_t idx); sentry_value_t sentry__envelope_item_get_header( const sentry_envelope_item_t *item, const char *key); + +// these for now are only needed for tests +#ifdef SENTRY_UNITTEST const char *sentry__envelope_item_get_payload( const sentry_envelope_item_t *item, size_t *payload_len_out); #endif diff --git a/src/sentry_logs.c b/src/sentry_logs.c index 9de16a64e..e75fa9c38 100644 --- a/src/sentry_logs.c +++ b/src/sentry_logs.c @@ -360,6 +360,8 @@ send_log(sentry_level_t level, sentry_value_t log) log, options->before_send_log_data); if (sentry_value_is_null(log)) { SENTRY_DEBUG("log was discarded by the `before_send_log` hook"); + sentry__client_report_discard(SENTRY_DISCARD_REASON_BEFORE_SEND, + SENTRY_DATA_CATEGORY_LOG_ITEM, 1); discarded = true; } } @@ -487,7 +489,8 @@ sentry_log( void sentry__logs_startup(const sentry_options_t *options) { - sentry_batcher_t *batcher = sentry__batcher_new(sentry__envelope_add_logs); + sentry_batcher_t *batcher = sentry__batcher_new( + sentry__envelope_add_logs, SENTRY_DATA_CATEGORY_LOG_ITEM); if (!batcher) { SENTRY_WARN("failed to allocate logs batcher"); return; diff --git a/src/sentry_metrics.c b/src/sentry_metrics.c index 58b00fa52..6c45923f9 100644 --- a/src/sentry_metrics.c +++ b/src/sentry_metrics.c @@ -84,6 +84,9 @@ record_metric(sentry_metric_type_t type, const char *name, sentry_value_t value, if (sentry_value_is_null(metric)) { SENTRY_DEBUG("metric was discarded by the " "`before_send_metric` hook"); + sentry__client_report_discard( + SENTRY_DISCARD_REASON_BEFORE_SEND, + SENTRY_DATA_CATEGORY_TRACE_METRIC, 1); discarded = true; } } @@ -131,8 +134,8 @@ sentry_metrics_distribution( void sentry__metrics_startup(const sentry_options_t *options) { - sentry_batcher_t *batcher - = sentry__batcher_new(sentry__envelope_add_metrics); + sentry_batcher_t *batcher = sentry__batcher_new( + sentry__envelope_add_metrics, SENTRY_DATA_CATEGORY_TRACE_METRIC); if (!batcher) { SENTRY_WARN("failed to allocate metrics batcher"); return; diff --git a/src/sentry_options.c b/src/sentry_options.c index 1b8665555..cdb497484 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -84,6 +84,7 @@ sentry_options_new(void) = SENTRY_CRASH_REPORTING_MODE_NATIVE_WITH_MINIDUMP; // Default: best of // both worlds opts->http_retry = false; + opts->send_client_reports = true; return opts; } @@ -902,3 +903,15 @@ sentry_options_get_propagate_traceparent(const sentry_options_t *opts) { return opts->propagate_traceparent; } + +void +sentry_options_set_send_client_reports(sentry_options_t *opts, int val) +{ + opts->send_client_reports = !!val; +} + +int +sentry_options_get_send_client_reports(const sentry_options_t *opts) +{ + return opts->send_client_reports; +} diff --git a/src/sentry_options.h b/src/sentry_options.h index 064c0d1fa..23bae517e 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -79,6 +79,7 @@ struct sentry_options_s { sentry_before_send_metric_function_t before_send_metric_func; void *before_send_metric_data; bool http_retry; + bool send_client_reports; /* everything from here on down are options which are stored here but not exposed through the options API */ diff --git a/src/transports/sentry_http_transport.c b/src/transports/sentry_http_transport.c index a40843e7b..d6154cb89 100644 --- a/src/transports/sentry_http_transport.c +++ b/src/transports/sentry_http_transport.c @@ -1,5 +1,6 @@ #include "sentry_http_transport.h" #include "sentry_alloc.h" +#include "sentry_client_report.h" #include "sentry_database.h" #include "sentry_envelope.h" #include "sentry_options.h" @@ -34,6 +35,7 @@ typedef struct { sentry_retry_t *retry; bool cache_keep; sentry_run_t *run; + bool send_client_reports; } http_transport_state_t; #ifdef SENTRY_TRANSPORT_COMPRESSION @@ -229,6 +231,17 @@ http_send_envelope(sentry_envelope_t *envelope, void *_state) return status_code; } +static int +retry_send_cb(sentry_envelope_t *envelope, void *_state) +{ + http_transport_state_t *state = _state; + if (state->send_client_reports + && !sentry__envelope_is_rate_limited(envelope, state->ratelimiter)) { + sentry__client_report_into_envelope(envelope); + } + return http_send_envelope(envelope, state); +} + static void http_transport_state_free(void *_state) { @@ -250,11 +263,25 @@ http_send_task(void *_envelope, void *_state) sentry_envelope_t *envelope = _envelope; http_transport_state_t *state = _state; + if (state->send_client_reports + && !sentry__envelope_is_rate_limited(envelope, state->ratelimiter)) { + sentry__client_report_into_envelope(envelope); + } + int status_code = http_send_envelope(envelope, state); - if (status_code < 0 && state->retry) { - sentry__retry_enqueue(state->retry, envelope); - } else if (status_code < 0 && state->cache_keep) { - sentry__run_write_cache(state->run, envelope, -1); + if (status_code < 0) { + if (state->retry) { + sentry__retry_enqueue(state->retry, envelope); + } else { + if (state->cache_keep) { + sentry__run_write_cache(state->run, envelope, -1); + } + sentry__client_report_discard_envelope(envelope, + SENTRY_DISCARD_REASON_NETWORK_ERROR, state->ratelimiter); + } + } else if (status_code >= 400) { + sentry__client_report_discard_envelope( + envelope, SENTRY_DISCARD_REASON_SEND_ERROR, state->ratelimiter); } } @@ -287,6 +314,7 @@ http_transport_start(const sentry_options_t *options, void *transport_state) state->user_agent = sentry__string_clone(options->user_agent); state->cache_keep = options->cache_keep; state->run = sentry__run_incref(options->run); + state->send_client_reports = options->send_client_reports; if (state->start_client) { int rv = state->start_client(state->client, options); @@ -303,8 +331,7 @@ http_transport_start(const sentry_options_t *options, void *transport_state) if (options->http_retry) { state->retry = sentry__retry_new(options); if (state->retry) { - sentry__retry_start( - state->retry, bgworker, http_send_envelope, state); + sentry__retry_start(state->retry, bgworker, retry_send_cb, state); } } diff --git a/tests/__init__.py b/tests/__init__.py index bcaf624c5..abd0f6f2f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -358,6 +358,7 @@ def deserialize_from( "user_report", "log", "trace_metric", + "client_report", ]: rv = cls(headers=headers, payload=PayloadRef(json=json.loads(payload))) else: diff --git a/tests/assertions.py b/tests/assertions.py index 8adb9f121..223293058 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -262,6 +262,8 @@ def assert_attachment(envelope): def assert_logs(envelope, expected_item_count=1, expected_trace_id=None): logs = None for item in envelope: + if item.headers.get("type") == "client_report": + continue assert item.headers.get("type") == "log" # >= because of random #lost logs in test_logs_threaded assert item.headers.get("item_count") >= expected_item_count @@ -294,6 +296,8 @@ def assert_logs(envelope, expected_item_count=1, expected_trace_id=None): def assert_metrics(envelope, expected_item_count=1, expected_trace_id=None): metrics = None for item in envelope: + if item.headers.get("type") == "client_report": + continue assert item.headers.get("type") == "trace_metric" assert item.headers.get("item_count") >= expected_item_count assert ( @@ -550,6 +554,73 @@ def assert_gzip_content_encoding(req): assert req.content_encoding == "gzip" +def assert_client_report(envelope, expected_discards=None): + """ + Assert that the envelope contains a client_report item. + + Args: + envelope: The envelope to check + expected_discards: Optional list of dicts with expected discarded_events entries. + Each dict should have 'reason', 'category', and optionally 'quantity' keys. + If quantity is not specified, it just checks that count > 0. + """ + client_report = None + for item in envelope: + if ( + item.headers.get("type") == "client_report" + and item.payload.json is not None + ): + client_report = item.payload.json + break + + assert client_report is not None, "No client_report item found in envelope" + + # Check timestamp exists and is valid + assert "timestamp" in client_report + assert_timestamp(client_report["timestamp"]) + + # Check discarded_events array exists + assert "discarded_events" in client_report + discarded_events = client_report["discarded_events"] + assert isinstance(discarded_events, list) + assert len(discarded_events) > 0 + + # Validate each discarded event entry + for entry in discarded_events: + assert "reason" in entry + assert "category" in entry + assert "quantity" in entry + assert entry["quantity"] > 0 + + # Check expected discards if provided + if expected_discards: + for expected in expected_discards: + found = False + for entry in discarded_events: + if ( + entry["reason"] == expected["reason"] + and entry["category"] == expected["category"] + ): + if "quantity" in expected: + assert entry["quantity"] == expected["quantity"], ( + f"Expected quantity {expected['quantity']} for {expected['reason']}/{expected['category']}, " + f"got {entry['quantity']}" + ) + found = True + break + assert found, ( + f"Expected discard entry with reason={expected['reason']}, " + f"category={expected['category']} not found" + ) + + +def assert_no_client_report(envelope): + """Assert that the envelope does NOT contain a client_report item.""" + for item in envelope: + if item.headers.get("type") == "client_report": + raise AssertionError("Unexpected client_report item found in envelope") + + def assert_no_proxy_request(stdout): assert "POST" not in stdout diff --git a/tests/test_integration_client_reports.py b/tests/test_integration_client_reports.py new file mode 100644 index 000000000..d6ce8e1f9 --- /dev/null +++ b/tests/test_integration_client_reports.py @@ -0,0 +1,257 @@ +import os +import pytest + +from . import make_dsn, run, Envelope +from .assertions import ( + assert_client_report, + assert_no_client_report, + assert_event, + assert_session, +) +from .conditions import has_http + +pytestmark = pytest.mark.skipif(not has_http, reason="tests need http") + + +def test_client_report_none(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "capture-event"], + env=env, + ) + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + + assert_no_client_report(envelope) + assert_event(envelope) + + +def test_client_report_before_send(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # The event is discarded by before_send. The session envelope sent at + # shutdown acts as a carrier for the client report. + run( + tmp_path, + "sentry_example", + ["log", "start-session", "discarding-before-send", "capture-event"], + env=env, + ) + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + + assert_session(envelope) + assert_client_report( + envelope, + [{"reason": "before_send", "category": "error", "quantity": 1}], + ) + + +def test_client_report_ratelimit(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + # The first event gets through but triggers a rate limit for the "error" + # category only. The error items of the remaining 9 events are filtered out + # during serialization, but their session updates still pass through (not + # rate-limited). Each subsequent envelope carries client report discards + # incrementally, so we aggregate across all envelopes. + request_count = [0] + + def ratelimit_first(request): + from werkzeug import Response + + request_count[0] += 1 + if request_count[0] == 1: + return Response( + "OK", 200, {"X-Sentry-Rate-Limits": "60:error:organization"} + ) + return Response("OK", 200) + + httpserver.expect_request("/api/123456/envelope/").respond_with_handler( + ratelimit_first + ) + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "start-session", "capture-multiple"], + env=env, + ) + + # First envelope: event + session (before rate limit). + # No event items in subsequent envelopes (rate-limited), but session + # updates still go through. + assert len(httpserver.log) >= 2 + + total_discards = {} + for req, _resp in httpserver.log: + envelope = Envelope.deserialize(req.get_data()) + for item in envelope: + if item.headers.get("type") != "client_report" or not item.payload.json: + continue + for entry in item.payload.json.get("discarded_events", []): + key = (entry["reason"], entry["category"]) + total_discards[key] = total_discards.get(key, 0) + entry["quantity"] + + assert total_discards == {("ratelimit_backoff", "error"): 9} + + +def test_client_report_sample_rate(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") + env = dict( + os.environ, + SENTRY_DSN=make_dsn(httpserver), + SENTRY_SAMPLE_RATE="0.0", + ) + + # Event is discarded by sample rate. The session at shutdown carries + # the client report. + run( + tmp_path, + "sentry_example", + ["log", "start-session", "capture-event"], + env=env, + ) + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + + assert_session(envelope) + assert_client_report( + envelope, + [{"reason": "sample_rate", "category": "error", "quantity": 1}], + ) + + +def test_client_report_before_send_log(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # Log is discarded by before_send_log. The event envelope carries + # the client report. + run( + tmp_path, + "sentry_example", + [ + "log", + "enable-logs", + "discarding-before-send-log", + "capture-log", + "capture-event", + ], + env=env, + ) + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + + assert_event(envelope) + assert_client_report( + envelope, + [{"reason": "before_send", "category": "log_item", "quantity": 1}], + ) + + +def test_client_report_before_send_metric(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # Metric is discarded by before_send_metric. The event envelope carries + # the client report. + run( + tmp_path, + "sentry_example", + [ + "log", + "enable-metrics", + "discarding-before-send-metric", + "capture-metric", + "capture-event", + ], + env=env, + ) + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + + assert_event(envelope) + assert_client_report( + envelope, + [{"reason": "before_send", "category": "trace_metric", "quantity": 1}], + ) + + +def test_client_report_before_send_transaction(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # Transaction is discarded by before_transaction. The session at + # shutdown carries the client report. + run( + tmp_path, + "sentry_example", + [ + "log", + "start-session", + "capture-transaction", + "discarding-before-transaction", + ], + env=env, + ) + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + + assert_session(envelope) + assert_client_report( + envelope, + [{"reason": "before_send", "category": "transaction", "quantity": 1}], + ) + + +def test_client_report_send_error(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data( + "Bad Request", status=400 + ) + httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # Event is rejected (400). The session at shutdown carries the + # client report. + run( + tmp_path, + "sentry_example", + ["log", "start-session", "capture-event"], + env=env, + ) + + assert len(httpserver.log) == 2 + envelope = Envelope.deserialize(httpserver.log[1][0].get_data()) + + assert_session(envelope) + assert_client_report( + envelope, + [{"reason": "send_error", "category": "error", "quantity": 1}], + ) diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index 0cfc122c7..7c76eb48d 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -16,12 +16,14 @@ ) from .assertions import ( assert_attachment, + assert_client_report, assert_meta, assert_breadcrumb, assert_stacktrace, assert_event, assert_exception, assert_inproc_crash, + assert_no_client_report, assert_session, assert_user_feedback, assert_user_report, @@ -877,6 +879,7 @@ def test_http_retry_on_network_error(cmake, httpserver, unreachable_dsn): envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) assert envelope.headers["event_id"] == envelope_uuid assert_meta(envelope, integration="inproc") + assert_no_client_report(envelope) cache_files = list(cache_dir.glob("*.envelope")) assert len(cache_files) == 0 @@ -1197,6 +1200,60 @@ def test_http_retry_session_on_network_error(cmake, httpserver, unreachable_dsn) assert len(httpserver.log) == 1 envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) assert_session(envelope, {"init": True, "status": "exited", "errors": 0}) + assert_no_client_report(envelope) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 0 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_with_client_report(cmake, httpserver, unreachable_dsn): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + # Run 1: event discarded by before_send (client report recorded). + # The session at shutdown picks up the client report, but send fails + # (unreachable), so the session+client_report is cached for retry. + env_unreachable = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run( + tmp_path, + "sentry_example", + [ + "log", + "http-retry", + "start-session", + "discarding-before-send", + "capture-event", + ], + env=env_unreachable, + ) + + assert cache_dir.exists() + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + + # Run 2: retry succeeds — the retried session should carry the + # client report from run 1. + env_reachable = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "http-retry", "no-setup"], + env=env_reachable, + ) + assert waiting.result + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + assert_session(envelope) + assert_client_report( + envelope, + [{"reason": "before_send", "category": "error", "quantity": 1}], + ) cache_files = list(cache_dir.glob("*.envelope")) assert len(cache_files) == 0 diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 3b3036259..f4e9cc09e 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -24,6 +24,7 @@ add_executable(sentry_test_unit test_attachments.c test_basic.c test_cache.c + test_client_report.c test_consent.c test_concurrency.c test_embedded_info.c diff --git a/tests/unit/test_client_report.c b/tests/unit/test_client_report.c new file mode 100644 index 000000000..0decd70aa --- /dev/null +++ b/tests/unit/test_client_report.c @@ -0,0 +1,296 @@ +#include "sentry_batcher.h" +#include "sentry_client_report.h" +#include "sentry_envelope.h" +#include "sentry_path.h" +#include "sentry_ratelimiter.h" +#include "sentry_testsupport.h" +#include "sentry_value.h" + +SENTRY_TEST(client_report_discard) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_init(options); + + TEST_CHECK(!sentry__client_report_has_pending()); + + sentry__client_report_discard( + SENTRY_DISCARD_REASON_SAMPLE_RATE, SENTRY_DATA_CATEGORY_ERROR, 2); + sentry__client_report_discard( + SENTRY_DISCARD_REASON_BEFORE_SEND, SENTRY_DATA_CATEGORY_TRANSACTION, 1); + + TEST_CHECK(sentry__client_report_has_pending()); + + sentry_envelope_t *envelope = sentry__envelope_new(); + TEST_CHECK(!!envelope); + + sentry_envelope_item_t *item + = sentry__client_report_into_envelope(envelope); + TEST_CHECK(!!item); + + TEST_CHECK(!sentry__client_report_has_pending()); + + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry__envelope_item_get_header(item, "type")), + "client_report"); + + size_t payload_len = 0; + const char *payload = sentry__envelope_item_get_payload(item, &payload_len); + TEST_CHECK(!!payload); + TEST_CHECK(payload_len > 0); + + sentry_value_t report = sentry__value_from_json(payload, payload_len); + TEST_CHECK(!sentry_value_is_null(report)); + + TEST_CHECK( + !sentry_value_is_null(sentry_value_get_by_key(report, "timestamp"))); + + sentry_value_t discarded + = sentry_value_get_by_key(report, "discarded_events"); + TEST_CHECK_INT_EQUAL(sentry_value_get_length(discarded), 2); + + sentry_value_t entry0 = sentry_value_get_by_index(discarded, 0); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry0, "reason")), + "sample_rate"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry0, "category")), + "error"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry0, "quantity")), 2); + + sentry_value_t entry1 = sentry_value_get_by_index(discarded, 1); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry1, "reason")), + "before_send"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry1, "category")), + "transaction"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry1, "quantity")), 1); + + sentry_value_decref(report); + sentry_envelope_free(envelope); + sentry_close(); +} + +SENTRY_TEST(client_report_discard_envelope) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_init(options); + + TEST_CHECK(!sentry__client_report_has_pending()); + + sentry_envelope_t *envelope = sentry__envelope_new(); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "event"); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "session"); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "transaction"); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "attachment"); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "log"); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "feedback"); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "client_report"); + + sentry__client_report_discard_envelope( + envelope, SENTRY_DISCARD_REASON_NETWORK_ERROR, NULL); + + TEST_CHECK(sentry__client_report_has_pending()); + + sentry_envelope_t *carrier = sentry__envelope_new(); + sentry_envelope_item_t *item = sentry__client_report_into_envelope(carrier); + TEST_CHECK(!!item); + + size_t payload_len = 0; + const char *payload = sentry__envelope_item_get_payload(item, &payload_len); + sentry_value_t report = sentry__value_from_json(payload, payload_len); + + sentry_value_t discarded + = sentry_value_get_by_key(report, "discarded_events"); + TEST_CHECK_INT_EQUAL(sentry_value_get_length(discarded), 6); + + sentry_value_t entry0 = sentry_value_get_by_index(discarded, 0); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry0, "reason")), + "network_error"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry0, "category")), + "error"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry0, "quantity")), 1); + + sentry_value_t entry1 = sentry_value_get_by_index(discarded, 1); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry1, "reason")), + "network_error"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry1, "category")), + "session"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry1, "quantity")), 1); + + sentry_value_t entry2 = sentry_value_get_by_index(discarded, 2); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry2, "reason")), + "network_error"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry2, "category")), + "transaction"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry2, "quantity")), 1); + + sentry_value_t entry3 = sentry_value_get_by_index(discarded, 3); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry3, "reason")), + "network_error"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry3, "category")), + "attachment"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry3, "quantity")), 1); + + sentry_value_t entry4 = sentry_value_get_by_index(discarded, 4); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry4, "reason")), + "network_error"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry4, "category")), + "log_item"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry4, "quantity")), 1); + + sentry_value_t entry5 = sentry_value_get_by_index(discarded, 5); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry5, "reason")), + "network_error"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry5, "category")), + "feedback"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry5, "quantity")), 1); + + sentry_value_decref(report); + sentry_envelope_free(carrier); + sentry_envelope_free(envelope); + sentry_close(); +} + +SENTRY_TEST(client_report_discard_rate_limited) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_init(options); + + sentry_envelope_t *envelope = sentry__envelope_new(); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "event"); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "session"); + + // Rate-limit the error category + sentry_rate_limiter_t *rl = sentry__rate_limiter_new(); + sentry__rate_limiter_update_from_header(rl, "60:error:organization"); + + // Discard with RL: should only record session, not event + sentry__client_report_discard_envelope( + envelope, SENTRY_DISCARD_REASON_NETWORK_ERROR, rl); + + sentry_envelope_t *carrier = sentry__envelope_new(); + sentry_envelope_item_t *item = sentry__client_report_into_envelope(carrier); + TEST_CHECK(!!item); + + size_t payload_len = 0; + const char *payload = sentry__envelope_item_get_payload(item, &payload_len); + sentry_value_t report = sentry__value_from_json(payload, payload_len); + + sentry_value_t discarded + = sentry_value_get_by_key(report, "discarded_events"); + TEST_CHECK_INT_EQUAL(sentry_value_get_length(discarded), 1); + + sentry_value_t entry0 = sentry_value_get_by_index(discarded, 0); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry0, "reason")), + "network_error"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry0, "category")), + "session"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry0, "quantity")), 1); + + sentry_value_decref(report); + sentry_envelope_free(carrier); + sentry_envelope_free(envelope); + sentry__rate_limiter_free(rl); + sentry_close(); +} + +SENTRY_TEST(client_report_none) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_init(options); + + TEST_CHECK(!sentry__client_report_has_pending()); + + sentry_envelope_t *envelope = sentry__envelope_new(); + TEST_CHECK(!!envelope); + + sentry_envelope_item_t *item + = sentry__client_report_into_envelope(envelope); + TEST_CHECK(!item); + + sentry_envelope_free(envelope); + sentry_close(); +} + +static sentry_envelope_item_t * +dummy_batch_func(sentry_envelope_t *envelope, sentry_value_t items) +{ + (void)envelope; + sentry_value_decref(items); + return NULL; +} + +SENTRY_TEST(client_report_queue_overflow) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_init(options); + + sentry_batcher_t *batcher + = sentry__batcher_new(dummy_batch_func, SENTRY_DATA_CATEGORY_LOG_ITEM); + TEST_CHECK(!!batcher); + + // Fill the buffer (SENTRY_BATCHER_QUEUE_LENGTH is 5 in unit tests) + for (int i = 0; i < SENTRY_BATCHER_QUEUE_LENGTH; i++) { + TEST_CHECK(sentry__batcher_enqueue(batcher, sentry_value_new_null())); + } + + TEST_CHECK(!sentry__client_report_has_pending()); + + // This should overflow and record a discard + TEST_CHECK(!sentry__batcher_enqueue(batcher, sentry_value_new_null())); + + TEST_CHECK(sentry__client_report_has_pending()); + + sentry_envelope_t *envelope = sentry__envelope_new(); + sentry_envelope_item_t *cr_item + = sentry__client_report_into_envelope(envelope); + TEST_CHECK(!!cr_item); + + size_t payload_len = 0; + const char *payload + = sentry__envelope_item_get_payload(cr_item, &payload_len); + sentry_value_t report = sentry__value_from_json(payload, payload_len); + + sentry_value_t discarded + = sentry_value_get_by_key(report, "discarded_events"); + TEST_CHECK_INT_EQUAL(sentry_value_get_length(discarded), 1); + + sentry_value_t entry0 = sentry_value_get_by_index(discarded, 0); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry0, "reason")), + "queue_overflow"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry0, "category")), + "log_item"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry0, "quantity")), 1); + + sentry_value_decref(report); + sentry_envelope_free(envelope); + sentry__batcher_release(batcher); + sentry_close(); +} diff --git a/tests/unit/test_envelopes.c b/tests/unit/test_envelopes.c index ba160a29e..9b1913113 100644 --- a/tests/unit/test_envelopes.c +++ b/tests/unit/test_envelopes.c @@ -1,6 +1,7 @@ #include "sentry_envelope.h" #include "sentry_json.h" #include "sentry_path.h" +#include "sentry_ratelimiter.h" #include "sentry_testsupport.h" #include "sentry_utils.h" #include "sentry_value.h" @@ -735,3 +736,52 @@ SENTRY_TEST(deserialize_envelope_invalid) snprintf(buf, sizeof(buf), "{}\n{\"length\":%zu}\n", SIZE_MAX); TEST_CHECK(!sentry_envelope_deserialize(buf, strlen(buf))); } + +SENTRY_TEST(envelope_is_rate_limited) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_init(options); + + sentry_envelope_t *envelope = sentry__envelope_new(); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "event"); + TEST_CHECK(!sentry__envelope_is_rate_limited(envelope, NULL)); + + sentry_rate_limiter_t *rl = sentry__rate_limiter_new(); + TEST_CHECK(!sentry__envelope_is_rate_limited(envelope, rl)); + + sentry__rate_limiter_update_from_header(rl, "60:error:organization"); + TEST_CHECK(sentry__envelope_is_rate_limited(envelope, rl)); + + sentry_envelope_free(envelope); + + // Empty envelope is not rate-limited + envelope = sentry__envelope_new(); + TEST_CHECK(!sentry__envelope_is_rate_limited(envelope, NULL)); + + sentry_envelope_free(envelope); + + // Envelope with only internal items is not rate-limited + envelope = sentry__envelope_new(); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "client_report"); + TEST_CHECK(!sentry__envelope_is_rate_limited(envelope, NULL)); + + sentry_envelope_free(envelope); + + // Raw envelope is not rate-limited + envelope = sentry__envelope_new(); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "event"); + const char *path_str = SENTRY_TEST_PATH_PREFIX "sentry_test_raw"; + sentry_path_t *path = sentry__path_from_str(path_str); + TEST_CHECK_INT_EQUAL(sentry_envelope_write_to_path(envelope, path), 0); + sentry_envelope_free(envelope); + + sentry_envelope_t *raw = sentry__envelope_from_path(path); + TEST_CHECK(!!raw); + TEST_CHECK(!sentry__envelope_is_rate_limited(raw, NULL)); + + sentry_envelope_free(raw); + sentry__path_remove(path); + sentry__path_free(path); + sentry__rate_limiter_free(rl); + sentry_close(); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 16791d956..52d57f5cf 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -55,6 +55,11 @@ XX(capture_minidump_without_sentry_init) XX(check_version) XX(child_spans) XX(child_spans_ts) +XX(client_report_discard) +XX(client_report_discard_envelope) +XX(client_report_discard_rate_limited) +XX(client_report_none) +XX(client_report_queue_overflow) XX(concurrent_init) XX(concurrent_uninit) XX(count_sampled_events) @@ -101,6 +106,7 @@ XX(embedded_info_disabled) XX(embedded_info_format) XX(embedded_info_sentry_version) XX(empty_transport) +XX(envelope_is_rate_limited) XX(event_with_id) XX(exception_without_type_or_value_still_valid) XX(feedback_with_bytes_attachment) From 5670fed8c27c4bfc4a82d824cb6d0059e2dedbab Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 2 Apr 2026 13:36:29 +0200 Subject: [PATCH 2/6] fix race between reading and resetting discard counters Subtract reported values instead of zeroing to preserve concurrent increments that happen between read and reset. --- src/sentry_client_report.c | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/sentry_client_report.c b/src/sentry_client_report.c index e38a0d2f0..65f8d677c 100644 --- a/src/sentry_client_report.c +++ b/src/sentry_client_report.c @@ -61,6 +61,24 @@ data_category_to_string(sentry_data_category_t category) } } +static void +reset_discard_counts( + long counts[SENTRY_DISCARD_REASON_MAX][SENTRY_DATA_CATEGORY_MAX]) +{ + for (int r = 0; r < SENTRY_DISCARD_REASON_MAX; r++) { + for (int c = 0; c < SENTRY_DATA_CATEGORY_MAX; c++) { + if (counts) { + if (counts[r][c] > 0) { + sentry__atomic_fetch_and_add( + (long *)&g_discard_counts[r][c], -counts[r][c]); + } + } else { + sentry__atomic_store((long *)&g_discard_counts[r][c], 0); + } + } + } +} + void sentry__client_report_discard(sentry_discard_reason_t reason, sentry_data_category_t category, long quantity) @@ -105,11 +123,14 @@ sentry__client_report_into_envelope(sentry_envelope_t *envelope) sentry__jsonwriter_write_str(jw, timestamp); sentry_free(timestamp); + long counts[SENTRY_DISCARD_REASON_MAX][SENTRY_DATA_CATEGORY_MAX]; + sentry__jsonwriter_write_key(jw, "discarded_events"); sentry__jsonwriter_write_list_start(jw); for (int r = 0; r < SENTRY_DISCARD_REASON_MAX; r++) { for (int c = 0; c < SENTRY_DATA_CATEGORY_MAX; c++) { long count = sentry__atomic_fetch((long *)&g_discard_counts[r][c]); + counts[r][c] = count; if (count > 0) { sentry__jsonwriter_write_object_start(jw); sentry__jsonwriter_write_key(jw, "reason"); @@ -139,7 +160,7 @@ sentry__client_report_into_envelope(sentry_envelope_t *envelope) return NULL; } - sentry__client_report_reset(); + reset_discard_counts(counts); return item; } @@ -189,9 +210,5 @@ sentry__client_report_discard_envelope(const sentry_envelope_t *envelope, void sentry__client_report_reset(void) { - for (int r = 0; r < SENTRY_DISCARD_REASON_MAX; r++) { - for (int c = 0; c < SENTRY_DATA_CATEGORY_MAX; c++) { - sentry__atomic_store((long *)&g_discard_counts[r][c], 0); - } - } + reset_discard_counts(NULL); } From a1b06047faa20a43fb051a5bc564976ac2412dc1 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 2 Apr 2026 13:48:12 +0200 Subject: [PATCH 3/6] fix rate-limited attachments reported as wrong data category Use the item type string directly to determine the data category instead of going through the lossy RL category mapping. --- src/sentry_client_report.c | 6 +++--- src/sentry_client_report.h | 2 ++ src/sentry_envelope.c | 19 ++----------------- tests/unit/test_client_report.c | 13 ++++++++++++- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/sentry_client_report.c b/src/sentry_client_report.c index 65f8d677c..502895e2a 100644 --- a/src/sentry_client_report.c +++ b/src/sentry_client_report.c @@ -164,8 +164,8 @@ sentry__client_report_into_envelope(sentry_envelope_t *envelope) return item; } -static sentry_data_category_t -item_type_to_data_category(const char *ty) +sentry_data_category_t +sentry__item_type_to_data_category(const char *ty) { if (sentry__string_eq(ty, "session")) { return SENTRY_DATA_CATEGORY_SESSION; @@ -203,7 +203,7 @@ sentry__client_report_discard_envelope(const sentry_envelope_t *envelope, continue; } sentry__client_report_discard( - reason, item_type_to_data_category(ty), 1); + reason, sentry__item_type_to_data_category(ty), 1); } } diff --git a/src/sentry_client_report.h b/src/sentry_client_report.h index 0ba229b95..a94fd9902 100644 --- a/src/sentry_client_report.h +++ b/src/sentry_client_report.h @@ -39,6 +39,8 @@ typedef enum { * Record a discarded event with the given reason and category. * This function is thread-safe using atomic operations. */ +sentry_data_category_t sentry__item_type_to_data_category(const char *ty); + void sentry__client_report_discard(sentry_discard_reason_t reason, sentry_data_category_t category, long quantity); diff --git a/src/sentry_envelope.c b/src/sentry_envelope.c index fcc006ba8..f3c45617f 100644 --- a/src/sentry_envelope.c +++ b/src/sentry_envelope.c @@ -155,20 +155,6 @@ sentry__envelope_is_rate_limited( return has_items; } -static sentry_data_category_t -ratelimiter_category_to_data_category(int rl_category) -{ - switch (rl_category) { - case SENTRY_RL_CATEGORY_SESSION: - return SENTRY_DATA_CATEGORY_SESSION; - case SENTRY_RL_CATEGORY_TRANSACTION: - return SENTRY_DATA_CATEGORY_TRANSACTION; - case SENTRY_RL_CATEGORY_ERROR: - default: - return SENTRY_DATA_CATEGORY_ERROR; - } -} - static sentry_envelope_item_t * envelope_add_from_owned_buffer( sentry_envelope_t *envelope, char *buf, size_t buf_len, const char *type) @@ -799,10 +785,9 @@ sentry_envelope_serialize_ratelimited(const sentry_envelope_t *envelope, // rl_category < 0 means the item should bypass rate limiting if (rl_category >= 0 && sentry__rate_limiter_is_disabled(rl, rl_category)) { - sentry_data_category_t data_category - = ratelimiter_category_to_data_category(rl_category); sentry__client_report_discard( - SENTRY_DISCARD_REASON_RATELIMIT_BACKOFF, data_category, 1); + SENTRY_DISCARD_REASON_RATELIMIT_BACKOFF, + sentry__item_type_to_data_category(ty), 1); continue; } } diff --git a/tests/unit/test_client_report.c b/tests/unit/test_client_report.c index 0decd70aa..e6fc89adf 100644 --- a/tests/unit/test_client_report.c +++ b/tests/unit/test_client_report.c @@ -87,6 +87,7 @@ SENTRY_TEST(client_report_discard_envelope) sentry__envelope_add_from_buffer(envelope, "{}", 2, "attachment"); sentry__envelope_add_from_buffer(envelope, "{}", 2, "log"); sentry__envelope_add_from_buffer(envelope, "{}", 2, "feedback"); + sentry__envelope_add_from_buffer(envelope, "{}", 2, "trace_metric"); sentry__envelope_add_from_buffer(envelope, "{}", 2, "client_report"); sentry__client_report_discard_envelope( @@ -104,7 +105,7 @@ SENTRY_TEST(client_report_discard_envelope) sentry_value_t discarded = sentry_value_get_by_key(report, "discarded_events"); - TEST_CHECK_INT_EQUAL(sentry_value_get_length(discarded), 6); + TEST_CHECK_INT_EQUAL(sentry_value_get_length(discarded), 7); sentry_value_t entry0 = sentry_value_get_by_index(discarded, 0); TEST_CHECK_STRING_EQUAL( @@ -166,6 +167,16 @@ SENTRY_TEST(client_report_discard_envelope) TEST_CHECK_INT_EQUAL( sentry_value_as_int32(sentry_value_get_by_key(entry5, "quantity")), 1); + sentry_value_t entry6 = sentry_value_get_by_index(discarded, 6); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry6, "reason")), + "network_error"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(entry6, "category")), + "trace_metric"); + TEST_CHECK_INT_EQUAL( + sentry_value_as_int32(sentry_value_get_by_key(entry6, "quantity")), 1); + sentry_value_decref(report); sentry_envelope_free(carrier); sentry_envelope_free(envelope); From 8d5dc8399e97c4ca6a3fee05daa3dedd083b4700 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 2 Apr 2026 14:15:18 +0200 Subject: [PATCH 4/6] fix send_error discards lost after rate limiter update The rate limiter is updated from the response headers before the status code is returned. Pass NULL to skip the stale rate limiter check since all items were already serialized and sent. --- src/transports/sentry_http_transport.c | 2 +- tests/test_integration_client_reports.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/transports/sentry_http_transport.c b/src/transports/sentry_http_transport.c index d6154cb89..80bbc90db 100644 --- a/src/transports/sentry_http_transport.c +++ b/src/transports/sentry_http_transport.c @@ -281,7 +281,7 @@ http_send_task(void *_envelope, void *_state) } } else if (status_code >= 400) { sentry__client_report_discard_envelope( - envelope, SENTRY_DISCARD_REASON_SEND_ERROR, state->ratelimiter); + envelope, SENTRY_DISCARD_REASON_SEND_ERROR, NULL); } } diff --git a/tests/test_integration_client_reports.py b/tests/test_integration_client_reports.py index d6ce8e1f9..8536bb8d4 100644 --- a/tests/test_integration_client_reports.py +++ b/tests/test_integration_client_reports.py @@ -232,14 +232,17 @@ def test_client_report_before_send_transaction(cmake, httpserver): def test_client_report_send_error(cmake, httpserver): tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + # 429 + rate limit header: the rate limiter is updated before the status + # code is returned. The rejected event must still be reported as + # send_error, not skipped due to the new rate limit. httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data( - "Bad Request", status=400 + "Rate Limited", + status=429, + headers={"X-Sentry-Rate-Limits": "60:error:organization"}, ) httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) - # Event is rejected (400). The session at shutdown carries the - # client report. run( tmp_path, "sentry_example", From 66dacf985ca8539a60b213e2948d9d99b423e22e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 2 Apr 2026 15:11:34 +0200 Subject: [PATCH 5/6] fix double-counting when rate limit and send error overlap Split http_send_request so the rate limiter is updated after recording discards, not before. This ensures items that were rate-limited during serialization are not also counted as send_error. --- src/transports/sentry_http_transport.c | 57 +++++++++++++++--------- tests/test_integration_client_reports.py | 49 ++++++++++++++++++++ 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/src/transports/sentry_http_transport.c b/src/transports/sentry_http_transport.c index 80bbc90db..b98138404 100644 --- a/src/transports/sentry_http_transport.c +++ b/src/transports/sentry_http_transport.c @@ -190,31 +190,33 @@ sentry__prepared_http_request_free(sentry_prepared_http_request_t *req) } static int -http_send_request( - http_transport_state_t *state, sentry_prepared_http_request_t *req) +http_send_request(http_transport_state_t *state, + sentry_prepared_http_request_t *req, sentry_http_response_t *resp) { - sentry_http_response_t resp; - memset(&resp, 0, sizeof(resp)); - - if (!state->send_func(state->client, req, &resp)) { - sentry_free(resp.retry_after); - sentry_free(resp.x_sentry_rate_limits); + memset(resp, 0, sizeof(*resp)); + if (!state->send_func(state->client, req, resp)) { + sentry_free(resp->retry_after); + sentry_free(resp->x_sentry_rate_limits); return -1; } + return resp->status_code; +} - if (resp.x_sentry_rate_limits) { +static void +http_update_ratelimiter( + http_transport_state_t *state, sentry_http_response_t *resp) +{ + if (resp->x_sentry_rate_limits) { sentry__rate_limiter_update_from_header( - state->ratelimiter, resp.x_sentry_rate_limits); - } else if (resp.retry_after) { + state->ratelimiter, resp->x_sentry_rate_limits); + } else if (resp->retry_after) { sentry__rate_limiter_update_from_http_retry_after( - state->ratelimiter, resp.retry_after); - } else if (resp.status_code == 429) { + state->ratelimiter, resp->retry_after); + } else if (resp->status_code == 429) { sentry__rate_limiter_update_from_429(state->ratelimiter); } - - sentry_free(resp.retry_after); - sentry_free(resp.x_sentry_rate_limits); - return resp.status_code; + sentry_free(resp->retry_after); + sentry_free(resp->x_sentry_rate_limits); } static int @@ -226,8 +228,12 @@ http_send_envelope(sentry_envelope_t *envelope, void *_state) if (!req) { return 0; } - int status_code = http_send_request(state, req); + sentry_http_response_t resp; + int status_code = http_send_request(state, req, &resp); sentry__prepared_http_request_free(req); + if (status_code >= 0) { + http_update_ratelimiter(state, &resp); + } return status_code; } @@ -268,7 +274,15 @@ http_send_task(void *_envelope, void *_state) sentry__client_report_into_envelope(envelope); } - int status_code = http_send_envelope(envelope, state); + sentry_prepared_http_request_t *req = sentry__prepare_http_request( + envelope, state->dsn, state->ratelimiter, state->user_agent); + if (!req) { + return; + } + sentry_http_response_t resp; + int status_code = http_send_request(state, req, &resp); + sentry__prepared_http_request_free(req); + if (status_code < 0) { if (state->retry) { sentry__retry_enqueue(state->retry, envelope); @@ -281,7 +295,10 @@ http_send_task(void *_envelope, void *_state) } } else if (status_code >= 400) { sentry__client_report_discard_envelope( - envelope, SENTRY_DISCARD_REASON_SEND_ERROR, NULL); + envelope, SENTRY_DISCARD_REASON_SEND_ERROR, state->ratelimiter); + http_update_ratelimiter(state, &resp); + } else { + http_update_ratelimiter(state, &resp); } } diff --git a/tests/test_integration_client_reports.py b/tests/test_integration_client_reports.py index 8536bb8d4..f55983515 100644 --- a/tests/test_integration_client_reports.py +++ b/tests/test_integration_client_reports.py @@ -108,6 +108,55 @@ def ratelimit_first(request): assert total_discards == {("ratelimit_backoff", "error"): 9} +def test_client_report_ratelimit_then_send_error(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + # First request rate-limits sessions. Second request returns 400. + # The session item is filtered during serialization (ratelimit_backoff). + # The event item is sent but rejected (send_error). Each item must be + # counted exactly once under the correct reason. + request_count = [0] + + def handler(request): + from werkzeug import Response + + request_count[0] += 1 + if request_count[0] == 1: + return Response( + "OK", 200, {"X-Sentry-Rate-Limits": "60:session:organization"} + ) + if request_count[0] == 2: + return Response("Bad Request", 400) + return Response("OK", 200) + + httpserver.expect_request("/api/123456/envelope/").respond_with_handler(handler) + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "start-session", "capture-multiple"], + env=env, + ) + + total_discards = {} + for req, _resp in httpserver.log: + envelope = Envelope.deserialize(req.get_data()) + for item in envelope: + if item.headers.get("type") != "client_report" or not item.payload.json: + continue + for entry in item.payload.json.get("discarded_events", []): + key = (entry["reason"], entry["category"]) + total_discards[key] = total_discards.get(key, 0) + entry["quantity"] + + # Session updates rate-limited after the first envelope + assert ("ratelimit_backoff", "session") in total_discards + # Second event rejected by the server + assert ("send_error", "error") in total_discards + # Sessions must NOT also be counted as send_error + assert ("send_error", "session") not in total_discards + + def test_client_report_sample_rate(cmake, httpserver): tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) From 47ca4e43100ff20ee2d4dbae3d88a287cdd1e61f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 2 Apr 2026 15:48:13 +0200 Subject: [PATCH 6/6] clean up unnecessary quantity cast --- src/sentry_client_report.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry_client_report.c b/src/sentry_client_report.c index 502895e2a..679f5ed46 100644 --- a/src/sentry_client_report.c +++ b/src/sentry_client_report.c @@ -138,7 +138,7 @@ sentry__client_report_into_envelope(sentry_envelope_t *envelope) sentry__jsonwriter_write_key(jw, "category"); sentry__jsonwriter_write_str(jw, data_category_to_string(c)); sentry__jsonwriter_write_key(jw, "quantity"); - sentry__jsonwriter_write_int32(jw, (int32_t)count); + sentry__jsonwriter_write_int64(jw, count); sentry__jsonwriter_write_object_end(jw); } }