From 78237b6a2639d30ccbd8463d590f98feac00fba7 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 17 Jun 2026 13:39:10 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20Make=20DistributedContextPropag?= =?UTF-8?q?ator=20opt-in=20via=20AppContext=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch to System.Diagnostics.DistributedContextPropagator changes the OpenTelemetry baggage wire format (W3C OWS encoding + whitespace trimming), which is breaking on rolling upgrades. Keep the legacy percent-encoded propagator as the default and gate the new propagator behind the NServiceBus.Core.OpenTelemetry.UseDistributedContextPropagator AppContext switch (default in v11). All temporary code (switch plumbing + legacy propagator) lives in obsolete_v11.cs so v11 cleanup is a single file deletion plus removing the two delegation blocks in ContextPropagation.cs. Follows the existing AppContextSwitches.UseV2DeterministicGuid / PreObsolete pattern. - ContextPropagation: delegate to the legacy propagator unless the switch is on - obsolete_v11.cs: switch + byte-for-byte revert of the pre-10.3 propagator - Tests asserting the new W3C format enable the switch per-fixture - New ContextPropagationDefaultBehaviorTests locks in legacy default behavior - Acceptance baggage assertion reverted to the legacy default wire format Span naming (UseMessageDestinationInSpanNames) is already opt-in and unchanged. --- .../When_outgoing_activity_has_baggage.cs | 5 +- .../ContextPropagationDefaultBehaviorTests.cs | 55 +++++++ .../ContextPropagationIncompatibilityTests.cs | 17 +++ .../OpenTelemetry/ContextPropagationTests.cs | 16 ++ .../Tracing/ContextPropagation.cs | 14 ++ .../OpenTelemetry/Tracing/obsolete_v11.cs | 143 ++++++++++++++++++ 6 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs create mode 100644 src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_outgoing_activity_has_baggage.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_outgoing_activity_has_baggage.cs index e8bed02db2..b6a2310ad2 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_outgoing_activity_has_baggage.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_outgoing_activity_has_baggage.cs @@ -33,7 +33,10 @@ public async Task Should_propagate_baggage_to_headers() ) .Run(); - Assert.That(context.BaggageHeader, Is.EqualTo("key3 = , key2 = value2, key1 = value1")); + // Default (backwards-compatible) propagation produces the legacy comma-separated, percent-encoded format. + // The W3C OWS format ("key3 = , key2 = value2, key1 = value1") is produced only when the + // NServiceBus.Core.OpenTelemetry.UseDistributedContextPropagator AppContext switch is enabled (default in v11). + Assert.That(context.BaggageHeader, Is.EqualTo("key3=,key2=value2,key1=value1")); } public class TestEndpoint : EndpointConfigurationBuilder diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs new file mode 100644 index 0000000000..99ae440a49 --- /dev/null +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs @@ -0,0 +1,55 @@ +namespace NServiceBus.Core.Tests.OpenTelemetry; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Extensibility; +using NUnit.Framework; + +[TestFixture] +public class ContextPropagationDefaultBehaviorTests +{ + // Without the opt-in switch, the endpoint default must remain the backwards-compatible + // legacy propagator (percent-encoded, comma-separated, whitespace preserved). + [SetUp] + public void EnsureDefault() + { + AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, false); + ObsoleteV11.ResetUseDistributedContextPropagator(); + } + + [Test] + public void Default_uses_legacy_percent_encoded_baggage_format() + { + using var activity = new Activity(ActivityNames.OutgoingMessageActivityName); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + activity.AddBaggage("serverNode", "DF 28"); + + var headers = new Dictionary(); + ContextPropagation.PropagateContextToHeaders(activity, headers, new ContextBag()); + + Assert.That(headers[Headers.DiagnosticsBaggage], Is.EqualTo("serverNode=DF%2028")); + } + + [Test] + public void Default_round_trip_preserves_value_whitespace() + { + using var outgoing = new Activity(ActivityNames.OutgoingMessageActivityName); + outgoing.SetIdFormat(ActivityIdFormat.W3C); + outgoing.Start(); + outgoing.AddBaggage("key1", " leading-and-trailing "); + + var headers = new Dictionary(); + ContextPropagation.PropagateContextToHeaders(outgoing, headers, new ContextBag()); + + using var incoming = new Activity(ActivityNames.IncomingMessageActivityName); + incoming.SetIdFormat(ActivityIdFormat.W3C); + incoming.Start(); + ContextPropagation.PropagateContextFromHeaders(incoming, headers); + + // Legacy propagation preserves leading/trailing whitespace via percent-encoding; + // the DistributedContextPropagator (opt-in) would trim it. + Assert.That(incoming.GetBaggageItem("key1"), Is.EqualTo(" leading-and-trailing ")); + } +} diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs index 4aa9314713..6b1790e10a 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs @@ -1,5 +1,6 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; +using System; using System.Collections.Generic; using System.Diagnostics; using Extensibility; @@ -8,6 +9,22 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; [TestFixture] public class ContextPropagationIncompatibilityTests { + // The "New*" delegates route through ContextPropagation, whose DistributedContextPropagator + // path is opt-in until v11, so the switch must be enabled for these tests. + [SetUp] + public void EnableDistributedContextPropagator() + { + AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, true); + ObsoleteV11.ResetUseDistributedContextPropagator(); + } + + [TearDown] + public void ResetDistributedContextPropagator() + { + AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, false); + ObsoleteV11.ResetUseDistributedContextPropagator(); + } + delegate void Writer(Activity activity, Dictionary headers, ContextBag context); delegate void Reader(Activity activity, IDictionary headers); diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs index 0141d6c9a1..91ce1d1304 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs @@ -1,5 +1,6 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; +using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; @@ -10,6 +11,21 @@ [TestFixture] public class ContextPropagationTests { + // These tests assert the W3C DistributedContextPropagator wire format, which is opt-in until v11. + [SetUp] + public void EnableDistributedContextPropagator() + { + AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, true); + ObsoleteV11.ResetUseDistributedContextPropagator(); + } + + [TearDown] + public void ResetDistributedContextPropagator() + { + AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, false); + ObsoleteV11.ResetUseDistributedContextPropagator(); + } + [Test] public void Propagate_activity_id_to_header() { diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs index b6ac0314b4..425eb93e6a 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs @@ -10,6 +10,13 @@ static class ContextPropagation { public static void PropagateContextToHeaders(Activity? activity, Dictionary headers, ContextBag contextBag) { + // Removed in v11, see obsolete_v11.cs + if (!ObsoleteV11.UseDistributedContextPropagator) + { + ObsoleteV11.PropagateContextToHeaders(activity, headers, contextBag); + return; + } + if (activity is null) { return; @@ -28,6 +35,13 @@ public static void PropagateContextToHeaders(Activity? activity, Dictionary headers) { + // Removed in v11, see obsolete_v11.cs + if (!ObsoleteV11.UseDistributedContextPropagator) + { + ObsoleteV11.PropagateContextFromHeaders(activity, headers); + return; + } + if (activity is null) { return; diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs new file mode 100644 index 0000000000..c11086d570 --- /dev/null +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs @@ -0,0 +1,143 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Extensibility; +using Particular.Obsoletes; + +// ============================================================================= +// EVERYTHING IN THIS FILE IS TEMPORARY AND WILL BE REMOVED IN v11. +// +// In v10.3, switching to System.Diagnostics.DistributedContextPropagator for +// OpenTelemetry trace-context/baggage propagation changes the baggage wire +// format (W3C OWS encoding + whitespace trimming) and is therefore breaking on +// rolling upgrades. To stay backwards compatible it is opt-in via an AppContext +// switch and the legacy propagator below remains the default. +// +// In v11 the new propagator becomes the default: delete this entire file and +// remove the two `if (!ObsoleteV11.UseDistributedContextPropagator)` delegation +// blocks in ContextPropagation.cs. +// ============================================================================= +static class ObsoleteV11 +{ + enum SwitchState : byte + { + Unchecked = 0, + Enabled = 1, + Disabled = 2 + } + + static SwitchState cachedUseDistributedContextPropagator; + + // TODO: replace with the real tracking issue before merge. + [PreObsolete("https://github.com/Particular/NServiceBus/issues/0000", + Note = "In v11, DistributedContextPropagator-based context propagation becomes the default and this switch will be removed together with the legacy propagator in obsolete_v11.cs.", + ReplacementTypeOrMember = "ContextPropagation")] + public const string UseDistributedContextPropagatorSwitchName = "NServiceBus.Core.OpenTelemetry.UseDistributedContextPropagator"; + + // TODO: replace with the real tracking issue before merge. + [PreObsolete("https://github.com/Particular/NServiceBus/issues/0000", + Note = "In v11, DistributedContextPropagator-based context propagation becomes the default and this switch will be removed together with the legacy propagator in obsolete_v11.cs.", + ReplacementTypeOrMember = "ContextPropagation")] + public static bool UseDistributedContextPropagator + { + get + { + var state = cachedUseDistributedContextPropagator; + if (state != SwitchState.Unchecked) + { + return state == SwitchState.Enabled; + } + + state = AppContext.TryGetSwitch(UseDistributedContextPropagatorSwitchName, out var isEnabled) && isEnabled + ? SwitchState.Enabled + : SwitchState.Disabled; + cachedUseDistributedContextPropagator = state; + + return state == SwitchState.Enabled; + } + } + + internal static void ResetUseDistributedContextPropagator() => cachedUseDistributedContextPropagator = SwitchState.Unchecked; + + // ===== Legacy propagator (pre-10.3 behavior, default until v11) ========== + + public static void PropagateContextToHeaders(Activity? activity, Dictionary headers, ContextBag contextBag) + { + if (activity is null) + { + return; + } + + if (activity.Id is not null) + { + headers[Headers.DiagnosticsTraceParent] = activity.Id; + } + + if (activity.TraceStateString is not null) + { + headers[Headers.DiagnosticsTraceState] = activity.TraceStateString; + } + + // Check whether the startnewtrace setting was set in the context, if so, add it to the headers now the trace parent was added + if (contextBag.TryGet(Headers.StartNewTrace, out var headerContent)) + { + headers[Headers.StartNewTrace] = headerContent; + } + + var baggage = string.Join(",", activity.Baggage.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value ?? string.Empty)}")); + if (!string.IsNullOrEmpty(baggage)) + { + headers[Headers.DiagnosticsBaggage] = baggage; + } + } + + public static void PropagateContextFromHeaders(Activity? activity, IDictionary headers) + { + if (activity is null) + { + return; + } + + if (headers.TryGetValue(Headers.DiagnosticsTraceState, out var traceState)) + { + activity.TraceStateString = traceState; + } + + if (headers.TryGetValue(Headers.DiagnosticsBaggage, out var baggageValue)) + { + var baggageSpan = baggageValue.AsSpan(); + // HINT: Iterate in reverse order because Activity baggage is LIFO + while (!baggageSpan.IsEmpty) + { + var lastComma = baggageSpan.LastIndexOf(','); + ReadOnlySpan baggageItem; + + if (lastComma >= 0) + { + baggageItem = baggageSpan[(lastComma + 1)..]; + baggageSpan = baggageSpan[..lastComma]; + } + else + { + baggageItem = baggageSpan; + baggageSpan = []; + } + + var firstEquals = baggageItem.IndexOf('='); + if (firstEquals < 0 || firstEquals >= baggageItem.Length) + { + continue; + } + + var key = baggageItem[..firstEquals].Trim(); + var value = baggageItem[(firstEquals + 1)..]; + activity.AddBaggage(key.ToString(), Uri.UnescapeDataString(value)); + } + } + } +} From 7063661048fdd8a47ffbe0036dafb53d6cd7d298 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 17 Jun 2026 14:47:18 +0200 Subject: [PATCH 2/9] Refactor: Replace `ObsoleteV11` with `LegacyContextPropagation` --- .../ContextPropagationDefaultBehaviorTests.cs | 4 ++-- .../ContextPropagationIncompatibilityTests.cs | 10 +++++----- .../OpenTelemetry/ContextPropagationTests.cs | 8 ++++---- .../OpenTelemetry/Tracing/ContextPropagation.cs | 10 ++++++---- .../OpenTelemetry/Tracing/obsolete_v11.cs | 12 ++++-------- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs index 99ae440a49..46fd7d665d 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs @@ -14,8 +14,8 @@ public class ContextPropagationDefaultBehaviorTests [SetUp] public void EnsureDefault() { - AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, false); - ObsoleteV11.ResetUseDistributedContextPropagator(); + AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, false); + LegacyContextPropagation.ResetUseDistributedContextPropagator(); } [Test] diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs index 6b1790e10a..0e8aad2685 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs @@ -14,15 +14,15 @@ public class ContextPropagationIncompatibilityTests [SetUp] public void EnableDistributedContextPropagator() { - AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, true); - ObsoleteV11.ResetUseDistributedContextPropagator(); + AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, true); + LegacyContextPropagation.ResetUseDistributedContextPropagator(); } [TearDown] public void ResetDistributedContextPropagator() { - AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, false); - ObsoleteV11.ResetUseDistributedContextPropagator(); + AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, false); + LegacyContextPropagation.ResetUseDistributedContextPropagator(); } delegate void Writer(Activity activity, Dictionary headers, ContextBag context); @@ -39,7 +39,7 @@ public void ResetDistributedContextPropagator() // value isolates "what happens to special characters" from the separate edge-whitespace // issue covered by New_propagation_loses_leading_whitespace_in_a_value. // This already includes property-like syntax (the ';' and '=' delimiters), so a value such as - // "zone=eu;sensitive" is just a subset and needs no separate case here. + // "zone=eu;sensitive" is just a subset and needs no separate case here. const string AllSpecialCharacters = "a b,c;d=e&f'g\"h\\i(j)k{l}m[n]o%p/q?r:s@t~u|vx é ü 😀 z"; static Dictionary Send(string value, Writer write) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs index 91ce1d1304..e805ee7fab 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs @@ -15,15 +15,15 @@ public class ContextPropagationTests [SetUp] public void EnableDistributedContextPropagator() { - AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, true); - ObsoleteV11.ResetUseDistributedContextPropagator(); + AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, true); + LegacyContextPropagation.ResetUseDistributedContextPropagator(); } [TearDown] public void ResetDistributedContextPropagator() { - AppContext.SetSwitch(ObsoleteV11.UseDistributedContextPropagatorSwitchName, false); - ObsoleteV11.ResetUseDistributedContextPropagator(); + AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, false); + LegacyContextPropagation.ResetUseDistributedContextPropagator(); } [Test] diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs index 425eb93e6a..26327bbeeb 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs @@ -8,12 +8,14 @@ namespace NServiceBus; static class ContextPropagation { + static readonly bool UseLegacyContextPropagator = !LegacyContextPropagation.UseDistributedContextPropagator; + public static void PropagateContextToHeaders(Activity? activity, Dictionary headers, ContextBag contextBag) { // Removed in v11, see obsolete_v11.cs - if (!ObsoleteV11.UseDistributedContextPropagator) + if (UseLegacyContextPropagator) { - ObsoleteV11.PropagateContextToHeaders(activity, headers, contextBag); + LegacyContextPropagation.PropagateContextToHeaders(activity, headers, contextBag); return; } @@ -36,9 +38,9 @@ public static void PropagateContextToHeaders(Activity? activity, Dictionary headers) { // Removed in v11, see obsolete_v11.cs - if (!ObsoleteV11.UseDistributedContextPropagator) + if (UseLegacyContextPropagator) { - ObsoleteV11.PropagateContextFromHeaders(activity, headers); + LegacyContextPropagation.PropagateContextFromHeaders(activity, headers); return; } diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs index c11086d570..b1dec66368 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs @@ -22,7 +22,7 @@ namespace NServiceBus; // remove the two `if (!ObsoleteV11.UseDistributedContextPropagator)` delegation // blocks in ContextPropagation.cs. // ============================================================================= -static class ObsoleteV11 +static class LegacyContextPropagation { enum SwitchState : byte { @@ -33,14 +33,12 @@ enum SwitchState : byte static SwitchState cachedUseDistributedContextPropagator; - // TODO: replace with the real tracking issue before merge. - [PreObsolete("https://github.com/Particular/NServiceBus/issues/0000", + [PreObsolete("https://github.com/Particular/NServiceBus/issues/7825", Note = "In v11, DistributedContextPropagator-based context propagation becomes the default and this switch will be removed together with the legacy propagator in obsolete_v11.cs.", ReplacementTypeOrMember = "ContextPropagation")] public const string UseDistributedContextPropagatorSwitchName = "NServiceBus.Core.OpenTelemetry.UseDistributedContextPropagator"; - // TODO: replace with the real tracking issue before merge. - [PreObsolete("https://github.com/Particular/NServiceBus/issues/0000", + [PreObsolete("https://github.com/Particular/NServiceBus/issues/7825", Note = "In v11, DistributedContextPropagator-based context propagation becomes the default and this switch will be removed together with the legacy propagator in obsolete_v11.cs.", ReplacementTypeOrMember = "ContextPropagation")] public static bool UseDistributedContextPropagator @@ -64,8 +62,6 @@ public static bool UseDistributedContextPropagator internal static void ResetUseDistributedContextPropagator() => cachedUseDistributedContextPropagator = SwitchState.Unchecked; - // ===== Legacy propagator (pre-10.3 behavior, default until v11) ========== - public static void PropagateContextToHeaders(Activity? activity, Dictionary headers, ContextBag contextBag) { if (activity is null) @@ -140,4 +136,4 @@ public static void PropagateContextFromHeaders(Activity? activity, IDictionary Date: Thu, 18 Jun 2026 15:06:54 +0200 Subject: [PATCH 3/9] Add test to verify null baggage value does not throw in legacy propagator --- .../ContextPropagationDefaultBehaviorTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs index 46fd7d665d..fbe65df4b5 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs @@ -32,6 +32,23 @@ public void Default_uses_legacy_percent_encoded_baggage_format() Assert.That(headers[Headers.DiagnosticsBaggage], Is.EqualTo("serverNode=DF%2028")); } + [Test] + public void Default_does_not_throw_when_baggage_value_is_null() + { + // Reproduces https://github.com/Particular/NServiceBus/issues/6983 on the legacy propagator. + // A null baggage value must not make the legacy propagator call Uri.EscapeDataString(null). + // Calls LegacyContextPropagation directly so the assertion is independent of the AppContext switch. + using var activity = new Activity(ActivityNames.OutgoingMessageActivityName); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + activity.AddBaggage("test", null); + + var headers = new Dictionary(); + + Assert.DoesNotThrow(() => LegacyContextPropagation.PropagateContextToHeaders(activity, headers, new ContextBag())); + Assert.That(headers[Headers.DiagnosticsBaggage], Is.EqualTo("test=")); + } + [Test] public void Default_round_trip_preserves_value_whitespace() { From 910b295e8fd889b3a8b3c9b27511e2d8d5fb7bad Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 18 Jun 2026 15:07:24 +0200 Subject: [PATCH 4/9] Added comments about preserve legacy baggage handling behavior when escaping and trimming values --- .../OpenTelemetry/LegacyContextPropagator.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs index dc35002215..2ffbf85929 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs @@ -33,7 +33,10 @@ public static void PropagateContextToHeaders(Activity activity, Dictionary $"{item.Key}={Uri.EscapeDataString(item.Value ?? string.Empty)}")); + // System.Diagnostics.DistributedContextPropagator trims values before escaping + // For backwards compatibility we DO NOT replicate this behavior + + var baggage = string.Join(',', activity.Baggage.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value ?? string.Empty)}")); if (!string.IsNullOrEmpty(baggage)) { headers[Headers.DiagnosticsBaggage] = baggage; @@ -80,6 +83,10 @@ public static void PropagateContextFromHeaders(Activity? activity, IDictionary Date: Thu, 18 Jun 2026 15:08:03 +0200 Subject: [PATCH 5/9] Add test to verify that we are preserving whitespace in legacy propagator baggage values. --- ...> ContextPropagationCompatibilityTests.cs} | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) rename src/NServiceBus.Core.Tests/OpenTelemetry/{ContextPropagationIncompatibilityTests.cs => ContextPropagationCompatibilityTests.cs} (83%) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs similarity index 83% rename from src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs rename to src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs index 0e8aad2685..1a9f8449f0 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs @@ -7,7 +7,7 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; using NUnit.Framework; [TestFixture] -public class ContextPropagationIncompatibilityTests +public class ContextPropagationCompatibilityTests { // The "New*" delegates route through ContextPropagation, whose DistributedContextPropagator // path is opt-in until v11, so the switch must be enabled for these tests. @@ -98,4 +98,21 @@ public void New_propagation_loses_leading_whitespace_in_a_value() "new propagation strips the leading whitespace from the value"); } } -} + + [TestCase(null, "", "")] + [TestCase("", "", "")] + [TestCase(" ", "", " ")] + [TestCase(" x ", "x", " x ")] + [TestCase(" x x ", "x x", " x x ")] + public void ValidateThatLegacyPropagatorPreservesLeadingAndTrailingWhitespaceInBaggageValues(string input, string expectedNew, string expectedLegacy) + { + var outputNew = Transmit(input, NewWrite, NewRead); + var outputLegacy = Transmit(input, LegacyWrite, LegacyRead); + + using (Assert.EnterMultipleScope()) + { + Assert.That(expectedNew, Is.EqualTo(outputNew), "Native propagator isn't trimming all leading and trailing whitespaces"); + Assert.That(expectedLegacy, Is.EqualTo(outputLegacy), "Legacy propagator isn't preserving leading and trailing whitespace for backwards compatibility"); + } + } +} \ No newline at end of file From 17dc6d4fb64e89138ac2d017b7165853bd5080e9 Mon Sep 17 00:00:00 2001 From: Tomasz Masternak Date: Fri, 19 Jun 2026 13:38:44 +0200 Subject: [PATCH 6/9] add comments to clarify intent behind legacy propagator handling --- .../OpenTelemetry/Tracing/ContextPropagation.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs index 26327bbeeb..ae4b0d5c8b 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs @@ -19,6 +19,8 @@ public static void PropagateContextToHeaders(Activity? activity, Dictionary Date: Fri, 19 Jun 2026 13:38:54 +0200 Subject: [PATCH 7/9] update ContextPropagationCompatibilityTests to use correct LegacyContextPropagation delegates --- .../OpenTelemetry/ContextPropagationCompatibilityTests.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs index 1a9f8449f0..7f4332e0cd 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs @@ -9,8 +9,6 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; [TestFixture] public class ContextPropagationCompatibilityTests { - // The "New*" delegates route through ContextPropagation, whose DistributedContextPropagator - // path is opt-in until v11, so the switch must be enabled for these tests. [SetUp] public void EnableDistributedContextPropagator() { @@ -28,8 +26,8 @@ public void ResetDistributedContextPropagator() delegate void Writer(Activity activity, Dictionary headers, ContextBag context); delegate void Reader(Activity activity, IDictionary headers); - static readonly Writer LegacyWrite = LegacyContextPropagator.PropagateContextToHeaders; - static readonly Reader LegacyRead = LegacyContextPropagator.PropagateContextFromHeaders; + static readonly Writer LegacyWrite = LegacyContextPropagation.PropagateContextToHeaders; + static readonly Reader LegacyRead = LegacyContextPropagation.PropagateContextFromHeaders; static readonly Writer NewWrite = ContextPropagation.PropagateContextToHeaders; static readonly Reader NewRead = ContextPropagation.PropagateContextFromHeaders; From f144b261dda4024c2bc4577d9d506f273d1f8ed2 Mon Sep 17 00:00:00 2001 From: Tomasz Masternak Date: Fri, 19 Jun 2026 13:39:02 +0200 Subject: [PATCH 8/9] rename ContextPropagationTests to LegacyContextPropagationTests and remove outdated baggage handling tests --- ...ts.cs => LegacyContextPropagationTests.cs} | 62 ++---------- .../OpenTelemetry/LegacyContextPropagator.cs | 94 ------------------- 2 files changed, 7 insertions(+), 149 deletions(-) rename src/NServiceBus.Core.Tests/OpenTelemetry/{ContextPropagationTests.cs => LegacyContextPropagationTests.cs} (79%) delete mode 100644 src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs similarity index 79% rename from src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs rename to src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs index e805ee7fab..4c62f70cd7 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs @@ -9,23 +9,8 @@ using NUnit.Framework; [TestFixture] -public class ContextPropagationTests +public class LegacyContextPropagationTests { - // These tests assert the W3C DistributedContextPropagator wire format, which is opt-in until v11. - [SetUp] - public void EnableDistributedContextPropagator() - { - AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, true); - LegacyContextPropagation.ResetUseDistributedContextPropagator(); - } - - [TearDown] - public void ResetDistributedContextPropagator() - { - AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, false); - LegacyContextPropagation.ResetUseDistributedContextPropagator(); - } - [Test] public void Propagate_activity_id_to_header() { @@ -209,39 +194,6 @@ public void Can_roundtrip_baggage(ContextPropagationTestCase testCase) } } - [Test] - public void Can_not_roundtrip_baggage_value_with_optional_whitespaces() - { - var outgoingHeaders = new Dictionary(); - using var outgoingActivity = new Activity(ActivityNames.OutgoingMessageActivityName); - outgoingActivity.SetIdFormat(ActivityIdFormat.W3C); - outgoingActivity.Start(); - - outgoingActivity.AddBaggage("key1", " value1"); - outgoingActivity.AddBaggage("key2", "value2 "); - - ContextPropagation.PropagateContextToHeaders(outgoingActivity, outgoingHeaders, new ContextBag()); - - // Simulate wire transfer - var incomingHeaders = outgoingHeaders; - using var incomingActivity = new Activity(ActivityNames.IncomingMessageActivityName); - incomingActivity.SetIdFormat(ActivityIdFormat.W3C); - incomingActivity.Start(); - - ContextPropagation.PropagateContextFromHeaders(incomingActivity, incomingHeaders); - - using (Assert.EnterMultipleScope()) - { - foreach (var baggageItem in outgoingActivity.Baggage) - { - var key = baggageItem.Key; - var actualValue = incomingActivity.GetBaggageItem(key); - Assert.That(actualValue, Is.Not.Null, $"Baggage is missing item with key |{key}|"); - Assert.That(actualValue, Is.EqualTo(baggageItem.Value.Trim()), $"Baggage item |{key}| has the wrong value"); - } - } - } - // HINT: Many of these test cases are given as examples in the spec https://www.w3.org/TR/baggage/#example static IEnumerable TestCases => new object[] { @@ -249,28 +201,28 @@ public void Can_not_roundtrip_baggage_value_with_optional_whitespaces() new ContextPropagationTestCase("with a single key") .WithBaggage("key1", "value1") - .WithHeaderValue("key1 = value1"), + .WithHeaderValue("key1=value1"), new ContextPropagationTestCase("with multiple keys") .WithBaggage("key1", "value1") .WithBaggage("key2", "value2") - .WithHeaderValue("key1 = value1, key2 = value2"), + .WithHeaderValue("key1=value1,key2=value2"), new ContextPropagationTestCase("with properties that do not have keys") .WithBaggage("key1", "value1;property1;property2") - .WithHeaderValue("key1 = value1%3Bproperty1%3Bproperty2"), + .WithHeaderValue("key1=value1%3Bproperty1%3Bproperty2"), new ContextPropagationTestCase("with properties that have keys") .WithBaggage("key3", "value3; propertyKey=propertyValue") - .WithHeaderValue("key3 = value3%3B%20propertyKey=propertyValue"), + .WithHeaderValue("key3=value3%3B%20propertyKey%3DpropertyValue"), new ContextPropagationTestCase("with values containing whitespace") .WithBaggage("serverNode", "DF 28") - .WithHeaderValue("serverNode = DF%2028"), + .WithHeaderValue("serverNode=DF%2028"), new ContextPropagationTestCase("with values containing unicode") .WithBaggage("userId", "Amélie") - .WithHeaderValue("userId = Am%C3%A9lie") + .WithHeaderValue("userId=Am%C3%A9lie") }; public class ContextPropagationTestCase(string caseName) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs deleted file mode 100644 index 2ffbf85929..0000000000 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs +++ /dev/null @@ -1,94 +0,0 @@ -#nullable enable - -namespace NServiceBus.Core.Tests.OpenTelemetry; - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using Extensibility; - -static class LegacyContextPropagator -{ - public static void PropagateContextToHeaders(Activity activity, Dictionary headers, ContextBag contextBag) - { - if (activity is null) - { - return; - } - - if (activity.Id is not null) - { - headers[Headers.DiagnosticsTraceParent] = activity.Id; - } - - if (activity.TraceStateString is not null) - { - headers[Headers.DiagnosticsTraceState] = activity.TraceStateString; - } - - // Check whether the startnewtrace setting was set in the context, if so, add it to the headers now the trace parent was added - if (contextBag.TryGet(Headers.StartNewTrace, out var headerContent)) - { - headers[Headers.StartNewTrace] = headerContent; - } - - // System.Diagnostics.DistributedContextPropagator trims values before escaping - // For backwards compatibility we DO NOT replicate this behavior - - var baggage = string.Join(',', activity.Baggage.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value ?? string.Empty)}")); - if (!string.IsNullOrEmpty(baggage)) - { - headers[Headers.DiagnosticsBaggage] = baggage; - } - } - - public static void PropagateContextFromHeaders(Activity? activity, IDictionary headers) - { - if (activity is null) - { - return; - } - - if (headers.TryGetValue(Headers.DiagnosticsTraceState, out var traceState)) - { - activity.TraceStateString = traceState; - } - - if (headers.TryGetValue(Headers.DiagnosticsBaggage, out var baggageValue)) - { - var baggageSpan = baggageValue.AsSpan(); - // HINT: Iterate in reverse order because Activity baggage is LIFO - while (!baggageSpan.IsEmpty) - { - var lastComma = baggageSpan.LastIndexOf(','); - ReadOnlySpan baggageItem; - - if (lastComma >= 0) - { - baggageItem = baggageSpan[(lastComma + 1)..]; - baggageSpan = baggageSpan[..lastComma]; - } - else - { - baggageItem = baggageSpan; - baggageSpan = []; - } - - var firstEquals = baggageItem.IndexOf('='); - if (firstEquals < 0 || firstEquals >= baggageItem.Length) - { - continue; - } - - var key = baggageItem[..firstEquals].Trim(); - var value = baggageItem[(firstEquals + 1)..]; - - // System.Diagnostics.DistributedContextPropagator will convert whitespace only values to empty string - // For backwards compatibility we DO NOT replicate this behavior. - - activity.AddBaggage(key.ToString(), Uri.UnescapeDataString(value)); - } - } - } -} \ No newline at end of file From f2b6066034382a1fcca530b7c3647449635b6a79 Mon Sep 17 00:00:00 2001 From: Tomasz Masternak Date: Fri, 19 Jun 2026 13:49:24 +0200 Subject: [PATCH 9/9] allow changing the propagator implementation at runtime --- .../OpenTelemetry/Tracing/ContextPropagation.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs index ae4b0d5c8b..8df1475201 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs @@ -8,12 +8,11 @@ namespace NServiceBus; static class ContextPropagation { - static readonly bool UseLegacyContextPropagator = !LegacyContextPropagation.UseDistributedContextPropagator; - public static void PropagateContextToHeaders(Activity? activity, Dictionary headers, ContextBag contextBag) { + // TODO: investigate if we need to improve the switch check for better performance // Removed in v11, see obsolete_v11.cs - if (UseLegacyContextPropagator) + if (!LegacyContextPropagation.UseDistributedContextPropagator) { LegacyContextPropagation.PropagateContextToHeaders(activity, headers, contextBag); return; @@ -40,7 +39,7 @@ public static void PropagateContextToHeaders(Activity? activity, Dictionary headers) { // Removed in v11, see obsolete_v11.cs - if (UseLegacyContextPropagator) + if (!LegacyContextPropagation.UseDistributedContextPropagator) { LegacyContextPropagation.PropagateContextFromHeaders(activity, headers); return;