From 63c2abdc061d80f14e3c1b9efb251e578979ca1a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 18:21:13 +0200 Subject: [PATCH 01/14] 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 136c09b13f..5c72ecf6f3 100644 --- a/src/sentry_utils.c +++ b/src/sentry_utils.c @@ -10,6 +10,7 @@ #include "sentry_random.h" +#include #include #include #include @@ -631,3 +632,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 75ea87d4c5..aa758d9c37 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 @@ -249,4 +250,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 4a95cef4bd..29d4d5f37e 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" @@ -464,3 +467,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 a4d72ca20c..eb421e15be 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -8,6 +8,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) @@ -195,6 +203,9 @@ XX(path_joining_windows) XX(path_mtime) XX(path_relative_filename) XX(path_rename) +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 6407c725ac21dd0e99580646f7a7b3d0c8233f39 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:19:17 +0200 Subject: [PATCH 02/14] 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 4b132d2643..3dd42c3037 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2316,6 +2316,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 d8a173278d..cb1d0469c1 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->cache_keep = false; @@ -922,6 +923,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 86ee949c2b..ececa05ac9 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 1ee555bade987f5bf2fc74a0bfc1444f88cc5486 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:30:10 +0200 Subject: [PATCH 03/14] 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 3dd42c3037..cf9696ff39 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2316,6 +2316,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 cb1d0469c1..96da95c064 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -123,6 +123,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); @@ -219,6 +220,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 ececa05ac9..09c29f5088 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 962741b2c0a1e695b4003c1519ea68d17974751b Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:35:40 +0200 Subject: [PATCH 04/14] 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 4b617c3154..b8fc992ccc 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1180,15 +1180,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 51fceba50ee0c950533030b8da85c7018144daca Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:39:52 +0200 Subject: [PATCH 05/14] 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 b8fc992ccc..fed8a16249 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 96da95c064..f7b8a6634e 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -241,6 +241,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 09c29f5088..6058991f7a 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -109,4 +109,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 25dd24c107..a88a116426 100644 --- a/tests/unit/test_envelopes.c +++ b/tests/unit/test_envelopes.c @@ -10,7 +10,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 8c26889ce4cc0643a24a655e5774138a7cea249c Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:42:35 +0200 Subject: [PATCH 06/14] 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 cf9696ff39..ac26b49a6e 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2344,7 +2344,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. */ @@ -2912,6 +2913,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 b2d40bc114bf0b1ae111402e9a542f1f872cc71b Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:45:46 +0200 Subject: [PATCH 07/14] 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 fed8a16249..9a863a2e7d 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) @@ -249,7 +214,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); @@ -1185,7 +1150,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); } } } @@ -1198,7 +1163,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 c14ab6f71e..c5fd7d1f11 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 5abb9d92c9fd526a52566819159b38d8cea650c0 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:49:48 +0200 Subject: [PATCH 08/14] 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 9a863a2e7d..45c564830c 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1236,6 +1236,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 c5fd7d1f11..3737e45910 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 8c85ceb9f927c5b5051e0aa14a9e77577aacd071 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:53:01 +0200 Subject: [PATCH 09/14] feat(tracing): emit outgoing baggage from scope DSC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (incl. `sentry-org_id` when present, satisfying the spec's MUST-propagate requirement) are appended with values percent-encoded per RFC 3986. Adds two thin internal helpers — `sentry__value_object_key_at` / `sentry__value_object_value_at` — to walk DSC pairs without exposing the object internals. https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/#baggage-header Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sentry_tracing.c | 61 ++++++++++++++++++++++++++++++++++++++++++-- src/sentry_value.c | 26 +++++++++++++++++++ src/sentry_value.h | 14 ++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/sentry_tracing.c b/src/sentry_tracing.c index 5424100f81..db437fe288 100644 --- a/src/sentry_tracing.c +++ b/src/sentry_tracing.c @@ -10,8 +10,26 @@ #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 sentry_value_t new_span_n(sentry_value_t parent, sentry_slice_t operation) { @@ -865,8 +883,47 @@ 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_t dsc = scope->dynamic_sampling_context; + size_t len = sentry_value_get_length(dsc); + for (size_t i = 0; i < len; i++) { + const char *k = sentry__value_object_key_at(dsc, i); + if (!k || strcmp(k, "trace_id") == 0) { + continue; + } + sentry_value_t v = sentry__value_object_value_at(dsc, i); + if (sentry_value_is_null(v)) { + continue; + } + char *vs = sentry__value_stringify(v); + if (!vs) { + continue; + } + sentry__stringbuilder_append(&sb, ",sentry-"); + sentry__stringbuilder_append(&sb, k); + sentry__stringbuilder_append_char(&sb, '='); + percent_encode_append(&sb, vs); + sentry_free(vs); + } + } + + char *baggage = sentry__stringbuilder_into_string(&sb); + if (baggage) { + callback("baggage", baggage, userdata); + sentry_free(baggage); + } + } SENTRY_WITH_OPTIONS (options) { if (options->propagate_traceparent) { diff --git a/src/sentry_value.c b/src/sentry_value.c index 6fc1b030fc..ca89512a65 100644 --- a/src/sentry_value.c +++ b/src/sentry_value.c @@ -934,6 +934,32 @@ sentry_value_get_by_index(sentry_value_t value, size_t index) return sentry_value_new_null(); } +const char * +sentry__value_object_key_at(sentry_value_t value, size_t idx) +{ + const thing_t *thing = value_as_thing(value); + if (thing && thing_get_type(thing) == THING_TYPE_OBJECT) { + const obj_t *o = thing->payload._ptr; + if (idx < o->len) { + return o->pairs[idx].k; + } + } + return NULL; +} + +sentry_value_t +sentry__value_object_value_at(sentry_value_t value, size_t idx) +{ + const thing_t *thing = value_as_thing(value); + if (thing && thing_get_type(thing) == THING_TYPE_OBJECT) { + const obj_t *o = thing->payload._ptr; + if (idx < o->len) { + return o->pairs[idx].v; + } + } + return sentry_value_new_null(); +} + 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..98338e3e4e 100644 --- a/src/sentry_value.h +++ b/src/sentry_value.h @@ -67,6 +67,20 @@ sentry_value_t sentry__value_new_list_with_size(size_t size); */ sentry_value_t sentry__value_new_object_with_size(size_t size); +/** + * Returns the key of the object pair at the given index, or NULL if the value + * is not an object or the index is out of range. Use `sentry_value_get_length` + * to determine the number of pairs. + */ +const char *sentry__value_object_key_at(sentry_value_t value, size_t idx); + +/** + * Returns the value of the object pair at the given index, or `null` if the + * value is not an object or the index is out of range. The returned value is + * a borrowed reference (not increfed). + */ +sentry_value_t sentry__value_object_value_at(sentry_value_t value, size_t idx); + /** * This will parse the Value into a UUID, or return a `nil` UUID on error. * See also `sentry_uuid_from_string`. From 1a13d4ccdb79893adacff2fbd4b6d197855eb064 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 16:57:59 +0200 Subject: [PATCH 10/14] 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 eb421e15be..fa182c7408 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -74,6 +74,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) @@ -112,6 +113,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) @@ -192,6 +194,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_current_exe) @@ -258,6 +261,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) @@ -267,10 +271,16 @@ 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(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 61bb274b526705f9c280e3d980e131c3ebf8dd66 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 18:30:36 +0200 Subject: [PATCH 11/14] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2196a97824..8e87533d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Features**: +- 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)) - Linux: support 32-bit ARM. ([#1659](https://github.com/getsentry/sentry-native/issues/1659)) **Fixes**: From 40225743a2fa85fae2aa22e3c0011820625b8474 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 21:49:38 +0200 Subject: [PATCH 12/14] 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 3737e45910..0f0c17923d 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 0a49a1e9b76c28898ac17efcbdeb13cea6d8c145 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 21:56:43 +0200 Subject: [PATCH 13/14] 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 45c564830c..32fa989fda 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -1289,7 +1289,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 37042154680354826337fa6480b9b09fe18306c3 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 22 Apr 2026 22:03:29 +0200 Subject: [PATCH 14/14] 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 db437fe288..d073aeca9d 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] };