diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e63e3e95..59c29ddcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +**Features**: + +- Add support for metrics. It is currently experimental, and one can enable it by setting `sentry_options_set_enable_metrics`. When enabled, you can record a metric using `sentry_metrics_count()`, `sentry_metrics_gauge()`, or `sentry_metrics_distribution()`. Metrics can be filtered by setting the `before_send_metric` hook. ([#1498](https://github.com/getsentry/sentry-native/pull/1498)) + ## 0.12.5 **Features**: diff --git a/examples/example.c b/examples/example.c index 311a1822b..33573c25a 100644 --- a/examples/example.c +++ b/examples/example.c @@ -183,6 +183,25 @@ discarding_before_send_log_callback(sentry_value_t log, void *user_data) return log; } +static sentry_value_t +before_send_metric_callback(sentry_value_t metric, void *user_data) +{ + (void)user_data; + sentry_value_t attribute + = sentry_value_new_attribute(sentry_value_new_string("little"), NULL); + sentry_value_set_by_key(sentry_value_get_by_key(metric, "attributes"), + "coffeepot.size", attribute); + return metric; +} + +static sentry_value_t +discarding_before_send_metric_callback(sentry_value_t metric, void *user_data) +{ + (void)user_data; + sentry_value_decref(metric); + return sentry_value_new_null(); +} + // Test logger that outputs in a format the integration tests can parse static void test_logger_callback( @@ -317,8 +336,8 @@ create_debug_crumb(const char *message) } #define NUM_THREADS 50 -#define NUM_LOGS 100 // how many log calls each thread makes -#define LOG_SLEEP_MS 1 // time (in ms) between log calls +#define NUM_TELEMETRY 100 // how many telemetry calls each thread makes +#define TELEMETRY_SLEEP_MS 1 // time (in ms) between telemetry calls #if defined(SENTRY_PLATFORM_WINDOWS) # define sleep_ms(MILLISECONDS) Sleep(MILLISECONDS) @@ -327,29 +346,80 @@ create_debug_crumb(const char *message) #endif #ifdef SENTRY_PLATFORM_WINDOWS +typedef DWORD(WINAPI *thread_func_t)(LPVOID); + DWORD WINAPI log_thread_func(LPVOID lpParam) { (void)lpParam; - for (int i = 0; i < NUM_LOGS; i++) { + for (int i = 0; i < NUM_TELEMETRY; i++) { sentry_log_debug( "thread log %d on thread %lu", i, get_current_thread_id()); - sleep_ms(LOG_SLEEP_MS); + sleep_ms(TELEMETRY_SLEEP_MS); + } + return 0; +} + +DWORD WINAPI +metric_thread_func(LPVOID lpParam) +{ + (void)lpParam; + for (int i = 0; i < NUM_TELEMETRY; i++) { + sentry_metrics_count("thread.counter", 1, sentry_value_new_null()); + sleep_ms(TELEMETRY_SLEEP_MS); } return 0; } + +static void +run_threads(thread_func_t func) +{ + HANDLE threads[NUM_THREADS]; + for (int t = 0; t < NUM_THREADS; t++) { + threads[t] = CreateThread(NULL, 0, func, NULL, 0, NULL); + } + WaitForMultipleObjects(NUM_THREADS, threads, TRUE, INFINITE); + for (int t = 0; t < NUM_THREADS; t++) { + CloseHandle(threads[t]); + } +} #else +typedef void *(*thread_func_t)(void *); + void * log_thread_func(void *arg) { (void)arg; - for (int i = 0; i < NUM_LOGS; i++) { + for (int i = 0; i < NUM_TELEMETRY; i++) { sentry_log_debug( "thread log %d on thread %llu", i, get_current_thread_id()); - sleep_ms(LOG_SLEEP_MS); + sleep_ms(TELEMETRY_SLEEP_MS); + } + return NULL; +} + +void * +metric_thread_func(void *arg) +{ + (void)arg; + for (int i = 0; i < NUM_TELEMETRY; i++) { + sentry_metrics_count("thread.counter", 1, sentry_value_new_null()); + sleep_ms(TELEMETRY_SLEEP_MS); } return NULL; } + +static void +run_threads(thread_func_t func) +{ + pthread_t threads[NUM_THREADS]; + for (int t = 0; t < NUM_THREADS; t++) { + pthread_create(&threads[t], NULL, func, NULL); + } + for (int t = 0; t < NUM_THREADS; t++) { + pthread_join(threads[t], NULL); + } +} #endif int @@ -504,6 +574,20 @@ main(int argc, char **argv) sentry_options_set_logs_with_attributes(options, true); } + if (has_arg(argc, argv, "enable-metrics")) { + sentry_options_set_enable_metrics(options, true); + } + + if (has_arg(argc, argv, "before-send-metric")) { + sentry_options_set_before_send_metric( + options, before_send_metric_callback, NULL); + } + + if (has_arg(argc, argv, "discarding-before-send-metric")) { + sentry_options_set_before_send_metric( + options, discarding_before_send_metric_callback, NULL); + } + if (0 != sentry_init(options)) { return EXIT_FAILURE; } @@ -580,28 +664,39 @@ main(int argc, char **argv) sentry_log_debug("post-sleep log"); } if (has_arg(argc, argv, "logs-threads")) { - // Spawn multiple threads to test concurrent logging -#ifdef SENTRY_PLATFORM_WINDOWS - HANDLE threads[NUM_THREADS]; - for (int t = 0; t < NUM_THREADS; t++) { - threads[t] - = CreateThread(NULL, 0, log_thread_func, NULL, 0, NULL); - } - - WaitForMultipleObjects(NUM_THREADS, threads, TRUE, INFINITE); + run_threads(log_thread_func); + } + } - for (int t = 0; t < NUM_THREADS; t++) { - CloseHandle(threads[t]); - } -#else - pthread_t threads[NUM_THREADS]; - for (int t = 0; t < NUM_THREADS; t++) { - pthread_create(&threads[t], NULL, log_thread_func, NULL); - } - for (int t = 0; t < NUM_THREADS; t++) { - pthread_join(threads[t], NULL); + if (sentry_options_get_enable_metrics(options)) { + if (has_arg(argc, argv, "capture-metric")) { + sentry_metrics_count("test.counter", 1, sentry_value_new_null()); + } + if (has_arg(argc, argv, "capture-metric-all-types")) { + sentry_metrics_count("test.counter", 1, sentry_value_new_null()); + sentry_metrics_gauge("test.gauge", 42.5, SENTRY_UNIT_PERCENT, + sentry_value_new_null()); + sentry_metrics_distribution("test.distribution", 123.456, + SENTRY_UNIT_MILLISECOND, sentry_value_new_null()); + } + if (has_arg(argc, argv, "metric-with-attributes")) { + sentry_value_t attributes = sentry_value_new_object(); + sentry_value_t attr = sentry_value_new_attribute( + sentry_value_new_string("my_value"), NULL); + sentry_value_set_by_key(attributes, "my.custom.attribute", attr); + sentry_metrics_count("test.counter.with.attributes", 1, attributes); + } + if (has_arg(argc, argv, "metrics-timer")) { + for (int i = 0; i < 10; i++) { + sentry_metrics_count( + "batch.counter", 1, sentry_value_new_null()); } -#endif + sleep_s(6); + sentry_metrics_count( + "post.sleep.counter", 1, sentry_value_new_null()); + } + if (has_arg(argc, argv, "metrics-threads")) { + run_threads(metric_thread_func); } } @@ -881,6 +976,10 @@ main(int argc, char **argv) if (has_arg(argc, argv, "logs-scoped-transaction")) { sentry_log_debug("logging during scoped transaction event"); } + if (has_arg(argc, argv, "metrics-scoped-transaction")) { + sentry_metrics_count( + "scoped.transaction.metric", 1, sentry_value_new_null()); + } } sentry_transaction_finish(tx); diff --git a/include/sentry.h b/include/sentry.h index 242bc4e60..d2b7dbeca 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -324,9 +324,49 @@ SENTRY_API sentry_value_t sentry_value_new_user_n(const char *id, size_t id_len, const char *username, size_t username_len, const char *email, size_t email_len, const char *ip_address, size_t ip_address_len); +/** + * Measurement units for telemetry. + * + * These constants represent the standardized units supported by Sentry. + * Custom units can also be passed as arbitrary strings. + * + * See: https://develop.sentry.dev/sdk/telemetry/attributes/#units + */ + +/* Duration units */ +#define SENTRY_UNIT_NANOSECOND "nanosecond" +#define SENTRY_UNIT_MICROSECOND "microsecond" +#define SENTRY_UNIT_MILLISECOND "millisecond" +#define SENTRY_UNIT_SECOND "second" +#define SENTRY_UNIT_MINUTE "minute" +#define SENTRY_UNIT_HOUR "hour" +#define SENTRY_UNIT_DAY "day" +#define SENTRY_UNIT_WEEK "week" + +/* Information units */ +#define SENTRY_UNIT_BIT "bit" +#define SENTRY_UNIT_BYTE "byte" +#define SENTRY_UNIT_KILOBYTE "kilobyte" +#define SENTRY_UNIT_KIBIBYTE "kibibyte" +#define SENTRY_UNIT_MEGABYTE "megabyte" +#define SENTRY_UNIT_MEBIBYTE "mebibyte" +#define SENTRY_UNIT_GIGABYTE "gigabyte" +#define SENTRY_UNIT_GIBIBYTE "gibibyte" +#define SENTRY_UNIT_TERABYTE "terabyte" +#define SENTRY_UNIT_TEBIBYTE "tebibyte" +#define SENTRY_UNIT_PETABYTE "petabyte" +#define SENTRY_UNIT_PEBIBYTE "pebibyte" +#define SENTRY_UNIT_EXABYTE "exabyte" +#define SENTRY_UNIT_EXBIBYTE "exbibyte" + +/* Fraction units */ +#define SENTRY_UNIT_RATIO "ratio" +#define SENTRY_UNIT_PERCENT "percent" + /** * Creates a new attribute object. - * value is required, unit is optional. + * value is required, unit is optional, but has to be one of the + * `SENTRY_UNIT_X` macros. * * value must be a bool, int, double or string `sentry_value_t` * OR a list of bool, int, double or string (with all items being the same type) @@ -1863,6 +1903,7 @@ SENTRY_API void sentry_remove_extra_n(const char *key, size_t key_len); * Sets attributes created with `sentry_value_new_attribute` to be applied to * all: * - logs + * - metrics */ SENTRY_API void sentry_set_attribute(const char *key, sentry_value_t attribute); SENTRY_API void sentry_set_attribute_n( @@ -2126,6 +2167,91 @@ typedef sentry_value_t (*sentry_before_send_log_function_t)( SENTRY_EXPERIMENTAL_API void sentry_options_set_before_send_log( sentry_options_t *opts, sentry_before_send_log_function_t func, void *data); +/** + * Enables or disables the metrics feature. + * When disabled, all calls to `sentry_metrics_*()` are no-ops. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_enable_metrics( + sentry_options_t *opts, int enable_metrics); +SENTRY_EXPERIMENTAL_API int sentry_options_get_enable_metrics( + const sentry_options_t *opts); + +/** + * Type of the `before_send_metric` callback. + * + * The callback takes ownership of the `metric` and should usually return + * that same metric. In case the metric should be discarded, the + * callback needs to call `sentry_value_decref` on the provided metric and + * return a `sentry_value_new_null()` instead. + */ +typedef sentry_value_t (*sentry_before_send_metric_function_t)( + sentry_value_t metric, void *user_data); + +/** + * Sets the `before_send_metric` callback. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_before_send_metric( + sentry_options_t *opts, sentry_before_send_metric_function_t func, + void *data); + +/** + * Result type for metric operations. + * - Success means the metric was enqueued + * - Discard means the `before_send_metric` callback discarded the metric + * - Failed means the metric wasn't enqueued (buffers are full) + * - Disabled means metrics are disabled + */ +typedef enum { + SENTRY_METRICS_RESULT_SUCCESS = 0, + SENTRY_METRICS_RESULT_DISCARD = 1, + SENTRY_METRICS_RESULT_FAILED = 2, + SENTRY_METRICS_RESULT_DISABLED = 3 +} sentry_metrics_result_t; + +/** + * Metrics interface for recording application metrics. + * + * Metrics are buffered and sent in batches. Each metric includes: + * - name: Hierarchical name with dot separators (e.g., "api.requests") + * - value: The numeric value to record + * - unit: Optional measurement unit (e.g., SENTRY_UNIT_MILLISECOND), or NULL + * - attributes: Optional sentry_value_t object with custom attributes, or + * sentry_value_new_null(). Each attribute should be created with + * sentry_value_new_attribute(). + * + * Ownership of the attributes is transferred to the metric function. + * + * To re-use the same attributes, call `sentry_value_incref` on it + * before passing the attributes to the metric function. + * + * Metrics are automatically associated with the current trace and span if + * available. Default attributes (environment, release, SDK info) are attached + * automatically. + */ + +/** + * Records a counter metric. Counters track incrementing values like + * request counts or error counts. + */ +SENTRY_EXPERIMENTAL_API sentry_metrics_result_t sentry_metrics_count( + const char *name, int64_t value, sentry_value_t attributes); + +/** + * Records a gauge metric. Gauges track values that can go up or down, + * like memory usage or active connections. + */ +SENTRY_EXPERIMENTAL_API sentry_metrics_result_t sentry_metrics_gauge( + const char *name, double value, const char *unit, + sentry_value_t attributes); + +/** + * Records a distribution metric. Distributions track the statistical + * distribution of values, useful for timing data and percentiles. + */ +SENTRY_EXPERIMENTAL_API sentry_metrics_result_t sentry_metrics_distribution( + const char *name, double value, const char *unit, + sentry_value_t attributes); + #ifdef SENTRY_PLATFORM_LINUX /** diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8e21f1ec6..3b874a887 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -24,6 +24,8 @@ sentry_target_sources_cwd(sentry sentry_logger.h sentry_logs.c sentry_logs.h + sentry_metrics.c + sentry_metrics.h sentry_options.c sentry_options.h sentry_os.c diff --git a/src/backends/sentry_backend_breakpad.cpp b/src/backends/sentry_backend_breakpad.cpp index 86eb3bec4..4d515b381 100644 --- a/src/backends/sentry_backend_breakpad.cpp +++ b/src/backends/sentry_backend_breakpad.cpp @@ -9,6 +9,7 @@ extern "C" { #include "sentry_envelope.h" #include "sentry_logger.h" #include "sentry_logs.h" +#include "sentry_metrics.h" #include "sentry_options.h" #ifdef SENTRY_PLATFORM_WINDOWS # include "sentry_os.h" @@ -152,10 +153,13 @@ breakpad_backend_callback(const google_breakpad::MinidumpDescriptor &descriptor, should_handle = !sentry_value_is_null(result); } - // Flush logs in a crash-safe manner before crash handling + // Flush logs and metrics in a crash-safe manner before crash handling if (options->enable_logs) { sentry__logs_flush_crash_safe(); } + if (options->enable_metrics) { + sentry__metrics_flush_crash_safe(); + } if (should_handle) { sentry_envelope_t *envelope = sentry__prepare_event( diff --git a/src/backends/sentry_backend_crashpad.cpp b/src/backends/sentry_backend_crashpad.cpp index 781481359..58c0da65a 100644 --- a/src/backends/sentry_backend_crashpad.cpp +++ b/src/backends/sentry_backend_crashpad.cpp @@ -10,6 +10,7 @@ extern "C" { #include "sentry_envelope.h" #include "sentry_logger.h" #include "sentry_logs.h" +#include "sentry_metrics.h" #include "sentry_options.h" #ifdef SENTRY_PLATFORM_WINDOWS # include "sentry_os.h" @@ -356,10 +357,13 @@ sentry__crashpad_handler(int signum, siginfo_t *info, ucontext_t *user_context) crash_event, nullptr, options->before_send_data); } - // Flush logs in a crash-safe manner before crash handling + // Flush logs and metrics in a crash-safe manner before crash handling if (options->enable_logs) { sentry__logs_flush_crash_safe(); } + if (options->enable_metrics) { + sentry__metrics_flush_crash_safe(); + } should_dump = !sentry_value_is_null(crash_event); diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 3f02024e3..76c73c175 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -8,6 +8,7 @@ #include "sentry_envelope.h" #include "sentry_logger.h" #include "sentry_logs.h" +#include "sentry_metrics.h" #include "sentry_options.h" #if defined(SENTRY_PLATFORM_WINDOWS) # include "sentry_os.h" @@ -699,10 +700,13 @@ handle_ucontext(const sentry_ucontext_t *uctx) should_handle = !sentry_value_is_null(event); } - // Flush logs in a crash-safe manner before crash handling + // Flush logs and metrics in a crash-safe manner before crash handling if (options->enable_logs) { sentry__logs_flush_crash_safe(); } + if (options->enable_metrics) { + sentry__metrics_flush_crash_safe(); + } if (should_handle) { sentry_envelope_t *envelope = sentry__prepare_event( diff --git a/src/sentry_batcher.c b/src/sentry_batcher.c index 9d60b4fe1..405acdc33 100644 --- a/src/sentry_batcher.c +++ b/src/sentry_batcher.c @@ -47,7 +47,7 @@ check_for_flush_condition(sentry_batcher_t *batcher) >= SENTRY_BATCHER_QUEUE_LENGTH; } -void +bool sentry__batcher_flush(sentry_batcher_t *batcher, bool crash_safe) { if (crash_safe) { @@ -59,7 +59,7 @@ sentry__batcher_flush(sentry_batcher_t *batcher, bool crash_safe) SENTRY_WARN( "sentry__batcher_flush: timeout waiting for flushing " "lock in crash-safe mode"); - return; + return false; } // backoff max-wait with max_attempts = 200 based sleep slots: @@ -74,7 +74,7 @@ sentry__batcher_flush(sentry_batcher_t *batcher, bool crash_safe) const long already_flushing = sentry__atomic_store(&batcher->flushing, 1); if (already_flushing) { - return; + return false; } } do { @@ -135,6 +135,7 @@ sentry__batcher_flush(sentry_batcher_t *batcher, bool crash_safe) } while (check_for_flush_condition(batcher)); sentry__atomic_store(&batcher->flushing, 0); + return true; } #define ENQUEUE_MAX_RETRIES 2 @@ -281,11 +282,8 @@ batcher_thread_func(void *data) } void -sentry__batcher_startup( - sentry_batcher_t *batcher, sentry_batch_func_t batch_func) +sentry__batcher_startup(sentry_batcher_t *batcher) { - batcher->batch_func = batch_func; - // Mark thread as starting before actually spawning so thread can transition // to RUNNING. This prevents shutdown from thinking the thread was never // started if it races with the thread's initialization. @@ -308,11 +306,9 @@ sentry__batcher_startup( } } -void -sentry__batcher_shutdown(sentry_batcher_t *batcher, uint64_t timeout) +bool +sentry__batcher_shutdown_begin(sentry_batcher_t *batcher) { - (void)timeout; - // Atomically transition to STOPPED and get the previous state // This handles the race where thread might be in STARTING state: // - If thread's CAS hasn't run yet: CAS will fail, thread exits cleanly @@ -323,11 +319,18 @@ sentry__batcher_shutdown(sentry_batcher_t *batcher, uint64_t timeout) // If thread was never started, nothing to do if (old_state == SENTRY_BATCHER_THREAD_STOPPED) { SENTRY_DEBUG("batcher thread was not started, skipping shutdown"); - return; + return false; } // Thread was started (either STARTING or RUNNING), signal it to stop sentry__cond_wake(&batcher->request_flush); + return true; +} + +void +sentry__batcher_shutdown_wait(sentry_batcher_t *batcher, uint64_t timeout) +{ + (void)timeout; // Always join the thread to avoid leaks sentry__thread_join(batcher->batching_thread); @@ -359,12 +362,21 @@ sentry__batcher_flush_crash_safe(sentry_batcher_t *batcher) } void -sentry__batcher_force_flush(sentry_batcher_t *batcher) +sentry__batcher_force_flush_begin(sentry_batcher_t *batcher) { - while (sentry__atomic_fetch(&batcher->flushing)) { - sentry__cpu_relax(); - } - sentry__batcher_flush(batcher, false); + sentry__cond_wake(&batcher->request_flush); +} + +void +sentry__batcher_force_flush_wait(sentry_batcher_t *batcher) +{ + do { + // wait for in-progress flush to complete + while (sentry__atomic_fetch(&batcher->flushing)) { + sentry__cpu_relax(); + } + // retry if the batcher thread (woken by _begin) wins the race + } while (!sentry__batcher_flush(batcher, false)); } #ifdef SENTRY_UNITTEST diff --git a/src/sentry_batcher.h b/src/sentry_batcher.h index 49fa4bc4f..899815581 100644 --- a/src/sentry_batcher.h +++ b/src/sentry_batcher.h @@ -43,13 +43,14 @@ typedef struct { sentry_batch_func_t batch_func; // function to add items to envelope } sentry_batcher_t; -void sentry__batcher_flush(sentry_batcher_t *batcher, bool crash_safe); +bool sentry__batcher_flush(sentry_batcher_t *batcher, bool crash_safe); bool sentry__batcher_enqueue(sentry_batcher_t *batcher, sentry_value_t item); -void sentry__batcher_startup( - sentry_batcher_t *batcher, sentry_batch_func_t batch_func); -void sentry__batcher_shutdown(sentry_batcher_t *batcher, uint64_t timeout); +void sentry__batcher_startup(sentry_batcher_t *batcher); +bool sentry__batcher_shutdown_begin(sentry_batcher_t *batcher); +void sentry__batcher_shutdown_wait(sentry_batcher_t *batcher, uint64_t timeout); void sentry__batcher_flush_crash_safe(sentry_batcher_t *batcher); -void sentry__batcher_force_flush(sentry_batcher_t *batcher); +void sentry__batcher_force_flush_begin(sentry_batcher_t *batcher); +void sentry__batcher_force_flush_wait(sentry_batcher_t *batcher); #ifdef SENTRY_UNITTEST void sentry__batcher_wait_for_thread_startup(sentry_batcher_t *batcher); diff --git a/src/sentry_core.c b/src/sentry_core.c index d4a2cb594..00cdf6537 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -10,6 +10,7 @@ #include "sentry_envelope.h" #include "sentry_hint.h" #include "sentry_logs.h" +#include "sentry_metrics.h" #include "sentry_options.h" #include "sentry_path.h" #include "sentry_process.h" @@ -297,6 +298,10 @@ sentry_init(sentry_options_t *options) sentry__logs_startup(); } + if (options->enable_metrics) { + sentry__metrics_startup(); + } + sentry__mutex_unlock(&g_options_lock); return 0; @@ -315,8 +320,18 @@ sentry_flush(uint64_t timeout) { int rv = 0; SENTRY_WITH_OPTIONS (options) { + // flush logs and metrics in parallel + if (options->enable_logs) { + sentry__logs_force_flush_begin(); + } + if (options->enable_metrics) { + sentry__metrics_force_flush_begin(); + } if (options->enable_logs) { - sentry__logs_force_flush(); + sentry__logs_force_flush_wait(); + } + if (options->enable_metrics) { + sentry__metrics_force_flush_wait(); } rv = sentry__transport_flush(options->transport, timeout); } @@ -326,12 +341,23 @@ sentry_flush(uint64_t timeout) int sentry_close(void) { - // Shutdown logs system before locking options to ensure logs are flushed. - // This prevents a potential deadlock on the options during log envelope - // creation. + // Shutdown logs and metrics in parallel before locking options to ensure + // they are flushed. This prevents a potential deadlock on the options + // during envelope creation. SENTRY_WITH_OPTIONS (options) { + bool wait_logs = false; if (options->enable_logs) { - sentry__logs_shutdown(options->shutdown_timeout); + wait_logs = sentry__logs_shutdown_begin(); + } + bool wait_metrics = false; + if (options->enable_metrics) { + wait_metrics = sentry__metrics_shutdown_begin(); + } + if (wait_logs) { + sentry__logs_shutdown_wait(options->shutdown_timeout); + } + if (wait_metrics) { + sentry__metrics_shutdown_wait(options->shutdown_timeout); } } diff --git a/src/sentry_envelope.c b/src/sentry_envelope.c index 72e6de221..fc81cea24 100644 --- a/src/sentry_envelope.c +++ b/src/sentry_envelope.c @@ -440,8 +440,9 @@ sentry__envelope_add_transaction( return item; } -sentry_envelope_item_t * -sentry__envelope_add_logs(sentry_envelope_t *envelope, sentry_value_t logs) +static sentry_envelope_item_t * +add_telemetry(sentry_envelope_t *envelope, sentry_value_t telemetry, + const char *type, const char *content_type) { sentry_envelope_item_t *item = envelope_add_item(envelope); if (!item) { @@ -453,25 +454,40 @@ sentry__envelope_add_logs(sentry_envelope_t *envelope, sentry_value_t logs) return NULL; } - sentry__jsonwriter_write_value(jw, logs); + sentry__jsonwriter_write_value(jw, telemetry); item->payload = sentry__jsonwriter_into_string(jw, &item->payload_len); if (!item->payload) { return NULL; } sentry__envelope_item_set_header( - item, "type", sentry_value_new_string("log")); + item, "type", sentry_value_new_string(type)); sentry__envelope_item_set_header(item, "item_count", sentry_value_new_int32((int32_t)sentry_value_get_length( - sentry_value_get_by_key(logs, "items")))); - sentry__envelope_item_set_header(item, "content_type", - sentry_value_new_string("application/vnd.sentry.items.log+json")); + sentry_value_get_by_key(telemetry, "items")))); + sentry__envelope_item_set_header( + item, "content_type", sentry_value_new_string(content_type)); sentry_value_t length = sentry_value_new_int32((int32_t)item->payload_len); sentry__envelope_item_set_header(item, "length", length); return item; } +sentry_envelope_item_t * +sentry__envelope_add_logs(sentry_envelope_t *envelope, sentry_value_t logs) +{ + return add_telemetry( + envelope, logs, "log", "application/vnd.sentry.items.log+json"); +} + +sentry_envelope_item_t * +sentry__envelope_add_metrics( + sentry_envelope_t *envelope, sentry_value_t metrics) +{ + return add_telemetry(envelope, metrics, "trace_metric", + "application/vnd.sentry.items.trace-metric+json"); +} + sentry_envelope_item_t * sentry__envelope_add_user_report( sentry_envelope_t *envelope, sentry_value_t user_report) diff --git a/src/sentry_envelope.h b/src/sentry_envelope.h index 3cbddd401..0cae74e69 100644 --- a/src/sentry_envelope.h +++ b/src/sentry_envelope.h @@ -62,6 +62,12 @@ sentry_envelope_item_t *sentry__envelope_add_user_report( sentry_envelope_item_t *sentry__envelope_add_logs( sentry_envelope_t *envelope, sentry_value_t logs); +/** + * Add a list of metrics to this envelope. + */ +sentry_envelope_item_t *sentry__envelope_add_metrics( + sentry_envelope_t *envelope, sentry_value_t metrics); + /** * Add a user feedback to this envelope. */ diff --git a/src/sentry_logs.c b/src/sentry_logs.c index 1b281afc2..1221e0938 100644 --- a/src/sentry_logs.c +++ b/src/sentry_logs.c @@ -460,14 +460,20 @@ sentry_log_fatal(const char *message, ...) void sentry__logs_startup(void) { - sentry__batcher_startup(&g_batcher, sentry__envelope_add_logs); + sentry__batcher_startup(&g_batcher); +} + +bool +sentry__logs_shutdown_begin(void) +{ + SENTRY_DEBUG("beginning logs system shutdown"); + return sentry__batcher_shutdown_begin(&g_batcher); } void -sentry__logs_shutdown(uint64_t timeout) +sentry__logs_shutdown_wait(uint64_t timeout) { - SENTRY_DEBUG("shutting down logs system"); - sentry__batcher_shutdown(&g_batcher, timeout); + sentry__batcher_shutdown_wait(&g_batcher, timeout); SENTRY_DEBUG("logs system shutdown complete"); } @@ -480,9 +486,15 @@ sentry__logs_flush_crash_safe(void) } void -sentry__logs_force_flush(void) +sentry__logs_force_flush_begin(void) +{ + sentry__batcher_force_flush_begin(&g_batcher); +} + +void +sentry__logs_force_flush_wait(void) { - sentry__batcher_force_flush(&g_batcher); + sentry__batcher_force_flush_wait(&g_batcher); } #ifdef SENTRY_UNITTEST diff --git a/src/sentry_logs.h b/src/sentry_logs.h index 866dd526c..402d2ff68 100644 --- a/src/sentry_logs.h +++ b/src/sentry_logs.h @@ -12,9 +12,15 @@ log_return_value_t sentry__logs_log( void sentry__logs_startup(void); /** - * Instructs the logs timer/flush thread to shut down. + * Begin non-blocking shutdown of the logs timer/flush thread. */ -void sentry__logs_shutdown(uint64_t timeout); +bool sentry__logs_shutdown_begin(void); + +/** + * Wait for the logs timer/flush thread to complete shutdown. + * Should only be called if sentry__logs_shutdown_begin returned true. + */ +void sentry__logs_shutdown_wait(uint64_t timeout); /** * Crash-safe logs flush that avoids thread synchronization. @@ -23,7 +29,15 @@ void sentry__logs_shutdown(uint64_t timeout); */ void sentry__logs_flush_crash_safe(void); -void sentry__logs_force_flush(void); +/** + * Begin non-blocking force flush of logs. + */ +void sentry__logs_force_flush_begin(void); + +/** + * Wait for the logs force flush to complete. + */ +void sentry__logs_force_flush_wait(void); #ifdef SENTRY_UNITTEST int populate_message_parameters( diff --git a/src/sentry_metrics.c b/src/sentry_metrics.c new file mode 100644 index 000000000..68e86cb51 --- /dev/null +++ b/src/sentry_metrics.c @@ -0,0 +1,195 @@ +#include "sentry_metrics.h" +#include "sentry_batcher.h" +#include "sentry_core.h" +#include "sentry_envelope.h" +#include "sentry_options.h" +#include "sentry_scope.h" +#include "sentry_utils.h" +#include "sentry_value.h" + +typedef enum { + SENTRY_METRIC_COUNT, + SENTRY_METRIC_GAUGE, + SENTRY_METRIC_DISTRIBUTION, +} sentry_metric_type_t; + +static sentry_batcher_t g_batcher = { + { + { + .index = 0, + .adding = 0, + .sealed = 0, + }, + { + .index = 0, + .adding = 0, + .sealed = 0, + }, + }, + .active_idx = 0, + .flushing = 0, + .thread_state = SENTRY_BATCHER_THREAD_STOPPED, + .batch_func = sentry__envelope_add_metrics, +}; + +static const char * +metric_type_string(sentry_metric_type_t type) +{ + switch (type) { + case SENTRY_METRIC_COUNT: + return "counter"; + case SENTRY_METRIC_GAUGE: + return "gauge"; + case SENTRY_METRIC_DISTRIBUTION: + return "distribution"; + default: + return "unknown"; + } +} + +static sentry_value_t +construct_metric(sentry_metric_type_t type, const char *name, + sentry_value_t value, const char *unit, sentry_value_t user_attributes) +{ + sentry_value_t metric = sentry_value_new_object(); + + uint64_t usec_time = sentry__usec_time(); + sentry_value_set_by_key(metric, "timestamp", + sentry_value_new_double((double)usec_time / 1000000.0)); + sentry_value_set_by_key( + metric, "type", sentry_value_new_string(metric_type_string(type))); + sentry_value_set_by_key(metric, "name", sentry_value_new_string(name)); + sentry_value_set_by_key(metric, "value", value); + if (unit && unit[0] != '\0') { + sentry_value_set_by_key(metric, "unit", sentry_value_new_string(unit)); + } + + sentry_value_t attributes + = sentry_value_get_type(user_attributes) == SENTRY_VALUE_TYPE_OBJECT + ? sentry__value_clone(user_attributes) + : sentry_value_new_object(); + sentry_value_decref(user_attributes); + + sentry__apply_attributes(metric, attributes); + + if (sentry_value_get_length(attributes) > 0) { + sentry_value_set_by_key(metric, "attributes", attributes); + } else { + sentry_value_decref(attributes); + } + + return metric; +} + +static sentry_metrics_result_t +record_metric(sentry_metric_type_t type, const char *name, sentry_value_t value, + const char *unit, sentry_value_t attributes) +{ + bool enable_metrics = false; + SENTRY_WITH_OPTIONS (options) { + if (options->enable_metrics) + enable_metrics = true; + } + if (enable_metrics) { + bool discarded = false; + sentry_value_t metric + = construct_metric(type, name, value, unit, attributes); + SENTRY_WITH_OPTIONS (options) { + if (options->before_send_metric_func) { + metric = options->before_send_metric_func( + metric, options->before_send_metric_data); + if (sentry_value_is_null(metric)) { + SENTRY_DEBUG("metric was discarded by the " + "`before_send_metric` hook"); + discarded = true; + } + } + } + if (discarded) { + return SENTRY_METRICS_RESULT_DISCARD; + } + if (!sentry__batcher_enqueue(&g_batcher, metric)) { + sentry_value_decref(metric); + return SENTRY_METRICS_RESULT_FAILED; + } + return SENTRY_METRICS_RESULT_SUCCESS; + } + sentry_value_decref(value); + sentry_value_decref(attributes); + return SENTRY_METRICS_RESULT_DISABLED; +} + +sentry_metrics_result_t +sentry_metrics_count(const char *name, int64_t value, sentry_value_t attributes) +{ + return record_metric(SENTRY_METRIC_COUNT, name, + sentry_value_new_int64(value), NULL, attributes); +} + +sentry_metrics_result_t +sentry_metrics_gauge( + const char *name, double value, const char *unit, sentry_value_t attributes) +{ + return record_metric(SENTRY_METRIC_GAUGE, name, + sentry_value_new_double(value), unit, attributes); +} + +sentry_metrics_result_t +sentry_metrics_distribution( + const char *name, double value, const char *unit, sentry_value_t attributes) +{ + return record_metric(SENTRY_METRIC_DISTRIBUTION, name, + sentry_value_new_double(value), unit, attributes); +} + +void +sentry__metrics_startup(void) +{ + sentry__batcher_startup(&g_batcher); +} + +bool +sentry__metrics_shutdown_begin(void) +{ + SENTRY_DEBUG("beginning metrics system shutdown"); + return sentry__batcher_shutdown_begin(&g_batcher); +} + +void +sentry__metrics_shutdown_wait(uint64_t timeout) +{ + sentry__batcher_shutdown_wait(&g_batcher, timeout); + SENTRY_DEBUG("metrics system shutdown complete"); +} + +void +sentry__metrics_flush_crash_safe(void) +{ + SENTRY_DEBUG("crash-safe metrics flush"); + sentry__batcher_flush_crash_safe(&g_batcher); + SENTRY_DEBUG("crash-safe metrics flush complete"); +} + +void +sentry__metrics_force_flush_begin(void) +{ + sentry__batcher_force_flush_begin(&g_batcher); +} + +void +sentry__metrics_force_flush_wait(void) +{ + sentry__batcher_force_flush_wait(&g_batcher); +} + +#ifdef SENTRY_UNITTEST +/** + * Wait for the metrics batching thread to be ready. + * This is a test-only helper to avoid race conditions in tests. + */ +void +sentry__metrics_wait_for_thread_startup(void) +{ + sentry__batcher_wait_for_thread_startup(&g_batcher); +} +#endif diff --git a/src/sentry_metrics.h b/src/sentry_metrics.h new file mode 100644 index 000000000..f62ca93ad --- /dev/null +++ b/src/sentry_metrics.h @@ -0,0 +1,47 @@ +#ifndef SENTRY_METRICS_H_INCLUDED +#define SENTRY_METRICS_H_INCLUDED + +#include "sentry_boot.h" + +/** + * Sets up the metrics timer/flush thread + */ +void sentry__metrics_startup(void); + +/** + * Begin non-blocking shutdown of the metrics timer/flush thread. + */ +bool sentry__metrics_shutdown_begin(void); + +/** + * Wait for the metrics timer/flush thread to complete shutdown. + * Should only be called if sentry__metrics_shutdown_begin returned true. + */ +void sentry__metrics_shutdown_wait(uint64_t timeout); + +/** + * Crash-safe metrics flush that avoids thread synchronization. + * This should be used during crash handling to flush metrics without + * waiting for the batching thread to shut down cleanly. + */ +void sentry__metrics_flush_crash_safe(void); + +/** + * Begin non-blocking force flush of metrics. + */ +void sentry__metrics_force_flush_begin(void); + +/** + * Wait for the metrics force flush to complete. + */ +void sentry__metrics_force_flush_wait(void); + +#ifdef SENTRY_UNITTEST +/** + * Wait for the metrics batching thread to be ready. + * This is a test-only helper to avoid race conditions in tests. + */ +void sentry__metrics_wait_for_thread_startup(void); +#endif + +#endif diff --git a/src/sentry_options.c b/src/sentry_options.c index b9b6ea4c6..0d71957e8 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -757,6 +757,26 @@ sentry_options_get_logs_with_attributes(const sentry_options_t *opts) return opts->logs_with_attributes; } +void +sentry_options_set_enable_metrics(sentry_options_t *opts, int enable_metrics) +{ + opts->enable_metrics = !!enable_metrics; +} + +int +sentry_options_get_enable_metrics(const sentry_options_t *opts) +{ + return opts->enable_metrics; +} + +void +sentry_options_set_before_send_metric(sentry_options_t *opts, + sentry_before_send_metric_function_t func, void *user_data) +{ + opts->before_send_metric_func = func; + opts->before_send_metric_data = user_data; +} + #ifdef SENTRY_PLATFORM_LINUX sentry_handler_strategy_t diff --git a/src/sentry_options.h b/src/sentry_options.h index 280703d40..9a035ebe2 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -68,6 +68,9 @@ struct sentry_options_s { // takes the first varg as a `sentry_value_t` object containing attributes // if no custom attributes are to be passed, use `sentry_value_new_object()` bool logs_with_attributes; + bool enable_metrics; + sentry_before_send_metric_function_t before_send_metric_func; + void *before_send_metric_data; /* everything from here on down are options which are stored here but not exposed through the options API */ diff --git a/tests/__init__.py b/tests/__init__.py index 0cf368fb6..e2845fdf3 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -58,6 +58,10 @@ def is_logs_envelope(data): return b'"type":"log"' in data +def is_metrics_envelope(data): + return b'"type":"trace_metric"' in data + + def is_feedback_envelope(data): return b'"type":"feedback"' in data @@ -346,6 +350,7 @@ def deserialize_from( "transaction", "user_report", "log", + "trace_metric", ]: rv = cls(headers=headers, payload=PayloadRef(json=json.loads(payload))) else: diff --git a/tests/assertions.py b/tests/assertions.py index 05d3ea320..ee3ef4886 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -274,6 +274,38 @@ def assert_logs(envelope, expected_item_count=1, expected_trace_id=None): assert log_item["trace_id"] == expected_trace_id +def assert_metrics(envelope, expected_item_count=1, expected_trace_id=None): + metrics = None + for item in envelope: + assert item.headers.get("type") == "trace_metric" + assert item.headers.get("item_count") >= expected_item_count + assert ( + item.headers.get("content_type") + == "application/vnd.sentry.items.trace-metric+json" + ) + metrics = item.payload.json + + assert isinstance(metrics, dict) + assert "items" in metrics + assert len(metrics["items"]) >= expected_item_count + for i in range(expected_item_count): + metric_item = metrics["items"][i] + assert "name" in metric_item + assert "type" in metric_item + assert metric_item["type"] in ["counter", "gauge", "distribution"] + assert "value" in metric_item + assert "timestamp" in metric_item + assert "trace_id" in metric_item + assert "attributes" in metric_item + attrs = metric_item["attributes"] + assert "sentry.environment" in attrs + assert "sentry.release" in attrs + assert "sentry.sdk.name" in attrs + assert "sentry.sdk.version" in attrs + if expected_trace_id: + assert metric_item["trace_id"] == expected_trace_id + + def assert_attachment_view_hierarchy(envelope): expected = { "type": "attachment", diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index 539e7192a..c7f389f08 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -17,6 +17,7 @@ split_log_request_cond, is_feedback_envelope, is_logs_envelope, + is_metrics_envelope, SENTRY_VERSION, ) from .proxy import ( @@ -43,6 +44,7 @@ assert_failed_proxy_auth_request, assert_attachment_view_hierarchy, assert_logs, + assert_metrics, ) from .conditions import has_http, has_breakpad, has_files, is_kcov @@ -1834,3 +1836,446 @@ def test_logs_global_and_local_attributes_merge(cmake, httpserver): assert "global.attribute.array" in attributes_0 assert attributes_0["global.attribute.array"]["value"] == ["item1", "item2"] assert attributes_0["global.attribute.array"]["type"] == "array" + + +def test_metrics_capture(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "capture-metric"], + env=env, + ) + + assert len(httpserver.log) == 1 + + req = httpserver.log[0][0] + body = req.get_data() + + envelope = Envelope.deserialize(body) + envelope.print_verbose() + assert_metrics(envelope, 1) + + +def test_metrics_all_types(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "capture-metric-all-types"], + env=env, + ) + + assert len(httpserver.log) == 1 + + req = httpserver.log[0][0] + body = req.get_data() + + envelope = Envelope.deserialize(body) + envelope.print_verbose() + assert_metrics(envelope, 3) + + # Verify the different metric types + metrics = envelope.items[0].payload.json + types_found = {item["type"] for item in metrics["items"]} + assert types_found == {"counter", "gauge", "distribution"} + + +def test_metrics_with_custom_attributes(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "metric-with-attributes"], + env=env, + ) + + assert len(httpserver.log) == 1 + + req = httpserver.log[0][0] + body = req.get_data() + + envelope = Envelope.deserialize(body) + envelope.print_verbose() + assert_metrics(envelope, 1) + + # Check custom attribute exists + metric_item = envelope.items[0].payload.json["items"][0] + attrs = metric_item["attributes"] + assert "my.custom.attribute" in attrs + assert attrs["my.custom.attribute"]["value"] == "my_value" + assert attrs["my.custom.attribute"]["type"] == "string" + + +def test_metrics_timer(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "metrics-timer"], + env=env, + ) + + # We expect 2 envelopes: one from the timer flush (after sleep), one at shutdown + assert len(httpserver.log) == 2 + + req_0 = httpserver.log[0][0] + body_0 = req_0.get_data() + envelope_0 = Envelope.deserialize(body_0) + assert_metrics(envelope_0, 10) + + req_1 = httpserver.log[1][0] + body_1 = req_1.get_data() + envelope_1 = Envelope.deserialize(body_1) + assert_metrics(envelope_1, 1) + + +def test_metrics_scoped_transaction(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + [ + "log", + "enable-metrics", + "metrics-scoped-transaction", + "capture-transaction", + "scope-transaction-event", + ], + env=env, + ) + + # Event, transaction, and metrics + assert len(httpserver.log) == 3 + + event_req = httpserver.log[0][0] + event_body = event_req.get_data() + event_envelope = Envelope.deserialize(event_body) + assert_event(event_envelope) + + event_trace_id = event_envelope.items[0].payload.json["contexts"]["trace"][ + "trace_id" + ] + + tx_req = httpserver.log[1][0] + tx_body = tx_req.get_data() + tx_envelope = Envelope.deserialize(tx_body) + tx_trace_id = tx_envelope.items[0].payload.json["contexts"]["trace"]["trace_id"] + assert tx_trace_id == event_trace_id + + metrics_req = httpserver.log[2][0] + metrics_body = metrics_req.get_data() + metrics_envelope = Envelope.deserialize(metrics_body) + assert_metrics(metrics_envelope, 1, event_trace_id) + + +def test_before_send_metric(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_oneshot_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "capture-metric", "before-send-metric"], + env=env, + ) + + assert len(httpserver.log) == 1 + req = httpserver.log[0][0] + body = req.get_data() + + envelope = Envelope.deserialize(body) + envelope.print_verbose() + + # Extract the metric item + (metric_item,) = envelope.items + + assert metric_item.headers["type"] == "trace_metric" + payload = metric_item.payload.json + + # Get the first metric item from the metrics payload + metric_entry = payload["items"][0] + attributes = metric_entry["attributes"] + + # Check that the before_send_metric callback added the expected attribute + assert "coffeepot.size" in attributes + assert attributes["coffeepot.size"]["value"] == "little" + assert attributes["coffeepot.size"]["type"] == "string" + + +def test_before_send_metric_discard(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_oneshot_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "capture-metric", "discarding-before-send-metric"], + env=env, + ) + + # metric should have been discarded + assert len(httpserver.log) == 0 + + +def test_metrics_disabled(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # Run without enable-metrics flag + run( + tmp_path, + "sentry_example", + ["log", "capture-metric"], + env=env, + ) + + # No metrics should be sent when feature is disabled + assert len(httpserver.log) == 0 + + +def test_metrics_event(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "capture-metric", "capture-event"], + env=env, + ) + + assert len(httpserver.log) == 2 + + event_req = httpserver.log[0][0] + event_body = event_req.get_data() + event_envelope = Envelope.deserialize(event_body) + assert_event(event_envelope) + + # ensure that the event and the metric are part of the same trace + event_trace_id = event_envelope.items[0].payload.json["contexts"]["trace"][ + "trace_id" + ] + + metrics_req = httpserver.log[1][0] + metrics_body = metrics_req.get_data() + metrics_envelope = Envelope.deserialize(metrics_body) + assert_metrics(metrics_envelope, 1, event_trace_id) + + +def test_metrics_threaded(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "metrics-threads"], + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + ) + + # there is a chance we drop metrics while flushing buffers + assert 1 <= len(httpserver.log) <= 50 + total_count = 0 + + for i in range(len(httpserver.log)): + req = httpserver.log[i][0] + body = req.get_data() + + envelope = Envelope.deserialize(body) + assert_metrics(envelope) + total_count += envelope.items[0].headers["item_count"] + print(f"Total amount of captured metrics: {total_count}") + assert total_count >= 100 + + +def test_metrics_global_and_local_attributes_merge(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_oneshot_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "set-global-attribute", "metric-with-attributes"], + env=env, + ) + + assert len(httpserver.log) == 1 + req = httpserver.log[0][0] + body = req.get_data() + + envelope = Envelope.deserialize(body) + + # Show what the envelope looks like if the test fails + envelope.print_verbose() + + # Extract the metric item + (metric_item,) = envelope.items + + assert metric_item.headers["type"] == "trace_metric" + payload = metric_item.payload.json + + assert len(payload["items"]) == 1 + + metric_entry = payload["items"][0] + attributes = metric_entry["attributes"] + + # Verify local attribute (should overwrite global with same key) + assert "my.custom.attribute" in attributes + assert attributes["my.custom.attribute"]["value"] == "my_value" + assert attributes["my.custom.attribute"]["type"] == "string" + + # Verify global attributes are present + assert "global.attribute.bool" in attributes + assert attributes["global.attribute.bool"]["value"] == True + assert attributes["global.attribute.bool"]["type"] == "boolean" + + assert "global.attribute.int" in attributes + assert attributes["global.attribute.int"]["value"] == 123 + assert attributes["global.attribute.int"]["type"] == "integer" + + assert "global.attribute.double" in attributes + assert attributes["global.attribute.double"]["value"] == 1.23 + assert attributes["global.attribute.double"]["type"] == "double" + + assert "global.attribute.string" in attributes + assert attributes["global.attribute.string"]["value"] == "my_global_value" + assert attributes["global.attribute.string"]["type"] == "string" + + assert "global.attribute.array" in attributes + assert attributes["global.attribute.array"]["value"] == ["item1", "item2"] + assert attributes["global.attribute.array"]["type"] == "array" + + +def test_metrics_discarded_on_crash_no_backend(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_oneshot_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver), SENTRY_RELEASE="🤮🚀") + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "capture-metric", "crash"], + expect_failure=True, + env=env, + ) + + # metric should have been discarded since we have no backend to hook into the crash + assert len(httpserver.log) == 0 + + +@pytest.mark.parametrize( + "backend", + [ + "inproc", + pytest.param( + "breakpad", + marks=pytest.mark.skipif( + not has_breakpad, reason="breakpad backend not available" + ), + ), + ], +) +def test_metrics_on_crash(cmake, httpserver, backend): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": backend}) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "enable-metrics", "capture-metric", "crash"], + expect_failure=True, + env=env, + ) + + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=env, + ) + + # we expect 1 envelope with the metric, and 1 for the crash + assert len(httpserver.log) == 2 + metrics_request, crash_request = split_log_request_cond( + httpserver.log, is_metrics_envelope + ) + metrics = metrics_request.get_data() + + metrics_envelope = Envelope.deserialize(metrics) + + assert metrics_envelope is not None + assert_metrics(metrics_envelope, 1) diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index c889b9320..bfcfb0193 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -33,6 +33,7 @@ add_executable(sentry_test_unit test_info.c test_logger.c test_logs.c + test_metrics.c test_modulefinder.c test_mpack.c test_options.c diff --git a/tests/unit/test_metrics.c b/tests/unit/test_metrics.c new file mode 100644 index 000000000..7b58fe8f5 --- /dev/null +++ b/tests/unit/test_metrics.c @@ -0,0 +1,417 @@ +#include "sentry_metrics.h" +#include "sentry_testsupport.h" + +#include "sentry_envelope.h" +#include + +#ifdef SENTRY_PLATFORM_WINDOWS +# include +# define sleep_ms(MILLISECONDS) Sleep(MILLISECONDS) +#else +# include +# define sleep_ms(MILLISECONDS) usleep(MILLISECONDS * 1000) +#endif + +typedef struct { + uint64_t called_count; + bool has_validation_error; +} transport_validation_data_t; + +static void +validate_metrics_envelope(sentry_envelope_t *envelope, void *data) +{ + transport_validation_data_t *validation_data = data; + + // Check we have at least one envelope item + if (sentry__envelope_get_item_count(envelope) == 0) { + validation_data->has_validation_error = true; + sentry_envelope_free(envelope); + return; + } + + // Get the first item and check its type + const sentry_envelope_item_t *item = sentry__envelope_get_item(envelope, 0); + sentry_value_t type_header = sentry__envelope_item_get_header(item, "type"); + const char *type = sentry_value_as_string(type_header); + + // Only validate and count metric envelopes, skip others (e.g., session) + if (strcmp(type, "trace_metric") == 0) { + validation_data->called_count += 1; + } + + sentry_envelope_free(envelope); +} + +SENTRY_TEST(metrics_count) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + // Record a counter metric + TEST_CHECK_INT_EQUAL( + sentry_metrics_count("test.counter", 1, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + + sentry_close(); + + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 1); +} + +SENTRY_TEST(metrics_gauge) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + // Record a gauge metric + TEST_CHECK_INT_EQUAL(sentry_metrics_gauge("test.gauge", 42.5, + SENTRY_UNIT_PERCENT, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + + sentry_close(); + + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 1); +} + +SENTRY_TEST(metrics_distribution) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + // Record a distribution metric + TEST_CHECK_INT_EQUAL( + sentry_metrics_distribution("test.distribution", 123.456, + SENTRY_UNIT_MILLISECOND, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + + sentry_close(); + + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 1); +} + +SENTRY_TEST(metrics_batch) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + // Batch buffer is 5 items in tests + for (int i = 0; i < 5; i++) { + TEST_CHECK_INT_EQUAL( + sentry_metrics_count("test.counter", 1, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + } + // Sleep to allow first batch to flush + sleep_ms(20); + TEST_CHECK_INT_EQUAL( + sentry_metrics_count("test.counter", 1, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + sentry_close(); + + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 2); +} + +SENTRY_TEST(metrics_with_attributes) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + // Record a metric with custom attributes + sentry_value_t attributes = sentry_value_new_object(); + sentry_value_set_by_key( + attributes, "environment", sentry_value_new_string("production")); + sentry_value_set_by_key( + attributes, "service", sentry_value_new_string("api")); + + TEST_CHECK_INT_EQUAL(sentry_metrics_count("requests.total", 1, attributes), + SENTRY_METRICS_RESULT_SUCCESS); + + sentry_close(); + + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 1); +} + +static sentry_value_t +before_send_metric_discard(sentry_value_t metric, void *data) +{ + (void)data; + sentry_value_decref(metric); + return sentry_value_new_null(); +} + +SENTRY_TEST(metrics_before_send_discard) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + sentry_options_set_before_send_metric( + options, before_send_metric_discard, NULL); + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + // This metric should be discarded by the before_send hook + TEST_CHECK_INT_EQUAL( + sentry_metrics_count("test.counter", 1, sentry_value_new_null()), + SENTRY_METRICS_RESULT_DISCARD); + + sentry_close(); + + // Transport should not be called since the metric was discarded + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 0); +} + +static sentry_value_t +before_send_metric_modify(sentry_value_t metric, void *data) +{ + (void)data; + // Modify the metric by adding a custom attribute + sentry_value_t attributes = sentry_value_get_by_key(metric, "attributes"); + if (sentry_value_is_null(attributes)) { + attributes = sentry_value_new_object(); + sentry_value_set_by_key(metric, "attributes", attributes); + } + sentry_value_set_by_key( + attributes, "modified", sentry_value_new_bool(true)); + return metric; +} + +SENTRY_TEST(metrics_before_send_modify) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + sentry_options_set_before_send_metric( + options, before_send_metric_modify, NULL); + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + // This metric should be modified by the before_send hook + TEST_CHECK_INT_EQUAL( + sentry_metrics_count("test.counter", 1, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + + sentry_close(); + + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 1); +} + +SENTRY_TEST(metrics_disabled) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + // Metrics are disabled by default + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + + // These should return DISABLED since metrics are not enabled + TEST_CHECK_INT_EQUAL( + sentry_metrics_count("test.counter", 1, sentry_value_new_null()), + SENTRY_METRICS_RESULT_DISABLED); + TEST_CHECK_INT_EQUAL( + sentry_metrics_gauge("test.gauge", 42.5, NULL, sentry_value_new_null()), + SENTRY_METRICS_RESULT_DISABLED); + TEST_CHECK_INT_EQUAL(sentry_metrics_distribution("test.distribution", 123.0, + NULL, sentry_value_new_null()), + SENTRY_METRICS_RESULT_DISABLED); + + sentry_close(); + + // Transport should not be called since metrics are disabled + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 0); +} + +SENTRY_TEST(metrics_force_flush) +{ + transport_validation_data_t validation_data = { 0, false }; + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + + sentry_transport_t *transport + = sentry_transport_new(validate_metrics_envelope); + sentry_transport_set_state(transport, &validation_data); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + // Record multiple metrics with force flush between each + TEST_CHECK_INT_EQUAL( + sentry_metrics_count("counter.1", 1, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + sentry_flush(5000); + TEST_CHECK_INT_EQUAL( + sentry_metrics_gauge("gauge.1", 42.5, NULL, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + sentry_flush(5000); + TEST_CHECK_INT_EQUAL(sentry_metrics_distribution("dist.1", 100.0, + SENTRY_UNIT_MILLISECOND, sentry_value_new_null()), + SENTRY_METRICS_RESULT_SUCCESS); + sentry_flush(5000); + + sentry_close(); + + TEST_CHECK(!validation_data.has_validation_error); + TEST_CHECK_INT_EQUAL(validation_data.called_count, 3); +} + +static sentry_value_t g_captured_metric = { 0 }; + +static sentry_value_t +capture_metric(sentry_value_t metric, void *data) +{ + (void)data; + g_captured_metric = metric; + sentry_value_incref(metric); + return metric; +} + +static void +discard_envelope(sentry_envelope_t *envelope, void *data) +{ + (void)data; + sentry_envelope_free(envelope); +} + +SENTRY_TEST(metrics_default_attributes) +{ + g_captured_metric = sentry_value_new_null(); + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_enable_metrics(options, true); + sentry_options_set_environment(options, "test-env"); + sentry_options_set_release(options, "1.0.0"); + sentry_options_set_before_send_metric(options, capture_metric, NULL); + + sentry_transport_t *transport = sentry_transport_new(discard_envelope); + sentry_options_set_transport(options, transport); + + sentry_init(options); + sentry__metrics_wait_for_thread_startup(); + + sentry_metrics_count("test.metric", 1, sentry_value_new_null()); + sentry_close(); + + // Validate trace_id is set directly on metric + sentry_value_t trace_id + = sentry_value_get_by_key(g_captured_metric, "trace_id"); + TEST_CHECK(!sentry_value_is_null(trace_id)); + + // Validate attributes object exists + sentry_value_t attributes + = sentry_value_get_by_key(g_captured_metric, "attributes"); + TEST_CHECK(!sentry_value_is_null(attributes)); + + // Validate default attributes are present with typed format + sentry_value_t env + = sentry_value_get_by_key(attributes, "sentry.environment"); + TEST_CHECK(!sentry_value_is_null(env)); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(env, "value")), + "test-env"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(env, "type")), "string"); + + sentry_value_t release + = sentry_value_get_by_key(attributes, "sentry.release"); + TEST_CHECK(!sentry_value_is_null(release)); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(release, "value")), + "1.0.0"); + + sentry_value_t sdk_name + = sentry_value_get_by_key(attributes, "sentry.sdk.name"); + TEST_CHECK(!sentry_value_is_null(sdk_name)); + + sentry_value_t sdk_version + = sentry_value_get_by_key(attributes, "sentry.sdk.version"); + TEST_CHECK(!sentry_value_is_null(sdk_version)); + + sentry_value_decref(g_captured_metric); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 835285c00..ffa5f35d3 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -100,6 +100,16 @@ XX(logs_force_flush) XX(logs_param_conversion) XX(logs_param_types) XX(message_with_null_text_is_valid) +XX(metrics_batch) +XX(metrics_before_send_discard) +XX(metrics_before_send_modify) +XX(metrics_count) +XX(metrics_default_attributes) +XX(metrics_disabled) +XX(metrics_distribution) +XX(metrics_force_flush) +XX(metrics_gauge) +XX(metrics_with_attributes) XX(module_addr) XX(module_finder) XX(mpack_newlines)