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..679f5ed46 --- /dev/null +++ b/src/sentry_client_report.c @@ -0,0 +1,214 @@ +#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"; + } +} + +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) +{ + 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); + + 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"); + 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_int64(jw, 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; + } + + reset_discard_counts(counts); + return item; +} + +sentry_data_category_t +sentry__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, sentry__item_type_to_data_category(ty), 1); + } +} + +void +sentry__client_report_reset(void) +{ + reset_discard_counts(NULL); +} diff --git a/src/sentry_client_report.h b/src/sentry_client_report.h new file mode 100644 index 000000000..a94fd9902 --- /dev/null +++ b/src/sentry_client_report.h @@ -0,0 +1,74 @@ +#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. + */ +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); + +/** + * 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..f3c45617f 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,53 @@ 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_envelope_item_t * envelope_add_from_owned_buffer( sentry_envelope_t *envelope, char *buf, size_t buf_len, const char *type) @@ -746,8 +779,15 @@ 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__client_report_discard( + SENTRY_DISCARD_REASON_RATELIMIT_BACKOFF, + sentry__item_type_to_data_category(ty), 1); continue; } } @@ -1137,8 +1177,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 +1211,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..b98138404 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 @@ -188,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 @@ -224,11 +228,26 @@ 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; } +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 +269,36 @@ http_send_task(void *_envelope, void *_state) sentry_envelope_t *envelope = _envelope; http_transport_state_t *state = _state; - 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 (state->send_client_reports + && !sentry__envelope_is_rate_limited(envelope, state->ratelimiter)) { + sentry__client_report_into_envelope(envelope); + } + + 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); + } 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); + http_update_ratelimiter(state, &resp); + } else { + http_update_ratelimiter(state, &resp); } } @@ -287,6 +331,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 +348,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..f55983515 --- /dev/null +++ b/tests/test_integration_client_reports.py @@ -0,0 +1,309 @@ +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_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"}) + + 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"}) + + # 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( + "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)) + + 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..e6fc89adf --- /dev/null +++ b/tests/unit/test_client_report.c @@ -0,0 +1,307 @@ +#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, "trace_metric"); + 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), 7); + + 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_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); + 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)