Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context>()
.WithEndpoint<EndpointWithAmbientActivity>(b => b
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public async Task Should_propagate_baggage_to_headers()
)
.Run();

// 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"));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context>()
.WithEndpoint<ReceivingEndpointWithDestinationNaming>(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<DefaultServer>(b => b.Tracing().UseMessageDestinationInSpanNames = true);

[Handler]
public class MessageHandler(Context testContext) : IHandleMessages<IncomingMessage>
{
public Task Handle(IncomingMessage message, IMessageHandlerContext context)
{
testContext.MarkAsCompleted();
return Task.CompletedTask;
}
}
}

public class IncomingMessage : IMessage;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context>()
.WithEndpoint<PublisherWithDestinationNaming>(b => b
.When(ctx => ctx.SomeEventSubscribed, s => s.Publish<ThisIsAnEvent>()))
.WithEndpoint<SubscriberForPublisherWithDestinationNaming>(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"));
}

public class PublisherWithDestinationNaming : EndpointConfigurationBuilder
{
public PublisherWithDestinationNaming() =>
EndpointSetup<DefaultServer>(b =>
{
b.Tracing().UseMessageDestinationInSpanNames = true;
b.OnEndpointSubscribed<Context>((s, context) =>
{
if (s.SubscriberEndpoint.Contains(Conventions.EndpointNamingConvention(typeof(SubscriberForPublisherWithDestinationNaming))))
{
if (s.MessageType == typeof(ThisIsAnEvent).AssemblyQualifiedName)
{
context.SomeEventSubscribed = true;
}
}
});
});
}

public class SubscriberForPublisherWithDestinationNaming : EndpointConfigurationBuilder
{
public SubscriberForPublisherWithDestinationNaming() =>
EndpointSetup<DefaultServer>(c => { },
metadata =>
{
metadata.RegisterPublisherFor<ThisIsAnEvent, PublisherWithDestinationNaming>();
});

[Handler]
public class ThisHandlesSomethingHandler(Context testContext) : IHandleMessages<ThisIsAnEvent>
{
public Task Handle(ThisIsAnEvent @event, IMessageHandlerContext context)
{
testContext.MarkAsCompleted();
return Task.CompletedTask;
}
}
}

public class ThisIsAnEvent : IEvent;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context>()
.WithEndpoint<TestEndpointWithDestinationNaming>(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<DefaultServer>(b => b.Tracing().UseMessageDestinationInSpanNames = true);

[Handler]
public class MessageHandler(Context testContext) : IHandleMessages<OutgoingMessage>
{
public Task Handle(OutgoingMessage message, IMessageHandlerContext context)
{
testContext.MarkAsCompleted();
return Task.CompletedTask;
}
}
}

public class OutgoingMessage : IMessage;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context>()
.WithEndpoint<TestEndpointWithDestinationNaming>(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<DefaultServer>(b => b.Tracing().UseMessageDestinationInSpanNames = true);

[Handler]
public class MessageHandler(Context testContext) : IHandleMessages<IncomingMessage>,
IHandleMessages<OutgoingReply>
{
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,11 @@ namespace NServiceBus
StopApplication = 0,
Continue = 1,
}
public class InstrumentationOptions
{
public InstrumentationOptions() { }
public bool UseMessageDestinationInSpanNames { get; set; }
}
public sealed class KeyedServiceKey
{
public const string Any = "_______<ANY>_______";
Expand Down Expand Up @@ -772,6 +777,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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
namespace NServiceBus.Core.Tests.OpenTelemetry;

using System;
using System.Collections.Generic;
using System.Diagnostics;
using Extensibility;
using NUnit.Framework;

[TestFixture]
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<string, string> headers, ContextBag context);
delegate void Reader(Activity activity, IDictionary<string, string> headers);

static readonly Writer LegacyWrite = LegacyContextPropagation.PropagateContextToHeaders;
static readonly Reader LegacyRead = LegacyContextPropagation.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|v<w>x é ü 😀 z";

static Dictionary<string, string> 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<string, string>();
write(sender, headers, new ContextBag());
sender.Stop();
return headers;
}

static string Receive(Dictionary<string, string> 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");
}
}

[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");
}
}
}
Loading