From 956a80393cb43050e716586026e7135c50ce2d93 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 18:21:13 +0200 Subject: [PATCH 01/20] ref(utils): extract W3C baggage iterator and percent-decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two reusable helpers — `sentry__baggage_iter_next`, which yields the next W3C baggage member as trimmed slices (with property suffixes stripped and malformed members skipped), and `sentry__percent_decode_inplace`, which pct-decodes a buffer in place with malformed escapes passed through verbatim. Both are covered by focused unit tests; no production call sites are rewired in this commit. --- src/sentry_utils.c | 56 ++++++++++ src/sentry_utils.h | 22 ++++ tests/unit/test_utils.c | 227 ++++++++++++++++++++++++++++++++++++++++ tests/unit/tests.inc | 11 ++ 4 files changed, 316 insertions(+) diff --git a/src/sentry_utils.c b/src/sentry_utils.c index 870b56a125..f0d5e7d856 100644 --- a/src/sentry_utils.c +++ b/src/sentry_utils.c @@ -10,6 +10,7 @@ #include "sentry_random.h" +#include #include #include #include @@ -662,3 +663,58 @@ sentry__generate_sample_rand(sentry_value_t context) sentry_value_set_by_key( context, "sample_rand", sentry_value_new_double(sample_rand)); } + +bool +sentry__baggage_iter_next( + sentry_slice_t *remaining, sentry_slice_t *key, sentry_slice_t *value) +{ + while (remaining->len > 0) { + size_t comma = sentry__slice_find(*remaining, ','); + sentry_slice_t member; + if (comma == (size_t)-1) { + member = *remaining; + *remaining = sentry__slice_advance(*remaining, remaining->len); + } else { + member = (sentry_slice_t) { remaining->ptr, comma }; + *remaining = sentry__slice_advance(*remaining, comma + 1); + } + member = sentry__slice_trim(member); + + size_t eq = sentry__slice_find(member, '='); + if (eq == (size_t)-1) { + continue; + } + sentry_slice_t k + = sentry__slice_trim((sentry_slice_t) { member.ptr, eq }); + if (k.len == 0) { + continue; + } + sentry_slice_t v = { member.ptr + eq + 1, member.len - eq - 1 }; + size_t semi = sentry__slice_find(v, ';'); + if (semi != (size_t)-1) { + v.len = semi; + } + *key = k; + *value = sentry__slice_trim(v); + return true; + } + return false; +} + +size_t +sentry__percent_decode_inplace(char *s, size_t len) +{ + size_t r = 0; + size_t w = 0; + while (r < len) { + if (s[r] == '%' && r + 2 < len && isxdigit((unsigned char)s[r + 1]) + && isxdigit((unsigned char)s[r + 2])) { + char hex[3] = { s[r + 1], s[r + 2], '\0' }; + s[w++] = (char)strtol(hex, NULL, 16); + r += 3; + } else { + s[w++] = s[r++]; + } + } + return w; +} diff --git a/src/sentry_utils.h b/src/sentry_utils.h index b3b7911278..74f3dc99ae 100644 --- a/src/sentry_utils.h +++ b/src/sentry_utils.h @@ -2,6 +2,7 @@ #define SENTRY_UTILS_H_INCLUDED #include "sentry_boot.h" +#include "sentry_slice.h" #ifdef SENTRY_PLATFORM_DARWIN # include @@ -289,4 +290,25 @@ bool sentry__check_min_version( */ void sentry__generate_sample_rand(sentry_value_t context); +/** + * Yields the next W3C Baggage member from `remaining`, advancing it past the + * yielded member. `key` and `value` are borrowed slices into the original + * buffer with surrounding whitespace trimmed; any property suffix (`;...`) + * after the value is stripped. Values are not percent-decoded; use + * `sentry__percent_decode_inplace` on a mutable copy if needed. + * + * Malformed members (missing `=`, empty key) are skipped silently. Returns + * false when `remaining` is exhausted. + */ +bool sentry__baggage_iter_next( + sentry_slice_t *remaining, sentry_slice_t *key, sentry_slice_t *value); + +/** + * Decodes `%XX` percent-escapes in the first `len` bytes of `s` in place. + * Malformed escapes (non-hex or truncated at the end) are passed through + * verbatim. Returns the new length; the caller is responsible for writing a + * terminating NUL if one is required. + */ +size_t sentry__percent_decode_inplace(char *s, size_t len); + #endif diff --git a/tests/unit/test_utils.c b/tests/unit/test_utils.c index 406844cdec..c8f736a54c 100644 --- a/tests/unit/test_utils.c +++ b/tests/unit/test_utils.c @@ -1,8 +1,11 @@ #include "sentry_os.h" +#include "sentry_slice.h" +#include "sentry_string.h" #include "sentry_testsupport.h" #include "sentry_utils.h" #include "sentry_value.h" #include +#include #ifdef SENTRY_PLATFORM_UNIX # include "sentry_unix_pageallocator.h" @@ -486,3 +489,227 @@ SENTRY_TEST(getenv_double) TEST_CHECK(sentry__getenv_double("SENTRY_TEST_DOUBLE", 42.0) == 42.0); #endif } + +#define CHECK_SLICE_EQ(Slice, Str) \ + do { \ + TEST_CHECK_INT_EQUAL((Slice).len, strlen(Str)); \ + TEST_CHECK((Slice).len == strlen(Str) \ + && memcmp((Slice).ptr, (Str), (Slice).len) == 0); \ + } while (0) + +SENTRY_TEST(baggage_iter_basic) +{ + const char *hdr = "a=1,b=2,c=3"; + sentry_slice_t remaining = { hdr, strlen(hdr) }; + sentry_slice_t key, val; + + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "a"); + CHECK_SLICE_EQ(val, "1"); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "b"); + CHECK_SLICE_EQ(val, "2"); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "c"); + CHECK_SLICE_EQ(val, "3"); + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); +} + +SENTRY_TEST(baggage_iter_ows_trimmed) +{ + // Per W3C baggage, optional whitespace around keys, values, and commas + // must be ignored. + const char *hdr = " a = 1 ,\tb=2 , c =\t3\t"; + sentry_slice_t remaining = { hdr, strlen(hdr) }; + sentry_slice_t key, val; + + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "a"); + CHECK_SLICE_EQ(val, "1"); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "b"); + CHECK_SLICE_EQ(val, "2"); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "c"); + CHECK_SLICE_EQ(val, "3"); + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); +} + +SENTRY_TEST(baggage_iter_empty_and_malformed_skipped) +{ + // Missing `=`, empty keys, and bare commas are all skipped; valid + // members on either side still yield. + const char *hdr = ",malformed, ,=orphan,a=1,=,bare,b=2,"; + sentry_slice_t remaining = { hdr, strlen(hdr) }; + sentry_slice_t key, val; + + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "a"); + CHECK_SLICE_EQ(val, "1"); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "b"); + CHECK_SLICE_EQ(val, "2"); + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); +} + +SENTRY_TEST(baggage_iter_empty_value_allowed) +{ + // Empty values are valid per spec. + const char *hdr = "a=,b=x"; + sentry_slice_t remaining = { hdr, strlen(hdr) }; + sentry_slice_t key, val; + + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "a"); + TEST_CHECK_INT_EQUAL(val.len, 0); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "b"); + CHECK_SLICE_EQ(val, "x"); + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); +} + +SENTRY_TEST(baggage_iter_properties_stripped) +{ + // Value ends at the first `;`; property text is discarded. + const char *hdr = "a=1;prop=x;q,b=2;meta,c=3"; + sentry_slice_t remaining = { hdr, strlen(hdr) }; + sentry_slice_t key, val; + + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "a"); + CHECK_SLICE_EQ(val, "1"); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "b"); + CHECK_SLICE_EQ(val, "2"); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "c"); + CHECK_SLICE_EQ(val, "3"); + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); +} + +SENTRY_TEST(baggage_iter_equals_in_value) +{ + // Only the first `=` separates key from value; subsequent ones are + // part of the value. + const char *hdr = "a=x=y=z"; + sentry_slice_t remaining = { hdr, strlen(hdr) }; + sentry_slice_t key, val; + + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "a"); + CHECK_SLICE_EQ(val, "x=y=z"); + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); +} + +SENTRY_TEST(baggage_iter_empty_input) +{ + sentry_slice_t remaining = { "", 0 }; + sentry_slice_t key, val; + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); + + const char *hdr = " "; + remaining = (sentry_slice_t) { hdr, strlen(hdr) }; + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); + + const char *only_commas = ",,,"; + remaining = (sentry_slice_t) { only_commas, strlen(only_commas) }; + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); +} + +SENTRY_TEST(baggage_iter_case_preserved) +{ + // Baggage keys are case-sensitive and the iterator must preserve case. + const char *hdr = "Sentry-Foo=Bar,sentry-foo=baz"; + sentry_slice_t remaining = { hdr, strlen(hdr) }; + sentry_slice_t key, val; + + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "Sentry-Foo"); + CHECK_SLICE_EQ(val, "Bar"); + TEST_CHECK(sentry__baggage_iter_next(&remaining, &key, &val)); + CHECK_SLICE_EQ(key, "sentry-foo"); + CHECK_SLICE_EQ(val, "baz"); + TEST_CHECK(!sentry__baggage_iter_next(&remaining, &key, &val)); +} + +static char * +decode_to_owned(const char *src) +{ + size_t len = strlen(src); + char *buf = sentry__string_clone_n(src, len); + size_t new_len = sentry__percent_decode_inplace(buf, len); + buf[new_len] = '\0'; + return buf; +} + +SENTRY_TEST(percent_decode_basic) +{ + char *s; + + s = decode_to_owned(""); + TEST_CHECK_STRING_EQUAL(s, ""); + sentry_free(s); + + s = decode_to_owned("no-escapes_here~."); + TEST_CHECK_STRING_EQUAL(s, "no-escapes_here~."); + sentry_free(s); + + s = decode_to_owned("a%40b%2Cc"); + TEST_CHECK_STRING_EQUAL(s, "a@b,c"); + sentry_free(s); + + // Both lower and upper case hex digits decode the same. + s = decode_to_owned("%2f%2F"); + TEST_CHECK_STRING_EQUAL(s, "//"); + sentry_free(s); + + // %XX decodes to one byte even when that byte is high-ASCII. + s = decode_to_owned("%E2%98%83"); + TEST_CHECK_INT_EQUAL((unsigned char)s[0], 0xE2); + TEST_CHECK_INT_EQUAL((unsigned char)s[1], 0x98); + TEST_CHECK_INT_EQUAL((unsigned char)s[2], 0x83); + TEST_CHECK_INT_EQUAL(s[3], '\0'); + sentry_free(s); +} + +SENTRY_TEST(percent_decode_malformed_passed_through) +{ + char *s; + + // Non-hex digits: left as-is. + s = decode_to_owned("%GG"); + TEST_CHECK_STRING_EQUAL(s, "%GG"); + sentry_free(s); + + s = decode_to_owned("a%Zbc"); + TEST_CHECK_STRING_EQUAL(s, "a%Zbc"); + sentry_free(s); + + // Truncated escape at end of string: left as-is. + s = decode_to_owned("abc%"); + TEST_CHECK_STRING_EQUAL(s, "abc%"); + sentry_free(s); + + s = decode_to_owned("abc%4"); + TEST_CHECK_STRING_EQUAL(s, "abc%4"); + sentry_free(s); + + // Mid-string escape followed by non-hex: left as-is, then resumes. + s = decode_to_owned("%4X%40"); + TEST_CHECK_STRING_EQUAL(s, "%4X@"); + sentry_free(s); +} + +SENTRY_TEST(percent_decode_does_not_read_past_len) +{ + // The decoder must respect `len` even when the buffer is longer; a + // trailing `%XX` after `len` must not be touched. + char buf[] = "a%40b%41"; + size_t new_len = sentry__percent_decode_inplace(buf, 3); + TEST_CHECK_INT_EQUAL(new_len, 3); + TEST_CHECK(memcmp(buf, "a%4", 3) == 0); + // Bytes past `len` are untouched. + TEST_CHECK_STRING_EQUAL(buf + 3, "0b%41"); +} + +#undef CHECK_SLICE_EQ diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 8be3d7181d..9f70e670d0 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -16,6 +16,14 @@ XX(attachments_bytes) XX(attachments_extend) XX(attachments_more_than_ten) XX(background_worker) +XX(baggage_iter_basic) +XX(baggage_iter_case_preserved) +XX(baggage_iter_empty_and_malformed_skipped) +XX(baggage_iter_empty_input) +XX(baggage_iter_empty_value_allowed) +XX(baggage_iter_equals_in_value) +XX(baggage_iter_ows_trimmed) +XX(baggage_iter_properties_stripped) XX(basic_consent_tracking) XX(basic_function_transport) XX(basic_function_transport_transaction) @@ -214,6 +222,9 @@ XX(path_mtime) XX(path_relative_filename) XX(path_rename) XX(path_unique) +XX(percent_decode_basic) +XX(percent_decode_does_not_read_past_len) +XX(percent_decode_malformed_passed_through) XX(process_invalid) XX(process_spawn) XX(procmaps_parser) From 0298c9157f7e65ab496e5726e414313d4b16bc81 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 4 May 2026 16:41:20 +0200 Subject: [PATCH 02/20] ref(value): Add key/value foreach helper Provide an internal object key/value iteration helper with focused unit coverage so later trace propagation code can iterate DSC values without indexed key/value access. Co-Authored-By: OpenAI Codex --- src/sentry_value.c | 15 ++++++++++++++ src/sentry_value.h | 8 +++++++ tests/unit/test_value.c | 46 +++++++++++++++++++++++++++++++++++++++++ tests/unit/tests.inc | 1 + 4 files changed, 70 insertions(+) diff --git a/src/sentry_value.c b/src/sentry_value.c index 046b061717..c2ca6bd5f9 100644 --- a/src/sentry_value.c +++ b/src/sentry_value.c @@ -934,6 +934,21 @@ sentry_value_get_by_index(sentry_value_t value, size_t index) return sentry_value_new_null(); } +void +sentry__value_foreach_key_value(sentry_value_t value, + void (*callback)(const char *key, sentry_value_t value, void *userdata), + void *userdata) +{ + const thing_t *thing = value_as_thing(value); + if (!thing || thing_get_type(thing) != THING_TYPE_OBJECT) { + return; + } + const obj_t *o = thing->payload._ptr; + for (size_t i = 0; i < o->len; i++) { + callback(o->pairs[i].k, o->pairs[i].v, userdata); + } +} + sentry_value_t sentry_value_get_by_index_owned(sentry_value_t value, size_t index) { diff --git a/src/sentry_value.h b/src/sentry_value.h index 7048b0befb..f8fb96fd2b 100644 --- a/src/sentry_value.h +++ b/src/sentry_value.h @@ -67,6 +67,14 @@ sentry_value_t sentry__value_new_list_with_size(size_t size); */ sentry_value_t sentry__value_new_object_with_size(size_t size); +/** + * Iterates over the key/value pairs of an object value. The callback receives a + * borrowed reference for each value. Does nothing if `value` is not an object. + */ +void sentry__value_foreach_key_value(sentry_value_t value, + void (*callback)(const char *key, sentry_value_t value, void *userdata), + void *userdata); + /** * This will parse the Value into a UUID, or return a `nil` UUID on error. * See also `sentry_uuid_from_string`. diff --git a/tests/unit/test_value.c b/tests/unit/test_value.c index 9b5c67f6e4..787f34cd87 100644 --- a/tests/unit/test_value.c +++ b/tests/unit/test_value.c @@ -15,6 +15,25 @@ breadcrumb_with_ts(const char *message, const char *timestamp) return breadcrumb; } +typedef struct { + const char *keys[4]; + sentry_value_t values[4]; + size_t count; +} value_foreach_key_value_collector_t; + +static void +collect_value_pair(const char *key, sentry_value_t value, void *userdata) +{ + value_foreach_key_value_collector_t *collector + = (value_foreach_key_value_collector_t *)userdata; + if (collector->count >= 4) { + return; + } + collector->keys[collector->count] = key; + collector->values[collector->count] = value; + collector->count++; +} + SENTRY_TEST(value_null) { sentry_value_t val = sentry_value_new_null(); @@ -656,6 +675,33 @@ SENTRY_TEST(value_freezing) sentry_value_decref(val); } +SENTRY_TEST(value_foreach_key_value) +{ + sentry_value_t value = sentry_value_new_object(); + sentry_value_set_by_key(value, "first", sentry_value_new_string("one")); + sentry_value_set_by_key(value, "second", sentry_value_new_int32(2)); + sentry_value_set_by_key(value, "third", sentry_value_new_bool(true)); + + value_foreach_key_value_collector_t collector = { 0 }; + sentry__value_foreach_key_value(value, collect_value_pair, &collector); + + TEST_CHECK_INT_EQUAL(collector.count, 3); + TEST_CHECK_STRING_EQUAL(collector.keys[0], "first"); + TEST_CHECK_STRING_EQUAL(sentry_value_as_string(collector.values[0]), "one"); + TEST_CHECK_STRING_EQUAL(collector.keys[1], "second"); + TEST_CHECK_INT_EQUAL(sentry_value_as_int32(collector.values[1]), 2); + TEST_CHECK_STRING_EQUAL(collector.keys[2], "third"); + TEST_CHECK(sentry_value_is_true(collector.values[2])); + + value_foreach_key_value_collector_t ignored = { 0 }; + sentry_value_t not_object = sentry_value_new_string("not-object"); + sentry__value_foreach_key_value(not_object, collect_value_pair, &ignored); + TEST_CHECK_INT_EQUAL(ignored.count, 0); + sentry_value_decref(not_object); + + sentry_value_decref(value); +} + SENTRY_TEST(value_stringify) { #define STRINGIFY_AND_CHECK(Val, Expected) \ diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 9f70e670d0..5bb337d522 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -333,6 +333,7 @@ XX(uuid_v4) XX(value_attribute) XX(value_bool) XX(value_double) +XX(value_foreach_key_value) XX(value_freezing) XX(value_from_msgpack_bool) XX(value_from_msgpack_double) From 0d99e4867427742c5ff97d4d615a60cf073fb2dc Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:19:17 +0200 Subject: [PATCH 03/20] feat(options): add strict trace continuation option Add `sentry_options_set_strict_trace_continuation` / `_get_` as an experimental API. The option defaults to false and is not wired up to any propagation logic yet; subsequent commits will consume it when the trace-continuation decision path is implemented. Preparation for strict trace continuation: https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation Co-Authored-By: Claude Opus 4.7 (1M context) --- include/sentry.h | 22 ++++++++++++++++++++++ src/sentry_options.c | 14 ++++++++++++++ src/sentry_options.h | 1 + 3 files changed, 37 insertions(+) diff --git a/include/sentry.h b/include/sentry.h index 210381223a..df45714364 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2328,6 +2328,28 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_propagate_traceparent( SENTRY_EXPERIMENTAL_API int sentry_options_get_propagate_traceparent( const sentry_options_t *opts); +/** + * Enables or disables strict trace continuation. + * + * Controls whether to continue an incoming trace when either the trace or the + * SDK has an organization ID (derived from the DSN), but not both. When set + * to true, a new trace is started in that case; when false, the incoming + * trace is continued. If both organization IDs are present and differ, the + * trace is never continued regardless of this setting. + * + * See https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation + * + * This is disabled by default. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_strict_trace_continuation( + sentry_options_t *opts, int strict_trace_continuation); + +/** + * Returns whether strict trace continuation is enabled. + */ +SENTRY_EXPERIMENTAL_API int sentry_options_get_strict_trace_continuation( + const sentry_options_t *opts); + /** * Enables or disables the structured logging feature. * When disabled, all calls to `sentry_log_X()` are no-ops. diff --git a/src/sentry_options.c b/src/sentry_options.c index 100015cfe5..a15b88d770 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -63,6 +63,7 @@ sentry_options_new(void) opts->enable_logging_when_crashed = true; #endif opts->propagate_traceparent = false; + opts->strict_trace_continuation = false; opts->crashpad_limit_stack_capture_to_sp = false; opts->enable_metrics = true; opts->enable_logs = true; @@ -937,6 +938,19 @@ sentry_options_get_propagate_traceparent(const sentry_options_t *opts) return opts->propagate_traceparent; } +void +sentry_options_set_strict_trace_continuation( + sentry_options_t *opts, int strict_trace_continuation) +{ + opts->strict_trace_continuation = !!strict_trace_continuation; +} + +int +sentry_options_get_strict_trace_continuation(const sentry_options_t *opts) +{ + return opts->strict_trace_continuation; +} + void sentry_options_set_send_client_reports(sentry_options_t *opts, int val) { diff --git a/src/sentry_options.h b/src/sentry_options.h index 0a063a3aa2..2e1fbbeee5 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -46,6 +46,7 @@ struct sentry_options_s { bool crashpad_wait_for_upload; bool enable_logging_when_crashed; bool propagate_traceparent; + bool strict_trace_continuation; bool crashpad_limit_stack_capture_to_sp; bool cache_keep; From 489bec5d59c21f3b84ca30501ac8a2b23697bef5 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:30:10 +0200 Subject: [PATCH 04/20] feat(options): add org_id option Add `sentry_options_set_org_id` / `_set_org_id_n` / `_get_org_id` as an experimental API. Overrides the organization ID derived from the DSN host, which is required for self-hosted setups whose ingest hostname does not encode the org. Nothing consumes the option yet; subsequent commits will route it through the effective-org_id resolver and the strict-trace-continuation decision. Preparation for strict trace continuation: https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation Co-Authored-By: Claude Opus 4.7 (1M context) --- include/sentry.h | 19 +++++++++++++++++++ src/sentry_options.c | 22 ++++++++++++++++++++++ src/sentry_options.h | 1 + 3 files changed, 42 insertions(+) diff --git a/include/sentry.h b/include/sentry.h index df45714364..cf03660727 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2328,6 +2328,25 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_propagate_traceparent( SENTRY_EXPERIMENTAL_API int sentry_options_get_propagate_traceparent( const sentry_options_t *opts); +/** + * Overrides the organization ID derived from the DSN host + * (e.g. `o123456.ingest.sentry.io` → `123456`). Typically only required for + * self-hosted setups where the DSN host does not encode the organization ID. + * + * The value is passed through as a string; no validation is performed. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_org_id( + sentry_options_t *opts, const char *org_id); +SENTRY_EXPERIMENTAL_API void sentry_options_set_org_id_n( + sentry_options_t *opts, const char *org_id, size_t org_id_len); + +/** + * Returns the organization ID previously set via `sentry_options_set_org_id`, + * or NULL if none was set. Does not fall back to the DSN-derived value. + */ +SENTRY_EXPERIMENTAL_API const char *sentry_options_get_org_id( + const sentry_options_t *opts); + /** * Enables or disables strict trace continuation. * diff --git a/src/sentry_options.c b/src/sentry_options.c index a15b88d770..4003dad671 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -125,6 +125,7 @@ sentry_options_free(sentry_options_t *opts) sentry_free(opts->dist); sentry_free(opts->proxy); sentry_free(opts->ca_certs); + sentry_free(opts->org_id); sentry_free(opts->transport_thread_name); sentry__path_free(opts->database_path); sentry__path_free(opts->handler_path); @@ -221,6 +222,27 @@ sentry_options_get_dsn(const sentry_options_t *opts) return opts->dsn ? opts->dsn->raw : NULL; } +void +sentry_options_set_org_id_n( + sentry_options_t *opts, const char *org_id, size_t org_id_len) +{ + sentry_free(opts->org_id); + opts->org_id = sentry__string_clone_n(org_id, org_id_len); +} + +void +sentry_options_set_org_id(sentry_options_t *opts, const char *org_id) +{ + sentry_free(opts->org_id); + opts->org_id = sentry__string_clone(org_id); +} + +const char * +sentry_options_get_org_id(const sentry_options_t *opts) +{ + return opts->org_id; +} + void sentry_options_set_sample_rate(sentry_options_t *opts, double sample_rate) { diff --git a/src/sentry_options.h b/src/sentry_options.h index 2e1fbbeee5..b0023b4876 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -73,6 +73,7 @@ struct sentry_options_s { double traces_sample_rate; sentry_traces_sampler_function traces_sampler; void *traces_sampler_data; + char *org_id; size_t max_spans; bool enable_logs; // takes the first varg as a `sentry_value_t` object containing attributes From a780b83af1bacccb3a906d216e454dcf70bdfc31 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:35:40 +0200 Subject: [PATCH 05/20] fix(scope): rebuild DSC when the propagation trace changes `sentry_set_trace` and `sentry_regenerate_trace` updated the scope's propagation context but left the dynamic sampling context (built once at `sentry_init`) untouched. The DSC's `sample_rand` therefore stayed tied to the trace generated at init, even after the caller switched traces. Outgoing propagation that consumes the scope DSC would emit stale values mismatched against `sentry-trace`. Refresh the scope DSC after each trace change. Surfaced while preparing strict trace continuation, where outgoing baggage will draw all DSC fields from the scope DSC. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sentry_core.c | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/sentry_core.c b/src/sentry_core.c index a556d1c740..080c735c6e 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1206,15 +1206,24 @@ sentry_set_trace_n(const char *trace_id, size_t trace_id_len, sentry__generate_sample_rand(context); sentry__set_propagation_context("trace", context); + + SENTRY_WITH_OPTIONS (options) { + SENTRY_WITH_SCOPE_MUT (scope) { + set_dynamic_sampling_context(options, scope); + } + } } } void sentry_regenerate_trace(void) { - SENTRY_WITH_SCOPE_MUT (scope) { - generate_propagation_context(scope->propagation_context); - scope->trace_managed = false; + SENTRY_WITH_OPTIONS (options) { + SENTRY_WITH_SCOPE_MUT (scope) { + generate_propagation_context(scope->propagation_context); + scope->trace_managed = false; + set_dynamic_sampling_context(options, scope); + } } } From 2c54b9884c74a5b848efa9b95bd3c8c19d586e2f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:39:52 +0200 Subject: [PATCH 06/20] feat(tracing): resolve effective org_id for DSC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `sentry__options_get_effective_org_id` (option > DSN > NULL, empty treated as absent) and consume it in the dynamic sampling context builder. The DSC now only carries `org_id` when the SDK actually has one — the previous code emitted `"org_id":""` for DSNs without an `o.` host prefix, which ran counter to the trace-propagation spec. Integration and envelope-serialization assertions updated to reflect the absent field. Preparation for strict trace continuation: https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sentry_core.c | 6 ++++-- src/sentry_options.c | 12 ++++++++++++ src/sentry_options.h | 8 ++++++++ tests/test_integration_transactions.py | 2 -- tests/unit/test_envelopes.c | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/sentry_core.c b/src/sentry_core.c index 080c735c6e..83ba0335ab 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -109,8 +109,10 @@ set_dynamic_sampling_context( if (options->dsn) { sentry_value_set_by_key(dsc, "public_key", sentry_value_new_string(options->dsn->public_key)); - sentry_value_set_by_key( - dsc, "org_id", sentry_value_new_string(options->dsn->org_id)); + } + const char *org_id = sentry__options_get_effective_org_id(options); + if (org_id) { + sentry_value_set_by_key(dsc, "org_id", sentry_value_new_string(org_id)); } sentry_value_set_by_key(dsc, "sample_rate", sentry_value_new_double(options->traces_sample_rate)); diff --git a/src/sentry_options.c b/src/sentry_options.c index 4003dad671..f71f83237a 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -243,6 +243,18 @@ sentry_options_get_org_id(const sentry_options_t *opts) return opts->org_id; } +const char * +sentry__options_get_effective_org_id(const sentry_options_t *opts) +{ + if (opts->org_id && *opts->org_id) { + return opts->org_id; + } + if (opts->dsn && opts->dsn->org_id && *opts->dsn->org_id) { + return opts->dsn->org_id; + } + return NULL; +} + void sentry_options_set_sample_rate(sentry_options_t *opts, double sample_rate) { diff --git a/src/sentry_options.h b/src/sentry_options.h index b0023b4876..ac14ec8f14 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -110,4 +110,12 @@ struct sentry_options_s { */ sentry_options_t *sentry__options_incref(sentry_options_t *options); +/** + * Returns the effective organization ID used for trace propagation: + * the `org_id` option if set and non-empty, otherwise the DSN-derived value + * if non-empty, otherwise NULL. + */ +const char *sentry__options_get_effective_org_id( + const sentry_options_t *options); + #endif diff --git a/tests/test_integration_transactions.py b/tests/test_integration_transactions.py index e8bf0ed2b3..092435c36b 100644 --- a/tests/test_integration_transactions.py +++ b/tests/test_integration_transactions.py @@ -255,7 +255,6 @@ def test_transaction_trace_header(cmake, httpserver): del trace_header["sample_rand"] assert trace_header == { "environment": "development", - "org_id": "", "public_key": "uiaeosnrtdy", "release": "test-example-release", "sample_rate": 1, @@ -301,7 +300,6 @@ def test_event_trace_header(cmake, httpserver): del trace_header["sample_rand"] assert trace_header == { "environment": "development", - "org_id": "", "public_key": "uiaeosnrtdy", "release": "test-example-release", "sample_rate": 0, # since we don't capture-transaction diff --git a/tests/unit/test_envelopes.c b/tests/unit/test_envelopes.c index 9cd4017ee5..49c9cfaa10 100644 --- a/tests/unit/test_envelopes.c +++ b/tests/unit/test_envelopes.c @@ -15,7 +15,7 @@ static char *const SERIALIZED_ENVELOPE_STR = "{\"dsn\":\"https://foo@sentry.invalid/42\"," "\"event_id\":\"c993afb6-b4ac-48a6-b61b-2558e601d65d\",\"trace\":{" - "\"public_key\":\"foo\",\"org_id\":\"\",\"sample_rate\":0,\"sample_" + "\"public_key\":\"foo\",\"sample_rate\":0,\"sample_" "rand\":0.01006918276309107,\"release\":\"test-release\",\"environment\":" "\"production\",\"sampled\":\"false\"}}\n" "{\"type\":\"event\",\"length\":71}\n" From 03adabfd5616393a927f0605b91cccc0535ae52d Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:42:35 +0200 Subject: [PATCH 07/20] feat(tracing): parse incoming baggage header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle `baggage` alongside `sentry-trace` in `sentry_transaction_context_update_from_header`. Per W3C baggage / RFC 7230 syntax: comma-separated members of the form `key=value` with optional surrounding whitespace. `sentry-*` members are collected (key stripped of the `sentry-` prefix) and their percent-encoded values decoded into a new `incoming_dsc` object on the transaction context's inner state. Non-sentry members are ignored. The `incoming_dsc` object is the input to the next step — the strict trace continuation decision. Nothing consumes it yet. Preparation for strict trace continuation: https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation Co-Authored-By: Claude Opus 4.7 (1M context) --- include/sentry.h | 7 +++++- src/sentry_tracing.c | 51 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/include/sentry.h b/include/sentry.h index cf03660727..c8b308803a 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2356,7 +2356,8 @@ SENTRY_EXPERIMENTAL_API const char *sentry_options_get_org_id( * trace is continued. If both organization IDs are present and differ, the * trace is never continued regardless of this setting. * - * See https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation + * See + * https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation * * This is disabled by default. */ @@ -2945,6 +2946,10 @@ SENTRY_EXPERIMENTAL_API void sentry_transaction_context_remove_sampled( * services. Therefore, the headers of incoming requests should be fed into this * function so that sentry is able to continue a trace that was started by an * upstream service. + * + * Recognized header keys are `sentry-trace` and `baggage` (case-insensitive); + * other keys are ignored. Feed both when available so that strict trace + * continuation can consult the incoming `sentry-org_id`. */ SENTRY_EXPERIMENTAL_API void sentry_transaction_context_update_from_header( sentry_transaction_context_t *tx_ctx, const char *key, const char *value); diff --git a/src/sentry_tracing.c b/src/sentry_tracing.c index 8ef7690bc6..7bb2c7f6b0 100644 --- a/src/sentry_tracing.c +++ b/src/sentry_tracing.c @@ -9,6 +9,7 @@ #include "sentry_string.h" #include "sentry_utils.h" #include "sentry_value.h" +#include #include static sentry_value_t @@ -296,6 +297,44 @@ parse_sentry_trace( sentry_value_set_by_key(inner, "sampled", sentry_value_new_bool(sampled)); } +static void +parse_baggage( + sentry_transaction_context_t *tx_ctx, const char *value, size_t value_len) +{ + // https://www.w3.org/TR/baggage/ — Sentry-prefixed members are kept and + // percent-decoded; non-sentry members are ignored. + static const char sentry_prefix[] = "sentry-"; + static const size_t sentry_prefix_len = sizeof(sentry_prefix) - 1; + + sentry_value_t inner = tx_ctx->inner; + sentry_value_t incoming = sentry_value_get_by_key(inner, "incoming_dsc"); + if (sentry_value_is_null(incoming)) { + incoming = sentry_value_new_object(); + sentry_value_set_by_key(inner, "incoming_dsc", incoming); + incoming = sentry_value_get_by_key(inner, "incoming_dsc"); + } + + sentry_slice_t remaining = { value, value_len }; + sentry_slice_t key, val; + while (sentry__baggage_iter_next(&remaining, &key, &val)) { + if (key.len <= sentry_prefix_len + || memcmp(key.ptr, sentry_prefix, sentry_prefix_len) != 0) { + continue; + } + const char *sub_key = key.ptr + sentry_prefix_len; + size_t sub_key_len = key.len - sentry_prefix_len; + + char *decoded = sentry__string_clone_n(val.ptr, val.len); + if (!decoded) { + continue; + } + size_t decoded_len = sentry__percent_decode_inplace(decoded, val.len); + decoded[decoded_len] = '\0'; + sentry_value_set_by_key_n(incoming, sub_key, sub_key_len, + sentry__value_new_string_owned(decoded)); + } +} + void sentry_transaction_context_update_from_header_n( sentry_transaction_context_t *tx_ctx, const char *key, size_t key_len, @@ -308,10 +347,16 @@ sentry_transaction_context_update_from_header_n( // do case-insensitive header key comparison const char sentry_trace[] = "sentry-trace"; const size_t sentry_trace_len = sizeof(sentry_trace) - 1; - bool is_sentry_trace - = compare_header_key(key, key_len, sentry_trace, sentry_trace_len); - if (is_sentry_trace) { + if (compare_header_key(key, key_len, sentry_trace, sentry_trace_len)) { parse_sentry_trace(tx_ctx, value, value_len); + return; + } + + const char baggage[] = "baggage"; + const size_t baggage_len = sizeof(baggage) - 1; + if (compare_header_key(key, key_len, baggage, baggage_len)) { + parse_baggage(tx_ctx, value, value_len); + return; } } From 3ac3f5dad7798da12f68bf27eae177e3b874889b Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:45:46 +0200 Subject: [PATCH 08/20] ref(scope): extract DSC builder as a scope helper Move the file-static `set_dynamic_sampling_context` from `sentry_core.c` into `sentry_scope.c` as `sentry__scope_rebuild_dsc_from_options`. The DSC fundamentally belongs to the scope, and the upcoming strict-trace-continuation work needs to call it from outside `sentry_core.c`. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sentry_core.c | 41 +++-------------------------------------- src/sentry_scope.c | 34 ++++++++++++++++++++++++++++++++++ src/sentry_scope.h | 7 +++++++ 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/sentry_core.c b/src/sentry_core.c index 83ba0335ab..8c182b3531 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -98,41 +98,6 @@ generate_propagation_context(sentry_value_t propagation_context) sentry_value_get_by_key(propagation_context, "trace")); } -static void -set_dynamic_sampling_context( - const sentry_options_t *options, sentry_scope_t *scope) -{ - sentry_value_decref(scope->dynamic_sampling_context); - // add the Dynamic Sampling Context to the `trace` header - sentry_value_t dsc = sentry_value_new_object(); - - if (options->dsn) { - sentry_value_set_by_key(dsc, "public_key", - sentry_value_new_string(options->dsn->public_key)); - } - const char *org_id = sentry__options_get_effective_org_id(options); - if (org_id) { - sentry_value_set_by_key(dsc, "org_id", sentry_value_new_string(org_id)); - } - sentry_value_set_by_key(dsc, "sample_rate", - sentry_value_new_double(options->traces_sample_rate)); - if (options->traces_sampler) { - sentry_value_set_by_key( - dsc, "sample_rate", sentry_value_new_double(1.0)); - } - sentry_value_t sample_rand = sentry_value_get_by_key( - sentry_value_get_by_key(scope->propagation_context, "trace"), - "sample_rand"); - sentry_value_set_by_key(dsc, "sample_rand", sample_rand); - sentry_value_incref(sample_rand); - sentry_value_set_by_key( - dsc, "release", sentry_value_new_string(scope->release)); - sentry_value_set_by_key( - dsc, "environment", sentry_value_new_string(scope->environment)); - - scope->dynamic_sampling_context = dsc; -} - #if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS) int sentry__native_init(sentry_options_t *options) @@ -252,7 +217,7 @@ sentry_init(sentry_options_t *options) sentry__ringbuffer_set_max_size( scope->breadcrumbs, options->max_breadcrumbs); - set_dynamic_sampling_context(options, scope); + sentry__scope_rebuild_dsc_from_options(scope, options); } if (backend && backend->user_consent_changed_func) { backend->user_consent_changed_func(backend); @@ -1211,7 +1176,7 @@ sentry_set_trace_n(const char *trace_id, size_t trace_id_len, SENTRY_WITH_OPTIONS (options) { SENTRY_WITH_SCOPE_MUT (scope) { - set_dynamic_sampling_context(options, scope); + sentry__scope_rebuild_dsc_from_options(scope, options); } } } @@ -1224,7 +1189,7 @@ sentry_regenerate_trace(void) SENTRY_WITH_SCOPE_MUT (scope) { generate_propagation_context(scope->propagation_context); scope->trace_managed = false; - set_dynamic_sampling_context(options, scope); + sentry__scope_rebuild_dsc_from_options(scope, options); } } } diff --git a/src/sentry_scope.c b/src/sentry_scope.c index c7020a978c..eebcc033fa 100644 --- a/src/sentry_scope.c +++ b/src/sentry_scope.c @@ -189,6 +189,40 @@ sentry__scope_free(sentry_scope_t *scope) sentry_free(scope); } +void +sentry__scope_rebuild_dsc_from_options( + sentry_scope_t *scope, const sentry_options_t *options) +{ + sentry_value_decref(scope->dynamic_sampling_context); + sentry_value_t dsc = sentry_value_new_object(); + + if (options->dsn) { + sentry_value_set_by_key(dsc, "public_key", + sentry_value_new_string(options->dsn->public_key)); + } + const char *org_id = sentry__options_get_effective_org_id(options); + if (org_id) { + sentry_value_set_by_key(dsc, "org_id", sentry_value_new_string(org_id)); + } + sentry_value_set_by_key(dsc, "sample_rate", + sentry_value_new_double(options->traces_sample_rate)); + if (options->traces_sampler) { + sentry_value_set_by_key( + dsc, "sample_rate", sentry_value_new_double(1.0)); + } + sentry_value_t sample_rand = sentry_value_get_by_key( + sentry_value_get_by_key(scope->propagation_context, "trace"), + "sample_rand"); + sentry_value_set_by_key(dsc, "sample_rand", sample_rand); + sentry_value_incref(sample_rand); + sentry_value_set_by_key( + dsc, "release", sentry_value_new_string(scope->release)); + sentry_value_set_by_key( + dsc, "environment", sentry_value_new_string(scope->environment)); + + scope->dynamic_sampling_context = dsc; +} + #if !defined(SENTRY_PLATFORM_NX) static void sentry__foreach_stacktrace( diff --git a/src/sentry_scope.h b/src/sentry_scope.h index 6deb1d11ae..ac530b8389 100644 --- a/src/sentry_scope.h +++ b/src/sentry_scope.h @@ -128,6 +128,13 @@ void sentry__scope_remove_attribute_n( for (sentry_scope_t *Scope = sentry__scope_lock(); Scope; \ sentry__scope_unlock(), Scope = NULL) +/** + * Rebuilds the scope's dynamic sampling context (DSC) from the SDK options + * and the current propagation context. The previous DSC is discarded. + */ +void sentry__scope_rebuild_dsc_from_options( + sentry_scope_t *scope, const sentry_options_t *options); + /** * Adds scoped attributes to the telemetry attributes object. */ From e77d560f814f7716e70f54d11453cb6f19c9a3ec Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:49:48 +0200 Subject: [PATCH 09/20] feat(tracing): apply strict trace continuation decision Wire incoming-baggage org_id and the SDK's effective org_id through `sentry__trace_continuation_allowed` (the spec truth table) when a transaction starts: - Both present and equal, both absent, or only one with strict off: continue. The scope DSC is frozen verbatim from the incoming DSC and propagated as-is from there on. - Both present and differing: never continue. - Exactly one present with strict on: do not continue. When not continuing, the transaction takes a fresh `trace_id`, drops `parent_span_id` and any inherited `sampled` flag (the sampler re-decides), and the scope DSC is rebuilt from the SDK's own options. The internal `incoming_dsc` carrier is stripped from the event before sampling so it never reaches the envelope. Outgoing baggage emission still TODO; spec compliance requires it (`sentry-org_id` MUST be propagated). Coming next. https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sentry_core.c | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/sentry_scope.c | 10 ++++++++++ src/sentry_scope.h | 9 +++++++++ src/sentry_tracing.c | 24 ++++++++++++++++++++++++ src/sentry_tracing.h | 11 +++++++++++ 5 files changed, 98 insertions(+) diff --git a/src/sentry_core.c b/src/sentry_core.c index 8c182b3531..5a2ea067f6 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1262,6 +1262,50 @@ sentry_transaction_start_ts(sentry_transaction_context_t *opaque_tx_ctx, sentry_value_remove_by_key(tx, "timestamp"); sentry__value_merge_objects(tx, tx_ctx); + + sentry_value_t incoming = sentry_value_get_by_key(tx, "incoming_dsc"); + if (!sentry_value_is_null(incoming)) { + SENTRY_WITH_OPTIONS (options) { + SENTRY_WITH_SCOPE_MUT (scope) { + const char *sdk_org + = sentry__options_get_effective_org_id(options); + const char *inc_org = sentry_value_as_string( + sentry_value_get_by_key(incoming, "org_id")); + if (!*inc_org) { + inc_org = NULL; + } + + if (sentry__trace_continuation_allowed( + sdk_org, inc_org, options->strict_trace_continuation)) { + // Freeze only when the upstream actually sent DSC values; + // a sentry-trace-only signal leaves incoming empty, in + // which case the SDK builds its own DSC. + if (sentry_value_get_length(incoming) > 0) { + sentry__scope_freeze_dsc_from_incoming(scope, incoming); + } else { + sentry__scope_rebuild_dsc_from_options(scope, options); + } + } else { + // Fork: ignore upstream trace, become head of a new trace. + // Regenerate the scope's propagation context so events + // captured outside this transaction also carry the new + // trace_id, and align the tx's trace_id with it. + generate_propagation_context(scope->propagation_context); + sentry_value_t scope_trace_id = sentry_value_get_by_key( + sentry_value_get_by_key( + scope->propagation_context, "trace"), + "trace_id"); + sentry_value_incref(scope_trace_id); + sentry_value_set_by_key(tx, "trace_id", scope_trace_id); + sentry_value_remove_by_key(tx, "parent_span_id"); + sentry_value_remove_by_key(tx, "sampled"); + sentry__scope_rebuild_dsc_from_options(scope, options); + } + } + } + } + sentry_value_remove_by_key(tx, "incoming_dsc"); + double sample_rand = 1.0; SENTRY_WITH_SCOPE (scope) { sample_rand = sentry_value_as_double(sentry_value_get_by_key( diff --git a/src/sentry_scope.c b/src/sentry_scope.c index eebcc033fa..cb2089e328 100644 --- a/src/sentry_scope.c +++ b/src/sentry_scope.c @@ -189,6 +189,16 @@ sentry__scope_free(sentry_scope_t *scope) sentry_free(scope); } +void +sentry__scope_freeze_dsc_from_incoming( + sentry_scope_t *scope, sentry_value_t incoming) +{ + sentry_value_decref(scope->dynamic_sampling_context); + sentry_value_t dsc = sentry_value_new_object(); + sentry__value_merge_objects(dsc, incoming); + scope->dynamic_sampling_context = dsc; +} + void sentry__scope_rebuild_dsc_from_options( sentry_scope_t *scope, const sentry_options_t *options) diff --git a/src/sentry_scope.h b/src/sentry_scope.h index ac530b8389..ec90500e7c 100644 --- a/src/sentry_scope.h +++ b/src/sentry_scope.h @@ -135,6 +135,15 @@ void sentry__scope_remove_attribute_n( void sentry__scope_rebuild_dsc_from_options( sentry_scope_t *scope, const sentry_options_t *options); +/** + * Replaces the scope's dynamic sampling context (DSC) with a verbatim copy + * of the incoming object. Used when continuing an upstream trace: per the + * trace-propagation spec, the receiving SDK MUST treat the incoming DSC as + * frozen and propagate its values "as is". + */ +void sentry__scope_freeze_dsc_from_incoming( + sentry_scope_t *scope, sentry_value_t incoming); + /** * Adds scoped attributes to the telemetry attributes object. */ diff --git a/src/sentry_tracing.c b/src/sentry_tracing.c index 7bb2c7f6b0..5424100f81 100644 --- a/src/sentry_tracing.c +++ b/src/sentry_tracing.c @@ -270,6 +270,15 @@ parse_sentry_trace( sentry_value_t trace_id = sentry__value_new_string_owned(s); sentry_value_set_by_key(inner, "trace_id", trace_id); + // Mark that an upstream trace was received. `incoming_dsc` doubles as this + // marker so the strict-continuation check fires even when no `baggage` + // arrives; baggage parsing merges into the same object regardless of + // header order. + if (sentry_value_is_null(sentry_value_get_by_key(inner, "incoming_dsc"))) { + sentry_value_set_by_key( + inner, "incoming_dsc", sentry_value_new_object()); + } + const char *span_id_start = trace_id_end + 1; const char *span_id_end = strchr(span_id_start, '-'); if (!span_id_end) { @@ -335,6 +344,21 @@ parse_baggage( } } +bool +sentry__trace_continuation_allowed( + const char *sdk_org_id, const char *incoming_org_id, bool strict) +{ + bool sdk_has = sdk_org_id && *sdk_org_id; + bool inc_has = incoming_org_id && *incoming_org_id; + if (sdk_has && inc_has) { + return strcmp(sdk_org_id, incoming_org_id) == 0; + } + if (sdk_has != inc_has) { + return !strict; + } + return true; +} + void sentry_transaction_context_update_from_header_n( sentry_transaction_context_t *tx_ctx, const char *key, size_t key_len, diff --git a/src/sentry_tracing.h b/src/sentry_tracing.h index f72836f6f0..4a3dd5cc0b 100644 --- a/src/sentry_tracing.h +++ b/src/sentry_tracing.h @@ -59,4 +59,15 @@ sentry_span_t *sentry__span_new( */ sentry_value_t sentry__value_get_trace_context(sentry_value_t span); +/** + * Returns whether to continue an incoming trace given the SDK's organization + * ID, the incoming trace's organization ID, and the strict-trace-continuation + * flag. Either ID may be NULL or empty to indicate "absent". + * + * See + * https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation + */ +bool sentry__trace_continuation_allowed( + const char *sdk_org_id, const char *incoming_org_id, bool strict); + #endif From 0f6acb090853b08f9cde2ffc2b46ac36d4e8fa7a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:53:01 +0200 Subject: [PATCH 10/20] feat(tracing): Emit outgoing baggage from scope DSC Replace the long-standing TODO in `sentry__span_iter_headers` with a proper `baggage` header emitter. The header is built from the scope DSC: when the scope continued an upstream trace, the DSC was frozen verbatim and propagation echoes upstream values "as is" per the trace-propagation spec; otherwise the DSC was rebuilt from the SDK's own options. `sentry-trace_id` is always taken from the span's own `trace_id` and emitted first, so it stays consistent with the `sentry-trace` header. The remaining DSC fields (including `sentry-org_id` when present) are appended with values percent-encoded per RFC 3986. https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/#baggage-header Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: OpenAI Codex --- src/sentry_tracing.c | 62 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/sentry_tracing.c b/src/sentry_tracing.c index 5424100f81..881f6af398 100644 --- a/src/sentry_tracing.c +++ b/src/sentry_tracing.c @@ -10,8 +10,46 @@ #include "sentry_utils.h" #include "sentry_value.h" #include +#include #include +static void +percent_encode_append(sentry_stringbuilder_t *sb, const char *value) +{ + // Encode every byte that isn't an RFC 3986 unreserved character + // (ALPHA / DIGIT / "-" / "." / "_" / "~") as %XX. + static const char hex[] = "0123456789ABCDEF"; + for (const unsigned char *p = (const unsigned char *)value; *p; p++) { + unsigned char c = *p; + if (isalnum(c) || c == '-' || c == '.' || c == '_' || c == '~') { + sentry__stringbuilder_append_char(sb, (char)c); + } else { + char esc[3] = { '%', hex[c >> 4], hex[c & 0xF] }; + sentry__stringbuilder_append_buf(sb, esc, 3); + } + } +} + +static void +append_baggage_member(const char *key, sentry_value_t value, void *userdata) +{ + if (!key || strcmp(key, "trace_id") == 0 || sentry_value_is_null(value)) { + return; + } + + char *value_str = sentry__value_stringify(value); + if (!value_str) { + return; + } + + sentry_stringbuilder_t *sb = userdata; + sentry__stringbuilder_append(sb, ",sentry-"); + sentry__stringbuilder_append(sb, key); + sentry__stringbuilder_append_char(sb, '='); + percent_encode_append(sb, value_str); + sentry_free(value_str); +} + static sentry_value_t new_span_n(sentry_value_t parent, sentry_slice_t operation) { @@ -865,8 +903,28 @@ sentry__span_iter_headers(sentry_value_t span, sentry_value_is_true(sampled) ? "1" : "0"); callback("sentry-trace", buf, userdata); - // TODO propagate dsc into outgoing bagage header - // https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/#baggage-header + // Outgoing baggage: build from the scope DSC (frozen from upstream when + // the trace was continued, otherwise from the SDK's own options). The + // span's own trace_id is preferred over any DSC trace_id to keep the + // baggage trace_id consistent with the `sentry-trace` header above. + // https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/#baggage-header + { + sentry_stringbuilder_t sb; + sentry__stringbuilder_init(&sb); + sentry__stringbuilder_append(&sb, "sentry-trace_id="); + sentry__stringbuilder_append(&sb, sentry_value_as_string(trace_id)); + + SENTRY_WITH_SCOPE (scope) { + sentry__value_foreach_key_value( + scope->dynamic_sampling_context, append_baggage_member, &sb); + } + + char *baggage = sentry__stringbuilder_into_string(&sb); + if (baggage) { + callback("baggage", baggage, userdata); + sentry_free(baggage); + } + } SENTRY_WITH_OPTIONS (options) { if (options->propagate_traceparent) { From 3c0dd04a95ff19e836ee049fea7f16a27f51c63e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:57:59 +0200 Subject: [PATCH 11/20] test(tracing): cover strict trace continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests for the new continuation pipeline: - `trace_continuation_truth_table`: pure check of the spec truth table. - `effective_org_id_resolution`: option > DSN > NULL precedence, empty option falls back to DSN. - `parse_baggage_basic_and_filtering`: percent-decoding, OWS trimming, non-`sentry-` members ignored, malformed members skipped. - `strict_continuation_*`: end-to-end via `sentry_transaction_context_update_from_header` → `sentry_transaction_start`, asserting both the resulting trace state (continued vs. forked) and the outgoing baggage emitted via `sentry_transaction_iter_headers` (frozen-from-upstream vs. rebuilt from options, including spec-required `sentry-org_id` propagation). - `set_trace_rebuilds_dsc_sample_rand`: regression for the earlier staleness fix. Also bumps the unreleased CHANGELOG entry now that the feature is observable end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/test_tracing.c | 335 ++++++++++++++++++++++++++++++++++++++ tests/unit/tests.inc | 10 ++ 2 files changed, 345 insertions(+) diff --git a/tests/unit/test_tracing.c b/tests/unit/test_tracing.c index 96843011b1..739e6a30b8 100644 --- a/tests/unit/test_tracing.c +++ b/tests/unit/test_tracing.c @@ -1,8 +1,10 @@ #include "sentry_testsupport.h" +#include "sentry_options.h" #include "sentry_scope.h" #include "sentry_string.h" #include "sentry_tracing.h" +#include "sentry_utils.h" #include "sentry_uuid.h" #define IS_NULL(Src, Field) \ @@ -1945,5 +1947,338 @@ SENTRY_TEST(traceparent_header_generation) sentry_close(); } +SENTRY_TEST(trace_continuation_truth_table) +{ + // Per + // https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation + // Both absent or both present-equal: continue regardless of strict. + TEST_CHECK(sentry__trace_continuation_allowed(NULL, NULL, false)); + TEST_CHECK(sentry__trace_continuation_allowed(NULL, NULL, true)); + TEST_CHECK(sentry__trace_continuation_allowed("1", "1", false)); + TEST_CHECK(sentry__trace_continuation_allowed("1", "1", true)); + // Empty string is treated as absent. + TEST_CHECK(sentry__trace_continuation_allowed("", "", true)); + + // Both present and differing: never continue. + TEST_CHECK(!sentry__trace_continuation_allowed("1", "2", false)); + TEST_CHECK(!sentry__trace_continuation_allowed("1", "2", true)); + + // Exactly one present: continue iff strict is false. + TEST_CHECK(sentry__trace_continuation_allowed("1", NULL, false)); + TEST_CHECK(sentry__trace_continuation_allowed(NULL, "1", false)); + TEST_CHECK(!sentry__trace_continuation_allowed("1", NULL, true)); + TEST_CHECK(!sentry__trace_continuation_allowed(NULL, "1", true)); +} + +SENTRY_TEST(effective_org_id_resolution) +{ + // No DSN, no option → NULL + SENTRY_TEST_OPTIONS_NEW(opts1); + TEST_CHECK(sentry__options_get_effective_org_id(opts1) == NULL); + sentry_options_free(opts1); + + // DSN with org → DSN value + SENTRY_TEST_OPTIONS_NEW(opts2); + sentry_options_set_dsn(opts2, "https://k@o123456.ingest.sentry.io/1"); + TEST_CHECK_STRING_EQUAL( + sentry__options_get_effective_org_id(opts2), "123456"); + sentry_options_free(opts2); + + // DSN without org_id-encoded host → NULL + SENTRY_TEST_OPTIONS_NEW(opts3); + sentry_options_set_dsn(opts3, "https://k@self-hosted.example.com/1"); + TEST_CHECK(sentry__options_get_effective_org_id(opts3) == NULL); + sentry_options_free(opts3); + + // Option overrides DSN + SENTRY_TEST_OPTIONS_NEW(opts4); + sentry_options_set_dsn(opts4, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_org_id(opts4, "999"); + TEST_CHECK_STRING_EQUAL(sentry__options_get_effective_org_id(opts4), "999"); + sentry_options_free(opts4); + + // Empty option falls back to DSN + SENTRY_TEST_OPTIONS_NEW(opts5); + sentry_options_set_dsn(opts5, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_org_id(opts5, ""); + TEST_CHECK_STRING_EQUAL( + sentry__options_get_effective_org_id(opts5), "123456"); + sentry_options_free(opts5); +} + +SENTRY_TEST(parse_baggage_basic_and_filtering) +{ + sentry_transaction_context_t *tx_ctx + = sentry_transaction_context_new("t", "op"); + sentry_transaction_context_update_from_header(tx_ctx, "baggage", + "sentry-org_id=123456 , sentry-environment=upstream,nonsentry=skip," + " sentry-release=app%401.0 ,malformed"); + + sentry_value_t inner + = sentry_value_get_by_key(tx_ctx->inner, "incoming_dsc"); + TEST_CHECK(!sentry_value_is_null(inner)); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(inner, "org_id")), + "123456"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(inner, "environment")), + "upstream"); + // percent-decoded value + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(inner, "release")), + "app@1.0"); + // non-sentry member ignored + TEST_CHECK( + sentry_value_is_null(sentry_value_get_by_key(inner, "nonsentry"))); + + sentry__transaction_context_free(tx_ctx); +} + +typedef struct { + char sentry_trace[64]; + char baggage[1024]; +} continuation_collector_t; + +static void +collect_continuation_headers(const char *key, const char *value, void *userdata) +{ + continuation_collector_t *c = (continuation_collector_t *)userdata; + if (strcmp(key, "sentry-trace") == 0) { + snprintf(c->sentry_trace, sizeof(c->sentry_trace), "%s", value); + } else if (strcmp(key, "baggage") == 0) { + snprintf(c->baggage, sizeof(c->baggage), "%s", value); + } +} + +#define UPSTREAM_TRACE_ID "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +#define UPSTREAM_PARENT_SPAN_ID "bbbbbbbbbbbbbbbb" +#define UPSTREAM_SENTRY_TRACE UPSTREAM_TRACE_ID "-" UPSTREAM_PARENT_SPAN_ID "-1" + +static void +discard_envelope(sentry_envelope_t *envelope, void *state) +{ + (void)state; + sentry_envelope_free(envelope); +} + +static sentry_transaction_t * +start_tx_with_upstream(const char *baggage) +{ + sentry_transaction_context_t *tx_ctx + = sentry_transaction_context_new("t", "op"); + sentry_transaction_context_update_from_header( + tx_ctx, "sentry-trace", UPSTREAM_SENTRY_TRACE); + if (baggage) { + sentry_transaction_context_update_from_header( + tx_ctx, "baggage", baggage); + } + return sentry_transaction_start(tx_ctx, sentry_value_new_null()); +} + +SENTRY_TEST(strict_continuation_matching_org_continues) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_transport( + options, sentry_transport_new(discard_envelope)); + sentry_options_set_traces_sample_rate(options, 1.0); + sentry_options_set_strict_trace_continuation(options, 1); + sentry_init(options); + + sentry_transaction_t *tx = start_tx_with_upstream( + "sentry-org_id=123456,sentry-environment=upstream," + "sentry-release=upstream-app%401.0"); + + // Trace continued: trace_id and parent_span_id preserved. + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(tx->inner, "trace_id")), + UPSTREAM_TRACE_ID); + TEST_CHECK_STRING_EQUAL(sentry_value_as_string(sentry_value_get_by_key( + tx->inner, "parent_span_id")), + UPSTREAM_PARENT_SPAN_ID); + // incoming_dsc must not leak into the event. + TEST_CHECK(sentry_value_is_null( + sentry_value_get_by_key(tx->inner, "incoming_dsc"))); + + // Outgoing baggage echoes the upstream environment / release verbatim. + continuation_collector_t c = { 0 }; + sentry_transaction_iter_headers(tx, collect_continuation_headers, &c); + TEST_CHECK(strstr(c.baggage, "sentry-trace_id=" UPSTREAM_TRACE_ID) != NULL); + TEST_CHECK(strstr(c.baggage, "sentry-org_id=123456") != NULL); + TEST_CHECK(strstr(c.baggage, "sentry-environment=upstream") != NULL); + // Percent-encoded as it came in. + TEST_CHECK(strstr(c.baggage, "sentry-release=upstream-app%401.0") != NULL); + + sentry_transaction_finish(tx); + sentry_close(); +} + +SENTRY_TEST(strict_continuation_org_mismatch_forks) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_transport( + options, sentry_transport_new(discard_envelope)); + sentry_options_set_traces_sample_rate(options, 1.0); + // Strict OFF: mismatch must still fork (spec MUST). + sentry_init(options); + + sentry_transaction_t *tx + = start_tx_with_upstream("sentry-org_id=99999,sentry-environment=up"); + + const char *trace_id = sentry_value_as_string( + sentry_value_get_by_key(tx->inner, "trace_id")); + TEST_CHECK(strcmp(trace_id, UPSTREAM_TRACE_ID) != 0); + TEST_CHECK(sentry_value_is_null( + sentry_value_get_by_key(tx->inner, "parent_span_id"))); + + // Outgoing baggage carries the SDK's own org_id, not upstream's. + continuation_collector_t c = { 0 }; + sentry_transaction_iter_headers(tx, collect_continuation_headers, &c); + TEST_CHECK(strstr(c.baggage, "sentry-org_id=123456") != NULL); + TEST_CHECK(strstr(c.baggage, "sentry-org_id=99999") == NULL); + TEST_CHECK(strstr(c.baggage, "sentry-environment=up") == NULL); + + sentry_transaction_finish(tx); + sentry_close(); +} + +SENTRY_TEST(strict_continuation_asymmetric_with_strict_forks) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_transport( + options, sentry_transport_new(discard_envelope)); + sentry_options_set_traces_sample_rate(options, 1.0); + sentry_options_set_strict_trace_continuation(options, 1); + sentry_init(options); + + // Upstream baggage with no org_id, SDK has 123456 → fork. + sentry_transaction_t *tx + = start_tx_with_upstream("sentry-environment=upstream"); + + const char *trace_id = sentry_value_as_string( + sentry_value_get_by_key(tx->inner, "trace_id")); + TEST_CHECK(strcmp(trace_id, UPSTREAM_TRACE_ID) != 0); + + sentry_transaction_finish(tx); + sentry_close(); +} + +SENTRY_TEST(strict_continuation_asymmetric_lenient_continues) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_transport( + options, sentry_transport_new(discard_envelope)); + sentry_options_set_traces_sample_rate(options, 1.0); + // Strict OFF. + sentry_init(options); + + sentry_transaction_t *tx + = start_tx_with_upstream("sentry-environment=upstream"); + + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(tx->inner, "trace_id")), + UPSTREAM_TRACE_ID); + + sentry_transaction_finish(tx); + sentry_close(); +} + +SENTRY_TEST(strict_continuation_no_baggage_forks) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_transport( + options, sentry_transport_new(discard_envelope)); + sentry_options_set_traces_sample_rate(options, 1.0); + sentry_options_set_strict_trace_continuation(options, 1); + sentry_init(options); + + // Only sentry-trace is received; no baggage at all. SDK has org_id, + // incoming has none (baggage absent) → strict MUST fork. + sentry_transaction_t *tx = start_tx_with_upstream(NULL); + + const char *trace_id = sentry_value_as_string( + sentry_value_get_by_key(tx->inner, "trace_id")); + TEST_CHECK(strcmp(trace_id, UPSTREAM_TRACE_ID) != 0); + TEST_CHECK(sentry_value_is_null( + sentry_value_get_by_key(tx->inner, "parent_span_id"))); + + // Scope propagation follows the fork: no lingering upstream trace_id. + SENTRY_WITH_SCOPE (scope) { + const char *scope_trace_id + = sentry_value_as_string(sentry_value_get_by_key( + sentry_value_get_by_key(scope->propagation_context, "trace"), + "trace_id")); + TEST_CHECK(strcmp(scope_trace_id, UPSTREAM_TRACE_ID) != 0); + TEST_CHECK_STRING_EQUAL(scope_trace_id, trace_id); + } + + sentry_transaction_finish(tx); + sentry_close(); +} + +SENTRY_TEST(continuation_no_baggage_uses_sdk_dsc) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_transport( + options, sentry_transport_new(discard_envelope)); + sentry_options_set_release(options, "sdk-app@2.0"); + sentry_options_set_traces_sample_rate(options, 1.0); + // Strict OFF + no baggage + SDK has org → continue; DSC built by SDK. + sentry_init(options); + + sentry_transaction_t *tx = start_tx_with_upstream(NULL); + + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(tx->inner, "trace_id")), + UPSTREAM_TRACE_ID); + + continuation_collector_t c = { 0 }; + sentry_transaction_iter_headers(tx, collect_continuation_headers, &c); + TEST_CHECK(strstr(c.baggage, "sentry-trace_id=" UPSTREAM_TRACE_ID) != NULL); + TEST_CHECK(strstr(c.baggage, "sentry-org_id=123456") != NULL); + TEST_CHECK(strstr(c.baggage, "sentry-release=sdk-app%402.0") != NULL); + + sentry_transaction_finish(tx); + sentry_close(); +} + +SENTRY_TEST(set_trace_rebuilds_dsc_sample_rand) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://k@o123456.ingest.sentry.io/1"); + sentry_options_set_transport( + options, sentry_transport_new(discard_envelope)); + sentry_options_set_traces_sample_rate(options, 1.0); + sentry_init(options); + + double init_sample_rand = 0.0; + SENTRY_WITH_SCOPE (scope) { + init_sample_rand = sentry_value_as_double(sentry_value_get_by_key( + scope->dynamic_sampling_context, "sample_rand")); + } + + sentry_set_trace("11112222333344445555666677778888", "1234567812345678"); + + double new_sample_rand = -1.0; + SENTRY_WITH_SCOPE (scope) { + new_sample_rand = sentry_value_as_double(sentry_value_get_by_key( + scope->dynamic_sampling_context, "sample_rand")); + } + // sample_rand is regenerated for the new trace, so the DSC must reflect + // the fresh value, not the init-time one. + TEST_CHECK(new_sample_rand != init_sample_rand); + + sentry_close(); +} + +#undef UPSTREAM_SENTRY_TRACE +#undef UPSTREAM_PARENT_SPAN_ID +#undef UPSTREAM_TRACE_ID + #undef IS_NULL #undef CHECK_STRING_PROPERTY diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 5bb337d522..9c7b1adb43 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -87,6 +87,7 @@ XX(client_report_restore) XX(client_report_save_raw_envelope) XX(concurrent_init) XX(concurrent_uninit) +XX(continuation_no_baggage_uses_sdk_dsc) XX(count_sampled_events) XX(crash_context_handler_path_propagation) XX(crash_context_null_options) @@ -126,6 +127,7 @@ XX(dsn_with_ending_forward_slash_will_be_cleaned) XX(dsn_with_non_http_scheme_is_invalid) XX(dsn_without_project_id_is_invalid) XX(dsn_without_url_scheme_is_invalid) +XX(effective_org_id_resolution) XX(embedded_info_basic) XX(embedded_info_build_id) XX(embedded_info_disabled) @@ -209,6 +211,7 @@ XX(os_release_non_existent_files) XX(os_releases_snapshot) XX(overflow_spans) XX(page_allocator) +XX(parse_baggage_basic_and_filtering) XX(path_basename) XX(path_basics) XX(path_copy) @@ -280,6 +283,7 @@ XX(set_trace) XX(set_trace_id_before_scoped_txn) XX(set_trace_id_twice) XX(set_trace_id_with_txn) +XX(set_trace_rebuilds_dsc_sample_rand) XX(set_trace_update_from_header) XX(slice) XX(span_data) @@ -289,12 +293,18 @@ XX(span_tagging_n) XX(spans_on_scope) XX(stack_guarantee) XX(stack_guarantee_auto_init) +XX(strict_continuation_asymmetric_lenient_continues) +XX(strict_continuation_asymmetric_with_strict_forks) +XX(strict_continuation_matching_org_continues) +XX(strict_continuation_no_baggage_forks) +XX(strict_continuation_org_mismatch_forks) XX(string_address_format) XX(stringbuilder_append_overflow) XX(stringbuilder_reserve_overflow) XX(symbolizer) XX(task_queue) XX(thread_without_name_still_valid) +XX(trace_continuation_truth_table) XX(traceparent_header_disabled_by_default) XX(traceparent_header_generation) XX(transaction_name_backfill_on_finish) From 9b66c4d303d36e53f6c39a67efb0ee26c77fd1cc Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 21:49:38 +0200 Subject: [PATCH 12/20] fix(tracing): freeze DSC from incoming trace Per the trace-propagation spec, the receiving SDK must treat the incoming Dynamic Sampling Context as instantly frozen and propagate its values "as is". `sentry__scope_freeze_dsc_from_incoming` built the DSC but didn't lock it, so a subsequent `sentry_set_release` / `sentry_set_environment` call would overwrite the upstream `release` / `environment` values in the outgoing `baggage` header. Freeze via `sentry_value_freeze` after the merge so the setters silently no-op against the active trace's DSC; the scope's own fields still update and feed the next trace's rebuilt DSC. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sentry_scope.c | 1 + tests/unit/test_tracing.c | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/sentry_scope.c b/src/sentry_scope.c index cb2089e328..7ee5fe0b5e 100644 --- a/src/sentry_scope.c +++ b/src/sentry_scope.c @@ -196,6 +196,7 @@ sentry__scope_freeze_dsc_from_incoming( sentry_value_decref(scope->dynamic_sampling_context); sentry_value_t dsc = sentry_value_new_object(); sentry__value_merge_objects(dsc, incoming); + sentry_value_freeze(dsc); scope->dynamic_sampling_context = dsc; } diff --git a/tests/unit/test_tracing.c b/tests/unit/test_tracing.c index 739e6a30b8..192cae8db9 100644 --- a/tests/unit/test_tracing.c +++ b/tests/unit/test_tracing.c @@ -2100,14 +2100,20 @@ SENTRY_TEST(strict_continuation_matching_org_continues) TEST_CHECK(sentry_value_is_null( sentry_value_get_by_key(tx->inner, "incoming_dsc"))); + // Late local updates must not mutate the frozen incoming DSC. + sentry_set_release("local-app@3.0"); + sentry_set_environment("local"); + // Outgoing baggage echoes the upstream environment / release verbatim. continuation_collector_t c = { 0 }; sentry_transaction_iter_headers(tx, collect_continuation_headers, &c); TEST_CHECK(strstr(c.baggage, "sentry-trace_id=" UPSTREAM_TRACE_ID) != NULL); TEST_CHECK(strstr(c.baggage, "sentry-org_id=123456") != NULL); TEST_CHECK(strstr(c.baggage, "sentry-environment=upstream") != NULL); + TEST_CHECK(strstr(c.baggage, "sentry-environment=local") == NULL); // Percent-encoded as it came in. TEST_CHECK(strstr(c.baggage, "sentry-release=upstream-app%401.0") != NULL); + TEST_CHECK(strstr(c.baggage, "sentry-release=local-app%403.0") == NULL); sentry_transaction_finish(tx); sentry_close(); From e46a43d5682d6eea92810adeb644ffe940bf6527 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 21:56:43 +0200 Subject: [PATCH 13/20] fix(tracing): drop upstream sampling decision on fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the strict-continuation decision forks into a new trace, the fork branch cleared `sampled` / `parent_span_id` on `tx` but not on `tx_ctx` (which `parse_sentry_trace` still populated from the incoming `sentry-trace` header). The subsequent `sentry__should_send_transaction(tx_ctx, ...)` call would therefore see the upstream `sampled` flag, treat it as `parent_sampled`, and short-circuit to the upstream decision — bypassing the local `traces_sample_rate` / `traces_sampler`. Pass `tx` (already merged from `tx_ctx` and, in the fork branch, stripped of `sampled`) to the sampler helper so the fork evaluates sampling locally. Non-fork paths are unchanged since `tx` agrees with `tx_ctx` on `sampled` there. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sentry_core.c | 2 +- tests/unit/test_tracing.c | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/sentry_core.c b/src/sentry_core.c index 5a2ea067f6..ec52dac1af 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1315,7 +1315,7 @@ sentry_transaction_start_ts(sentry_transaction_context_t *opaque_tx_ctx, sentry_sampling_context_t sampling_ctx = { opaque_tx_ctx, custom_sampling_ctx, NULL, sample_rand }; - bool should_sample = sentry__should_send_transaction(tx_ctx, &sampling_ctx); + bool should_sample = sentry__should_send_transaction(tx, &sampling_ctx); sentry_value_set_by_key( tx, "sampled", sentry_value_new_bool(should_sample)); sentry_value_decref(custom_sampling_ctx); diff --git a/tests/unit/test_tracing.c b/tests/unit/test_tracing.c index 192cae8db9..75c489571c 100644 --- a/tests/unit/test_tracing.c +++ b/tests/unit/test_tracing.c @@ -2125,7 +2125,9 @@ SENTRY_TEST(strict_continuation_org_mismatch_forks) sentry_options_set_dsn(options, "https://k@o123456.ingest.sentry.io/1"); sentry_options_set_transport( options, sentry_transport_new(discard_envelope)); - sentry_options_set_traces_sample_rate(options, 1.0); + // sample_rate=0 + upstream sentry-trace ending in `-1`: only the fork + // dropping the inherited sampling decision lets the local rate win. + sentry_options_set_traces_sample_rate(options, 0.0); // Strict OFF: mismatch must still fork (spec MUST). sentry_init(options); @@ -2137,6 +2139,8 @@ SENTRY_TEST(strict_continuation_org_mismatch_forks) TEST_CHECK(strcmp(trace_id, UPSTREAM_TRACE_ID) != 0); TEST_CHECK(sentry_value_is_null( sentry_value_get_by_key(tx->inner, "parent_span_id"))); + TEST_CHECK( + !sentry_value_is_true(sentry_value_get_by_key(tx->inner, "sampled"))); // Outgoing baggage carries the SDK's own org_id, not upstream's. continuation_collector_t c = { 0 }; From b87d5a0caeb35eda41a858017ef1d7b0676ac70a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 22:03:29 +0200 Subject: [PATCH 14/20] fix(tracing): use locale-independent isalnum in baggage encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isalnum` from `` is locale-dependent: in non-"C" locales (e.g. ISO-8859-1) bytes > 127 — such as UTF-8 continuation bytes in release / environment values — can be classified as alphanumeric and left unencoded, producing a malformed baggage header. RFC 3986's `unreserved` set is strict ASCII by definition, so replace the call with a small locale-independent ASCII-range helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sentry_tracing.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/sentry_tracing.c b/src/sentry_tracing.c index 881f6af398..e3f95f24fc 100644 --- a/src/sentry_tracing.c +++ b/src/sentry_tracing.c @@ -13,6 +13,13 @@ #include #include +static inline bool +isalnum_c(unsigned char c) +{ + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z'); +} + static void percent_encode_append(sentry_stringbuilder_t *sb, const char *value) { @@ -21,7 +28,7 @@ percent_encode_append(sentry_stringbuilder_t *sb, const char *value) static const char hex[] = "0123456789ABCDEF"; for (const unsigned char *p = (const unsigned char *)value; *p; p++) { unsigned char c = *p; - if (isalnum(c) || c == '-' || c == '.' || c == '_' || c == '~') { + if (isalnum_c(c) || c == '-' || c == '.' || c == '_' || c == '~') { sentry__stringbuilder_append_char(sb, (char)c); } else { char esc[3] = { '%', hex[c >> 4], hex[c & 0xF] }; From f097da5692769a9521b5438063d23697db2aac5f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 4 May 2026 15:07:06 +0200 Subject: [PATCH 15/20] test(tracing): Add strict continuation integration coverage Exercise the transaction example with fixed incoming trace and baggage flags so integration tests cover continuation, mismatched org IDs, and missing baggage. Co-Authored-By: OpenAI Codex --- examples/example.c | 22 ++++++ tests/test_integration_transactions.py | 95 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/examples/example.c b/examples/example.c index 54cf5cd967..0ddd27c844 100644 --- a/examples/example.c +++ b/examples/example.c @@ -600,6 +600,14 @@ main(int argc, char **argv) sentry_options_set_traces_sample_rate(options, 1.0); } + if (has_arg(argc, argv, "org-id")) { + sentry_options_set_org_id(options, "123456"); + } + + if (has_arg(argc, argv, "strict-trace-continuation")) { + sentry_options_set_strict_trace_continuation(options, 1); + } + if (has_arg(argc, argv, "child-spans")) { sentry_options_set_max_spans(options, 5); } @@ -1162,6 +1170,20 @@ main(int argc, char **argv) sentry_transaction_context_update_from_header( tx_ctx, "sentry-trace", trace_header); } + if (has_arg(argc, argv, "incoming-trace")) { + sentry_transaction_context_update_from_header(tx_ctx, + "sentry-trace", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-1"); + } + if (has_arg(argc, argv, "incoming-baggage")) { + sentry_transaction_context_update_from_header(tx_ctx, "baggage", + "sentry-org_id=123456,sentry-environment=upstream," + "sentry-release=upstream-app%401.0"); + } + if (has_arg(argc, argv, "incoming-baggage-mismatch")) { + sentry_transaction_context_update_from_header(tx_ctx, "baggage", + "sentry-org_id=99999,sentry-environment=upstream"); + } sentry_transaction_t *tx = sentry_transaction_start(tx_ctx, custom_sampling_ctx); diff --git a/tests/test_integration_transactions.py b/tests/test_integration_transactions.py index 092435c36b..c9f32b8fd0 100644 --- a/tests/test_integration_transactions.py +++ b/tests/test_integration_transactions.py @@ -21,6 +21,8 @@ pytestmark = pytest.mark.skipif(not has_http, reason="tests need http") RFC3339_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" +UPSTREAM_TRACE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +UPSTREAM_PARENT_SPAN_ID = "bbbbbbbbbbbbbbbb" @pytest.mark.parametrize( @@ -464,3 +466,96 @@ def test_set_trace_transaction_update_from_header_event(cmake, httpserver): # tx gets parent span_id from update_from_header assert trace_context["parent_span_id"] assert trace_context["parent_span_id"] == expected_parent_span_id + + +@pytest.mark.parametrize( + "strict,incoming_baggage_flag,continues,expected_dsc", + [ + pytest.param( + True, + "incoming-baggage", + True, + { + "environment": "upstream", + "org_id": "123456", + "release": "upstream-app@1.0", + }, + id="strict-matching-org-continues", + ), + pytest.param( + False, + "incoming-baggage-mismatch", + False, + { + "environment": "development", + "org_id": "123456", + "release": "test-example-release", + }, + id="lenient-mismatched-org-forks", + ), + pytest.param( + True, + None, + False, + { + "environment": "development", + "org_id": "123456", + "release": "test-example-release", + }, + id="strict-no-baggage-forks", + ), + pytest.param( + False, + None, + True, + { + "environment": "development", + "org_id": "123456", + "release": "test-example-release", + }, + id="lenient-no-baggage-continues", + ), + ], +) +def test_strict_trace_continuation( + cmake, httpserver, strict, incoming_baggage_flag, continues, expected_dsc +): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver), SENTRY_RELEASE="🤮🚀") + + args = [ + "log", + "org-id", + "capture-transaction", + "incoming-trace", + ] + if strict: + args.append("strict-trace-continuation") + if incoming_baggage_flag: + args.append(incoming_baggage_flag) + + run(tmp_path, "sentry_example", args, env=env) + + assert len(httpserver.log) == 1 + event_envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + event_envelope.print_verbose() + + (event,) = event_envelope.items + assert event.headers["type"] == "transaction" + payload = event.payload.json + trace_context = payload["contexts"]["trace"] + trace_header = event_envelope.headers["trace"] + + assert "incoming_dsc" not in payload + assert trace_header["trace_id"] == trace_context["trace_id"] + for key, value in expected_dsc.items(): + assert trace_header[key] == value + + if continues: + assert trace_context["trace_id"] == UPSTREAM_TRACE_ID + assert trace_context["parent_span_id"] == UPSTREAM_PARENT_SPAN_ID + else: + assert trace_context["trace_id"] != UPSTREAM_TRACE_ID + assert "parent_span_id" not in trace_context From 7ef93154d81bc591493368507178cc46522aa20c Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 18:30:36 +0200 Subject: [PATCH 16/20] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b85251c3a6..b5890e239e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ **Features**: - Auto-populate `event.user.id` with a persistent per-installation UUID when no explicit user ID is set. ([#1661](https://github.com/getsentry/sentry-native/pull/1661)) +- Add [strict trace continuation](https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation) via `sentry_options_set_strict_trace_continuation`. ([#1663](https://github.com/getsentry/sentry-native/pull/1663)) ## 0.14.0 From 8cff16154d70229de8247b374f4e2b15915706e4 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 4 May 2026 15:56:48 +0200 Subject: [PATCH 17/20] ref(tracing): Remove unused org ID getter Drop the unused public org ID getter and use a single internal helper for the trace propagation org ID resolved from options or the DSN. Co-Authored-By: OpenAI Codex --- include/sentry.h | 7 ------- src/sentry_core.c | 3 +-- src/sentry_options.c | 8 +------- src/sentry_options.h | 9 ++++----- src/sentry_scope.c | 2 +- tests/unit/test_tracing.c | 12 +++++------- 6 files changed, 12 insertions(+), 29 deletions(-) diff --git a/include/sentry.h b/include/sentry.h index c8b308803a..0fda1ed5cd 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2340,13 +2340,6 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_org_id( SENTRY_EXPERIMENTAL_API void sentry_options_set_org_id_n( sentry_options_t *opts, const char *org_id, size_t org_id_len); -/** - * Returns the organization ID previously set via `sentry_options_set_org_id`, - * or NULL if none was set. Does not fall back to the DSN-derived value. - */ -SENTRY_EXPERIMENTAL_API const char *sentry_options_get_org_id( - const sentry_options_t *opts); - /** * Enables or disables strict trace continuation. * diff --git a/src/sentry_core.c b/src/sentry_core.c index ec52dac1af..efa86d7b68 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1267,8 +1267,7 @@ sentry_transaction_start_ts(sentry_transaction_context_t *opaque_tx_ctx, if (!sentry_value_is_null(incoming)) { SENTRY_WITH_OPTIONS (options) { SENTRY_WITH_SCOPE_MUT (scope) { - const char *sdk_org - = sentry__options_get_effective_org_id(options); + const char *sdk_org = sentry__options_get_org_id(options); const char *inc_org = sentry_value_as_string( sentry_value_get_by_key(incoming, "org_id")); if (!*inc_org) { diff --git a/src/sentry_options.c b/src/sentry_options.c index f71f83237a..9fd79b4e58 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -238,13 +238,7 @@ sentry_options_set_org_id(sentry_options_t *opts, const char *org_id) } const char * -sentry_options_get_org_id(const sentry_options_t *opts) -{ - return opts->org_id; -} - -const char * -sentry__options_get_effective_org_id(const sentry_options_t *opts) +sentry__options_get_org_id(const sentry_options_t *opts) { if (opts->org_id && *opts->org_id) { return opts->org_id; diff --git a/src/sentry_options.h b/src/sentry_options.h index ac14ec8f14..4a45027c5a 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -111,11 +111,10 @@ struct sentry_options_s { sentry_options_t *sentry__options_incref(sentry_options_t *options); /** - * Returns the effective organization ID used for trace propagation: - * the `org_id` option if set and non-empty, otherwise the DSN-derived value - * if non-empty, otherwise NULL. + * Returns the organization ID used for trace propagation: the `org_id` option + * if set and non-empty, otherwise the DSN-derived value if non-empty, + * otherwise NULL. */ -const char *sentry__options_get_effective_org_id( - const sentry_options_t *options); +const char *sentry__options_get_org_id(const sentry_options_t *options); #endif diff --git a/src/sentry_scope.c b/src/sentry_scope.c index 7ee5fe0b5e..45fa62de4e 100644 --- a/src/sentry_scope.c +++ b/src/sentry_scope.c @@ -211,7 +211,7 @@ sentry__scope_rebuild_dsc_from_options( sentry_value_set_by_key(dsc, "public_key", sentry_value_new_string(options->dsn->public_key)); } - const char *org_id = sentry__options_get_effective_org_id(options); + const char *org_id = sentry__options_get_org_id(options); if (org_id) { sentry_value_set_by_key(dsc, "org_id", sentry_value_new_string(org_id)); } diff --git a/tests/unit/test_tracing.c b/tests/unit/test_tracing.c index 75c489571c..ba0c90ce49 100644 --- a/tests/unit/test_tracing.c +++ b/tests/unit/test_tracing.c @@ -1974,35 +1974,33 @@ SENTRY_TEST(effective_org_id_resolution) { // No DSN, no option → NULL SENTRY_TEST_OPTIONS_NEW(opts1); - TEST_CHECK(sentry__options_get_effective_org_id(opts1) == NULL); + TEST_CHECK(sentry__options_get_org_id(opts1) == NULL); sentry_options_free(opts1); // DSN with org → DSN value SENTRY_TEST_OPTIONS_NEW(opts2); sentry_options_set_dsn(opts2, "https://k@o123456.ingest.sentry.io/1"); - TEST_CHECK_STRING_EQUAL( - sentry__options_get_effective_org_id(opts2), "123456"); + TEST_CHECK_STRING_EQUAL(sentry__options_get_org_id(opts2), "123456"); sentry_options_free(opts2); // DSN without org_id-encoded host → NULL SENTRY_TEST_OPTIONS_NEW(opts3); sentry_options_set_dsn(opts3, "https://k@self-hosted.example.com/1"); - TEST_CHECK(sentry__options_get_effective_org_id(opts3) == NULL); + TEST_CHECK(sentry__options_get_org_id(opts3) == NULL); sentry_options_free(opts3); // Option overrides DSN SENTRY_TEST_OPTIONS_NEW(opts4); sentry_options_set_dsn(opts4, "https://k@o123456.ingest.sentry.io/1"); sentry_options_set_org_id(opts4, "999"); - TEST_CHECK_STRING_EQUAL(sentry__options_get_effective_org_id(opts4), "999"); + TEST_CHECK_STRING_EQUAL(sentry__options_get_org_id(opts4), "999"); sentry_options_free(opts4); // Empty option falls back to DSN SENTRY_TEST_OPTIONS_NEW(opts5); sentry_options_set_dsn(opts5, "https://k@o123456.ingest.sentry.io/1"); sentry_options_set_org_id(opts5, ""); - TEST_CHECK_STRING_EQUAL( - sentry__options_get_effective_org_id(opts5), "123456"); + TEST_CHECK_STRING_EQUAL(sentry__options_get_org_id(opts5), "123456"); sentry_options_free(opts5); } From a00313f23d453bd1be9ac78ba482e8532124ca9a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 4 May 2026 17:28:18 +0200 Subject: [PATCH 18/20] ref(tracing): Rename scope DSC helpers Use shorter internal names for updating the scope DSC from SDK options and freezing it from incoming trace continuation data. Co-Authored-By: OpenAI Codex --- src/sentry_core.c | 12 ++++++------ src/sentry_scope.c | 6 ++---- src/sentry_scope.h | 5 ++--- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/sentry_core.c b/src/sentry_core.c index efa86d7b68..6283723e3d 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -217,7 +217,7 @@ sentry_init(sentry_options_t *options) sentry__ringbuffer_set_max_size( scope->breadcrumbs, options->max_breadcrumbs); - sentry__scope_rebuild_dsc_from_options(scope, options); + sentry__scope_update_dsc(scope, options); } if (backend && backend->user_consent_changed_func) { backend->user_consent_changed_func(backend); @@ -1176,7 +1176,7 @@ sentry_set_trace_n(const char *trace_id, size_t trace_id_len, SENTRY_WITH_OPTIONS (options) { SENTRY_WITH_SCOPE_MUT (scope) { - sentry__scope_rebuild_dsc_from_options(scope, options); + sentry__scope_update_dsc(scope, options); } } } @@ -1189,7 +1189,7 @@ sentry_regenerate_trace(void) SENTRY_WITH_SCOPE_MUT (scope) { generate_propagation_context(scope->propagation_context); scope->trace_managed = false; - sentry__scope_rebuild_dsc_from_options(scope, options); + sentry__scope_update_dsc(scope, options); } } } @@ -1280,9 +1280,9 @@ sentry_transaction_start_ts(sentry_transaction_context_t *opaque_tx_ctx, // a sentry-trace-only signal leaves incoming empty, in // which case the SDK builds its own DSC. if (sentry_value_get_length(incoming) > 0) { - sentry__scope_freeze_dsc_from_incoming(scope, incoming); + sentry__scope_freeze_dsc(scope, incoming); } else { - sentry__scope_rebuild_dsc_from_options(scope, options); + sentry__scope_update_dsc(scope, options); } } else { // Fork: ignore upstream trace, become head of a new trace. @@ -1298,7 +1298,7 @@ sentry_transaction_start_ts(sentry_transaction_context_t *opaque_tx_ctx, sentry_value_set_by_key(tx, "trace_id", scope_trace_id); sentry_value_remove_by_key(tx, "parent_span_id"); sentry_value_remove_by_key(tx, "sampled"); - sentry__scope_rebuild_dsc_from_options(scope, options); + sentry__scope_update_dsc(scope, options); } } } diff --git a/src/sentry_scope.c b/src/sentry_scope.c index 45fa62de4e..9dfec8bf29 100644 --- a/src/sentry_scope.c +++ b/src/sentry_scope.c @@ -190,8 +190,7 @@ sentry__scope_free(sentry_scope_t *scope) } void -sentry__scope_freeze_dsc_from_incoming( - sentry_scope_t *scope, sentry_value_t incoming) +sentry__scope_freeze_dsc(sentry_scope_t *scope, sentry_value_t incoming) { sentry_value_decref(scope->dynamic_sampling_context); sentry_value_t dsc = sentry_value_new_object(); @@ -201,8 +200,7 @@ sentry__scope_freeze_dsc_from_incoming( } void -sentry__scope_rebuild_dsc_from_options( - sentry_scope_t *scope, const sentry_options_t *options) +sentry__scope_update_dsc(sentry_scope_t *scope, const sentry_options_t *options) { sentry_value_decref(scope->dynamic_sampling_context); sentry_value_t dsc = sentry_value_new_object(); diff --git a/src/sentry_scope.h b/src/sentry_scope.h index ec90500e7c..6047fa8035 100644 --- a/src/sentry_scope.h +++ b/src/sentry_scope.h @@ -132,7 +132,7 @@ void sentry__scope_remove_attribute_n( * Rebuilds the scope's dynamic sampling context (DSC) from the SDK options * and the current propagation context. The previous DSC is discarded. */ -void sentry__scope_rebuild_dsc_from_options( +void sentry__scope_update_dsc( sentry_scope_t *scope, const sentry_options_t *options); /** @@ -141,8 +141,7 @@ void sentry__scope_rebuild_dsc_from_options( * trace-propagation spec, the receiving SDK MUST treat the incoming DSC as * frozen and propagate its values "as is". */ -void sentry__scope_freeze_dsc_from_incoming( - sentry_scope_t *scope, sentry_value_t incoming); +void sentry__scope_freeze_dsc(sentry_scope_t *scope, sentry_value_t incoming); /** * Adds scoped attributes to the telemetry attributes object. From a4f8744d927841a2f3bbfc98dcab918adcc6a02c Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 4 May 2026 17:33:35 +0200 Subject: [PATCH 19/20] ref(tracing): Rename trace continuation predicate Use a shorter internal predicate name for deciding whether an incoming trace can continue under strict trace continuation rules. Co-Authored-By: OpenAI Codex --- src/sentry_core.c | 2 +- src/sentry_tracing.c | 2 +- src/sentry_tracing.h | 2 +- tests/unit/test_tracing.c | 22 +++++++++++----------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/sentry_core.c b/src/sentry_core.c index 6283723e3d..648da4a4b1 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1274,7 +1274,7 @@ sentry_transaction_start_ts(sentry_transaction_context_t *opaque_tx_ctx, inc_org = NULL; } - if (sentry__trace_continuation_allowed( + if (sentry__trace_can_continue( sdk_org, inc_org, options->strict_trace_continuation)) { // Freeze only when the upstream actually sent DSC values; // a sentry-trace-only signal leaves incoming empty, in diff --git a/src/sentry_tracing.c b/src/sentry_tracing.c index e3f95f24fc..0888e03d03 100644 --- a/src/sentry_tracing.c +++ b/src/sentry_tracing.c @@ -390,7 +390,7 @@ parse_baggage( } bool -sentry__trace_continuation_allowed( +sentry__trace_can_continue( const char *sdk_org_id, const char *incoming_org_id, bool strict) { bool sdk_has = sdk_org_id && *sdk_org_id; diff --git a/src/sentry_tracing.h b/src/sentry_tracing.h index 4a3dd5cc0b..6bb4829e77 100644 --- a/src/sentry_tracing.h +++ b/src/sentry_tracing.h @@ -67,7 +67,7 @@ sentry_value_t sentry__value_get_trace_context(sentry_value_t span); * See * https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation */ -bool sentry__trace_continuation_allowed( +bool sentry__trace_can_continue( const char *sdk_org_id, const char *incoming_org_id, bool strict); #endif diff --git a/tests/unit/test_tracing.c b/tests/unit/test_tracing.c index ba0c90ce49..baaa0b3946 100644 --- a/tests/unit/test_tracing.c +++ b/tests/unit/test_tracing.c @@ -1952,22 +1952,22 @@ SENTRY_TEST(trace_continuation_truth_table) // Per // https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation // Both absent or both present-equal: continue regardless of strict. - TEST_CHECK(sentry__trace_continuation_allowed(NULL, NULL, false)); - TEST_CHECK(sentry__trace_continuation_allowed(NULL, NULL, true)); - TEST_CHECK(sentry__trace_continuation_allowed("1", "1", false)); - TEST_CHECK(sentry__trace_continuation_allowed("1", "1", true)); + TEST_CHECK(sentry__trace_can_continue(NULL, NULL, false)); + TEST_CHECK(sentry__trace_can_continue(NULL, NULL, true)); + TEST_CHECK(sentry__trace_can_continue("1", "1", false)); + TEST_CHECK(sentry__trace_can_continue("1", "1", true)); // Empty string is treated as absent. - TEST_CHECK(sentry__trace_continuation_allowed("", "", true)); + TEST_CHECK(sentry__trace_can_continue("", "", true)); // Both present and differing: never continue. - TEST_CHECK(!sentry__trace_continuation_allowed("1", "2", false)); - TEST_CHECK(!sentry__trace_continuation_allowed("1", "2", true)); + TEST_CHECK(!sentry__trace_can_continue("1", "2", false)); + TEST_CHECK(!sentry__trace_can_continue("1", "2", true)); // Exactly one present: continue iff strict is false. - TEST_CHECK(sentry__trace_continuation_allowed("1", NULL, false)); - TEST_CHECK(sentry__trace_continuation_allowed(NULL, "1", false)); - TEST_CHECK(!sentry__trace_continuation_allowed("1", NULL, true)); - TEST_CHECK(!sentry__trace_continuation_allowed(NULL, "1", true)); + TEST_CHECK(sentry__trace_can_continue("1", NULL, false)); + TEST_CHECK(sentry__trace_can_continue(NULL, "1", false)); + TEST_CHECK(!sentry__trace_can_continue("1", NULL, true)); + TEST_CHECK(!sentry__trace_can_continue(NULL, "1", true)); } SENTRY_TEST(effective_org_id_resolution) From 2794991ba68cd8612c8a6f08e224053a55a9731d Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 4 May 2026 17:43:07 +0200 Subject: [PATCH 20/20] ref(tracing): Move continuation org lookup into predicate Let the trace continuation predicate derive SDK and incoming organization IDs from the options and incoming DSC so transaction startup only asks whether the incoming trace can continue. Co-Authored-By: OpenAI Codex --- src/sentry_core.c | 10 +-------- src/sentry_tracing.c | 7 ++++-- src/sentry_tracing.h | 7 +++--- tests/unit/test_tracing.c | 45 +++++++++++++++++++++++++++++---------- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/sentry_core.c b/src/sentry_core.c index 648da4a4b1..6f035074dc 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1267,15 +1267,7 @@ sentry_transaction_start_ts(sentry_transaction_context_t *opaque_tx_ctx, if (!sentry_value_is_null(incoming)) { SENTRY_WITH_OPTIONS (options) { SENTRY_WITH_SCOPE_MUT (scope) { - const char *sdk_org = sentry__options_get_org_id(options); - const char *inc_org = sentry_value_as_string( - sentry_value_get_by_key(incoming, "org_id")); - if (!*inc_org) { - inc_org = NULL; - } - - if (sentry__trace_can_continue( - sdk_org, inc_org, options->strict_trace_continuation)) { + if (sentry__trace_can_continue(incoming, options)) { // Freeze only when the upstream actually sent DSC values; // a sentry-trace-only signal leaves incoming empty, in // which case the SDK builds its own DSC. diff --git a/src/sentry_tracing.c b/src/sentry_tracing.c index 0888e03d03..80f571d519 100644 --- a/src/sentry_tracing.c +++ b/src/sentry_tracing.c @@ -391,15 +391,18 @@ parse_baggage( bool sentry__trace_can_continue( - const char *sdk_org_id, const char *incoming_org_id, bool strict) + sentry_value_t incoming, const sentry_options_t *options) { + const char *sdk_org_id = sentry__options_get_org_id(options); + const char *incoming_org_id + = sentry_value_as_string(sentry_value_get_by_key(incoming, "org_id")); bool sdk_has = sdk_org_id && *sdk_org_id; bool inc_has = incoming_org_id && *incoming_org_id; if (sdk_has && inc_has) { return strcmp(sdk_org_id, incoming_org_id) == 0; } if (sdk_has != inc_has) { - return !strict; + return !options->strict_trace_continuation; } return true; } diff --git a/src/sentry_tracing.h b/src/sentry_tracing.h index 6bb4829e77..d8910ba7f1 100644 --- a/src/sentry_tracing.h +++ b/src/sentry_tracing.h @@ -60,14 +60,13 @@ sentry_span_t *sentry__span_new( sentry_value_t sentry__value_get_trace_context(sentry_value_t span); /** - * Returns whether to continue an incoming trace given the SDK's organization - * ID, the incoming trace's organization ID, and the strict-trace-continuation - * flag. Either ID may be NULL or empty to indicate "absent". + * Returns whether to continue an incoming trace given the SDK options and the + * incoming dynamic sampling context. * * See * https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation */ bool sentry__trace_can_continue( - const char *sdk_org_id, const char *incoming_org_id, bool strict); + sentry_value_t incoming, const sentry_options_t *options); #endif diff --git a/tests/unit/test_tracing.c b/tests/unit/test_tracing.c index baaa0b3946..9a32a01676 100644 --- a/tests/unit/test_tracing.c +++ b/tests/unit/test_tracing.c @@ -1947,27 +1947,50 @@ SENTRY_TEST(traceparent_header_generation) sentry_close(); } +static bool +trace_can_continue( + const char *sdk_org_id, const char *incoming_org_id, bool strict) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_strict_trace_continuation(options, strict); + if (sdk_org_id) { + sentry_options_set_org_id(options, sdk_org_id); + } + + sentry_value_t incoming = sentry_value_new_object(); + if (incoming_org_id) { + sentry_value_set_by_key( + incoming, "org_id", sentry_value_new_string(incoming_org_id)); + } + + bool rv = sentry__trace_can_continue(incoming, options); + + sentry_value_decref(incoming); + sentry_options_free(options); + return rv; +} + SENTRY_TEST(trace_continuation_truth_table) { // Per // https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation // Both absent or both present-equal: continue regardless of strict. - TEST_CHECK(sentry__trace_can_continue(NULL, NULL, false)); - TEST_CHECK(sentry__trace_can_continue(NULL, NULL, true)); - TEST_CHECK(sentry__trace_can_continue("1", "1", false)); - TEST_CHECK(sentry__trace_can_continue("1", "1", true)); + TEST_CHECK(trace_can_continue(NULL, NULL, false)); + TEST_CHECK(trace_can_continue(NULL, NULL, true)); + TEST_CHECK(trace_can_continue("1", "1", false)); + TEST_CHECK(trace_can_continue("1", "1", true)); // Empty string is treated as absent. - TEST_CHECK(sentry__trace_can_continue("", "", true)); + TEST_CHECK(trace_can_continue("", "", true)); // Both present and differing: never continue. - TEST_CHECK(!sentry__trace_can_continue("1", "2", false)); - TEST_CHECK(!sentry__trace_can_continue("1", "2", true)); + TEST_CHECK(!trace_can_continue("1", "2", false)); + TEST_CHECK(!trace_can_continue("1", "2", true)); // Exactly one present: continue iff strict is false. - TEST_CHECK(sentry__trace_can_continue("1", NULL, false)); - TEST_CHECK(sentry__trace_can_continue(NULL, "1", false)); - TEST_CHECK(!sentry__trace_can_continue("1", NULL, true)); - TEST_CHECK(!sentry__trace_can_continue(NULL, "1", true)); + TEST_CHECK(trace_can_continue("1", NULL, false)); + TEST_CHECK(trace_can_continue(NULL, "1", false)); + TEST_CHECK(!trace_can_continue("1", NULL, true)); + TEST_CHECK(!trace_can_continue(NULL, "1", true)); } SENTRY_TEST(effective_org_id_resolution)