From fd48fac2cd61c410795627b785f356b5b52414bb Mon Sep 17 00:00:00 2001 From: Irina Dominte Date: Tue, 2 Jun 2026 16:08:34 +0300 Subject: [PATCH 1/8] Starting working branch for Otel --- .../When_processing_incoming_message.cs | 32 ++++++++++ .../Traces/When_publishing_messages.cs | 63 +++++++++++++++++++ .../Traces/When_sending_messages.cs | 32 ++++++++++ .../Traces/When_sending_replies.cs | 34 ++++++++++ ...IApprovals.ApproveNServiceBus.approved.txt | 14 +++++ .../OpenTelemetry/ActivityFactoryTests.cs | 4 +- .../Pipeline/MainPipelineExecutorTests.cs | 2 +- .../Pipeline/TestableMessageOperations.cs | 2 +- .../RoutingToDispatchConnectorTests.cs | 20 +++--- .../Hosting/HostingComponent.Configuration.cs | 2 +- .../Hosting/HostingComponent.Settings.cs | 2 + .../OpenTelemetry/InstrumentationOptions.cs | 42 +++++++++++++ .../OpenTelemetry/OpenTelemetryExtensions.cs | 13 ++++ .../OpenTelemetry/OpenTelemetryFeature.cs | 15 +++++ .../PromoteMessagePropertiesToTagsBehavior.cs | 51 +++++++++++++++ .../Tracing/ActivityDisplayNames.cs | 5 ++ .../OpenTelemetry/Tracing/ActivityFactory.cs | 11 +++- .../OpenTelemetry/Tracing/IActivityFactory.cs | 1 + .../Tracing/NoOpActivityFactory.cs | 2 + .../Outgoing/RoutingToDispatchConnector.cs | 18 ++++++ .../Pipeline/Outgoing/SendComponent.cs | 2 +- .../Unicast/MessageOperations.cs | 6 +- 22 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 src/NServiceBus.Core/OpenTelemetry/InstrumentationOptions.cs create mode 100644 src/NServiceBus.Core/OpenTelemetry/PromoteMessagePropertiesToTagsBehavior.cs diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_incoming_message.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_incoming_message.cs index 99fecbaf11c..d386a9906d2 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_incoming_message.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_incoming_message.cs @@ -80,5 +80,37 @@ public Task Handle(IncomingMessage message, IMessageHandlerContext context) } } + [Test] + public async Task Should_use_receive_address_in_span_name_when_opted_in() + { + await Scenario.Define() + .WithEndpoint(e => e + .When(s => s.SendLocal(new IncomingMessage()))) + .Run(); + + var incomingMessageActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities(); + Assert.That(incomingMessageActivities, Has.Count.EqualTo(1)); + + var incomingActivity = incomingMessageActivities.Single(); + Assert.That(incomingActivity.DisplayName, Does.StartWith("process ")); + Assert.That(incomingActivity.DisplayName, Is.Not.EqualTo("process message")); + } + + public class ReceivingEndpointWithDestinationNaming : EndpointConfigurationBuilder + { + public ReceivingEndpointWithDestinationNaming() => + EndpointSetup(b => b.Tracing().UseMessageDestinationInSpanNames = true); + + [Handler] + public class MessageHandler(Context testContext) : IHandleMessages + { + public Task Handle(IncomingMessage message, IMessageHandlerContext context) + { + testContext.MarkAsCompleted(); + return Task.CompletedTask; + } + } + } + public class IncomingMessage : IMessage; } \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_publishing_messages.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_publishing_messages.cs index 8c402afb504..0595803fbef 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_publishing_messages.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_publishing_messages.cs @@ -179,5 +179,68 @@ public Task Handle(ThisIsAnEvent @event, IMessageHandlerContext context) } } + [Test] + public async Task Should_use_event_type_in_span_name_when_opted_in() + { + await Scenario.Define() + .WithEndpoint(b => b + .When(ctx => ctx.SomeEventSubscribed, s => s.Publish())) + .WithEndpoint(b => b.When((session, ctx) => + { + if (ctx.HasNativePubSubSupport) + { + ctx.SomeEventSubscribed = true; + } + + return Task.CompletedTask; + })) + .Run(); + + var outgoingEventActivities = NServiceBusActivityListener.CompletedActivities.GetPublishEventActivities(); + Assert.That(outgoingEventActivities, Has.Count.EqualTo(1)); + + var publishedMessage = outgoingEventActivities.Single(); + Assert.That(publishedMessage.DisplayName, Is.EqualTo("publish ThisIsAnEvent")); + } + + class PublisherWithDestinationNaming : EndpointConfigurationBuilder + { + public PublisherWithDestinationNaming() => + EndpointSetup(b => + { + b.Tracing().UseMessageDestinationInSpanNames = true; + b.OnEndpointSubscribed((s, context) => + { + if (s.SubscriberEndpoint.Contains(Conventions.EndpointNamingConvention(typeof(SubscriberForPublisherWithDestinationNaming)))) + { + if (s.MessageType == typeof(ThisIsAnEvent).AssemblyQualifiedName) + { + context.SomeEventSubscribed = true; + } + } + }); + }); + } + + class SubscriberForPublisherWithDestinationNaming : EndpointConfigurationBuilder + { + public SubscriberForPublisherWithDestinationNaming() => + EndpointSetup(c => { }, + metadata => + { + metadata.RegisterPublisherFor(); + }); + + [Handler] + public class ThisHandlesSomethingHandler(Context testContext) : IHandleMessages + { + public Task Handle(ThisIsAnEvent @event, IMessageHandlerContext context) + { + testContext.MarkAsCompleted(); + return Task.CompletedTask; + } + } + } + public class ThisIsAnEvent : IEvent; } \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_messages.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_messages.cs index af0751530d7..e14df1d05e6 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_messages.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_messages.cs @@ -131,5 +131,37 @@ public Task Handle(OutgoingMessage message, IMessageHandlerContext context) } } + [Test] + public async Task Should_use_destination_in_send_span_name_when_opted_in() + { + await Scenario.Define() + .WithEndpoint(b => b + .When(s => s.SendLocal(new OutgoingMessage()))) + .Run(); + + var outgoingMessageActivities = NServiceBusActivityListener.CompletedActivities.GetSendMessageActivities(); + Assert.That(outgoingMessageActivities, Has.Count.EqualTo(1)); + + var sentMessage = outgoingMessageActivities.Single(); + Assert.That(sentMessage.DisplayName, Does.StartWith("send ")); + Assert.That(sentMessage.DisplayName, Is.Not.EqualTo("send message")); + } + + public class TestEndpointWithDestinationNaming : EndpointConfigurationBuilder + { + public TestEndpointWithDestinationNaming() => + EndpointSetup(b => b.Tracing().UseMessageDestinationInSpanNames = true); + + [Handler] + public class MessageHandler(Context testContext) : IHandleMessages + { + public Task Handle(OutgoingMessage message, IMessageHandlerContext context) + { + testContext.MarkAsCompleted(); + return Task.CompletedTask; + } + } + } + public class OutgoingMessage : IMessage; } \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_replies.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_replies.cs index e6299389c51..c6c505d9236 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_replies.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_replies.cs @@ -55,6 +55,40 @@ public Task Handle(OutgoingReply message, IMessageHandlerContext context) } } + [Test] + public async Task Should_use_destination_in_reply_span_name_when_opted_in() + { + await Scenario.Define() + .WithEndpoint(b => b + .When(s => s.SendLocal(new IncomingMessage()))) + .Run(); + + var outgoingMessageActivities = NServiceBusActivityListener.CompletedActivities.GetSendMessageActivities(); + Assert.That(outgoingMessageActivities, Has.Count.EqualTo(2), "2 messages are being sent"); + var replyMessage = outgoingMessageActivities[1]; + + Assert.That(replyMessage.DisplayName, Does.StartWith("reply ")); + } + + public class TestEndpointWithDestinationNaming : EndpointConfigurationBuilder + { + public TestEndpointWithDestinationNaming() => + EndpointSetup(b => b.Tracing().UseMessageDestinationInSpanNames = true); + + [Handler] + public class MessageHandler(Context testContext) : IHandleMessages, + IHandleMessages + { + public Task Handle(IncomingMessage message, IMessageHandlerContext context) => context.Reply(new OutgoingReply()); + + public Task Handle(OutgoingReply message, IMessageHandlerContext context) + { + testContext.MarkAsCompleted(); + return Task.CompletedTask; + } + } + } + public class IncomingMessage : IMessage; public class OutgoingReply : IMessage; diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt index acbacb5d5ae..e5b50f8da1b 100644 --- a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt +++ b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt @@ -632,6 +632,12 @@ namespace NServiceBus StopApplication = 0, Continue = 1, } + public class InstrumentationOptions + { + public InstrumentationOptions() { } + public NServiceBus.MessagePayloadAsTag MessagePayloadAsTag { get; set; } + public bool UseMessageDestinationInSpanNames { get; set; } + } public sealed class KeyedServiceKey { public const string Any = "______________"; @@ -718,6 +724,13 @@ namespace NServiceBus Unsubscribe = 4, Reply = 5, } + public enum MessagePayloadAsTag + { + None = 0, + IncomingMessage = 1, + OutgoingMessage = 2, + All = 3, + } public static class MessageProcessingContextExtensions { public static System.Threading.Tasks.Task Reply(this NServiceBus.IMessageProcessingContext context, object message) { } @@ -772,6 +785,7 @@ namespace NServiceBus { public static void ContinueExistingTraceOnReceive(this NServiceBus.PublishOptions publishOptions) { } public static void StartNewTraceOnReceive(this NServiceBus.SendOptions sendOptions) { } + public static NServiceBus.InstrumentationOptions Tracing(this NServiceBus.EndpointConfiguration config) { } } public static class OutboxConfigExtensions { diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityFactoryTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityFactoryTests.cs index 6c22b89bb7b..500740cc6ba 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityFactoryTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityFactoryTests.cs @@ -17,7 +17,7 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; [TestFixture] public class ActivityFactoryTests { - readonly ActivityFactory activityFactory = new(); + readonly ActivityFactory activityFactory = new(new InstrumentationOptions()); TestingActivityListener nsbActivityListener; @@ -29,7 +29,7 @@ public class ActivityFactoryTests class NoDiagnosticListeners { - readonly ActivityFactory activityFactory = new(); + readonly ActivityFactory activityFactory = new(new InstrumentationOptions()); [Test] public void Should_return_null_incoming_activity_when_no_listeners() diff --git a/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs b/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs index f6223a25eac..ee6b76d9e3b 100644 --- a/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs +++ b/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs @@ -130,7 +130,7 @@ static MainPipelineExecutor CreateMainPipelineExecutor(ServiceProvider servicePr new TestableMessageOperations(), new Notification(), receivePipeline, - new ActivityFactory(), + new ActivityFactory(new InstrumentationOptions()), incomingPipelineMetrics, new EnvelopeUnwrapper([], incomingPipelineMetrics)); diff --git a/src/NServiceBus.Core.Tests/Pipeline/TestableMessageOperations.cs b/src/NServiceBus.Core.Tests/Pipeline/TestableMessageOperations.cs index ddb58d3a66e..909b9fb796a 100644 --- a/src/NServiceBus.Core.Tests/Pipeline/TestableMessageOperations.cs +++ b/src/NServiceBus.Core.Tests/Pipeline/TestableMessageOperations.cs @@ -13,7 +13,7 @@ class TestableMessageOperations : MessageOperations public Pipeline SubscribePipeline => (Pipeline)subscribePipeline; public Pipeline UnsubscribePipeline => (Pipeline)unsubscribePipeline; - public TestableMessageOperations() : base(new MessageMapper(), new Pipeline(), new Pipeline(), new Pipeline(), new Pipeline(), new Pipeline(), new ActivityFactory()) + public TestableMessageOperations() : base(new MessageMapper(), new Pipeline(), new Pipeline(), new Pipeline(), new Pipeline(), new Pipeline(), new ActivityFactory(new InstrumentationOptions())) { } diff --git a/src/NServiceBus.Core.Tests/Routing/RoutingToDispatchConnectorTests.cs b/src/NServiceBus.Core.Tests/Routing/RoutingToDispatchConnectorTests.cs index ad4d34e8037..da97e86e33f 100644 --- a/src/NServiceBus.Core.Tests/Routing/RoutingToDispatchConnectorTests.cs +++ b/src/NServiceBus.Core.Tests/Routing/RoutingToDispatchConnectorTests.cs @@ -17,7 +17,7 @@ public class RoutingToDispatchConnectorTests [Test] public async Task Should_preserve_message_state_for_one_routing_strategy_for_allocation_reasons() { - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); IEnumerable operations = null; var testableRoutingContext = new TestableRoutingContext { @@ -59,7 +59,7 @@ await behavior.Invoke(testableRoutingContext, context => [Test] public async Task Should_copy_message_state_for_multiple_routing_strategies() { - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); List operations = null; var testableRoutingContext = new TestableRoutingContext { @@ -135,7 +135,7 @@ await behavior.Invoke(testableRoutingContext, context => [Test] public async Task Should_preserve_headers_generated_by_custom_routing_strategy() { - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); Dictionary headers = null; await behavior.Invoke(new TestableRoutingContext { RoutingStrategies = [new HeaderModifyingRoutingStrategy()] }, context => { @@ -153,7 +153,7 @@ public async Task Should_dispatch_immediately_if_user_requested() options.RequireImmediateDispatch(); var dispatched = false; - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); var message = new OutgoingMessage("ID", [], Array.Empty()); await behavior.Invoke(new RoutingContext(message, @@ -170,7 +170,7 @@ await behavior.Invoke(new RoutingContext(message, public async Task Should_dispatch_immediately_if_not_sending_from_a_handler() { var dispatched = false; - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); var message = new OutgoingMessage("ID", [], Array.Empty()); await behavior.Invoke(new RoutingContext(message, @@ -187,7 +187,7 @@ await behavior.Invoke(new RoutingContext(message, public async Task Should_not_dispatch_by_default() { var dispatched = false; - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); var message = new OutgoingMessage("ID", [], Array.Empty()); await behavior.Invoke(new RoutingContext(message, @@ -203,7 +203,7 @@ await behavior.Invoke(new RoutingContext(message, [Test] public async Task Should_promote_message_headers_to_pipeline_activity() { - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); var routingContext = new TestableRoutingContext(); routingContext.Message.Headers[Headers.ContentType] = "test content type"; // one of the headers that will be mapped to tags @@ -257,7 +257,7 @@ class MyMessage : IMessage; [Test] public async Task Should_merge_receive_properties_when_declared_by_transport() { - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); var receiveProperties = new ReceiveProperties(new Dictionary { @@ -290,7 +290,7 @@ await behavior.Invoke(routingContext, context => [Test] public async Task Should_not_override_user_set_dispatch_property() { - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); var receiveProperties = new ReceiveProperties(new Dictionary { @@ -324,7 +324,7 @@ await behavior.Invoke(routingContext, context => [Test] public async Task Should_preserve_user_dispatch_properties_even_with_receive_properties() { - var behavior = new RoutingToDispatchConnector(); + var behavior = new RoutingToDispatchConnector(new NoOpActivityFactory()); var receiveProperties = new ReceiveProperties(new Dictionary { diff --git a/src/NServiceBus.Core/Hosting/HostingComponent.Configuration.cs b/src/NServiceBus.Core/Hosting/HostingComponent.Configuration.cs index 4ed38527c71..6326b1a5006 100644 --- a/src/NServiceBus.Core/Hosting/HostingComponent.Configuration.cs +++ b/src/NServiceBus.Core/Hosting/HostingComponent.Configuration.cs @@ -26,7 +26,7 @@ public static Configuration PrepareConfiguration(Settings settings, List a serviceCollection, settings.ShouldRunInstallers, settings.UserRegistrations, - new ActivityFactory(), + new ActivityFactory(settings.InstrumentationOptions), persistenceConfiguration, installerComponent); diff --git a/src/NServiceBus.Core/Hosting/HostingComponent.Settings.cs b/src/NServiceBus.Core/Hosting/HostingComponent.Settings.cs index f46a6101f05..8e85864c2be 100644 --- a/src/NServiceBus.Core/Hosting/HostingComponent.Settings.cs +++ b/src/NServiceBus.Core/Hosting/HostingComponent.Settings.cs @@ -95,6 +95,8 @@ public bool WriteDiagnosticsToLog get; set; } + public InstrumentationOptions InstrumentationOptions => settings.GetOrDefault() ?? new InstrumentationOptions(); + internal void ConfigureHostLogging(object? endpointIdentifier) { EndpointIdentifier = endpointIdentifier; diff --git a/src/NServiceBus.Core/OpenTelemetry/InstrumentationOptions.cs b/src/NServiceBus.Core/OpenTelemetry/InstrumentationOptions.cs new file mode 100644 index 00000000000..aeb480769e1 --- /dev/null +++ b/src/NServiceBus.Core/OpenTelemetry/InstrumentationOptions.cs @@ -0,0 +1,42 @@ +#nullable enable + +namespace NServiceBus; + +/// +/// Controls opt-in OpenTelemetry instrumentation behaviors. +/// Accessed via endpointConfiguration.Tracing(). +/// +public class InstrumentationOptions +{ + /// + /// Appends the destination to span names following the OTel messaging convention + /// {messaging.operation.name} {destination}, e.g. "process orders" or "send payments". + /// Disabled by default for backward compatibility. + /// + public bool UseMessageDestinationInSpanNames { get; set; } + + /// + /// Promotes public properties of message instances to span attributes + /// as nservicebus.message.{PropertyName}. + /// Defaults to . May expose sensitive data and incurs reflection cost. + /// + public MessagePayloadAsTag MessagePayloadAsTag { get; set; } +} + +/// +/// Controls which message payloads are promoted to span attributes. +/// +public enum MessagePayloadAsTag +{ + /// No message properties are promoted to span tags. + None, + + /// Public properties of the incoming message instance are promoted to span tags. + IncomingMessage, + + /// Public properties of outgoing message instances are promoted to span tags. + OutgoingMessage, + + /// Public properties of both incoming and outgoing message instances are promoted to span tags. + All +} diff --git a/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryExtensions.cs b/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryExtensions.cs index bc263ffe423..9c3ad6e63e8 100644 --- a/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryExtensions.cs +++ b/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryExtensions.cs @@ -2,11 +2,24 @@ namespace NServiceBus; +using System; + /// /// Gives users control over the depth of an OpenTelemetry trace. /// public static class OpenTelemetryExtensions { + /// + /// Provides access to instrumentation options for OpenTelemetry tracing. + /// + /// The endpoint configuration. + /// The instance for this endpoint. + public static InstrumentationOptions Tracing(this EndpointConfiguration config) + { + ArgumentNullException.ThrowIfNull(config); + return config.Settings.GetOrCreate(); + } + /// /// Start a new OpenTelemetry trace conversation. /// diff --git a/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryFeature.cs b/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryFeature.cs index c485129eee0..e4804d76a0c 100644 --- a/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryFeature.cs +++ b/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryFeature.cs @@ -22,5 +22,20 @@ protected override void Setup(FeatureConfigurationContext context) new PopulateRecoverabilityTraceMetadataBehavior(), "Populates the recoverability metadata" ); + + var options = context.Settings.GetOrDefault(); + if (options?.MessagePayloadAsTag is MessagePayloadAsTag.IncomingMessage or MessagePayloadAsTag.All) + { + context.Pipeline.Register( + new IncomingMessagePayloadToTagsBehavior(), + "Promotes incoming message properties to span tags"); + } + + if (options?.MessagePayloadAsTag is MessagePayloadAsTag.OutgoingMessage or MessagePayloadAsTag.All) + { + context.Pipeline.Register( + new OutgoingMessagePayloadToTagsBehavior(), + "Promotes outgoing message properties to span tags"); + } } } \ No newline at end of file diff --git a/src/NServiceBus.Core/OpenTelemetry/PromoteMessagePropertiesToTagsBehavior.cs b/src/NServiceBus.Core/OpenTelemetry/PromoteMessagePropertiesToTagsBehavior.cs new file mode 100644 index 00000000000..4435190901a --- /dev/null +++ b/src/NServiceBus.Core/OpenTelemetry/PromoteMessagePropertiesToTagsBehavior.cs @@ -0,0 +1,51 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Diagnostics; +using System.Reflection; +using System.Threading.Tasks; +using Pipeline; + +class IncomingMessagePayloadToTagsBehavior : IBehavior +{ + public Task Invoke(IIncomingLogicalMessageContext context, Func next) + { + var activity = Activity.Current; + if (activity?.IsAllDataRequested == true) + { + PromoteProperties(activity, context.Message.Instance); + } + + return next(context); + } + + // this needs to be changed to be base64 encoding of the entire body not just the properties + + internal static void PromoteProperties(Activity activity, object instance) + { + foreach (var property in instance.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var value = property.GetValue(instance); + if (value is not null) + { + activity.SetTag($"nservicebus.message.{property.Name}", value.ToString()); + } + } + } +} + +class OutgoingMessagePayloadToTagsBehavior : IBehavior +{ + public Task Invoke(IOutgoingLogicalMessageContext context, Func next) + { + var activity = Activity.Current; + if (activity?.IsAllDataRequested == true) + { + IncomingMessagePayloadToTagsBehavior.PromoteProperties(activity, context.Message.Instance); + } + + return next(context); + } +} diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityDisplayNames.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityDisplayNames.cs index ca6d81aff76..cf56402f4dd 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityDisplayNames.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityDisplayNames.cs @@ -10,4 +10,9 @@ static class ActivityDisplayNames public const string UnsubscribeEvent = "unsubscribe event"; public const string SendMessage = "send message"; public const string ReplyMessage = "reply"; + + // Operation-only prefixes used when UseMessageDestinationInSpanNames is enabled + internal const string ProcessOperation = "process"; + internal const string PublishOperation = "publish"; + internal const string SendOperation = "send"; } \ No newline at end of file diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityFactory.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityFactory.cs index 6c0202a51d7..b6548eef2f0 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityFactory.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityFactory.cs @@ -8,6 +8,13 @@ namespace NServiceBus; sealed class ActivityFactory : IActivityFactory { + public ActivityFactory(InstrumentationOptions options) + { + Options = options; + } + + public InstrumentationOptions Options { get; } + public Activity? StartIncomingPipelineActivity(MessageContext context) { // CreateActivity is a no-op if there are no listeners but we are doing a fast path check @@ -66,7 +73,9 @@ sealed class ActivityFactory : IActivityFactory ContextPropagation.PropagateContextFromHeaders(activity, context.Headers); - activity.DisplayName = ActivityDisplayNames.ProcessMessage; + activity.DisplayName = Options.UseMessageDestinationInSpanNames + ? $"{ActivityDisplayNames.ProcessOperation} {context.ReceiveAddress}" + : ActivityDisplayNames.ProcessMessage; activity.SetIdFormat(ActivityIdFormat.W3C); activity.AddTag(ActivityTags.NativeMessageId, context.NativeMessageId); diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/IActivityFactory.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/IActivityFactory.cs index 8a47acb7fdd..daa6553a2eb 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/IActivityFactory.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/IActivityFactory.cs @@ -8,6 +8,7 @@ namespace NServiceBus; interface IActivityFactory { + InstrumentationOptions Options { get; } Activity? StartIncomingPipelineActivity(MessageContext context); Activity? StartOutgoingPipelineActivity(string activityName, string displayName, IBehaviorContext outgoingContext); Activity? StartHandlerActivity(MessageHandler messageHandler); diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/NoOpActivityFactory.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/NoOpActivityFactory.cs index a36d268a3bd..fe1f2f82c12 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/NoOpActivityFactory.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/NoOpActivityFactory.cs @@ -8,6 +8,8 @@ namespace NServiceBus; sealed class NoOpActivityFactory : IActivityFactory { + public InstrumentationOptions Options { get; } = new InstrumentationOptions(); + public Activity? StartIncomingPipelineActivity(MessageContext context) => null; public Activity? StartOutgoingPipelineActivity(string activityName, string displayName, IBehaviorContext outgoingContext) => null; diff --git a/src/NServiceBus.Core/Pipeline/Outgoing/RoutingToDispatchConnector.cs b/src/NServiceBus.Core/Pipeline/Outgoing/RoutingToDispatchConnector.cs index bc4be17db8b..55f62023b4b 100644 --- a/src/NServiceBus.Core/Pipeline/Outgoing/RoutingToDispatchConnector.cs +++ b/src/NServiceBus.Core/Pipeline/Outgoing/RoutingToDispatchConnector.cs @@ -14,6 +14,13 @@ namespace NServiceBus; class RoutingToDispatchConnector : StageConnector { + readonly IActivityFactory activityFactory; + + public RoutingToDispatchConnector(IActivityFactory activityFactory) + { + this.activityFactory = activityFactory; + } + public override Task Invoke(IRoutingContext context, Func stage) { var dispatchConsistency = DispatchConsistency.Default; @@ -60,6 +67,17 @@ public override Task Invoke(IRoutingContext context, Func 0 + && operations[0].AddressTag is UnicastAddressTag unicastTag + && outgoingMessage.Headers.TryGetValue(Headers.MessageIntent, out var intentStr) + && intentStr is "Send" or "Reply") + { + activity.DisplayName = $"{activity.DisplayName} {unicastTag.Destination}"; + } } if (dispatchConsistency == DispatchConsistency.Default && context.Extensions.TryGet(out var pendingOperations)) diff --git a/src/NServiceBus.Core/Pipeline/Outgoing/SendComponent.cs b/src/NServiceBus.Core/Pipeline/Outgoing/SendComponent.cs index c00d0880656..5899d352981 100644 --- a/src/NServiceBus.Core/Pipeline/Outgoing/SendComponent.cs +++ b/src/NServiceBus.Core/Pipeline/Outgoing/SendComponent.cs @@ -27,7 +27,7 @@ public static SendComponent Initialize(PipelineSettings pipelineSettings, Hostin pipelineSettings.Register(new OutgoingPhysicalToRoutingConnector(), "Starts the message dispatch pipeline"); - pipelineSettings.Register(new RoutingToDispatchConnector(), + pipelineSettings.Register(new RoutingToDispatchConnector(hostingConfiguration.ActivityFactory), "Decides if the current message should be batched or immediately be dispatched to the transport"); pipelineSettings.Register(new BatchToDispatchConnector(), "Passes batched messages over to the immediate dispatch part of the pipeline"); pipelineSettings.Register(b => new ImmediateDispatchTerminator(b.GetRequiredService()), "Hands the outgoing messages over to the transport for immediate delivery"); diff --git a/src/NServiceBus.Core/Unicast/MessageOperations.cs b/src/NServiceBus.Core/Unicast/MessageOperations.cs index 34c60f3cda1..7f3af927a2a 100644 --- a/src/NServiceBus.Core/Unicast/MessageOperations.cs +++ b/src/NServiceBus.Core/Unicast/MessageOperations.cs @@ -65,7 +65,11 @@ async Task Publish(IBehaviorContext context, Type messageType, object message, P MergeDispatchProperties(publishContext, options.DispatchProperties); - using var activity = activityFactory.StartOutgoingPipelineActivity(ActivityNames.OutgoingEventActivityName, ActivityDisplayNames.PublishEvent, publishContext); + var publishDisplayName = activityFactory.Options.UseMessageDestinationInSpanNames + ? $"{ActivityDisplayNames.PublishOperation} {messageType.Name}" + : ActivityDisplayNames.PublishEvent; + + using var activity = activityFactory.StartOutgoingPipelineActivity(ActivityNames.OutgoingEventActivityName, publishDisplayName, publishContext); await publishPipeline.Invoke(publishContext, activity).ConfigureAwait(false); } From dd3a9dbe1e2fbb6b997cacc42a9460f95f96a16c Mon Sep 17 00:00:00 2001 From: Irina Dominte Date: Tue, 2 Jun 2026 16:59:57 +0300 Subject: [PATCH 2/8] Added base64 encoded body --- .../MessagePayloadToTagsBehaviorTests.cs | 102 ++++++++++++++++++ ...or.cs => MessagePayloadToTagsBehaviors.cs} | 22 ++-- 2 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 src/NServiceBus.Core.Tests/OpenTelemetry/MessagePayloadToTagsBehaviorTests.cs rename src/NServiceBus.Core/OpenTelemetry/{PromoteMessagePropertiesToTagsBehavior.cs => MessagePayloadToTagsBehaviors.cs} (57%) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/MessagePayloadToTagsBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/MessagePayloadToTagsBehaviorTests.cs new file mode 100644 index 00000000000..4a2cfd6ce2a --- /dev/null +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/MessagePayloadToTagsBehaviorTests.cs @@ -0,0 +1,102 @@ +#nullable enable + +namespace NServiceBus.Core.Tests.OpenTelemetry; + +using System; +using System.Collections.Immutable; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Helpers; +using NServiceBus.Pipeline; +using NUnit.Framework; +using Testing; +using Unicast.Messages; + +[TestFixture] +public class MessagePayloadToTagsBehaviorTests +{ + TestingActivityListener activityListener; + + [SetUp] + public void SetUp() => activityListener = TestingActivityListener.SetupNServiceBusDiagnosticListener(); + + [TearDown] + public void TearDown() => activityListener.Dispose(); + + [Test] + public async Task Incoming_should_set_base64_encoded_json_body_tag() + { + using var activity = ActivitySources.Main.StartActivity("test"); + + var message = new TestMessage { Name = "Hello", Value = 42 }; + var context = new TestableIncomingLogicalMessageContext + { + Message = new LogicalMessage(new MessageMetadata(typeof(TestMessage)), message) + }; + + await new IncomingMessagePayloadToTagsBehavior().Invoke(context, _ => Task.CompletedTask); + + var tags = activity!.Tags.ToImmutableDictionary(); + Assert.That(tags.ContainsKey("nservicebus.message.body"), Is.True); + + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(tags["nservicebus.message.body"]!)); + var deserialized = JsonSerializer.Deserialize(decoded); + + using (Assert.EnterMultipleScope()) + { + Assert.That(deserialized!.Name, Is.EqualTo("Hello")); + Assert.That(deserialized.Value, Is.EqualTo(42)); + } + } + + [Test] + public async Task Incoming_should_not_set_tag_when_no_active_activity() + { + var context = new TestableIncomingLogicalMessageContext + { + Message = new LogicalMessage(new MessageMetadata(typeof(TestMessage)), new TestMessage()) + }; + + Assert.DoesNotThrowAsync(() => new IncomingMessagePayloadToTagsBehavior().Invoke(context, _ => Task.CompletedTask)); + } + + [Test] + public async Task Outgoing_should_set_base64_encoded_json_body_tag() + { + using var activity = ActivitySources.Main.StartActivity("test"); + + var message = new TestMessage { Name = "World", Value = 99 }; + var context = new TestableOutgoingLogicalMessageContext(); + context.UpdateMessage(message); + + await new OutgoingMessagePayloadToTagsBehavior().Invoke(context, _ => Task.CompletedTask); + + var tags = activity!.Tags.ToImmutableDictionary(); + Assert.That(tags.ContainsKey("nservicebus.message.body"), Is.True); + + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(tags["nservicebus.message.body"]!)); + var deserialized = JsonSerializer.Deserialize(decoded); + + using (Assert.EnterMultipleScope()) + { + Assert.That(deserialized!.Name, Is.EqualTo("World")); + Assert.That(deserialized.Value, Is.EqualTo(99)); + } + } + + [Test] + public async Task Outgoing_should_not_set_tag_when_no_active_activity() + { + var context = new TestableOutgoingLogicalMessageContext(); + context.UpdateMessage(new TestMessage()); + + Assert.DoesNotThrowAsync(() => new OutgoingMessagePayloadToTagsBehavior().Invoke(context, _ => Task.CompletedTask)); + } + + class TestMessage + { + public string? Name { get; set; } + public int Value { get; set; } + } +} diff --git a/src/NServiceBus.Core/OpenTelemetry/PromoteMessagePropertiesToTagsBehavior.cs b/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs similarity index 57% rename from src/NServiceBus.Core/OpenTelemetry/PromoteMessagePropertiesToTagsBehavior.cs rename to src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs index 4435190901a..7868307458f 100644 --- a/src/NServiceBus.Core/OpenTelemetry/PromoteMessagePropertiesToTagsBehavior.cs +++ b/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs @@ -4,7 +4,8 @@ namespace NServiceBus; using System; using System.Diagnostics; -using System.Reflection; +using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Pipeline; @@ -15,24 +16,17 @@ public Task Invoke(IIncomingLogicalMessageContext context, Func Date: Wed, 3 Jun 2026 11:01:48 +0200 Subject: [PATCH 3/8] Apply suggestion from @ramonsmits --- .../OpenTelemetry/MessagePayloadToTagsBehaviors.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs b/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs index 7868307458f..7f4129c4705 100644 --- a/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs +++ b/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs @@ -35,7 +35,7 @@ class OutgoingMessagePayloadToTagsBehavior : IBehavior next) { var activity = Activity.Current; - if (activity?.IsAllDataRequested == true) + if (activity?.IsAllDataRequested) { IncomingMessagePayloadToTagsBehavior.SetMessageBodyTag(activity, context.Message.Instance); } From b083629851bab4a72c9b622fd2aa53f2f9a068e5 Mon Sep 17 00:00:00 2001 From: Irina Dominte Date: Wed, 10 Jun 2026 14:15:16 +0300 Subject: [PATCH 4/8] removed the body serialization --- ...IApprovals.ApproveNServiceBus.approved.txt | 8 -- .../MessagePayloadToTagsBehaviorTests.cs | 102 ------------------ .../OpenTelemetry/InstrumentationOptions.cs | 25 ----- .../MessagePayloadToTagsBehaviors.cs | 45 -------- .../OpenTelemetry/OpenTelemetryFeature.cs | 15 --- 5 files changed, 195 deletions(-) delete mode 100644 src/NServiceBus.Core.Tests/OpenTelemetry/MessagePayloadToTagsBehaviorTests.cs delete mode 100644 src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt index e5b50f8da1b..a5bdc1d3893 100644 --- a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt +++ b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt @@ -635,7 +635,6 @@ namespace NServiceBus public class InstrumentationOptions { public InstrumentationOptions() { } - public NServiceBus.MessagePayloadAsTag MessagePayloadAsTag { get; set; } public bool UseMessageDestinationInSpanNames { get; set; } } public sealed class KeyedServiceKey @@ -724,13 +723,6 @@ namespace NServiceBus Unsubscribe = 4, Reply = 5, } - public enum MessagePayloadAsTag - { - None = 0, - IncomingMessage = 1, - OutgoingMessage = 2, - All = 3, - } public static class MessageProcessingContextExtensions { public static System.Threading.Tasks.Task Reply(this NServiceBus.IMessageProcessingContext context, object message) { } diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/MessagePayloadToTagsBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/MessagePayloadToTagsBehaviorTests.cs deleted file mode 100644 index 4a2cfd6ce2a..00000000000 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/MessagePayloadToTagsBehaviorTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -#nullable enable - -namespace NServiceBus.Core.Tests.OpenTelemetry; - -using System; -using System.Collections.Immutable; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Helpers; -using NServiceBus.Pipeline; -using NUnit.Framework; -using Testing; -using Unicast.Messages; - -[TestFixture] -public class MessagePayloadToTagsBehaviorTests -{ - TestingActivityListener activityListener; - - [SetUp] - public void SetUp() => activityListener = TestingActivityListener.SetupNServiceBusDiagnosticListener(); - - [TearDown] - public void TearDown() => activityListener.Dispose(); - - [Test] - public async Task Incoming_should_set_base64_encoded_json_body_tag() - { - using var activity = ActivitySources.Main.StartActivity("test"); - - var message = new TestMessage { Name = "Hello", Value = 42 }; - var context = new TestableIncomingLogicalMessageContext - { - Message = new LogicalMessage(new MessageMetadata(typeof(TestMessage)), message) - }; - - await new IncomingMessagePayloadToTagsBehavior().Invoke(context, _ => Task.CompletedTask); - - var tags = activity!.Tags.ToImmutableDictionary(); - Assert.That(tags.ContainsKey("nservicebus.message.body"), Is.True); - - var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(tags["nservicebus.message.body"]!)); - var deserialized = JsonSerializer.Deserialize(decoded); - - using (Assert.EnterMultipleScope()) - { - Assert.That(deserialized!.Name, Is.EqualTo("Hello")); - Assert.That(deserialized.Value, Is.EqualTo(42)); - } - } - - [Test] - public async Task Incoming_should_not_set_tag_when_no_active_activity() - { - var context = new TestableIncomingLogicalMessageContext - { - Message = new LogicalMessage(new MessageMetadata(typeof(TestMessage)), new TestMessage()) - }; - - Assert.DoesNotThrowAsync(() => new IncomingMessagePayloadToTagsBehavior().Invoke(context, _ => Task.CompletedTask)); - } - - [Test] - public async Task Outgoing_should_set_base64_encoded_json_body_tag() - { - using var activity = ActivitySources.Main.StartActivity("test"); - - var message = new TestMessage { Name = "World", Value = 99 }; - var context = new TestableOutgoingLogicalMessageContext(); - context.UpdateMessage(message); - - await new OutgoingMessagePayloadToTagsBehavior().Invoke(context, _ => Task.CompletedTask); - - var tags = activity!.Tags.ToImmutableDictionary(); - Assert.That(tags.ContainsKey("nservicebus.message.body"), Is.True); - - var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(tags["nservicebus.message.body"]!)); - var deserialized = JsonSerializer.Deserialize(decoded); - - using (Assert.EnterMultipleScope()) - { - Assert.That(deserialized!.Name, Is.EqualTo("World")); - Assert.That(deserialized.Value, Is.EqualTo(99)); - } - } - - [Test] - public async Task Outgoing_should_not_set_tag_when_no_active_activity() - { - var context = new TestableOutgoingLogicalMessageContext(); - context.UpdateMessage(new TestMessage()); - - Assert.DoesNotThrowAsync(() => new OutgoingMessagePayloadToTagsBehavior().Invoke(context, _ => Task.CompletedTask)); - } - - class TestMessage - { - public string? Name { get; set; } - public int Value { get; set; } - } -} diff --git a/src/NServiceBus.Core/OpenTelemetry/InstrumentationOptions.cs b/src/NServiceBus.Core/OpenTelemetry/InstrumentationOptions.cs index aeb480769e1..f920a28b93a 100644 --- a/src/NServiceBus.Core/OpenTelemetry/InstrumentationOptions.cs +++ b/src/NServiceBus.Core/OpenTelemetry/InstrumentationOptions.cs @@ -14,29 +14,4 @@ public class InstrumentationOptions /// Disabled by default for backward compatibility. /// public bool UseMessageDestinationInSpanNames { get; set; } - - /// - /// Promotes public properties of message instances to span attributes - /// as nservicebus.message.{PropertyName}. - /// Defaults to . May expose sensitive data and incurs reflection cost. - /// - public MessagePayloadAsTag MessagePayloadAsTag { get; set; } -} - -/// -/// Controls which message payloads are promoted to span attributes. -/// -public enum MessagePayloadAsTag -{ - /// No message properties are promoted to span tags. - None, - - /// Public properties of the incoming message instance are promoted to span tags. - IncomingMessage, - - /// Public properties of outgoing message instances are promoted to span tags. - OutgoingMessage, - - /// Public properties of both incoming and outgoing message instances are promoted to span tags. - All } diff --git a/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs b/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs deleted file mode 100644 index 7f4129c4705..00000000000 --- a/src/NServiceBus.Core/OpenTelemetry/MessagePayloadToTagsBehaviors.cs +++ /dev/null @@ -1,45 +0,0 @@ -#nullable enable - -namespace NServiceBus; - -using System; -using System.Diagnostics; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Pipeline; - -class IncomingMessagePayloadToTagsBehavior : IBehavior -{ - public Task Invoke(IIncomingLogicalMessageContext context, Func next) - { - var activity = Activity.Current; - if (activity?.IsAllDataRequested == true) - { - SetMessageBodyTag(activity, context.Message.Instance); - } - - return next(context); - } - - internal static void SetMessageBodyTag(Activity activity, object instance) - { - var json = JsonSerializer.Serialize(instance, instance.GetType()); - var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); - activity.SetTag("nservicebus.message.body", base64); - } -} - -class OutgoingMessagePayloadToTagsBehavior : IBehavior -{ - public Task Invoke(IOutgoingLogicalMessageContext context, Func next) - { - var activity = Activity.Current; - if (activity?.IsAllDataRequested) - { - IncomingMessagePayloadToTagsBehavior.SetMessageBodyTag(activity, context.Message.Instance); - } - - return next(context); - } -} diff --git a/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryFeature.cs b/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryFeature.cs index e4804d76a0c..c485129eee0 100644 --- a/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryFeature.cs +++ b/src/NServiceBus.Core/OpenTelemetry/OpenTelemetryFeature.cs @@ -22,20 +22,5 @@ protected override void Setup(FeatureConfigurationContext context) new PopulateRecoverabilityTraceMetadataBehavior(), "Populates the recoverability metadata" ); - - var options = context.Settings.GetOrDefault(); - if (options?.MessagePayloadAsTag is MessagePayloadAsTag.IncomingMessage or MessagePayloadAsTag.All) - { - context.Pipeline.Register( - new IncomingMessagePayloadToTagsBehavior(), - "Promotes incoming message properties to span tags"); - } - - if (options?.MessagePayloadAsTag is MessagePayloadAsTag.OutgoingMessage or MessagePayloadAsTag.All) - { - context.Pipeline.Register( - new OutgoingMessagePayloadToTagsBehavior(), - "Promotes outgoing message properties to span tags"); - } } } \ No newline at end of file From af64227212fae3bbe38e38272cd1a7182b692125 Mon Sep 17 00:00:00 2001 From: Tomasz Masternak Date: Tue, 16 Jun 2026 13:36:27 +0200 Subject: [PATCH 5/8] access level fixes for acceptance tests --- .../Core/OpenTelemetry/Traces/When_publishing_messages.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_publishing_messages.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_publishing_messages.cs index 0595803fbef..4281ddc170a 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_publishing_messages.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_publishing_messages.cs @@ -203,7 +203,7 @@ await Scenario.Define() Assert.That(publishedMessage.DisplayName, Is.EqualTo("publish ThisIsAnEvent")); } - class PublisherWithDestinationNaming : EndpointConfigurationBuilder + public class PublisherWithDestinationNaming : EndpointConfigurationBuilder { public PublisherWithDestinationNaming() => EndpointSetup(b => @@ -222,7 +222,7 @@ public PublisherWithDestinationNaming() => }); } - class SubscriberForPublisherWithDestinationNaming : EndpointConfigurationBuilder + public class SubscriberForPublisherWithDestinationNaming : EndpointConfigurationBuilder { public SubscriberForPublisherWithDestinationNaming() => EndpointSetup(c => { }, From bba80f7bd2e6ef25bd942bb85032c0bf5b14c88d Mon Sep 17 00:00:00 2001 From: Tomasz Masternak Date: Tue, 16 Jun 2026 15:12:22 +0200 Subject: [PATCH 6/8] Using DistributedContextPropagator instead of hand-written baggage propagator (#7820) * initial migration to the DistributedContextPropagator for W3C compatibility * new propagator with backwards compatiblity tests * fixing the startnewtrace header progagation * refactor ContextPropagation.cs * small tweak * warning fixes * att tests reflect supported baggage and tracestate formats --- .../Metrics/When_envelope_handler_succeeds.cs | 1 + .../When_ambient_trace_in_message_session.cs | 2 +- ...hen_incoming_message_has_baggage_header.cs | 4 +- .../When_outgoing_activity_has_baggage.cs | 2 +- .../ContextPropagationIncompatibilityTests.cs | 84 +++++++++++++++++ .../OpenTelemetry/ContextPropagationTests.cs | 91 +++++++++++++------ .../OpenTelemetry/LegacyContextPropagator.cs | 87 ++++++++++++++++++ .../Tracing/ActivityExtensions.cs | 2 + .../Tracing/ContextPropagation.cs | 74 ++++++--------- 9 files changed, 269 insertions(+), 78 deletions(-) create mode 100644 src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs create mode 100644 src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_envelope_handler_succeeds.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_envelope_handler_succeeds.cs index 16c7d1da43b..faca6eed9bb 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_envelope_handler_succeeds.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_envelope_handler_succeeds.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.ApplicationInsights.Extensibility; using NServiceBus; using NServiceBus.AcceptanceTesting; using NServiceBus.AcceptanceTests.Core.OpenTelemetry; diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_ambient_trace_in_message_session.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_ambient_trace_in_message_session.cs index 51ae62b9a41..6c3f58c65e5 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_ambient_trace_in_message_session.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_ambient_trace_in_message_session.cs @@ -15,7 +15,7 @@ public async Task Should_attach_to_ambient_trace() using var externalActivitySource = new ActivitySource("external trace source"); using var _ = TestingActivityListener.SetupDiagnosticListener(externalActivitySource.Name); // need to have a registered listener for activities to be created - const string wrapperActivityTraceState = "test trace state"; + const string wrapperActivityTraceState = "tracekey=traceValue"; var context = await Scenario.Define() .WithEndpoint(b => b diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_baggage_header.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_baggage_header.cs index 29acf036898..ffcc39fd5a1 100644 --- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_baggage_header.cs +++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_baggage_header.cs @@ -17,7 +17,7 @@ public async Task Should_propagate_baggage_to_activity() { var sendOptions = new SendOptions(); sendOptions.RouteToThisEndpoint(); - sendOptions.SetHeader(Headers.DiagnosticsBaggage, "key1=value1,key2=value2,key3="); + sendOptions.SetHeader(Headers.DiagnosticsBaggage, "key1=value1,key2=value2,key3=value3"); await session.Send(new SomeMessage(), sendOptions); }) ) @@ -29,7 +29,7 @@ public async Task Should_propagate_baggage_to_activity() VerifyBaggageItem("key1", "value1"); VerifyBaggageItem("key2", "value2"); - VerifyBaggageItem("key3", ""); + VerifyBaggageItem("key3", "value3"); return; void VerifyBaggageItem(string key, string expectedValue) 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 93ff05bcfbf..e8bed02db26 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,7 @@ public async Task Should_propagate_baggage_to_headers() ) .Run(); - Assert.That(context.BaggageHeader, Is.EqualTo("key3=,key2=value2,key1=value1")); + Assert.That(context.BaggageHeader, Is.EqualTo("key3 = , key2 = value2, key1 = value1")); } public class TestEndpoint : EndpointConfigurationBuilder diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs new file mode 100644 index 00000000000..4aa93147137 --- /dev/null +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs @@ -0,0 +1,84 @@ +namespace NServiceBus.Core.Tests.OpenTelemetry; + +using System.Collections.Generic; +using System.Diagnostics; +using Extensibility; +using NUnit.Framework; + +[TestFixture] +public class ContextPropagationIncompatibilityTests +{ + 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 NewWrite = ContextPropagation.PropagateContextToHeaders; + static readonly Reader NewRead = ContextPropagation.PropagateContextFromHeaders; + + // A value exercising every class of special character: structural baggage delimiters + // (',' ';' '='), the escape char '%', quotes, brackets, slashes, ampersand, Unicode and + // an emoji, plus interior spaces. Deliberately has NO leading/trailing whitespace, so this + // 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. + 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) + { + using var sender = new Activity(ActivityNames.OutgoingMessageActivityName); + sender.SetIdFormat(ActivityIdFormat.W3C); + sender.Start(); + sender.AddBaggage("key", value); + + var headers = new Dictionary(); + write(sender, headers, new ContextBag()); + sender.Stop(); + return headers; + } + + static string Receive(Dictionary headers, Reader read) + { + using var receiver = new Activity(ActivityNames.IncomingMessageActivityName); + receiver.SetIdFormat(ActivityIdFormat.W3C); + receiver.Start(); + read(receiver, headers); + return receiver.GetBaggageItem("key"); + } + + static string Transmit(string value, Writer write, Reader read) => Receive(Send(value, write), read); + + [Test] + public void Legacy_sender_to_new_receiver_preserves_the_value() + { + var received = Transmit(AllSpecialCharacters, LegacyWrite, NewRead); + Assert.That(received, Is.EqualTo(AllSpecialCharacters)); + } + + [Test] + public void New_sender_to_legacy_receiver_prepends_a_leading_space_but_keeps_the_special_characters() + { + var received = Transmit(AllSpecialCharacters, NewWrite, LegacyRead); + + Assert.That(received, Is.EqualTo(" " + AllSpecialCharacters), + "ignoring the leading space, every special character round-trips correctly"); + } + + [Test] + public void New_propagation_loses_leading_whitespace_in_a_value() + { + const string valueWithLeadingSpace = " hasLeadingSpace"; + + var legacyRoundTrip = Transmit(valueWithLeadingSpace, LegacyWrite, LegacyRead); + var newRoundTrip = Transmit(valueWithLeadingSpace, NewWrite, NewRead); + + using (Assert.EnterMultipleScope()) + { + Assert.That(legacyRoundTrip, Is.EqualTo(valueWithLeadingSpace), + "legacy propagation preserves leading whitespace via percent-encoding"); + Assert.That(newRoundTrip, Is.EqualTo("hasLeadingSpace"), + "new propagation strips the leading whitespace from the value"); + } + } +} diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs index 6de0d1ade7a..48f95c1fe86 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs @@ -1,6 +1,5 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; -using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; @@ -94,7 +93,9 @@ public void Can_propagate_baggage_from_header_to_activity(ContextPropagationTest headers[Headers.DiagnosticsBaggage] = testCase.BaggageHeaderValue; } - var activity = new Activity(ActivityNames.IncomingMessageActivityName); + using var activity = new Activity(ActivityNames.IncomingMessageActivityName); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); ContextPropagation.PropagateContextFromHeaders(activity, headers); @@ -114,7 +115,9 @@ public void Can_propagate_baggage_from_activity_to_header(ContextPropagationTest var headers = new Dictionary(); - var activity = new Activity(ActivityNames.OutgoingMessageActivityName); + using var activity = new Activity(ActivityNames.OutgoingMessageActivityName); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); foreach (var baggageItem in testCase.ExpectedBaggageItems.Reverse()) { @@ -131,7 +134,7 @@ public void Can_propagate_baggage_from_activity_to_header(ContextPropagationTest { Assert.That(baggageHeaderSet, Is.True, "Should have a baggage header if there is baggage"); - Assert.That(baggageValue, Is.EqualTo(testCase.BaggageHeaderValueWithoutOptionalWhitespace), "baggage header is set but is not correct"); + Assert.That(baggageValue, Is.EqualTo(testCase.BaggageHeaderValue), "baggage header is set but is not correct"); } } else @@ -146,7 +149,9 @@ public void Can_roundtrip_baggage(ContextPropagationTestCase testCase) TestContext.Out.WriteLine($"Baggage header: {testCase.BaggageHeaderValue}"); var outgoingHeaders = new Dictionary(); - var outgoingActivity = new Activity(ActivityNames.OutgoingMessageActivityName); + using var outgoingActivity = new Activity(ActivityNames.OutgoingMessageActivityName); + outgoingActivity.SetIdFormat(ActivityIdFormat.W3C); + outgoingActivity.Start(); foreach (var baggageItem in testCase.ExpectedBaggageItems.Reverse()) { @@ -157,7 +162,9 @@ public void Can_roundtrip_baggage(ContextPropagationTestCase testCase) // Simulate wire transfer var incomingHeaders = outgoingHeaders; - var incomingActivity = new Activity(ActivityNames.IncomingMessageActivityName); + using var incomingActivity = new Activity(ActivityNames.IncomingMessageActivityName); + incomingActivity.SetIdFormat(ActivityIdFormat.W3C); + incomingActivity.Start(); ContextPropagation.PropagateContextFromHeaders(incomingActivity, incomingHeaders); @@ -170,55 +177,87 @@ 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[] { new ContextPropagationTestCase("without any baggage"), new ContextPropagationTestCase("with a single key") - .WithBaggage("key1", "value1"), + .WithBaggage("key1", "value1") + .WithHeaderValue("key1 = value1"), new ContextPropagationTestCase("with multiple keys") .WithBaggage("key1", "value1") - .WithBaggage("key2", "value2"), - - new ContextPropagationTestCase("with whitespace") - .WithBaggage("key1 ", " value1") - .WithBaggage(" key2", "value2 ") - .WithBaggage(" key3 ", " value3 "), + .WithBaggage("key2", "value2") + .WithHeaderValue("key1 = value1, key2 = value2"), new ContextPropagationTestCase("with properties that do not have keys") - .WithBaggage("key1", "value1;property1;property2"), + .WithBaggage("key1", "value1;property1;property2") + .WithHeaderValue("key1 = value1%3Bproperty1%3Bproperty2"), new ContextPropagationTestCase("with properties that have keys") - .WithBaggage("key3", "value3; propertyKey=propertyValue"), + .WithBaggage("key3", "value3; propertyKey=propertyValue") + .WithHeaderValue("key3 = value3%3B%20propertyKey=propertyValue"), new ContextPropagationTestCase("with values containing whitespace") - .WithBaggage("serverNode", "DF 28"), + .WithBaggage("serverNode", "DF 28") + .WithHeaderValue("serverNode = DF%2028"), new ContextPropagationTestCase("with values containing unicode") .WithBaggage("userId", "Amélie") + .WithHeaderValue("userId = Am%C3%A9lie") }; - public class ContextPropagationTestCase + public class ContextPropagationTestCase(string caseName) { - string caseName; - Dictionary baggageItems = []; + readonly Dictionary baggageItems = []; - public ContextPropagationTestCase(string caseName) + public ContextPropagationTestCase WithBaggage(string key, string value) { - this.caseName = caseName; + baggageItems.Add(key, value); + return this; } - public ContextPropagationTestCase WithBaggage(string key, string value) + public ContextPropagationTestCase WithHeaderValue(string headerValue) { - baggageItems.Add(key, value); + BaggageHeaderValue = headerValue; return this; } - public string BaggageHeaderValue => string.Join(",", from kvp in baggageItems select $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"); - public string BaggageHeaderValueWithoutOptionalWhitespace - => string.Join(",", from kvp in baggageItems select $"{kvp.Key.Trim()}={Uri.EscapeDataString(kvp.Value)}"); + public string BaggageHeaderValue { get; private set; } public IEnumerable> ExpectedBaggageItems => from kvp in baggageItems select new KeyValuePair( kvp.Key.Trim(), diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs new file mode 100644 index 00000000000..dc35002215b --- /dev/null +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs @@ -0,0 +1,87 @@ +#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; + } + + 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)); + } + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityExtensions.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityExtensions.cs index 04e287679c0..30b0bf1066f 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityExtensions.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityExtensions.cs @@ -41,6 +41,8 @@ public static void SetErrorStatus(this Activity activity, Exception ex) activity.SetStatus(ActivityStatusCode.Error, ex.Message); activity.SetTag("otel.status_code", "ERROR"); activity.SetTag("otel.status_description", ex.Message); + + activity.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, [ new KeyValuePair("exception.escaped", true), diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs index 0f1d8868270..b6ac0314b4c 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs @@ -2,10 +2,8 @@ namespace NServiceBus; -using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using Extensibility; static class ContextPropagation @@ -17,26 +15,14 @@ public static void PropagateContextToHeaders(Activity? activity, Dictionary(Headers.StartNewTrace, out var startNewTrace); - // 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)) + if (traceParentExists && startNewTraceOnReceive) { - 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; + headers[Headers.StartNewTrace] = startNewTrace!; } } @@ -47,41 +33,33 @@ public static void PropagateContextFromHeaders(Activity? activity, IDictionary baggageItem; + var baggage = DistributedContextPropagator.Current.ExtractBaggage(headers, Getter); - 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; - } + if (baggage is null) + { + return; + } - var key = baggageItem[..firstEquals].Trim(); - var value = baggageItem[(firstEquals + 1)..]; - activity.AddBaggage(key.ToString(), Uri.UnescapeDataString(value)); - } + foreach (var baggageItem in baggage) + { + activity.AddBaggage(baggageItem.Key, baggageItem.Value); } } + + static readonly DistributedContextPropagator.PropagatorSetterCallback Setter = static (carrier, key, value) => + ((IDictionary)carrier!)[key] = value; + + static readonly DistributedContextPropagator.PropagatorGetterCallback Getter = + static (carrier, key, out value, out values) => + { + values = null; + value = ((IReadOnlyDictionary)carrier!).GetValueOrDefault(key); + }; } \ No newline at end of file From ca842e67166c25f183fc0da3744d7b1e9e001df5 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 17 Jun 2026 12:47:23 +0200 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=90=9B=20Add=20regression=20test=20fo?= =?UTF-8?q?r=20null=20baggage=20value=20(#6983)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activity baggage with a null value used to throw ArgumentNullException during context propagation (Uri.EscapeDataString(null)). The switch to DistributedContextPropagator on this branch fixes the root cause; this test pins the behavior so it cannot regress. --- .../OpenTelemetry/ContextPropagationTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs index 48f95c1fe86..0141d6c9a15 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs @@ -81,6 +81,22 @@ public void Overwrites_existing_propagation_header() Assert.That(activity.Id, Is.EqualTo(headers[Headers.DiagnosticsTraceParent])); } + [Test] + public void Should_not_throw_when_baggage_value_is_null() + { + // Reproduces https://github.com/Particular/NServiceBus/issues/6983 + // A baggage item with a null value used to make the hand-written propagator call + // Uri.EscapeDataString(null), throwing ArgumentNullException while sending a message. + using var activity = new Activity(ActivityNames.OutgoingMessageActivityName); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + activity.AddBaggage("test", null); + + var headers = new Dictionary(); + + Assert.DoesNotThrow(() => ContextPropagation.PropagateContextToHeaders(activity, headers, new ContextBag())); + } + [TestCaseSource(nameof(TestCases))] public void Can_propagate_baggage_from_header_to_activity(ContextPropagationTestCase testCase) { From a33484eaa8812a4d148c801bfc1f2f18b4f4091d Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 14:10:30 +0200 Subject: [PATCH 8/8] Make DistributedContextPropagator opt-in (keep OTel propagation backwards compatible until v11) (#7825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Make DistributedContextPropagator opt-in via AppContext switch 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. * Refactor: Replace `ObsoleteV11` with `LegacyContextPropagation` * Add test to verify null baggage value does not throw in legacy propagator * Added comments about preserve legacy baggage handling behavior when escaping and trimming values * Add test to verify that we are preserving whitespace in legacy propagator baggage values. * add comments to clarify intent behind legacy propagator handling * update ContextPropagationCompatibilityTests to use correct LegacyContextPropagation delegates * rename ContextPropagationTests to LegacyContextPropagationTests and remove outdated baggage handling tests * allow changing the propagator implementation at runtime --------- Co-authored-by: Tomasz Masternak --- .../When_outgoing_activity_has_baggage.cs | 5 +- ...> ContextPropagationCompatibilityTests.cs} | 42 +++++- .../ContextPropagationDefaultBehaviorTests.cs | 72 +++++++++ ...ts.cs => LegacyContextPropagationTests.cs} | 48 +----- .../OpenTelemetry/LegacyContextPropagator.cs | 87 ----------- .../Tracing/ContextPropagation.cs | 19 +++ .../OpenTelemetry/Tracing/obsolete_v11.cs | 139 ++++++++++++++++++ 7 files changed, 279 insertions(+), 133 deletions(-) rename src/NServiceBus.Core.Tests/OpenTelemetry/{ContextPropagationIncompatibilityTests.cs => ContextPropagationCompatibilityTests.cs} (68%) create mode 100644 src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs rename src/NServiceBus.Core.Tests/OpenTelemetry/{ContextPropagationTests.cs => LegacyContextPropagationTests.cs} (83%) delete mode 100644 src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.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 e8bed02db26..b6a2310ad25 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/ContextPropagationIncompatibilityTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs similarity index 68% rename from src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs rename to src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs index 4aa93147137..7f4332e0cde 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationIncompatibilityTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs @@ -1,18 +1,33 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; +using System; using System.Collections.Generic; using System.Diagnostics; using Extensibility; using NUnit.Framework; [TestFixture] -public class ContextPropagationIncompatibilityTests +public class ContextPropagationCompatibilityTests { + [SetUp] + public void EnableDistributedContextPropagator() + { + AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, true); + LegacyContextPropagation.ResetUseDistributedContextPropagator(); + } + + [TearDown] + public void ResetDistributedContextPropagator() + { + AppContext.SetSwitch(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, false); + LegacyContextPropagation.ResetUseDistributedContextPropagator(); + } + 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; @@ -22,7 +37,7 @@ public class ContextPropagationIncompatibilityTests // 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) @@ -81,4 +96,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 diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs new file mode 100644 index 00000000000..fbe65df4b51 --- /dev/null +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs @@ -0,0 +1,72 @@ +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(LegacyContextPropagation.UseDistributedContextPropagatorSwitchName, false); + LegacyContextPropagation.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_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() + { + 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/ContextPropagationTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs similarity index 83% rename from src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs rename to src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs index 0141d6c9a15..4c62f70cd71 100644 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs +++ b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs @@ -1,5 +1,6 @@ namespace NServiceBus.Core.Tests.OpenTelemetry; +using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; @@ -8,7 +9,7 @@ using NUnit.Framework; [TestFixture] -public class ContextPropagationTests +public class LegacyContextPropagationTests { [Test] public void Propagate_activity_id_to_header() @@ -193,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[] { @@ -233,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 dc35002215b..00000000000 --- a/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagator.cs +++ /dev/null @@ -1,87 +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; - } - - 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)); - } - } - } -} \ No newline at end of file diff --git a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs index b6ac0314b4c..8df14752012 100644 --- a/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/ContextPropagation.cs @@ -10,6 +10,16 @@ static class ContextPropagation { 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 (!LegacyContextPropagation.UseDistributedContextPropagator) + { + LegacyContextPropagation.PropagateContextToHeaders(activity, headers, contextBag); + return; + } + + // The following part was intentionally not extracted to a separate class to prevent + // accidental leftovers when because that the legacy propagator will be removed in v11 if (activity is null) { return; @@ -28,6 +38,15 @@ public static void PropagateContextToHeaders(Activity? activity, Dictionary headers) { + // Removed in v11, see obsolete_v11.cs + if (!LegacyContextPropagation.UseDistributedContextPropagator) + { + LegacyContextPropagation.PropagateContextFromHeaders(activity, headers); + return; + } + + // The following part was intentionally not extracted to a separate class to prevent + // accidental leftovers when because that the legacy propagator will be removed in v11 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 00000000000..b1dec663683 --- /dev/null +++ b/src/NServiceBus.Core/OpenTelemetry/Tracing/obsolete_v11.cs @@ -0,0 +1,139 @@ +#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 LegacyContextPropagation +{ + enum SwitchState : byte + { + Unchecked = 0, + Enabled = 1, + Disabled = 2 + } + + static SwitchState cachedUseDistributedContextPropagator; + + [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"; + + [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 + { + 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; + + 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)); + } + } + } +} \ No newline at end of file