diff --git a/include/sentry.h b/include/sentry.h index 41e15d23d..9de98716c 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2187,6 +2187,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, before_send hooks, + * rate limiting, network errors, 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 87098e2f3..46ecb4b0e 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 05c0d9757..79582184f 100644 --- a/src/sentry_batcher.c +++ b/src/sentry_batcher.c @@ -1,4 +1,5 @@ #include "sentry_batcher.h" +#include "sentry_client_report.h" #include "sentry_cpu_relax.h" #include "sentry_options.h" @@ -204,7 +205,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, + SENTRY_DATA_CATEGORY_LOG_ITEM, 1); return false; } } diff --git a/src/sentry_client_report.c b/src/sentry_client_report.c new file mode 100644 index 000000000..c2c2b60a8 --- /dev/null +++ b/src/sentry_client_report.c @@ -0,0 +1,210 @@ +#include "sentry_client_report.h" +#include "sentry_alloc.h" +#include "sentry_envelope.h" +#include "sentry_json.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_SPAN: + return "span"; + 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_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 reason = 0; reason < SENTRY_DISCARD_REASON_MAX; reason++) { + for (int category = 0; category < SENTRY_DATA_CATEGORY_MAX; + category++) { + if (sentry__atomic_fetch( + (long *)&g_discard_counts[reason][category]) + > 0) { + return true; + } + } + } + return false; +} + +sentry_envelope_item_t * +sentry__client_report_into_envelope(sentry_envelope_t *envelope) +{ + if (!envelope) { + return NULL; + } + + long counts[SENTRY_DISCARD_REASON_MAX][SENTRY_DATA_CATEGORY_MAX] + = { { 0 } }; + bool has_data = false; + + for (int reason = 0; reason < SENTRY_DISCARD_REASON_MAX; reason++) { + for (int category = 0; category < SENTRY_DATA_CATEGORY_MAX; + category++) { + long count = sentry__atomic_store( + (long *)&g_discard_counts[reason][category], 0); + counts[reason][category] = count; + if (count > 0) { + has_data = true; + } + } + } + + if (!has_data) { + return NULL; + } + + sentry_value_t client_report = sentry_value_new_object(); + + sentry_value_set_by_key(client_report, "timestamp", + sentry__value_new_string_owned( + sentry__usec_time_to_iso8601(sentry__usec_time()))); + + sentry_value_t discarded_events = sentry_value_new_list(); + + for (int reason = 0; reason < SENTRY_DISCARD_REASON_MAX; reason++) { + for (int category = 0; category < SENTRY_DATA_CATEGORY_MAX; + category++) { + long count = counts[reason][category]; + if (count > 0) { + sentry_value_t entry = sentry_value_new_object(); + sentry_value_set_by_key(entry, "reason", + sentry_value_new_string(discard_reason_to_string( + (sentry_discard_reason_t)reason))); + sentry_value_set_by_key(entry, "category", + sentry_value_new_string(data_category_to_string( + (sentry_data_category_t)category))); + sentry_value_set_by_key( + entry, "quantity", sentry_value_new_int32((int32_t)count)); + sentry_value_append(discarded_events, entry); + } + } + } + + sentry_value_set_by_key( + client_report, "discarded_events", discarded_events); + + sentry_jsonwriter_t *jw = sentry__jsonwriter_new_sb(NULL); + if (!jw) { + sentry_value_decref(client_report); + return NULL; + } + + sentry__jsonwriter_write_value(jw, client_report); + size_t payload_len = 0; + char *payload = sentry__jsonwriter_into_string(jw, &payload_len); + sentry_value_decref(client_report); + + if (!payload) { + return NULL; + } + + sentry_envelope_item_t *item = sentry__envelope_add_from_buffer( + envelope, payload, payload_len, "client_report"); + sentry_free(payload); + + 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; + } + return SENTRY_DATA_CATEGORY_ERROR; +} + +void +sentry__client_report_discard_envelope( + const sentry_envelope_t *envelope, sentry_discard_reason_t reason) +{ + 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")); + if (sentry__string_eq(ty, "client_report")) { + continue; + } + sentry__client_report_discard( + reason, item_type_to_data_category(ty), 1); + } +} + +void +sentry__client_report_reset(void) +{ + for (int reason = 0; reason < SENTRY_DISCARD_REASON_MAX; reason++) { + for (int category = 0; category < SENTRY_DATA_CATEGORY_MAX; + category++) { + sentry__atomic_store( + (long *)&g_discard_counts[reason][category], 0); + } + } +} diff --git a/src/sentry_client_report.h b/src/sentry_client_report.h new file mode 100644 index 000000000..4d39bfec8 --- /dev/null +++ b/src/sentry_client_report.h @@ -0,0 +1,71 @@ +#ifndef SENTRY_CLIENT_REPORT_H_INCLUDED +#define SENTRY_CLIENT_REPORT_H_INCLUDED + +#include "sentry_boot.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_SPAN, + SENTRY_DATA_CATEGORY_ATTACHMENT, + SENTRY_DATA_CATEGORY_LOG_ITEM, + SENTRY_DATA_CATEGORY_FEEDBACK, + 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); + +/** + * 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 135acd1e9..c8b389052 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 @@ -625,6 +628,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); @@ -718,6 +723,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; } } @@ -769,6 +776,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 27a836765..5227eabe1 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" @@ -106,6 +107,11 @@ sentry__envelope_item_set_header( sentry_value_set_by_key(item->headers, key, value); } +/** + * Returns the rate limiter category for an envelope item, or -1 if the item + * should bypass rate limiting (e.g., client_report items are internal + * telemetry and should always be sent). + */ static int envelope_item_get_ratelimiter_category(const sentry_envelope_item_t *item) { @@ -115,12 +121,50 @@ envelope_item_get_ratelimiter_category(const sentry_envelope_item_t *item) 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_has_sendable_items( + const sentry_envelope_t *envelope, const sentry_rate_limiter_t *rl) +{ + if (envelope->is_raw) { + return true; + } + for (const sentry_envelope_item_t *item + = envelope->contents.items.first_item; + item; item = item->next) { + int rl_category = envelope_item_get_ratelimiter_category(item); + if (rl_category < 0) { + continue; + } + if (!rl || !sentry__rate_limiter_is_disabled(rl, rl_category)) { + return true; + } + } + return false; +} + +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) @@ -745,8 +789,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)) { + int rl_category = envelope_item_get_ratelimiter_category(item); + // rl_category < 0 means the item should bypass rate limiting + if (rl_category >= 0 + && sentry__rate_limiter_is_disabled(rl, rl_category)) { + // Track rate-limited items for client reports + 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; } } @@ -1020,7 +1071,6 @@ sentry_envelope_read_from_filew_n(const wchar_t *path, size_t path_len) } #endif -#ifdef SENTRY_UNITTEST size_t sentry__envelope_get_item_count(const sentry_envelope_t *envelope) { @@ -1055,6 +1105,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 f0e9bbc73..a87df0839 100644 --- a/src/sentry_envelope.h +++ b/src/sentry_envelope.h @@ -124,6 +124,13 @@ void sentry__envelope_set_header( void sentry__envelope_item_set_header( sentry_envelope_item_t *item, const char *key, sentry_value_t value); +/** + * Returns true if the envelope has at least one item that would survive + * rate limiting, ignoring internal items like client_report. + */ +bool sentry__envelope_has_sendable_items( + 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 @@ -147,13 +154,14 @@ void sentry__envelope_serialize_into_stringbuilder( MUST_USE int sentry_envelope_write_to_path( const sentry_envelope_t *envelope, const sentry_path_t *path); -// 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_options.c b/src/sentry_options.c index 8cc2eb587..f47ad975f 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -79,6 +79,7 @@ sentry_options_new(void) opts->max_spans = SENTRY_SPANS_MAX; opts->handler_strategy = SENTRY_HANDLER_STRATEGY_DEFAULT; opts->http_retry = true; + opts->send_client_reports = true; return opts; } @@ -864,3 +865,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 9cda3d00a..f74ba8fca 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 237052689..98110468d 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" @@ -32,6 +33,7 @@ typedef struct { sentry_http_send_func_t send_func; void (*shutdown_client)(void *client); sentry_retry_t *retry; + bool send_client_reports; } http_transport_state_t; #ifdef SENTRY_TRANSPORT_COMPRESSION @@ -230,6 +232,10 @@ static int retry_send_cb(sentry_envelope_t *envelope, void *_state) { http_transport_state_t *state = _state; + if (state->send_client_reports + && sentry__envelope_has_sendable_items(envelope, state->ratelimiter)) { + sentry__client_report_into_envelope(envelope); + } return http_send_envelope(state, envelope); } @@ -253,9 +259,17 @@ 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_has_sendable_items(envelope, state->ratelimiter)) { + sentry__client_report_into_envelope(envelope); + } + int status_code = http_send_envelope(state, envelope); if (status_code < 0 && state->retry) { sentry__retry_enqueue(state->retry, envelope); + } else if (status_code < 0) { + sentry__client_report_discard_envelope( + envelope, SENTRY_DISCARD_REASON_NETWORK_ERROR); } } @@ -278,6 +292,7 @@ http_transport_start(const sentry_options_t *options, void *transport_state) state->dsn = sentry__dsn_incref(options->dsn); state->user_agent = sentry__string_clone(options->user_agent); + state->send_client_reports = options->send_client_reports; if (state->start_client) { int rv = state->start_client(state->client, options); diff --git a/tests/__init__.py b/tests/__init__.py index 58ec5b82e..dff4d8e61 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -351,6 +351,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 16cc5bea0..26f6b827f 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -255,6 +255,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 @@ -287,6 +289,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 ( @@ -541,6 +545,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..228b9cb14 --- /dev/null +++ b/tests/test_integration_client_reports.py @@ -0,0 +1,108 @@ +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} diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index c85da1702..aed9590dd 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, @@ -767,6 +769,7 @@ def test_http_retry_on_network_error(cmake, httpserver): 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 @@ -1065,6 +1068,54 @@ def test_http_retry_session_on_network_error(cmake, httpserver): 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): + 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", "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", "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 a3b88efa5..6515e2b7d 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..3d5f71f94 --- /dev/null +++ b/tests/unit/test_client_report.c @@ -0,0 +1,155 @@ +#include "sentry_client_report.h" +#include "sentry_envelope.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, "client_report"); + + sentry__client_report_discard_envelope( + envelope, SENTRY_DISCARD_REASON_NETWORK_ERROR); + + 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), 3); + + 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_decref(report); + sentry_envelope_free(carrier); + sentry_envelope_free(envelope); + 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(); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index a5de45549..39dd2ba24 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -53,6 +53,9 @@ 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_none) XX(concurrent_init) XX(concurrent_uninit) XX(count_sampled_events)