diff --git a/docs/decisions/2026-10-01-receive-side-trace-state-and-baggage-propagation.md b/docs/decisions/2026-10-01-receive-side-trace-state-and-baggage-propagation.md
new file mode 100644
index 00000000000..6a3b0d9253d
--- /dev/null
+++ b/docs/decisions/2026-10-01-receive-side-trace-state-and-baggage-propagation.md
@@ -0,0 +1,177 @@
+# NServiceBus propagates trace state and baggage on the receive side
+
+**Date:** 2026-10-01
+**Pull request:** [#7954](https://github.com/Particular/NServiceBus/pull/7954)
+**Related:**
+
+- [#7947](https://github.com/Particular/NServiceBus/pull/7947) NServiceBus-specific trace parent header
+- [#7949](https://github.com/Particular/NServiceBus/pull/7949) ambient transport SDK span as parent
+- [#7952](https://github.com/Particular/NServiceBus/pull/7952) propagation only in the outgoing pipeline
+
+## Context
+
+NServiceBus writes OpenTelemetry context to each outgoing message as headers. The headers are
+`NServiceBus.TraceParent` and the W3C headers `traceparent`, `tracestate` and `baggage`. On receive,
+`ActivityFactory` creates the incoming span in one of three shapes. Then it reads the headers back.
+
+```mermaid
+flowchart TD
+ A[Incoming message] --> B{NServiceBus trace parent header present?}
+ B -- no --> C[Span adopts Activity.Current as parent if any
Nothing read from headers]
+ B -- yes --> D{StartNewTrace header?}
+ D -- yes --> E[New trace, link to sender span
Activity.Current cleared]
+ D -- no --> F{Ambient SDK span and
UseTransportSpanAsParent?}
+ F -- yes --> G[Child of ambient SDK span
link to sender span]
+ F -- no --> H[Child of sender span
v10 default]
+ E --> K[Propagate baggage from headers
tracestate stays with the old trace]
+ G --> J[Propagate tracestate from headers
Propagate baggage, skip keys the SDK span already has]
+ H --> I[Propagate tracestate and baggage from headers]
+```
+
+This record answers two questions:
+
+- Who owns baggage on the receive side, now that transport SDKs have their own OpenTelemetry
+ instrumentation?
+- What happens when those SDKs start to propagate baggage?
+
+Three sets of facts shaped the answer.
+
+### How the .NET runtime reads baggage
+
+`Activity.Baggage`, `Activity.GetBaggageItem` and `Activity.TraceStateString` all walk the
+`Activity.Parent` chain. `Activity.Start()` sets `Parent` only in one case. The activity has no explicit
+parent context, and `Activity.Current` is set at that moment.
+
+An activity created from an `ActivityContext` gets the trace id and the parent span id. The trace tree is
+correct. But `Parent` stays null. Such a span inherits no baggage, also when the context belongs to the
+ambient `Activity.Current`. A small program against .NET 10 confirmed this: same trace id, same parent
+span id, `Parent` null, baggage count zero.
+
+So only two branches inherit the baggage of the ambient span. These are the branches that pass a default
+parent context: the ambient SDK span as parent, and no sender context. The v10 default branch and the
+start-new-trace branch do not inherit it. The runtime does not close that gap.
+
+`Start()` sets `Parent`. So a check against the parent chain must run on a started activity. `SetIdFormat`
+works only before `Start()`. On a started activity the runtime ignores it. The runtime throws and catches
+an `InvalidOperationException` internally.
+
+### What the transport SDKs do today
+
+Verified against the SDK sources on 2026-09-30:
+
+| SDK | Injects baggage on send | Extracts baggage on receive |
+|---|---|---|
+| Azure.Messaging.ServiceBus (`Azure.Core` `MessagingClientDiagnostics`) | no, only `Diagnostic-Id`, `traceparent`, `tracestate` | no |
+| RabbitMQ.Client 7 (`RabbitMQActivitySource`) | yes, with `DistributedContextPropagator.Current.Inject`. It overwrites existing keys. | no, `DefaultContextExtractor` reads trace id and state only |
+| AWS SDK for SQS | no tracing in the SDK. The OpenTelemetry contrib instrumentation uses `Baggage.Current`. That store is separate from `Activity.Baggage`. | same |
+| SQL Server, MSMQ, Azure Storage Queues, Learning transport | no SDK tracing | no SDK tracing |
+
+No supported SDK delivers baggage end to end. The one SDK that writes baggage does not read it. The header
+that NServiceBus reads is always a superset of what an SDK receive span can carry. RabbitMQ basic headers
+and Azure Service Bus application properties map one to one to NServiceBus headers.
+
+### Constraints of the DistributedContextPropagator path
+
+.NET 10 changed `DistributedContextPropagator.CreateDefaultPropagator()`. It now returns the W3C
+propagator. The baggage header on that path is `baggage`, not `Correlation-Context`. `Inject` writes
+`traceparent`, `tracestate` and `baggage` in one call. The runtime has W3C, pre-W3C, pass-through and
+no-output propagators. It has no option to suppress baggage alone. To disable baggage on the outgoing
+side, NServiceBus has two options. It can filter header names in the setter. Or it can write trace
+context by hand again.
+
+## Decision
+
+1. **Trace state and baggage from the headers are propagated only when the message has NServiceBus trace
+ context.** NServiceBus trace context is a parseable `NServiceBus.TraceParent` or `traceparent` header.
+ The W3C specifications define `tracestate` and `baggage` as companions of `traceparent`. Without
+ `traceparent`, NServiceBus has nothing of its own to propagate. The span adopts `Activity.Current` as
+ parent if one exists. It inherits the trace state and baggage of that activity through the parent chain.
+
+2. **NServiceBus always propagates the header baggage.** This includes the case where an ambient transport
+ SDK span is the parent. No supported SDK propagates baggage. NServiceBus is the middleware, so it
+ carries the baggage.
+
+3. **When the ambient SDK span is the parent, NServiceBus skips a header key that the span already has.**
+ This prepares for the future. When an SDK extracts baggage to its receive span, the parent gets the same
+ items. Without the skip, the child would get them too. Outgoing serialization would write both. The next hop
+ would double them again. The incoming activity is started before the headers are read. So the skip
+ reads through the `Activity.Parent` chain of the runtime. NServiceBus does not track the adopted parent
+ separately.
+
+4. **Trace state from the headers is not propagated when the message starts a new trace.** `tracestate`
+ carries vendor data about the trace of the sender, such as sampling decisions. A new trace has no
+ relation to that data. The ambient-parent branch and the child-of-sender branch propagate trace state as
+ before.
+
+5. **Baggage is never copied from an ambient activity that does not become the parent.** Baggage on such an
+ activity has one of two sources. It is from the same message, and the header already covers it. Or it is
+ process-local context, and NServiceBus deliberately did not parent on it.
+
+6. **Outgoing propagation does not change.** NServiceBus writes `baggage` together with its trace headers.
+
+Mechanically, `ActivityFactory` creates the incoming activity, forces the W3C id format, adds the tags and
+starts it. Only then does it read the headers. `ContextPropagation.PropagateContextFromHeaders` is split
+into `PropagateTraceStateFromHeaders(activity, headers)` and `PropagateBaggageFromHeaders(activity,
+headers)`. The split applies to both propagator paths, including the legacy path in `obsoletes-v10.cs`.
+Both methods expect a started activity. The combined method stays for callers that need both.
+
+## Consequences
+
+- Handlers can read message baggage with `Activity.Current.GetBaggageItem` on every transport. The
+ transport SDK does not affect this. The v10 default branch did this before. The ambient-parent branch
+ now guarantees it too.
+- A `baggage` header on a message without a NServiceBus trace header is ignored. Accepted. A producer
+ that wants its baggage honored must also send `traceparent`. The W3C specification requires this
+ anyway. No NServiceBus version sent one header without the other.
+- When the ambient SDK span is the parent, a key can exist on the span and in the header. The value
+ on the span wins, because the header item is skipped. Accepted. Both values come from the same message,
+ so they are the same in practice. The alternative doubles baggage per hop.
+- Under the v10 default, the sender span is the parent. Process-local baggage that code around the
+ message pump adds is then not visible to handlers. Accepted. This is consistent with not parenting on
+ that activity. In v11 the ambient span becomes the parent, and the runtime inherits that baggage.
+- Duplicate keys inside one `baggage` header are still added as before. The skip looks only at the parent
+ chain, not at the activity itself. Legacy parsing does not change.
+- Trace state needs no equal treatment. `TraceStateString` also reads through the parent chain. But it is
+ a single value. The value of the child hides the value of the parent, so nothing accumulates.
+- A message that starts a new trace drops the `tracestate` of the sender. Accepted. The value describes a
+ trace that the new trace is deliberately detached from. The sender span is still reachable through the
+ link.
+- `ActivityFactory` starts the incoming activity before it returns it. The caller sets the display name
+ after that. `ActivityListener.ActivityStarted` callbacks see the operation name and the tags, but not
+ the display name. Exporters read the activity when it stops, so this is cosmetic.
+- Cost: one `GetBaggageItem` lookup per header item. This applies only when the activity has a parent.
+- Follow-up: document the receive-side behavior on the public OpenTelemetry page on docs.particular.net.
+- Open: whether `InstrumentationOptions` needs an opt-out for baggage propagation. This record does not
+ decide it.
+- Open: when an SDK starts to extract baggage, verify the skip again against the real span.
+
+## Alternative approaches
+
+### Make NServiceBus baggage propagation opt-in and rely on the transport SDKs
+
+The proposal: default on in the next minor, default off in v11. The assumption: the native SDK propagates
+baggage. Rejected for four reasons:
+
+- The assumption does not hold for any supported transport. See the table above.
+- The SDK propagates the ambient context at physical dispatch, not the logical send context. These differ
+ under the outbox, batched dispatch, the messaging bridge and ServiceControl retries. #7947 introduced
+ `NServiceBus.TraceParent` for the same reason.
+- Default off fails silently. Traces stay intact. Only downstream logic that reads baggage breaks.
+- `DistributedContextPropagator.Inject` has no option to drop baggage alone.
+
+### Copy the baggage of the ambient activity to the incoming span when it is not the parent
+
+This was implemented first in this change, then reverted. Each source of ambient baggage on receive is one
+of two things. It is the same message, where the header is a superset. Or it is process-local context that
+NServiceBus chose not to parent on. The copy added allocations per message and a precedence rule for a
+case that cannot occur.
+
+### Add header baggage unconditionally, also under an ambient parent
+
+This is the simplest reading of "NServiceBus always propagates baggage". Rejected. It doubles baggage on
+each hop as soon as an SDK extracts baggage to the parent span. There is no error, and the headers grow.
+
+### Skip all header baggage when the ambient parent has any baggage
+
+Rejected. Unrelated baggage that host code adds would suppress the baggage of the message. The per-key
+check costs the same and does not depend on the source of the baggage of the parent.
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/TestingMetricListener.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/TestingMetricListener.cs
index 3b7e9a10932..218652d1c9e 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/TestingMetricListener.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/TestingMetricListener.cs
@@ -96,6 +96,16 @@ public object AssertTagKeyExists(string metricName, string tagKey)
return meterTag.Value;
}
+ public void AssertTagKeyDoesNotExist(string metricName, string tagKey)
+ {
+ if (!Tags.ContainsKey(metricName))
+ {
+ Assert.Fail($"'{metricName}' metric was not reported");
+ }
+
+ Assert.That(Tags[metricName].Select(t => t.Key), Does.Not.Contain(tagKey));
+ }
+
public void AssertTags(string metricName, Dictionary expectedTags)
{
foreach (var kvp in expectedTags)
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_customizing_metric_tags.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_customizing_metric_tags.cs
new file mode 100644
index 00000000000..e964f5faf57
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_customizing_metric_tags.cs
@@ -0,0 +1,110 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Metrics;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using EndpointTemplates;
+using NServiceBus;
+using AcceptanceTesting;
+using NServiceBus.Pipeline;
+using NUnit.Framework;
+using global::OpenTelemetry;
+using global::OpenTelemetry.Metrics;
+
+public class When_customizing_metric_tags : OpenTelemetryAcceptanceTest
+{
+ const string TotalFetched = "nservicebus.messaging.fetches";
+ const string MessageDeserializeTime = "nservicebus.messaging.deserialize_time";
+ const string EndpointDiscriminatorTag = "nservicebus.discriminator";
+ const string EnclosedMessageTypesTag = "nservicebus.enclosed_message_types";
+ const string TenantTag = "acceptance.tenant_id";
+ const string FriendlyMessageTypeName = "Order placed (friendly name)";
+
+ [Test]
+ public async Task Should_allow_adding_removing_and_overriding_tags_per_instrument()
+ {
+ using var metricsListener = TestingMetricListener.SetupNServiceBusMetricsListener();
+
+ List exportedMetrics = [];
+ using var meterProvider = Sdk.CreateMeterProviderBuilder()
+ .AddMeter("NServiceBus.Core.Pipeline.Incoming")
+ .AddView(TotalFetched, new MetricStreamConfiguration
+ {
+ TagKeys = ["nservicebus.queue", "nservicebus.message_type", TenantTag]
+ })
+ .AddReader(new BaseExportingMetricReader(new CapturingExporter(exportedMetrics)))
+ .Build();
+
+ await Scenario.Define()
+ .WithEndpoint(b => b.CustomConfig(c => c.MakeInstanceUniquelyAddressable("disc"))
+ .When(async session =>
+ {
+ var sendOptions = new SendOptions();
+ sendOptions.RouteToThisEndpoint();
+ sendOptions.SetHeader(TenantTag, "acme-corp");
+ await session.Send(new MyMessage(), sendOptions);
+ }))
+ .Run();
+
+ meterProvider.ForceFlush();
+
+ metricsListener.AssertTags(TotalFetched, new Dictionary { [TenantTag] = "acme-corp" });
+
+ metricsListener.AssertTagKeyExists(TotalFetched, EndpointDiscriminatorTag);
+
+ var overriddenValue = metricsListener.AssertTagKeyExists(MessageDeserializeTime, EnclosedMessageTypesTag);
+ Assert.That(overriddenValue, Is.EqualTo(FriendlyMessageTypeName));
+ }
+
+ public class Context : ScenarioContext;
+
+ public class EndpointWithCustomTags : EndpointConfigurationBuilder
+ {
+ public EndpointWithCustomTags() =>
+ EndpointSetup(c => c.Pipeline.Register(
+ new CustomizeMetricTagsBehavior(), "Adds a tenant tag from a header and overrides the enclosed message type tag"));
+
+ [Handler]
+ public class MyHandler(Context testContext) : IHandleMessages
+ {
+ public Task Handle(MyMessage message, IMessageHandlerContext context)
+ {
+ testContext.MarkAsCompleted();
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ class CustomizeMetricTagsBehavior : Behavior
+ {
+ public override Task Invoke(IIncomingPhysicalMessageContext context, Func next)
+ {
+ var tags = context.MetricTags;
+
+ if (context.Message.Headers.TryGetValue(TenantTag, out var tenantId))
+ {
+ tags.AddOrOverride(TenantTag, tenantId, TotalFetched);
+ }
+
+ tags.AddOrOverride(EnclosedMessageTypesTag, FriendlyMessageTypeName, MessageDeserializeTime);
+
+ return next();
+ }
+ }
+
+ class CapturingExporter(List exportedMetrics) : BaseExporter
+ {
+ public override ExportResult Export(in Batch batch)
+ {
+ foreach (var metric in batch)
+ {
+ exportedMetrics.Add(metric);
+ }
+
+ return ExportResult.Success;
+ }
+ }
+
+ public class MyMessage : IMessage;
+}
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/Metrics/When_message_is_processed_successfully.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_message_is_processed_successfully.cs
index d798721b267..f83b9a767df 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_message_is_processed_successfully.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_message_is_processed_successfully.cs
@@ -11,6 +11,15 @@
public class When_message_is_processed_successfully : OpenTelemetryAcceptanceTest
{
+ const string SuccessesMetric = "nservicebus.messaging.successes";
+ const string FetchesMetric = "nservicebus.messaging.fetches";
+ const string CriticalTimeMetric = "nservicebus.messaging.critical_time";
+ const string ProcessingTimeMetric = "nservicebus.messaging.processing_time";
+ const string HandlerTimeMetric = "nservicebus.messaging.handler_time";
+ const string QueueTag = "nservicebus.queue";
+ const string DiscriminatorTag = "nservicebus.discriminator";
+ const string MessageTypeTag = "nservicebus.message_type";
+
[Test]
public async Task Should_report_successful_message_metric()
{
@@ -27,51 +36,51 @@ public async Task Should_report_successful_message_metric()
}))
.Run();
- metricsListener.AssertMetric("nservicebus.messaging.successes", 5);
- metricsListener.AssertMetric("nservicebus.messaging.fetches", 5);
+ metricsListener.AssertMetric(SuccessesMetric, 5);
+ metricsListener.AssertMetric(FetchesMetric, 5);
metricsListener.AssertMetric("nservicebus.messaging.failures", 0);
- metricsListener.AssertMetric("nservicebus.messaging.critical_time", 5);
- metricsListener.AssertMetric("nservicebus.messaging.processing_time", 5);
- metricsListener.AssertMetric("nservicebus.messaging.handler_time", 5);
+ metricsListener.AssertMetric(CriticalTimeMetric, 5);
+ metricsListener.AssertMetric(ProcessingTimeMetric, 5);
+ metricsListener.AssertMetric(HandlerTimeMetric, 5);
- metricsListener.AssertTags("nservicebus.messaging.fetches",
+ metricsListener.AssertTags(FetchesMetric,
new Dictionary
{
- ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
- ["nservicebus.discriminator"] = "disc",
- ["nservicebus.message_type"] = typeof(OutgoingMessage).FullName
+ [QueueTag] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
+ [DiscriminatorTag] = "disc",
+ [MessageTypeTag] = typeof(OutgoingMessage).FullName
});
- metricsListener.AssertTags("nservicebus.messaging.successes",
+ metricsListener.AssertTags(SuccessesMetric,
new Dictionary
{
- ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
- ["nservicebus.discriminator"] = "disc",
- ["nservicebus.message_type"] = typeof(OutgoingMessage).FullName
+ [QueueTag] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
+ [DiscriminatorTag] = "disc",
+ [MessageTypeTag] = typeof(OutgoingMessage).FullName
});
- metricsListener.AssertTags("nservicebus.messaging.critical_time",
+ metricsListener.AssertTags(CriticalTimeMetric,
new Dictionary
{
- ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
- ["nservicebus.discriminator"] = "disc",
- ["nservicebus.message_type"] = typeof(OutgoingMessage).FullName
+ [QueueTag] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
+ [DiscriminatorTag] = "disc",
+ [MessageTypeTag] = typeof(OutgoingMessage).FullName
});
- metricsListener.AssertTags("nservicebus.messaging.processing_time",
+ metricsListener.AssertTags(ProcessingTimeMetric,
new Dictionary
{
- ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
- ["nservicebus.discriminator"] = "disc",
- ["nservicebus.message_type"] = typeof(OutgoingMessage).FullName
+ [QueueTag] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
+ [DiscriminatorTag] = "disc",
+ [MessageTypeTag] = typeof(OutgoingMessage).FullName
});
- metricsListener.AssertTags("nservicebus.messaging.handler_time",
+ metricsListener.AssertTags(HandlerTimeMetric,
new Dictionary
{
- ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
- ["nservicebus.discriminator"] = "disc",
- ["nservicebus.message_type"] = typeof(OutgoingMessage).FullName,
+ [QueueTag] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
+ [DiscriminatorTag] = "disc",
+ [MessageTypeTag] = typeof(OutgoingMessage).FullName,
["nservicebus.message_handler_type"] = typeof(EndpointWithMetrics.MessageHandler).FullName,
["execution.result"] = "success"
});
@@ -93,18 +102,18 @@ public async Task Should_only_tag_most_concrete_type_on_metric()
}))
.Run();
- metricsListener.AssertMetric("nservicebus.messaging.successes", 5);
- metricsListener.AssertMetric("nservicebus.messaging.fetches", 5);
+ metricsListener.AssertMetric(SuccessesMetric, 5);
+ metricsListener.AssertMetric(FetchesMetric, 5);
metricsListener.AssertMetric("nservicebus.messaging.failures", 0);
var successEndpoint =
- metricsListener.AssertTagKeyExists("nservicebus.messaging.successes", "nservicebus.queue");
+ metricsListener.AssertTagKeyExists(SuccessesMetric, QueueTag);
var successType =
- metricsListener.AssertTagKeyExists("nservicebus.messaging.successes", "nservicebus.message_type");
+ metricsListener.AssertTagKeyExists(SuccessesMetric, MessageTypeTag);
var successHandlerType =
- metricsListener.AssertTagKeyExists("nservicebus.messaging.successes", "nservicebus.message_handler_types");
+ metricsListener.AssertTagKeyExists(SuccessesMetric, "nservicebus.message_handler_types");
- var fetchedEndpoint = metricsListener.AssertTagKeyExists("nservicebus.messaging.fetches", "nservicebus.queue");
+ var fetchedEndpoint = metricsListener.AssertTagKeyExists(FetchesMetric, QueueTag);
using (Assert.EnterMultipleScope())
{
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_message_processing_fails.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_message_processing_fails.cs
index 15a7172bde4..fa982f05e43 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_message_processing_fails.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_message_processing_fails.cs
@@ -9,6 +9,14 @@
public class When_message_processing_fails : OpenTelemetryAcceptanceTest
{
+ const string FetchesMetric = "nservicebus.messaging.fetches";
+ const string FailuresMetric = "nservicebus.messaging.failures";
+ const string HandlerTimeMetric = "nservicebus.messaging.handler_time";
+ const string QueueTag = "nservicebus.queue";
+ const string DiscriminatorTag = "nservicebus.discriminator";
+ const string MessageTypeTag = "nservicebus.message_type";
+ const string ErrorTypeTag = "error.type";
+
[Test]
public async Task Should_report_failing_message_metrics()
{
@@ -20,38 +28,38 @@ public async Task Should_report_failing_message_metrics()
.When(s => s.SendLocal(new FailingMessage())))
.Run();
- metricsListener.AssertMetric("nservicebus.messaging.fetches", 1);
- metricsListener.AssertMetric("nservicebus.messaging.failures", 1);
+ metricsListener.AssertMetric(FetchesMetric, 1);
+ metricsListener.AssertMetric(FailuresMetric, 1);
metricsListener.AssertMetric("nservicebus.messaging.successes", 0);
metricsListener.AssertMetric("nservicebus.messaging.critical_time", 0);
metricsListener.AssertMetric("nservicebus.messaging.processing_time", 0);
- metricsListener.AssertMetric("nservicebus.messaging.handler_time", 1);
+ metricsListener.AssertMetric(HandlerTimeMetric, 1);
- metricsListener.AssertTags("nservicebus.messaging.fetches",
+ metricsListener.AssertTags(FetchesMetric,
new Dictionary
{
- ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(FailingEndpoint)),
- ["nservicebus.discriminator"] = "disc",
- ["nservicebus.message_type"] = typeof(FailingMessage).FullName
+ [QueueTag] = Conventions.EndpointNamingConvention(typeof(FailingEndpoint)),
+ [DiscriminatorTag] = "disc",
+ [MessageTypeTag] = typeof(FailingMessage).FullName
});
- metricsListener.AssertTags("nservicebus.messaging.failures",
+ metricsListener.AssertTags(FailuresMetric,
new Dictionary
{
- ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(FailingEndpoint)),
- ["nservicebus.discriminator"] = "disc",
- ["nservicebus.message_type"] = typeof(FailingMessage).FullName,
- ["error.type"] = typeof(SimulatedException).FullName,
+ [QueueTag] = Conventions.EndpointNamingConvention(typeof(FailingEndpoint)),
+ [DiscriminatorTag] = "disc",
+ [MessageTypeTag] = typeof(FailingMessage).FullName,
+ [ErrorTypeTag] = typeof(SimulatedException).FullName,
});
- metricsListener.AssertTags("nservicebus.messaging.handler_time",
+ metricsListener.AssertTags(HandlerTimeMetric,
new Dictionary
{
- ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(FailingEndpoint)),
- ["nservicebus.discriminator"] = "disc",
- ["nservicebus.message_type"] = typeof(FailingMessage).FullName,
+ [QueueTag] = Conventions.EndpointNamingConvention(typeof(FailingEndpoint)),
+ [DiscriminatorTag] = "disc",
+ [MessageTypeTag] = typeof(FailingMessage).FullName,
["execution.result"] = "failure",
- ["error.type"] = typeof(SimulatedException).FullName,
+ [ErrorTypeTag] = typeof(SimulatedException).FullName
});
}
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_messages_are_processed_concurrently.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_messages_are_processed_concurrently.cs
new file mode 100644
index 00000000000..da6b3382f0e
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_messages_are_processed_concurrently.cs
@@ -0,0 +1,72 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Metrics;
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using EndpointTemplates;
+using NServiceBus;
+using AcceptanceTesting;
+using NUnit.Framework;
+using Conventions = AcceptanceTesting.Customization.Conventions;
+
+public class When_messages_are_processed_concurrently : OpenTelemetryAcceptanceTest
+{
+ const string ActiveMessagesMetric = "nservicebus.messaging.active_messages";
+ const int numberOfMessages = 5;
+
+ [Test]
+ public async Task Should_report_active_messages_gauge_that_balances_once_idle()
+ {
+ using var metricsListener = TestingMetricListener.SetupNServiceBusMetricsListener();
+
+ _ = await Scenario.Define()
+ .WithEndpoint(b => b.CustomConfig(c =>
+ {
+ c.MakeInstanceUniquelyAddressable("instanceId");
+ c.LimitMessageProcessingConcurrencyTo(10);
+ }).When(async (session, ctx) =>
+ {
+ for (var x = 0; x < numberOfMessages; x++)
+ {
+ await session.SendLocal(new OutgoingMessage());
+ }
+ }))
+ .Run();
+
+ Assert.That(metricsListener.ReportedMeters.TryGetValue(ActiveMessagesMetric, out var net), Is.True,
+ $"'{ActiveMessagesMetric}' gauge should be reported");
+ Assert.That(net, Is.EqualTo(0),
+ "increments and decrements should balance once all messages have been processed");
+
+ metricsListener.AssertTags(ActiveMessagesMetric,
+ new Dictionary
+ {
+ ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
+ ["nservicebus.discriminator"] = "instanceId",
+ ["nservicebus.enclosed_message_types"] = typeof(OutgoingMessage).AssemblyQualifiedName
+ });
+ }
+
+ public class Context : ScenarioContext
+ {
+ public int OutgoingMessagesReceived;
+ }
+
+ public class EndpointWithMetrics : EndpointConfigurationBuilder
+ {
+ public EndpointWithMetrics() => EndpointSetup();
+
+ [Handler]
+ public class MessageHandler(Context testContext) : IHandleMessages
+ {
+ public Task Handle(OutgoingMessage message, IMessageHandlerContext context)
+ {
+ var messagesHandled = Interlocked.Increment(ref testContext.OutgoingMessagesReceived);
+ testContext.MarkAsCompleted(messagesHandled == numberOfMessages);
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ public class OutgoingMessage : IMessage;
+}
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_retrying_messages.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_retrying_messages.cs
index 0664a40a70e..808aff5c388 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_retrying_messages.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_retrying_messages.cs
@@ -8,6 +8,10 @@
public class When_retrying_messages : OpenTelemetryAcceptanceTest
{
+ const string ImmediateRetriesMetric = "nservicebus.recoverability.immediate";
+ const string DelayedRetriesMetric = "nservicebus.recoverability.delayed";
+ const string ErrorQueueMetric = "nservicebus.recoverability.error";
+
[Test]
public async Task Should_increment_immediate_meter()
{
@@ -20,9 +24,9 @@ await Scenario.Define()
.When(s => s.SendLocal(new FailingMessage())))
.Run();
- metricsListener.AssertMetric("nservicebus.recoverability.immediate", 1);
- metricsListener.AssertMetric("nservicebus.recoverability.delayed", 0);
- metricsListener.AssertMetric("nservicebus.recoverability.error", 0);
+ metricsListener.AssertMetric(ImmediateRetriesMetric, 1);
+ metricsListener.AssertMetric(DelayedRetriesMetric, 0);
+ metricsListener.AssertMetric(ErrorQueueMetric, 0);
}
[Test]
@@ -43,9 +47,9 @@ await Scenario.Define()
.When(s => s.SendLocal(new FailingMessage())))
.Run();
- metricsListener.AssertMetric("nservicebus.recoverability.immediate", 0);
- metricsListener.AssertMetric("nservicebus.recoverability.delayed", 1);
- metricsListener.AssertMetric("nservicebus.recoverability.error", 0);
+ metricsListener.AssertMetric(ImmediateRetriesMetric, 0);
+ metricsListener.AssertMetric(DelayedRetriesMetric, 1);
+ metricsListener.AssertMetric(ErrorQueueMetric, 0);
}
[Test]
@@ -66,9 +70,9 @@ await Scenario.Define()
.Run();
}, Throws.Exception);
- metricsListener.AssertMetric("nservicebus.recoverability.immediate", 0);
- metricsListener.AssertMetric("nservicebus.recoverability.delayed", 0);
- metricsListener.AssertMetric("nservicebus.recoverability.error", 1);
+ metricsListener.AssertMetric(ImmediateRetriesMetric, 0);
+ metricsListener.AssertMetric(DelayedRetriesMetric, 0);
+ metricsListener.AssertMetric(ErrorQueueMetric, 1);
}
public class Context : ScenarioContext
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_serializing_outgoing_messages.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_serializing_outgoing_messages.cs
new file mode 100644
index 00000000000..04349df4f0c
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_serializing_outgoing_messages.cs
@@ -0,0 +1,85 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Metrics;
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using EndpointTemplates;
+using NServiceBus;
+using NServiceBus.AcceptanceTesting;
+using NServiceBus.AcceptanceTesting.Customization;
+using NUnit.Framework;
+using Conventions = AcceptanceTesting.Customization.Conventions;
+
+public class When_serializing_outgoing_messages : OpenTelemetryAcceptanceTest
+{
+ const string MessageSerializeTime = "nservicebus.messaging.serialize_time";
+
+ [Test]
+ public async Task Should_add_the_queue_name_and_discriminator_tags_to_serialize_time_instrument_when_full_endpoint()
+ {
+ using var metricsListener = TestingMetricListener.SetupNServiceBusMetricsListener();
+
+ _ = await Scenario.Define()
+ .WithEndpoint(b => b
+ .CustomConfig(x => x.MakeInstanceUniquelyAddressable("disc"))
+ .When(session => session.SendLocal(new OutgoingMessage())))
+ .Run();
+
+ metricsListener.AssertTags(MessageSerializeTime,
+ new Dictionary
+ {
+ ["nservicebus.queue"] = Conventions.EndpointNamingConvention(typeof(EndpointWithMetrics)),
+ ["nservicebus.discriminator"] = "disc",
+ ["nservicebus.message_type"] = typeof(OutgoingMessage).FullName
+ });
+ }
+
+ [Test]
+ public async Task Should_not_add_the_queue_name_and_discriminator_tags_to_serialize_time_instrument_when_send_only()
+ {
+ // A send-only endpoint never creates its input queue, so reporting one would be misleading.
+ using var metricsListener = TestingMetricListener.SetupNServiceBusMetricsListener();
+
+ _ = await Scenario.Define()
+ .WithEndpoint(b => b.When(session => session.Send(new OutgoingMessage())))
+ .WithEndpoint()
+ .Run();
+
+ //using (Assert.EnterMultipleScope())
+ {
+ metricsListener.AssertTagKeyDoesNotExist(MessageSerializeTime, "nservicebus.queue");
+ metricsListener.AssertTagKeyDoesNotExist(MessageSerializeTime, "nservicebus.discriminator");
+ // the metric itself is still recorded, only the two tags are unavailable
+ Assert.That(metricsListener.AssertTagKeyExists(MessageSerializeTime, "nservicebus.message_type"),
+ Is.EqualTo(typeof(OutgoingMessage).FullName));
+ }
+ }
+
+ public class Context : ScenarioContext;
+
+ public class SendOnlyEndpoint : EndpointConfigurationBuilder
+ {
+ public SendOnlyEndpoint() =>
+ EndpointSetup(c =>
+ {
+ c.SendOnly();
+ c.ConfigureRouting().RouteToEndpoint(typeof(OutgoingMessage), typeof(EndpointWithMetrics));
+ });
+ }
+
+ public class EndpointWithMetrics : EndpointConfigurationBuilder
+ {
+ public EndpointWithMetrics() => EndpointSetup();
+
+ [Handler]
+ public class MessageHandler(Context testContext) : IHandleMessages
+ {
+ public Task Handle(OutgoingMessage message, IMessageHandlerContext context)
+ {
+ testContext.MarkAsCompleted();
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ public class OutgoingMessage : IMessage;
+}
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/OpenTelemetryAcceptanceTest.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/OpenTelemetryAcceptanceTest.cs
index 99b8b43ab54..f67d0ab06bd 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/OpenTelemetryAcceptanceTest.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/OpenTelemetryAcceptanceTest.cs
@@ -9,7 +9,7 @@ public abstract class OpenTelemetryAcceptanceTest : NServiceBusAcceptanceTest
protected TestingActivityListener NServiceBusActivityListener { get; private set; }
[SetUp]
- public void Setup() => NServiceBusActivityListener = TestingActivityListener.SetupDiagnosticListener("NServiceBus.Core");
+ public void Setup() => NServiceBusActivityListener = TestingActivityListener.SetupDiagnosticListener("NServiceBus.Core", "NServiceBus.Core.Handler", "NServiceBus.Core.Recoverability");
[TearDown]
public void Cleanup()
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/TestingActivityListener.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/TestingActivityListener.cs
index a5fdb5ce06a..38dc6113d83 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/TestingActivityListener.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/TestingActivityListener.cs
@@ -11,19 +11,19 @@ public class TestingActivityListener : IDisposable
{
readonly ActivityListener activityListener;
- public static TestingActivityListener SetupDiagnosticListener(string sourceName)
+ public static TestingActivityListener SetupDiagnosticListener(params string[] sourceNames)
{
- var testingListener = new TestingActivityListener(sourceName);
+ var testingListener = new TestingActivityListener(sourceNames);
ActivitySource.AddActivityListener(testingListener.activityListener);
return testingListener;
}
- TestingActivityListener(string sourceName = null)
+ TestingActivityListener(params string[] sourceNames)
{
activityListener = new ActivityListener
{
- ShouldListenTo = source => string.IsNullOrEmpty(sourceName) || source.Name == sourceName,
+ ShouldListenTo = source => sourceNames.Length == 0 || sourceNames.Contains(source.Name),
Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData,
SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData
};
@@ -62,4 +62,5 @@ public static List GetReceiveMessageActivities(this ConcurrentQueue GetSendMessageActivities(this ConcurrentQueue activities) => activities.Where(a => a.OperationName == "NServiceBus.Diagnostics.SendMessage").ToList();
public static List GetPublishEventActivities(this ConcurrentQueue activities) => activities.Where(a => a.OperationName == "NServiceBus.Diagnostics.PublishMessage").ToList();
public static List GetInvokedHandlerActivities(this ConcurrentQueue activities) => activities.Where(a => a.OperationName == "NServiceBus.Diagnostics.InvokeHandler").ToList();
+ public static List GetRecoverabilityActivities(this ConcurrentQueue activities) => activities.Where(a => a.OperationName == "NServiceBus.Diagnostics.Recoverability").ToList();
}
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_incoming_message_has_no_trace.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_no_trace.cs
index 69360955a0e..ecb290027bf 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_no_trace.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_no_trace.cs
@@ -36,6 +36,7 @@ public override Task Invoke(IDispatchContext context, Func next)
{
foreach (TransportOperation transportOperation in context.Operations)
{
+ transportOperation.Message.Headers.Remove(Headers.NServiceBusDiagnosticsTraceParent);
transportOperation.Message.Headers.Remove(Headers.DiagnosticsTraceParent);
transportOperation.Message.Headers.Remove(Headers.DiagnosticsTraceState);
transportOperation.Message.Headers.Remove(Headers.DiagnosticsBaggage);
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_trace.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_trace.cs
index fcfa4bd264e..099ab50cde9 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_trace.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_trace.cs
@@ -9,6 +9,8 @@
public class When_incoming_message_has_trace : OpenTelemetryAcceptanceTest // assuming W3C trace!
{
+ const string MessageIdTag = "nservicebus.message_id";
+
[Test]
public async Task Should_correlate_trace_from_send()
{
@@ -40,10 +42,10 @@ public async Task Should_correlate_trace_from_send()
Assert.That(receiveReply.ParentId, Is.EqualTo(sendReply.Id), "second incoming message is correlated to the second send operation");
Assert.That(receiveReply.RootId, Is.EqualTo(sendRequest.RootId), "first send operation is the root activity");
- Assert.That(sendRequest.Tags.ToImmutableDictionary()["nservicebus.message_id"], Is.EqualTo(context.IncomingMessageId));
- Assert.That(receiveRequest.Tags.ToImmutableDictionary()["nservicebus.message_id"], Is.EqualTo(context.IncomingMessageId));
- Assert.That(sendReply.Tags.ToImmutableDictionary()["nservicebus.message_id"], Is.EqualTo(context.ReplyMessageId));
- Assert.That(receiveReply.Tags.ToImmutableDictionary()["nservicebus.message_id"], Is.EqualTo(context.ReplyMessageId));
+ Assert.That(sendRequest.Tags.ToImmutableDictionary()[MessageIdTag], Is.EqualTo(context.IncomingMessageId));
+ Assert.That(receiveRequest.Tags.ToImmutableDictionary()[MessageIdTag], Is.EqualTo(context.IncomingMessageId));
+ Assert.That(sendReply.Tags.ToImmutableDictionary()[MessageIdTag], Is.EqualTo(context.ReplyMessageId));
+ Assert.That(receiveReply.Tags.ToImmutableDictionary()[MessageIdTag], Is.EqualTo(context.ReplyMessageId));
}
}
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..33cfd28674b 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,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.UseV11Behavior AppContext switch is enabled (the only behavior in v11).
Assert.That(context.BaggageHeader, Is.EqualTo("key3=,key2=value2,key1=value1"));
}
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_fails.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_fails.cs
index a752b82ef29..52ab5ba660e 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_fails.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_fails.cs
@@ -44,6 +44,13 @@ public async Task Should_mark_span_as_failed()
handlerActivityTags.VerifyTag("otel.status_code", "ERROR");
handlerActivityTags.VerifyTag("otel.status_description", ErrorMessage);
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(failedHandlerActivity.Events, Has.Exactly(1).Items,
+ "the innermost span (the handler invocation) should record the exception details");
+ Assert.That(failedPipelineActivity.Events, Is.Empty,
+ "the outer span should not duplicate the exception details already recorded on the inner span");
+ }
}
public class Context : ScenarioContext;
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_fails_with_exception_logs_opt_in.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_fails_with_exception_logs_opt_in.cs
new file mode 100644
index 00000000000..c5d6b664333
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_fails_with_exception_logs_opt_in.cs
@@ -0,0 +1,59 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Traces;
+
+using System.Linq;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using Configuration.AdvancedExtensibility;
+using EndpointTemplates;
+using NServiceBus;
+using NUnit.Framework;
+
+// The OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN override is applied while the OpenTelemetryFeature defaults
+// run, which is AFTER the endpoint's activity factory has already been built from the instrumentation
+// options. The opt-in only takes effect when the activity factory and the settings share a single
+// InstrumentationOptions instance.
+public class When_processing_fails_with_exception_logs_opt_in : OpenTelemetryAcceptanceTest
+{
+ [Test]
+ public async Task Should_record_the_exception_as_a_log_instead_of_a_span_event()
+ {
+ var context = await Scenario.Define()
+ .WithEndpoint(e => e
+ .DoNotFailOnErrorMessages()
+ .When(s => s.SendLocal(new FailingMessage())))
+ .Run();
+
+ Assert.That(context.FailedMessages, Has.Count.EqualTo(1), "the message should have failed");
+
+ var handlerActivity = NServiceBusActivityListener.CompletedActivities.GetInvokedHandlerActivities().Single();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(handlerActivity.Events, Is.Empty, "the exception should not be recorded as a span event when the endpoint opted in to exceptions as logs");
+ Assert.That(context.Logs.Any(l => l.LoggerName == "NServiceBus.ActivityFactory" && l.Level == Logging.LogLevel.Error && l.Message.Contains(ErrorMessage)), Is.True, "the exception should be recorded as an error log instead");
+ }
+ }
+
+ public class Context : ScenarioContext;
+
+ public class FailingEndpoint : EndpointConfigurationBuilder
+ {
+ // Does not call endpointConfiguration.Tracing(): the instrumentation options only come into existence while the endpoint is being created.
+ public FailingEndpoint() => EndpointSetup(c => c.GetSettings().Set($"ACCEPTANCETEST_ENV:{OptInEnvironmentVariable}", "logs"));
+
+ [Handler]
+ public class FailingMessageHandler(Context testContext) : IHandleMessages
+ {
+ public Task Handle(FailingMessage message, IMessageHandlerContext context)
+ {
+ testContext.MarkAsCompleted();
+ throw new SimulatedException(ErrorMessage);
+ }
+ }
+ }
+
+ public class FailingMessage : IMessage;
+
+ const string OptInEnvironmentVariable = "OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN";
+ const string ErrorMessage = "boom!";
+}
\ No newline at end of file
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..567d360fbf7 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,38 @@ public Task Handle(IncomingMessage message, IMessageHandlerContext context)
}
}
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_include_receive_address_in_span_name()
+ {
+ 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"));
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_tag_enclosed_message_types_as_array()
+ {
+ var context = await Scenario.Define()
+ .WithEndpoint(e => e
+ .When(s => s.SendLocal(new IncomingMessage())))
+ .Run();
+
+ var incomingActivity = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities().Single();
+
+ // Array-valued tags are only visible through TagObjects, not Tags.
+ var enclosedMessageTypes = incomingActivity.TagObjects.ToImmutableDictionary()["nservicebus.enclosed_message_types"];
+ Assert.That(enclosedMessageTypes, Is.EqualTo(context.ReceivedHeaders[Headers.EnclosedMessageTypes].Split(';')));
+ }
+
public class IncomingMessage : IMessage;
}
\ No newline at end of file
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_message_with_default_activity_sources.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_message_with_default_activity_sources.cs
new file mode 100644
index 00000000000..4b6c631b89b
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_processing_message_with_default_activity_sources.cs
@@ -0,0 +1,48 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Traces;
+
+using System.Linq;
+using System.Threading.Tasks;
+using EndpointTemplates;
+using NServiceBus.AcceptanceTesting;
+using NUnit.Framework;
+
+public class When_processing_message_with_default_activity_sources : OpenTelemetryAcceptanceTest
+{
+ // Until v11, handler spans are emitted from the "NServiceBus.Core" ActivitySource by default
+ // for backwards compatibility. The dedicated "NServiceBus.Core.Handler" source is opt-in via
+ // the NServiceBus.Core.OpenTelemetry.UseV11Behavior AppContext switch (the only behavior in v11).
+ [Test]
+ public async Task Should_emit_handler_span_from_main_source()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b =>
+ b.When(session => session.SendLocal(new SomeMessage()))
+ )
+ .Run();
+
+ var invokedHandlerActivities = NServiceBusActivityListener.CompletedActivities.GetInvokedHandlerActivities();
+
+ Assert.That(invokedHandlerActivities, Has.Count.EqualTo(1));
+ Assert.That(invokedHandlerActivities.Single().Source.Name, Is.EqualTo("NServiceBus.Core"),
+ "without the opt-in switch, handler spans must keep coming from the main source so existing OpenTelemetry configurations keep seeing them");
+ }
+
+ public class Context : ScenarioContext;
+
+ public class ReceivingEndpoint : EndpointConfigurationBuilder
+ {
+ public ReceivingEndpoint() => EndpointSetup();
+
+ [Handler]
+ public class MessageHandler(Context testContext) : IHandleMessages
+ {
+ public Task Handle(SomeMessage message, IMessageHandlerContext context)
+ {
+ testContext.MarkAsCompleted();
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ public class SomeMessage : IMessage;
+}
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..c0359fd053f 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,188 @@ public Task Handle(ThisIsAnEvent @event, IMessageHandlerContext context)
}
}
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_include_event_type_in_span_name()
+ {
+ 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"));
+ }
+
+ public class PublisherForV11Naming : EndpointConfigurationBuilder
+ {
+ public PublisherForV11Naming() =>
+ EndpointSetup(b =>
+ {
+ b.OnEndpointSubscribed((s, context) =>
+ {
+ if (s.SubscriberEndpoint.Contains(Conventions.EndpointNamingConvention(typeof(SubscriberForPublisherForV11Naming))))
+ {
+ if (s.MessageType == typeof(ThisIsAnEvent).AssemblyQualifiedName)
+ {
+ context.SomeEventSubscribed = true;
+ }
+ }
+ });
+ });
+ }
+
+ public class SubscriberForPublisherForV11Naming : EndpointConfigurationBuilder
+ {
+ public SubscriberForPublisherForV11Naming() =>
+ EndpointSetup(c => { },
+ metadata =>
+ {
+ metadata.RegisterPublisherFor();
+ });
+
+ [Handler]
+ public class ThisHandlesSomethingHandler(Context testContext) : IHandleMessages
+ {
+ public Task Handle(ThisIsAnEvent @event, IMessageHandlerContext context)
+ {
+ testContext.MarkAsCompleted();
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ [Test]
+ public async Task Should_create_child_on_receive_when_endpoint_defaults_to_child_span()
+ {
+ var context = await Scenario.Define()
+ .WithEndpoint(b => b
+ .When(ctx => ctx.SomeEventSubscribed, s => s.Publish(new ThisIsAnEvent())))
+ .WithEndpoint(b => b.When((session, ctx) =>
+ {
+ if (ctx.HasNativePubSubSupport)
+ {
+ ctx.SomeEventSubscribed = true;
+ }
+
+ return Task.CompletedTask;
+ }))
+ .Run();
+
+ var publishMessageActivities = NServiceBusActivityListener.CompletedActivities.GetPublishEventActivities();
+ var receiveMessageActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities();
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(publishMessageActivities, Has.Count.EqualTo(1), "1 message is published as part of this test");
+ Assert.That(receiveMessageActivities, Has.Count.EqualTo(1), "1 message is received as part of this test");
+ }
+
+ var publishRequest = publishMessageActivities[0];
+ var receiveRequest = receiveMessageActivities[0];
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(receiveRequest.RootId, Is.EqualTo(publishRequest.RootId), "publish and receive operations are part the same root activity");
+ Assert.That(receiveRequest.ParentId, Is.Not.Null, "incoming message does have a parent");
+ }
+
+ Assert.That(receiveRequest.Links, Is.Empty, "receive does not have links");
+ }
+
+ [Test]
+ public async Task Should_create_new_linked_trace_on_receive_when_option_overrides_endpoint_connector()
+ {
+ var context = await Scenario.Define()
+ .WithEndpoint(b => b
+ .When(ctx => ctx.SomeEventSubscribed, s =>
+ {
+ var publishOptions = new PublishOptions();
+ publishOptions.StartNewTraceOnReceive();
+ return s.Publish(new ThisIsAnEvent(), publishOptions);
+ }))
+ .WithEndpoint(b => b.When((session, ctx) =>
+ {
+ if (ctx.HasNativePubSubSupport)
+ {
+ ctx.SomeEventSubscribed = true;
+ }
+
+ return Task.CompletedTask;
+ }))
+ .Run();
+
+ var publishMessageActivities = NServiceBusActivityListener.CompletedActivities.GetPublishEventActivities();
+ var receiveMessageActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities();
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(publishMessageActivities, Has.Count.EqualTo(1), "1 message is published as part of this test");
+ Assert.That(receiveMessageActivities, Has.Count.EqualTo(1), "1 message is received as part of this test");
+ }
+
+ var publishRequest = publishMessageActivities[0];
+ var receiveRequest = receiveMessageActivities[0];
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(receiveRequest.RootId, Is.Not.EqualTo(publishRequest.RootId), "publish and receive operations are part of different root activities");
+ Assert.That(receiveRequest.ParentId, Is.Null, "incoming message does not have a parent, it's a root");
+ }
+
+ ActivityLink link = receiveRequest.Links.FirstOrDefault();
+ Assert.That(link, Is.Not.EqualTo(default(ActivityLink)), "Receive has a link");
+ Assert.That(link.Context.TraceId, Is.EqualTo(publishRequest.TraceId), "receive is linked to publish operation");
+ }
+
+ public class PublisherWithChildSpanConnector : EndpointConfigurationBuilder
+ {
+ public PublisherWithChildSpanConnector() =>
+ EndpointSetup(b =>
+ {
+ b.Tracing().PublishTraceMode = TraceMode.ContinueExisting;
+ b.OnEndpointSubscribed((s, context) =>
+ {
+ if (s.SubscriberEndpoint.Contains(Conventions.EndpointNamingConvention(typeof(SubscriberForPublisherWithChildSpanConnector))))
+ {
+ if (s.MessageType == typeof(ThisIsAnEvent).AssemblyQualifiedName)
+ {
+ context.SomeEventSubscribed = true;
+ }
+ }
+ });
+ });
+ }
+
+ public class SubscriberForPublisherWithChildSpanConnector : EndpointConfigurationBuilder
+ {
+ public SubscriberForPublisherWithChildSpanConnector() =>
+ 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_recoverability_action_occurs.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_recoverability_action_occurs.cs
new file mode 100644
index 00000000000..09e3b64de80
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_recoverability_action_occurs.cs
@@ -0,0 +1,109 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Traces;
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using AcceptanceTesting.Customization;
+using EndpointTemplates;
+using NUnit.Framework;
+
+public class When_recoverability_action_occurs : OpenTelemetryAcceptanceTest
+{
+ [Test]
+ public async Task Should_create_spans_for_all_recoverability_actions()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b
+ .CustomConfig(c => c.Recoverability()
+ .Immediate(i => i.NumberOfRetries(1))
+ .Delayed(i => i.NumberOfRetries(1).TimeIncrease(TimeSpan.FromMilliseconds(1)))
+ .CustomPolicy((cfg, errorContext) =>
+ errorContext.Headers[Headers.EnclosedMessageTypes].Contains(nameof(DiscardMessage))
+ ? RecoverabilityAction.Discard("test discard reason")
+ : DefaultRecoverabilityPolicy.Invoke(cfg, errorContext)))
+ .DoNotFailOnErrorMessages()
+ .When(async s =>
+ {
+ await s.SendLocal(new FailingMessage());
+ await s.SendLocal(new DiscardMessage());
+ }))
+ .Done(_ => ActionTags().Contains("move_to_error") && ActionTags().Contains("discard"))
+ .Run();
+
+ var activities = NServiceBusActivityListener.CompletedActivities.GetRecoverabilityActivities();
+
+ var immediateRetry = activities.FirstOrDefault(a => (string)a.GetTagItem(ActivityTagName) == "immediate_retry");
+ var delayedRetry = activities.FirstOrDefault(a => (string)a.GetTagItem(ActivityTagName) == "delayed_retry");
+ var moveToError = activities.Single(a => (string)a.GetTagItem(ActivityTagName) == "move_to_error");
+ var discard = activities.Single(a => (string)a.GetTagItem(ActivityTagName) == "discard");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(immediateRetry, Is.Not.Null, "expected at least one immediate retry span");
+ Assert.That(immediateRetry.DisplayName, Is.EqualTo("immediate retry"));
+
+ Assert.That(delayedRetry, Is.Not.Null, "expected at least one delayed retry span");
+ Assert.That(delayedRetry.DisplayName, Is.EqualTo("delayed retry"));
+
+ Assert.That(moveToError.DisplayName, Does.StartWith("move to "));
+ Assert.That(discard.DisplayName, Is.EqualTo("discard"));
+ }
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_include_destination_in_display_name()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b
+ .CustomConfig(c => c.Recoverability().Immediate(i => i.NumberOfRetries(1)).Delayed(i => i.NumberOfRetries(0)))
+ .DoNotFailOnErrorMessages()
+ .When(s => s.SendLocal(new FailingMessage())))
+ .Done(_ => ActionTags().Contains("move_to_error"))
+ .Run();
+
+ var immediateRetry = NServiceBusActivityListener.CompletedActivities.GetRecoverabilityActivities()
+ .First(a => (string)a.GetTagItem(ActivityTagName) == "immediate_retry");
+
+ var endpointName = Conventions.EndpointNamingConvention(typeof(RecoverabilityEndpoint));
+ Assert.That(immediateRetry.DisplayName, Is.EqualTo($"immediate retry {endpointName}"));
+ }
+
+ string[] ActionTags() =>
+ NServiceBusActivityListener.CompletedActivities.GetRecoverabilityActivities()
+ .Select(a => (string)a.GetTagItem(ActivityTagName))
+ .ToArray();
+
+ const string ActivityTagName = "nservicebus.recoverability_action";
+
+ public class Context : ScenarioContext;
+
+ public class RecoverabilityEndpoint : EndpointConfigurationBuilder
+ {
+ public RecoverabilityEndpoint()
+ {
+ var template = new DefaultServer
+ {
+ TransportConfiguration = new ConfigureEndpointAcceptanceTestingTransport(false, true)
+ };
+ EndpointSetup(template, (c, _) => { }, metadata => { });
+ }
+
+ [Handler]
+ public class FailingMessageHandler : IHandleMessages
+ {
+ public Task Handle(FailingMessage message, IMessageHandlerContext context) => throw new SimulatedException("always fails");
+ }
+
+ [Handler]
+ public class DiscardMessageHandler : IHandleMessages
+ {
+ public Task Handle(DiscardMessage message, IMessageHandlerContext context) => throw new SimulatedException("always fails");
+ }
+ }
+
+ public class FailingMessage : IMessage;
+
+ public class DiscardMessage : IMessage;
+}
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_retrying_messages.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_retrying_messages.cs
index b0f02c3c4b5..76f76a5f3e9 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_retrying_messages.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_retrying_messages.cs
@@ -1,10 +1,11 @@
namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Traces;
using System;
+using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using EndpointTemplates;
-using NServiceBus.AcceptanceTesting;
+using AcceptanceTesting;
using NUnit.Framework;
public class When_retrying_messages : OpenTelemetryAcceptanceTest
@@ -37,10 +38,8 @@ await Scenario.Define()
}
[Test]
- public async Task Should_correlate_delayed_retry_with_send()
+ public async Task Should_start_new_trace_on_receive_by_default()
{
- Requires.DelayedDelivery();
-
await Scenario.Define()
.WithEndpoint(e => e
.CustomConfig(c => c.Recoverability().Delayed(i => i.NumberOfRetries(1).TimeIncrease(TimeSpan.FromMilliseconds(1))))
@@ -48,21 +47,58 @@ await Scenario.Define()
.When(s => s.SendLocal(new FailingMessage())))
.Run();
- var receiveActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities();
- var sendActivities = NServiceBusActivityListener.CompletedActivities.GetSendMessageActivities();
+ var (sendRequest, firstAttempt, retryAttempt) = GetDelayedRetryActivities();
using (Assert.EnterMultipleScope())
{
- Assert.That(sendActivities, Has.Count.EqualTo(1));
- Assert.That(receiveActivities, Has.Count.EqualTo(2), "the message should be processed twice due to one immediate retry");
+ Assert.That(firstAttempt.TraceId, Is.EqualTo(sendRequest.TraceId), "the first attempt is part of the original send's trace");
+ Assert.That(firstAttempt.ParentId, Is.EqualTo(sendRequest.Id));
+
+ Assert.That(retryAttempt.TraceId, Is.Not.EqualTo(sendRequest.TraceId), "a delayed retry should start a new trace on receive by default (backward compatible)");
+ Assert.That(retryAttempt.ParentId, Is.Null, "the retry attempt should be a new root");
}
+
+ var link = retryAttempt.Links.FirstOrDefault();
+ Assert.That(link, Is.Not.Default, "the retry attempt should be linked back to the original send operation");
+ Assert.That(link.Context.TraceId, Is.EqualTo(sendRequest.TraceId));
+ }
+
+ [Test]
+ public async Task Should_continue_existing_trace_on_receive_when_configured()
+ {
+ await Scenario.Define()
+ .WithEndpoint(e => e
+ .CustomConfig(c =>
+ {
+ c.Recoverability().Delayed(i => i.NumberOfRetries(1).TimeIncrease(TimeSpan.FromMilliseconds(1)));
+ c.Tracing().Recoverability.DelayedRetryTraceMode = TraceMode.ContinueExisting;
+ })
+ .DoNotFailOnErrorMessages()
+ .When(s => s.SendLocal(new FailingMessage())))
+ .Run();
+
+ var (sendRequest, _, retryAttempt) = GetDelayedRetryActivities();
+
using (Assert.EnterMultipleScope())
{
- Assert.That(receiveActivities[0].ParentId, Is.EqualTo(sendActivities[0].Id), "should not change parent span");
- Assert.That(receiveActivities[1].ParentId, Is.EqualTo(sendActivities[0].Id), "should not change parent span");
+ Assert.That(retryAttempt.TraceId, Is.EqualTo(sendRequest.TraceId), "a delayed retry should continue the existing trace when DelayedRetryTraceMode is set to ContinueExisting");
+ Assert.That(retryAttempt.ParentId, Is.EqualTo(sendRequest.Id));
+ Assert.That(retryAttempt.Links, Is.Empty);
+ }
+ }
- Assert.That(sendActivities.Concat(receiveActivities).All(a => a.TraceId == sendActivities[0].TraceId), Is.True, "all activities should be part of the same trace");
+ (Activity SendRequest, Activity FirstAttempt, Activity RetryAttempt) GetDelayedRetryActivities()
+ {
+ var receiveActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities();
+ var sendActivities = NServiceBusActivityListener.CompletedActivities.GetSendMessageActivities();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(sendActivities, Has.Count.EqualTo(1));
+ Assert.That(receiveActivities, Has.Count.EqualTo(2), "the message should be processed twice due to one delayed retry");
}
+
+ return (sendActivities[0], receiveActivities[0], receiveActivities[1]);
}
public class Context : ScenarioContext
@@ -72,7 +108,11 @@ public class Context : ScenarioContext
public class RetryingEndpoint : EndpointConfigurationBuilder
{
- public RetryingEndpoint() => EndpointSetup();
+ public RetryingEndpoint() =>
+ EndpointSetup(new DefaultServer
+ {
+ TransportConfiguration = new ConfigureEndpointAcceptanceTestingTransport(false, true)
+ }, (c, _) => { }, _ => { });
[Handler]
public class Handler(Context testContext) : IHandleMessages
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_saga_requests_a_timeout.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_saga_requests_a_timeout.cs
new file mode 100644
index 00000000000..d0dfc71b143
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_saga_requests_a_timeout.cs
@@ -0,0 +1,115 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Traces;
+
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using EndpointTemplates;
+using NUnit.Framework;
+
+public class When_saga_requests_a_timeout : OpenTelemetryAcceptanceTest
+{
+ [Test]
+ public async Task Should_start_new_trace_on_receive_by_default()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b
+ .When(s => s.SendLocal(new StartSagaMessage { SomeId = Guid.NewGuid().ToString() })))
+ .Run();
+
+ var (timeoutSend, timeoutReceive) = GetTimeoutActivities();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(timeoutReceive.TraceId, Is.Not.EqualTo(timeoutSend.TraceId), "a saga timeout should start a new trace on receive by default (backward compatible)");
+ Assert.That(timeoutReceive.ParentId, Is.Null, "timeout receive should be a new root");
+ }
+
+ var link = timeoutReceive.Links.FirstOrDefault();
+ Assert.That(link, Is.Not.Default, "timeout receive should be linked back to the timeout send operation");
+ Assert.That(link.Context.TraceId, Is.EqualTo(timeoutSend.TraceId));
+ }
+
+ [Test]
+ public async Task Should_continue_existing_trace_on_receive_when_configured()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b.CustomConfig(c =>
+ {
+ c.Tracing().DelayedDelivery.SagaTimeoutTraceMode = TraceMode.ContinueExisting;
+ })
+ .When(s => s.SendLocal(new StartSagaMessage { SomeId = Guid.NewGuid().ToString() })))
+ .Run();
+
+ var (timeoutSend, timeoutReceive) = GetTimeoutActivities();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(timeoutReceive.TraceId, Is.EqualTo(timeoutSend.TraceId), "a saga timeout should continue the existing trace when SagaTimeoutTraceMode is set to ContinueExisting");
+ Assert.That(timeoutReceive.ParentId, Is.EqualTo(timeoutSend.Id));
+ Assert.That(timeoutReceive.Links, Is.Empty);
+ }
+ }
+
+ (Activity TimeoutSend, Activity TimeoutReceive) GetTimeoutActivities()
+ {
+ var sendActivities = NServiceBusActivityListener.CompletedActivities.GetSendMessageActivities();
+ var receiveActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(sendActivities, Has.Count.EqualTo(2), "start-saga send and timeout send");
+ Assert.That(receiveActivities, Has.Count.EqualTo(2), "start-saga receive and timeout receive");
+ }
+
+ return (sendActivities[1], receiveActivities[1]);
+ }
+
+ public class Context : ScenarioContext
+ {
+ public bool SagaMarkedComplete { get; set; }
+ }
+
+ public class SagaEndpoint : EndpointConfigurationBuilder
+ {
+ public SagaEndpoint() =>
+ EndpointSetup(new DefaultServer
+ {
+ TransportConfiguration = new ConfigureEndpointAcceptanceTestingTransport(false, true)
+ }, (c, _) => { }, _ => { });
+
+ [Saga]
+ public class TimeoutSaga(Context testContext) : Saga, IAmStartedByMessages, IHandleTimeouts
+ {
+ protected override void ConfigureHowToFindSaga(SagaPropertyMapper mapper) =>
+ mapper.MapSaga(s => s.SomeId).ToMessage(m => m.SomeId);
+
+ public Task Handle(StartSagaMessage message, IMessageHandlerContext context)
+ {
+ Data.SomeId = message.SomeId;
+ return RequestTimeout(context, DateTimeOffset.UtcNow.AddMilliseconds(2));
+ }
+
+ public Task Timeout(SagaTimeout state, IMessageHandlerContext context)
+ {
+ MarkAsComplete();
+ testContext.SagaMarkedComplete = true;
+ testContext.MarkAsCompleted();
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ public class TimeoutSagaData : ContainSagaData
+ {
+ public virtual string SomeId { get; set; }
+ }
+
+ public class StartSagaMessage : IMessage
+ {
+ public string SomeId { get; set; }
+ }
+
+ public class SagaTimeout;
+}
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_a_delayed_message.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_a_delayed_message.cs
new file mode 100644
index 00000000000..1e0e72e17cc
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_sending_a_delayed_message.cs
@@ -0,0 +1,124 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Traces;
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using EndpointTemplates;
+using NUnit.Framework;
+
+public class When_sending_a_delayed_message : OpenTelemetryAcceptanceTest
+{
+ [Test]
+ public async Task Should_start_new_trace_on_receive_by_default()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b
+ .When(s => s.Send(new DelayedMessage(), DelayedSend())))
+ .Run();
+
+ var (send, receive) = GetActivities();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(receive.TraceId, Is.Not.EqualTo(send.TraceId), "a delayed send should start a new trace on receive by default (backward compatible)");
+ Assert.That(receive.ParentId, Is.Null, "receive should be a new root");
+ }
+
+ var link = receive.Links.FirstOrDefault();
+ Assert.That(link, Is.Not.Default, "receive should be linked back to the send operation");
+ Assert.That(link.Context.TraceId, Is.EqualTo(send.TraceId));
+ }
+
+ [Test]
+ public async Task Should_continue_existing_trace_on_receive_when_configured()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b.CustomConfig(c =>
+ {
+ c.Tracing().DelayedDelivery.SendOperationTraceMode = TraceMode.ContinueExisting;
+ })
+ .When(s => s.Send(new DelayedMessage(), DelayedSend())))
+ .Run();
+
+ var (send, receive) = GetActivities();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(receive.TraceId, Is.EqualTo(send.TraceId), "a delayed send should continue the existing trace when SendOperationTraceMode is set to ContinueExisting");
+ Assert.That(receive.ParentId, Is.EqualTo(send.Id));
+ Assert.That(receive.Links, Is.Empty);
+ }
+ }
+
+ [Test]
+ public async Task Should_start_new_trace_by_default_no_matter_per_message_option()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b
+ .When(s =>
+ {
+ var sendOptions = DelayedSend();
+ sendOptions.ContinueExistingTraceOnReceive();
+ return s.Send(new DelayedMessage(), sendOptions);
+ }))
+ .Run();
+
+ var (send, receive) = GetActivities();
+
+ Assert.That(receive.TraceId, Is.Not.EqualTo(send.TraceId),
+ "a per-message request to continue the existing trace must not defeat the backward-compatible default for delayed sends");
+ }
+
+ static SendOptions DelayedSend()
+ {
+ var sendOptions = new SendOptions();
+ sendOptions.RouteToThisEndpoint();
+ sendOptions.DelayDeliveryWith(TimeSpan.FromMilliseconds(1));
+ return sendOptions;
+ }
+
+ (System.Diagnostics.Activity Send, System.Diagnostics.Activity Receive) GetActivities()
+ {
+ var sendActivities = NServiceBusActivityListener.CompletedActivities.GetSendMessageActivities();
+ var receiveActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(sendActivities, Has.Count.EqualTo(1), "1 message is sent as part of this test");
+ Assert.That(receiveActivities, Has.Count.EqualTo(1), "1 message is received as part of this test");
+ }
+
+ return (sendActivities[0], receiveActivities[0]);
+ }
+
+ public class Context : ScenarioContext
+ {
+ public bool DelayedMessageReceived { get; set; }
+ }
+
+ public class TestEndpoint : EndpointConfigurationBuilder
+ {
+ public TestEndpoint()
+ {
+ var template = new DefaultServer
+ {
+ TransportConfiguration = new ConfigureEndpointAcceptanceTestingTransport(false, true)
+ };
+ EndpointSetup(template, (c, _) => { }, metadata => { });
+ }
+
+ [Handler]
+ public class DelayedMessageHandler(Context testContext) : IHandleMessages
+ {
+ public Task Handle(DelayedMessage message, IMessageHandlerContext context)
+ {
+ testContext.DelayedMessageReceived = true;
+ testContext.MarkAsCompleted();
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ public class DelayedMessage : IMessage;
+}
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..61499ddee54 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,102 @@ public Task Handle(OutgoingMessage message, IMessageHandlerContext context)
}
}
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_include_destination_in_send_span_name()
+ {
+ 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"));
+ }
+
+ [Test]
+ public async Task Should_create_new_linked_trace_on_receive_when_endpoint_defaults_to_span_link()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b
+ .When(s => s.SendLocal(new OutgoingMessage())))
+ .Run();
+
+ var sendMessageActivities = NServiceBusActivityListener.CompletedActivities.GetSendMessageActivities();
+ var receiveMessageActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities();
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(sendMessageActivities, Has.Count.EqualTo(1), "1 message is sent as part of this test");
+ Assert.That(receiveMessageActivities, Has.Count.EqualTo(1), "1 message is received as part of this test");
+ }
+
+ var sendRequest = sendMessageActivities[0];
+ var receiveRequest = receiveMessageActivities[0];
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(receiveRequest.RootId, Is.Not.EqualTo(sendRequest.RootId), "send and receive operations are part of different root activities");
+ Assert.That(receiveRequest.ParentId, Is.Null, "incoming message does not have a parent, it's a root");
+ }
+
+ ActivityLink link = receiveRequest.Links.FirstOrDefault();
+ Assert.That(link, Is.Not.EqualTo(default(ActivityLink)), "Receive has a link");
+ Assert.That(link.Context.TraceId, Is.EqualTo(sendRequest.TraceId), "receive is linked to send operation");
+ }
+
+ [Test]
+ public async Task Should_create_child_on_receive_when_option_overrides_endpoint_connector()
+ {
+ await Scenario.Define()
+ .WithEndpoint(b => b
+ .When(s =>
+ {
+ var sendOptions = new SendOptions();
+ sendOptions.RouteToThisEndpoint();
+ sendOptions.ContinueExistingTraceOnReceive();
+ return s.Send(new OutgoingMessage(), sendOptions);
+ }))
+ .Run();
+
+ var sendMessageActivities = NServiceBusActivityListener.CompletedActivities.GetSendMessageActivities();
+ var receiveMessageActivities = NServiceBusActivityListener.CompletedActivities.GetReceiveMessageActivities();
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(sendMessageActivities, Has.Count.EqualTo(1), "1 message is sent as part of this test");
+ Assert.That(receiveMessageActivities, Has.Count.EqualTo(1), "1 message is received as part of this test");
+ }
+
+ var sendRequest = sendMessageActivities[0];
+ var receiveRequest = receiveMessageActivities[0];
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(receiveRequest.RootId, Is.EqualTo(sendRequest.RootId), "send and receive operations are part of the same root activity");
+ Assert.That(receiveRequest.ParentId, Is.Not.Null, "incoming message does have a parent");
+ }
+
+ Assert.That(receiveRequest.Links, Is.Empty, "receive does not have links");
+ }
+
+ public class TestEndpointWithSpanLinkConnector : EndpointConfigurationBuilder
+ {
+ public TestEndpointWithSpanLinkConnector() =>
+ EndpointSetup(b => b.Tracing().SendTraceMode = TraceMode.StartNew);
+
+ [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..8077fbb2027 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,22 @@ public Task Handle(OutgoingReply message, IMessageHandlerContext context)
}
}
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_include_destination_in_reply_span_name()
+ {
+ 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 IncomingMessage : IMessage;
public class OutgoingReply : IMessage;
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_subscribing.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_subscribing.cs
index ea93a2f1098..174b468d264 100644
--- a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_subscribing.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_subscribing.cs
@@ -64,6 +64,25 @@ public async Task Should_create_subscription_span_when_native_pubsub()
Assert.That(subscriptionReceiveActivity, Is.Empty, "native pubsub should not produce a message");
}
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_tag_event_types_as_array()
+ {
+ Requires.MessageDrivenPubSub();
+
+ await Scenario.Define()
+ .WithEndpoint(e => e
+ .When(s => s.Subscribe()))
+ .WithEndpoint()
+ .Run();
+
+ var subscribeActivity = NServiceBusActivityListener.CompletedActivities.Single(a => a.OperationName == "NServiceBus.Diagnostics.Subscribe");
+
+ // Array-valued tags are only visible through TagObjects, not Tags.
+ var eventTypes = subscribeActivity.TagObjects.ToImmutableDictionary()["nservicebus.event_types"];
+ Assert.That(eventTypes, Is.EqualTo(new[] { typeof(DemoEvent).FullName }));
+ }
+
class Context : ScenarioContext;
class SubscribingEndpoint : EndpointConfigurationBuilder
diff --git a/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/When_endpoint_starts_with_instrumentation_options.cs b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/When_endpoint_starts_with_instrumentation_options.cs
new file mode 100644
index 00000000000..9274ccb07ea
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/When_endpoint_starts_with_instrumentation_options.cs
@@ -0,0 +1,67 @@
+namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry;
+
+using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using AcceptanceTesting.Customization;
+using EndpointTemplates;
+using NUnit.Framework;
+
+public class When_endpoint_starts_with_instrumentation_options : NServiceBusAcceptanceTest
+{
+ static string basePath = Path.Combine(TestContext.CurrentContext.TestDirectory, TestContext.CurrentContext.Test.ID);
+
+ [Test]
+ public async Task Should_write_instrumentation_options_to_startup_diagnostics()
+ {
+ // TestContext.CurrentContext.Test.ID is stable across test runs,
+ // therefore we need to clear existing diagnostics file to avoid asserting on a stale file
+ if (Directory.Exists(basePath))
+ {
+ Directory.Delete(basePath, true);
+ }
+
+ await Scenario.Define()
+ .WithEndpoint()
+ .Done(c => c.EndpointsStarted)
+ .Run();
+
+ var endpointName = Conventions.EndpointNamingConvention(typeof(MyEndpoint));
+ var pathToFile = Path.Combine(basePath, $"{endpointName}-configuration.txt");
+
+ using var document = JsonDocument.Parse(await File.ReadAllTextAsync(pathToFile));
+ var section = document.RootElement.GetProperty("OpenTelemetry");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(section.GetProperty("SendTraceMode").GetString(), Is.EqualTo("StartNew"));
+ Assert.That(section.GetProperty("PublishTraceMode").GetString(), Is.EqualTo("ContinueExisting"));
+ Assert.That(section.GetProperty("ExceptionRecordingMode").GetString(), Is.EqualTo("Logs"));
+ Assert.That(section.GetProperty("Recoverability").GetProperty("DelayedRetryTraceMode").GetString(), Is.EqualTo("ContinueExisting"));
+ Assert.That(section.GetProperty("DelayedDelivery").GetProperty("SendOperationTraceMode").GetString(), Is.EqualTo("ContinueExisting"));
+ Assert.That(section.GetProperty("DelayedDelivery").GetProperty("SagaTimeoutTraceMode").GetString(), Is.EqualTo("ContinueExisting"));
+ Assert.That(section.GetProperty("UseV11Behavior").ValueKind, Is.EqualTo(JsonValueKind.False).Or.EqualTo(JsonValueKind.True));
+ });
+ }
+
+ class Context : ScenarioContext;
+
+ public class MyEndpoint : EndpointConfigurationBuilder
+ {
+ public MyEndpoint() =>
+ EndpointSetup(c =>
+ {
+ c.SetDiagnosticsPath(basePath);
+
+ // Flip every option away from its default so the test proves the configured values are written
+ var tracing = c.Tracing();
+ tracing.SendTraceMode = TraceMode.StartNew;
+ tracing.PublishTraceMode = TraceMode.ContinueExisting;
+ tracing.ExceptionRecordingMode = ExceptionRecordingMode.Logs;
+ tracing.Recoverability.DelayedRetryTraceMode = TraceMode.ContinueExisting;
+ tracing.DelayedDelivery.SendOperationTraceMode = TraceMode.ContinueExisting;
+ tracing.DelayedDelivery.SagaTimeoutTraceMode = TraceMode.ContinueExisting;
+ }).EnableStartupDiagnostics();
+ }
+}
diff --git a/src/NServiceBus.AcceptanceTests/Core/SelfVerification/ConventionEnforcementTests.cs b/src/NServiceBus.AcceptanceTests/Core/SelfVerification/ConventionEnforcementTests.cs
index 68477fa9496..76591f5d998 100644
--- a/src/NServiceBus.AcceptanceTests/Core/SelfVerification/ConventionEnforcementTests.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/SelfVerification/ConventionEnforcementTests.cs
@@ -4,6 +4,7 @@
using System.Linq;
using System.Reflection;
using NUnit.Framework;
+using OpenTelemetry;
[TestFixture]
public class ConventionEnforcementTests : NServiceBusAcceptanceTest
@@ -51,7 +52,11 @@ public void Ensure_all_diagnostics_tests_are_run_sequentially()
diagnosticTests.Where(t => t.GetCustomAttribute() == null);
Assert.That(diagnosticTests, Is.Not.Empty);
- Assert.That(diagnosticTestsWithoutNonParallelizableAttribute, Is.Empty, string.Join(",", diagnosticTests));
+
+ var parallelizeTests = new[] { typeof(When_endpoint_starts_with_instrumentation_options) };
+ Assert.That(diagnosticTestsWithoutNonParallelizableAttribute,
+ Is.EquivalentTo(parallelizeTests),
+ string.Join(",", diagnosticTests));
}
[Test]
diff --git a/src/NServiceBus.AcceptanceTests/NServiceBus.AcceptanceTests.csproj b/src/NServiceBus.AcceptanceTests/NServiceBus.AcceptanceTests.csproj
index a4802fd05e8..82995eed81c 100644
--- a/src/NServiceBus.AcceptanceTests/NServiceBus.AcceptanceTests.csproj
+++ b/src/NServiceBus.AcceptanceTests/NServiceBus.AcceptanceTests.csproj
@@ -14,6 +14,7 @@
+
@@ -47,6 +48,7 @@
+
diff --git a/src/NServiceBus.AcceptanceTests/obsoletes-v10.cs b/src/NServiceBus.AcceptanceTests/obsoletes-v10.cs
new file mode 100644
index 00000000000..1b6fd71d6eb
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/obsoletes-v10.cs
@@ -0,0 +1,40 @@
+namespace NServiceBus.AcceptanceTests;
+
+using System;
+using System.Reflection;
+using NUnit.Framework;
+using NUnit.Framework.Interfaces;
+
+// =============================================================================
+// EVERYTHING IN THIS FILE IS TEMPORARY AND WILL BE REMOVED IN v11, together with
+// the V11BehaviorSwitch block in NServiceBus.Core/obsoletes-v10.cs. The file is
+// excluded from the shipped acceptance test sources in the project file.
+// =============================================================================
+
+// Runs the test with the NServiceBus.Core.OpenTelemetry.UseV11Behavior AppContext switch enabled, i.e. with
+// the OpenTelemetry defaults of v11. The switch value is cached process-wide, so the cache is reset through
+// the internal V11BehaviorSwitch.ResetUseV11Behavior method. Only safe in [NonParallelizable] fixtures such
+// as OpenTelemetryAcceptanceTest. In v11 these defaults are the only behavior: delete this attribute and
+// remove it from the tests that use it.
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true)]
+public sealed class OpenTelemetryV11DefaultsAttribute : Attribute, ITestAction
+{
+ const string SwitchName = "NServiceBus.Core.OpenTelemetry.UseV11Behavior";
+
+ static readonly MethodInfo ResetSwitch = typeof(EndpointConfiguration).Assembly
+ .GetType("NServiceBus.V11BehaviorSwitch", throwOnError: true)!
+ .GetMethod("ResetUseV11Behavior", BindingFlags.Static | BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException("NServiceBus.V11BehaviorSwitch.ResetUseV11Behavior not found");
+
+ public ActionTargets Targets => ActionTargets.Test;
+
+ public void BeforeTest(ITest test) => Set(true);
+
+ public void AfterTest(ITest test) => Set(false);
+
+ static void Set(bool enabled)
+ {
+ AppContext.SetSwitch(SwitchName, enabled);
+ ResetSwitch.Invoke(null, null);
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt
index 64e921462ef..25091c74f63 100644
--- a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt
+++ b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt
@@ -213,6 +213,12 @@ namespace NServiceBus
public int MaxNumberOfRetries { get; }
public System.TimeSpan TimeIncrease { get; }
}
+ public class DelayedDeliveryInstrumentationOptions
+ {
+ public DelayedDeliveryInstrumentationOptions() { }
+ public NServiceBus.TraceMode SagaTimeoutTraceMode { get; set; }
+ public NServiceBus.TraceMode SendOperationTraceMode { get; set; }
+ }
public static class DelayedDeliveryOptionExtensions
{
public static void DelayDeliveryWith(this NServiceBus.SendOptions options, System.TimeSpan delay) { }
@@ -326,6 +332,11 @@ namespace NServiceBus
public bool TryGetExplicitlyConfiguredErrorQueueAddress([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? errorQueue) { }
}
}
+ public enum ExceptionRecordingMode
+ {
+ Logs = 0,
+ SpanAndLogs = 1,
+ }
public class FailedConfig
{
public FailedConfig(string errorQueue, System.Collections.Generic.HashSet unrecoverableExceptionTypes) { }
@@ -388,6 +399,7 @@ namespace NServiceBus
public const string IsSagaTimeoutMessage = "NServiceBus.IsSagaTimeoutMessage";
public const string MessageId = "NServiceBus.MessageId";
public const string MessageIntent = "NServiceBus.MessageIntent";
+ public const string NServiceBusDiagnosticsTraceParent = "NServiceBus.TraceParent";
public const string NServiceBusVersion = "NServiceBus.Version";
public const string NonDurableMessage = "NServiceBus.NonDurableMessage";
public const string OriginatingAddress = "NServiceBus.OriginatingAddress";
@@ -571,6 +583,10 @@ namespace NServiceBus
System.Threading.Tasks.Task Subscribe(System.Type eventType, NServiceBus.SubscribeOptions subscribeOptions, System.Threading.CancellationToken cancellationToken = default);
System.Threading.Tasks.Task Unsubscribe(System.Type eventType, NServiceBus.UnsubscribeOptions unsubscribeOptions, System.Threading.CancellationToken cancellationToken = default);
}
+ public interface IMetricsTags
+ {
+ void AddOrOverride(string tagKey, object value, string instrumentName);
+ }
public interface INeedInitialization
{
void Customize(NServiceBus.EndpointConfiguration configuration);
@@ -653,13 +669,6 @@ namespace NServiceBus
public override NServiceBus.Transport.ErrorHandleResult ErrorHandleResult { get; }
public override System.Collections.Generic.IReadOnlyCollection GetRoutingContexts(NServiceBus.Pipeline.IRecoverabilityActionContext context) { }
}
- public sealed class IncomingPipelineMetricTags
- {
- public IncomingPipelineMetricTags() { }
- public void Add(string tagKey, object value) { }
- public void ApplyTag(ref System.Diagnostics.TagList tagList, string tagKey) { }
- public void ApplyTags(ref System.Diagnostics.TagList tagList, System.ReadOnlySpan tagKeys) { }
- }
public static class InstallConfigExtensions
{
public static void AddInstaller<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.None | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] TInstaller>(this NServiceBus.EndpointConfiguration config)
@@ -678,6 +687,15 @@ namespace NServiceBus
StopApplication = 0,
Continue = 1,
}
+ public class InstrumentationOptions
+ {
+ public InstrumentationOptions() { }
+ public NServiceBus.DelayedDeliveryInstrumentationOptions DelayedDelivery { get; }
+ public NServiceBus.ExceptionRecordingMode ExceptionRecordingMode { get; set; }
+ public NServiceBus.TraceMode PublishTraceMode { get; set; }
+ public NServiceBus.RecoverabilityInstrumentationOptions Recoverability { get; }
+ public NServiceBus.TraceMode SendTraceMode { get; set; }
+ }
public sealed class KeyedServiceKey
{
public const string Any = "______________";
@@ -836,6 +854,16 @@ namespace NServiceBus
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(@"AddMessageType relies on an NServiceBus source-generated interceptor to register the statically known message hierarchy without reflection when trimming is enabled. If this warning is reported, the interceptor was not used for this call; enable or restore the NServiceBus analyzer/source-generator tooling and use a statically known message type.")]
public static void AddMessageType<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.None | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicConstructors | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] TMessage>(this NServiceBus.EndpointConfiguration config) { }
}
+ public static class MetricTagsExtensions
+ {
+ extension(NServiceBus.Pipeline.IBehaviorContext context)
+ {
+ public NServiceBus.IMetricsTags MetricTags { get; }
+ }
+ extension(NServiceBus.Transport.MessageContext context)
+ {
+ }
+ }
public class MoveToError : NServiceBus.RecoverabilityAction
{
protected MoveToError(string errorQueue) { }
@@ -861,7 +889,10 @@ namespace NServiceBus
public static class OpenTelemetryExtensions
{
public static void ContinueExistingTraceOnReceive(this NServiceBus.PublishOptions publishOptions) { }
+ public static void ContinueExistingTraceOnReceive(this NServiceBus.SendOptions sendOptions) { }
+ public static void StartNewTraceOnReceive(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
{
@@ -1003,6 +1034,11 @@ namespace NServiceBus
{
public static NServiceBus.RecoverabilitySettings Recoverability(this NServiceBus.EndpointConfiguration configuration) { }
}
+ public class RecoverabilityInstrumentationOptions
+ {
+ public RecoverabilityInstrumentationOptions() { }
+ public NServiceBus.TraceMode DelayedRetryTraceMode { get; set; }
+ }
public class RecoverabilitySettings : NServiceBus.Configuration.AdvancedExtensibility.ExposeSettings
{
public NServiceBus.RecoverabilitySettings AddUnrecoverableException(System.Type exceptionType) { }
@@ -1302,6 +1338,11 @@ namespace NServiceBus
public ToSagaExpression(NServiceBus.IConfigureHowToFindSagaWithMessage sagaMessageFindingConfiguration, System.Linq.Expressions.Expression> messageProperty) { }
public void ToSaga(System.Linq.Expressions.Expression> sagaEntityProperty) { }
}
+ public enum TraceMode
+ {
+ ContinueExisting = 0,
+ StartNew = 1,
+ }
public static class TransportConfig
{
extension(NServiceBus.EndpointConfiguration endpointConfiguration)
diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/ActivityDecoratorTests.Verify_promotable_headers.ignored.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/ActivityDecoratorTests.Verify_promotable_headers.ignored.approved.txt
index 2366e3eaa9d..1f74f1e4add 100644
--- a/src/NServiceBus.Core.Tests/ApprovalFiles/ActivityDecoratorTests.Verify_promotable_headers.ignored.approved.txt
+++ b/src/NServiceBus.Core.Tests/ApprovalFiles/ActivityDecoratorTests.Verify_promotable_headers.ignored.approved.txt
@@ -11,6 +11,7 @@ Headers.TimeSent
Headers.SubscriptionMessageType
Headers.SubscriberTransportAddress
Headers.SubscriberEndpoint
+Headers.NServiceBusDiagnosticsTraceParent
Headers.DiagnosticsTraceParent
Headers.DiagnosticsTraceState
Headers.DiagnosticsBaggage
diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/ActivityTagsTests.Verify_ActivityTags.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/ActivityTagsTests.Verify_ActivityTags.approved.txt
index 27d7bc2fc19..e93fea165f5 100644
--- a/src/NServiceBus.Core.Tests/ApprovalFiles/ActivityTagsTests.Verify_ActivityTags.approved.txt
+++ b/src/NServiceBus.Core.Tests/ApprovalFiles/ActivityTagsTests.Verify_ActivityTags.approved.txt
@@ -1,6 +1,5 @@
{
"Note": "Changes to activity tags should result in ActivitySource version updates",
- "ActivitySourceVersion": "0.1.0",
"Tags": [
"SagaId => nservicebus.saga.saga_id",
"MessageId => nservicebus.message_id",
@@ -38,6 +37,13 @@
"HandlerType => nservicebus.handler.handler_type",
"HandlerSagaId => nservicebus.handler.saga_id",
"EventTypes => nservicebus.event_types",
- "CancelledTask => nservicebus.cancelled"
- ]
+ "CancelledTask => nservicebus.cancelled",
+ "ErrorType => error.type",
+ "RecoverabilityAction => nservicebus.recoverability_action",
+ "OutboxDeduplicatedMessage => nservicebus.outbox.deduplicated_message"
+ ],
+ "ActivitySourceVersion": {
+ "PreV11Version": "0.1.0",
+ "Version": "1.0.0"
+ }
}
\ No newline at end of file
diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/LogManagerUsage.ApproveLogManagerUsage.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/LogManagerUsage.ApproveLogManagerUsage.approved.txt
index 60420d9a588..fb3f7c59c76 100644
--- a/src/NServiceBus.Core.Tests/ApprovalFiles/LogManagerUsage.ApproveLogManagerUsage.approved.txt
+++ b/src/NServiceBus.Core.Tests/ApprovalFiles/LogManagerUsage.ApproveLogManagerUsage.approved.txt
@@ -10,10 +10,8 @@ NServiceBus.AuditInvalidLicenseBehavior
NServiceBus.ConsecutiveFailuresCircuitBreaker
NServiceBus.Conventions
NServiceBus.DelayedMessagePoller
-NServiceBus.DelayedRetry
NServiceBus.DeserializeMessageConnector
NServiceBus.DirectoryBasedTransaction
-NServiceBus.Discard
NServiceBus.EnvelopeUnwrapper
NServiceBus.ErrorQueueSettings
NServiceBus.Features.Audit
@@ -23,7 +21,6 @@ NServiceBus.Features.LicenseReminder
NServiceBus.HostStartupDiagnosticsWriter
NServiceBus.HostStartupDiagnosticsWriterFactory
NServiceBus.HostingComponent
-NServiceBus.ImmediateRetry
NServiceBus.LearningTransportMessagePump
NServiceBus.LicenseManager
NServiceBus.LoadHandlersConnector
@@ -32,7 +29,6 @@ NServiceBus.MessageDrivenSubscribeTerminator
NServiceBus.MessageDrivenUnsubscribeTerminator
NServiceBus.MigrationSubscribeTerminator
NServiceBus.MigrationUnsubscribeTerminator
-NServiceBus.MoveToError
NServiceBus.PersistenceComponent
NServiceBus.PipelineModelBuilder
NServiceBus.Pipeline`1
diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/MeterTests.Verify_MeterAPI.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/MeterTests.Verify_MeterAPI.approved.txt
index a572d43098f..b632b411cce 100644
--- a/src/NServiceBus.Core.Tests/ApprovalFiles/MeterTests.Verify_MeterAPI.approved.txt
+++ b/src/NServiceBus.Core.Tests/ApprovalFiles/MeterTests.Verify_MeterAPI.approved.txt
@@ -1,27 +1,37 @@
{
"Note": "Changes to metrics API should result in an update to NServiceBusMeter version.",
"MetricsSourceName": "NServiceBus.Core.Pipeline.Incoming",
- "MetricsSourceVersion": "0.2.0",
+ "MetricsSourceVersion": "0.4.0",
"Tags": [
"error.type",
"execution.result",
"nservicebus.discriminator",
+ "nservicebus.enclosed_message_types",
"nservicebus.envelope.unwrapper_type",
"nservicebus.message_handler_type",
"nservicebus.message_handler_types",
"nservicebus.message_type",
- "nservicebus.queue"
+ "nservicebus.queue",
+ "nservicebus.saga_type"
],
"Metrics": [
"nservicebus.envelope.unwrapped => Counter",
+ "nservicebus.messaging.active_messages => UpDownCounter",
"nservicebus.messaging.critical_time => Histogram, Unit: s",
+ "nservicebus.messaging.deserialize_time => Histogram, Unit: s",
"nservicebus.messaging.failures => Counter",
"nservicebus.messaging.fetches => Counter",
"nservicebus.messaging.handler_time => Histogram, Unit: s",
"nservicebus.messaging.processing_time => Histogram, Unit: s",
+ "nservicebus.messaging.serialize_time => Histogram, Unit: s",
"nservicebus.messaging.successes => Counter",
+ "nservicebus.outbox.duplicates => Counter",
+ "nservicebus.outbox.fetch_time => Histogram, Unit: s",
+ "nservicebus.outbox.store_time => Histogram, Unit: s",
+ "nservicebus.persistence.commit_time => Histogram, Unit: s",
"nservicebus.recoverability.delayed => Counter",
"nservicebus.recoverability.error => Counter",
- "nservicebus.recoverability.immediate => Counter"
+ "nservicebus.recoverability.immediate => Counter",
+ "nservicebus.sagas.fetch_time => Histogram, Unit: s"
]
}
\ No newline at end of file
diff --git a/src/NServiceBus.Core.Tests/Envelopes/EnvelopeUnwrapperTests.cs b/src/NServiceBus.Core.Tests/Envelopes/EnvelopeUnwrapperTests.cs
index 1f2460a8b9c..d5bd75d9546 100644
--- a/src/NServiceBus.Core.Tests/Envelopes/EnvelopeUnwrapperTests.cs
+++ b/src/NServiceBus.Core.Tests/Envelopes/EnvelopeUnwrapperTests.cs
@@ -4,12 +4,10 @@ namespace NServiceBus.Core.Tests.Envelopes;
using System;
using System.Buffers;
using System.Collections.Generic;
-using System.Text;
using Extensibility;
using NUnit.Framework;
using Transport;
-
public class EnvelopeUnwrapperTests
{
string nativeId;
@@ -17,7 +15,7 @@ public class EnvelopeUnwrapperTests
ReadOnlyMemory originalBody;
MessageContext messageContext;
TestMeterFactory meterFactory;
- IncomingPipelineMetrics incomingPipelineMetrics;
+ PipelineMetrics pipelineMetrics;
List envelopeHandlers;
[SetUp]
@@ -31,7 +29,7 @@ public void Setup()
originalBody = "payload"u8.ToArray().AsMemory();
messageContext = new MessageContext(nativeId, originalHeaders, originalBody, new TransportTransaction(), "receiveAddress", new ContextBag());
meterFactory = new TestMeterFactory();
- incomingPipelineMetrics = new IncomingPipelineMetrics(meterFactory, "queue", "disc");
+ pipelineMetrics = new PipelineMetrics(meterFactory, "queue", "disc");
}
[TearDown]
@@ -154,7 +152,7 @@ public void ModifiedAndThrowingBodyWriterIsReset()
Assert.That(result.Body.Span.SequenceEqual(firstBody.Span), Is.True);
}
- EnvelopeUnwrapper.IncomingMessageHandle RunTest() => new EnvelopeUnwrapper([.. envelopeHandlers], incomingPipelineMetrics).UnwrapEnvelope(messageContext);
+ EnvelopeUnwrapper.IncomingMessageHandle RunTest() => new EnvelopeUnwrapper([.. envelopeHandlers], pipelineMetrics).UnwrapEnvelope(messageContext);
class ReturningHandler(Dictionary headersToReturn, ReadOnlyMemory bodyToReturn) : IEnvelopeHandler
{
diff --git a/src/NServiceBus.Core.Tests/NServiceBus.Core.Tests.csproj b/src/NServiceBus.Core.Tests/NServiceBus.Core.Tests.csproj
index 3815ed7e74f..a8a2aa6f64b 100644
--- a/src/NServiceBus.Core.Tests/NServiceBus.Core.Tests.csproj
+++ b/src/NServiceBus.Core.Tests/NServiceBus.Core.Tests.csproj
@@ -4,7 +4,6 @@
net10.0
true
..\NServiceBusTests.snk
- 13.0
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityDecoratorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityDecoratorTests.cs
index c9c08d0ec64..a71ddcc2880 100644
--- a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityDecoratorTests.cs
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityDecoratorTests.cs
@@ -59,4 +59,29 @@ public void PromoteHeadersToTags_should_promote_promotable_headers_to_tags()
Assert.That(tags["nservicebus.control_message"], Is.EqualTo(headers[Headers.ControlMessageHeader]));
}
}
+
+ [Test]
+ public void PromoteHeadersToTags_should_promote_enclosed_message_types_as_string()
+ {
+ var activity = new Activity("test");
+ var headers = new Dictionary { { Headers.EnclosedMessageTypes, string.Join(';', EnclosedMessageTypes) } };
+
+ ActivityDecorator.PromoteHeadersToTags(activity, headers);
+
+ Assert.That(activity.TagObjects.ToImmutableDictionary()["nservicebus.enclosed_message_types"], Is.EqualTo("Ns.First;Ns.Second"));
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public void PromoteHeadersToTags_should_promote_enclosed_message_types_as_array()
+ {
+ var activity = new Activity("test");
+ var headers = new Dictionary { { Headers.EnclosedMessageTypes, string.Join(';', EnclosedMessageTypes) } };
+
+ ActivityDecorator.PromoteHeadersToTags(activity, headers);
+
+ Assert.That(activity.TagObjects.ToImmutableDictionary()["nservicebus.enclosed_message_types"], Is.EqualTo(EnclosedMessageTypes));
+ }
+
+ static readonly string[] EnclosedMessageTypes = ["Ns.First", "Ns.Second"];
}
\ No newline at end of file
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityExtensionsTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityExtensionsTests.cs
index a8880919f67..c4825b33064 100644
--- a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityExtensionsTests.cs
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityExtensionsTests.cs
@@ -16,7 +16,7 @@ public void TryGetRecordingPipelineActivity_should_return_false_when_key_not_fou
var contextBag = new ContextBag();
using (Assert.EnterMultipleScope())
{
- Assert.That(contextBag.TryGetRecordingOutgoingPipelineActivity(out var activity), Is.False);
+ Assert.That(contextBag.TryGetOutgoingPipelineActivity(out var activity), Is.False);
Assert.That(activity, Is.Null);
}
}
@@ -31,7 +31,7 @@ public void TryGetRecordingPipelineActivity_should_return_false_when_value_null(
using (Assert.EnterMultipleScope())
{
- Assert.That(contextBag.TryGetRecordingOutgoingPipelineActivity(out var activity), Is.False);
+ Assert.That(contextBag.TryGetOutgoingPipelineActivity(out var activity), Is.False);
Assert.That(activity, Is.Null);
}
}
@@ -50,7 +50,7 @@ public void TryGetRecordingPipelineActivity_should_return_false_when_not_recordi
using (Assert.EnterMultipleScope())
{
- Assert.That(contextBag.TryGetRecordingOutgoingPipelineActivity(out var activity), Is.True);
+ Assert.That(contextBag.TryGetOutgoingPipelineActivity(out var activity), Is.True);
Assert.That(activity, Is.EqualTo(recordingActivity));
}
}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityFactoryTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityFactoryTests.cs
index 6c22b89bb7b..6c10d823757 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()
@@ -49,43 +49,47 @@ static MessageContext CreateMessageContext() =>
new(Guid.NewGuid().ToString(), [], Array.Empty(), new TransportTransaction(), "receiver", new ContextBag());
}
+ // Until v11 the "transport span as parent" behavior is opt-in. This fixture runs with the v11
+ // defaults because that is what the tests below describe; the pre-v11 default is covered by
+ // TransportParentSpanDefaultBehaviorTests. In v11, remove the attribute together with the
+ // V11BehaviorSwitch block in obsoletes-v10.cs.
+ [OpenTelemetryV11Defaults]
class StartIncomingActivity : ActivityFactoryTests
{
- [Test]
- public void Should_attach_to_context_activity_when_activity_on_context()
+ [TestCase(Headers.NServiceBusDiagnosticsTraceParent)]
+ [TestCase(Headers.DiagnosticsTraceParent)] // for backwards compatibility
+ public void Should_attach_to_header_trace_when_available_and_no_ambient_activity(string headerName)
{
- using var contextActivity = CreateCompletedActivity("transport receive activity");
+ var sendActivity = CreateCompletedActivity("send activity");
- var contextBag = new ContextBag();
- contextBag.Set(contextActivity);
+ var messageHeaders = new Dictionary { { headerName, sendActivity.Id! } };
- var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(contextBag: contextBag));
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
using (Assert.EnterMultipleScope())
{
- Assert.That(activity.ParentId, Is.EqualTo(contextActivity.Id), "should use context activity as parent");
+ Assert.That(activity.ParentId, Is.EqualTo(sendActivity.Id));
Assert.That(activity.Links.Count(), Is.EqualTo(0), "should not link to logical send span");
}
}
[Test]
- public void Should_attach_to_context_activity_when_activity_on_context_and_trace_message_header()
+ public void Should_attach_to_ambient_activity_when_available_and_link_to_header_trace()
{
- using var contextActivity = CreateCompletedActivity("transport receive activity");
- using var sendActivity = CreateCompletedActivity("send activity");
+ var sendActivity = CreateCompletedActivity("send activity");
- var contextBag = new ContextBag();
- contextBag.Set(contextActivity);
+ using var ambientActivity = new Activity("transport sdk receive activity");
+ ambientActivity.Start();
- var messageHeaders = new Dictionary { { Headers.DiagnosticsTraceParent, sendActivity.Id! } };
+ var messageHeaders = new Dictionary { { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! } };
- var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders, contextBag));
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
using (Assert.EnterMultipleScope())
{
- Assert.That(activity.ParentId, Is.EqualTo(contextActivity.Id), "should use context activity as parent");
+ Assert.That(activity.ParentId, Is.EqualTo(ambientActivity.Id), "should use the ambient transport activity as parent");
Assert.That(activity.Links.Count(), Is.EqualTo(1), "should link to logical send span");
Assert.That(activity.Links.Single().Context.TraceId, Is.EqualTo(sendActivity.TraceId));
Assert.That(activity.Links.Single().Context.SpanId, Is.EqualTo(sendActivity.SpanId));
@@ -93,103 +97,234 @@ public void Should_attach_to_context_activity_when_activity_on_context_and_trace
}
[Test]
- public void Should_attach_to_context_activity_when_activity_on_context_and_ambient_activity()
+ public void Should_prefer_nservicebus_trace_header_over_w3c_trace_header()
{
- using var contextActivity = CreateCompletedActivity("transport receive activity");
- var contextBag = new ContextBag();
- contextBag.Set(contextActivity);
+ var sendActivity = CreateCompletedActivity("send activity");
+ var transportActivity = CreateCompletedActivity("transport activity that overwrote the w3c header");
- using var ambientActivity = ActivitySources.Main.StartActivity("ambient activity");
- Assert.That(Activity.Current, Is.EqualTo(ambientActivity));
+ var messageHeaders = new Dictionary
+ {
+ { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! },
+ { Headers.DiagnosticsTraceParent, transportActivity.Id! }
+ };
- var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(contextBag: contextBag));
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
- Assert.That(activity.ParentId, Is.EqualTo(contextActivity.Id), "should use context activity as parent");
+ Assert.That(activity.ParentId, Is.EqualTo(sendActivity.Id), "should use the NServiceBus header, not the W3C one");
}
- [Test]
- public void Should_start_new_trace_when_activity_on_context_uses_legacy_id_format()
+ [TestCase(ActivityIdFormat.W3C)]
+ [TestCase(ActivityIdFormat.Hierarchical)]
+ public void Should_attach_to_ambient_trace_when_no_activity_on_context_and_no_trace_header_and_ambient_activity(ActivityIdFormat ambientActivityIdFormat)
{
- using var contextActivity = CreateCompletedActivity("transport receive activity", ActivityIdFormat.Hierarchical);
- Assert.That(contextActivity.IdFormat, Is.EqualTo(ActivityIdFormat.Hierarchical));
-
- var contextBag = new ContextBag();
- contextBag.Set(contextActivity);
+ using var ambientActivity = new Activity("ambient activity");
+ ambientActivity.SetIdFormat(ambientActivityIdFormat);
+ ambientActivity.Start();
- var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(contextBag: contextBag));
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext());
Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
using (Assert.EnterMultipleScope())
{
- Assert.That(activity.ParentId, Is.Null, "should create a new trace");
+ Assert.That(activity.ParentId, Is.EqualTo(ambientActivity.Id), "should attach to ambient activity");
Assert.That(activity.IdFormat, Is.EqualTo(ActivityIdFormat.W3C));
}
}
[Test]
- public void Should_attach_to_header_trace_when_no_activity_on_context_and_trace_header()
+ public void Should_start_new_trace_when_no_activity_on_context_and_no_trace_message_header_and_no_ambient_activity()
{
- using var sendActivity = CreateCompletedActivity("send activity");
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext());
- var messageHeaders = new Dictionary { { Headers.DiagnosticsTraceParent, sendActivity.Id! } };
+ Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(activity.ParentId, Is.Null, "should start a new trace");
+ Assert.That(activity.IdFormat, Is.EqualTo(ActivityIdFormat.W3C));
+ }
+ }
+
+ [Test]
+ public void Should_start_new_trace_when_trace_header_contains_invalid_data()
+ {
+ var messageHeaders = new Dictionary { { Headers.DiagnosticsTraceParent, "Some invalid traceparent format" } };
var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
using (Assert.EnterMultipleScope())
{
- Assert.That(activity.ParentId, Is.EqualTo(sendActivity.Id));
+ Assert.That(activity.ParentId, Is.Null, "should start new trace");
Assert.That(activity.Links.Count(), Is.EqualTo(0), "should not link to logical send span");
}
}
- [TestCase(ActivityIdFormat.W3C)]
- [TestCase(ActivityIdFormat.Hierarchical)]
- public void Should_attach_to_ambient_trace_when_no_activity_on_context_and_no_trace_header_and_ambient_activity(ActivityIdFormat ambientActivityIdFormat)
+ [Test]
+ public void Should_propagate_header_trace_state_when_attached_to_header_trace()
{
- using var ambientActivity = new Activity("ambient activity");
- ambientActivity.SetIdFormat(ambientActivityIdFormat);
+ var sendActivity = CreateCompletedActivity("send activity");
+
+ var messageHeaders = new Dictionary
+ {
+ { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! },
+ { Headers.DiagnosticsTraceState, "vendor=value" }
+ };
+
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
+
+ Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
+ Assert.That(activity.TraceStateString, Is.EqualTo("vendor=value"));
+ }
+
+ [Test]
+ public void Should_propagate_header_trace_state_when_attached_to_ambient_activity()
+ {
+ var sendActivity = CreateCompletedActivity("send activity");
+
+ using var ambientActivity = new Activity("transport sdk receive activity");
ambientActivity.Start();
- var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext());
+ var messageHeaders = new Dictionary
+ {
+ { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! },
+ { Headers.DiagnosticsTraceState, "vendor=value" }
+ };
+
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
using (Assert.EnterMultipleScope())
{
- Assert.That(activity.ParentId, Is.EqualTo(ambientActivity.Id), "should attach to ambient activity");
- Assert.That(activity.IdFormat, Is.EqualTo(ActivityIdFormat.W3C));
+ Assert.That(activity.Parent, Is.SameAs(ambientActivity));
+ Assert.That(activity.TraceStateString, Is.EqualTo("vendor=value"));
}
}
[Test]
- public void Should_start_new_trace_when_no_activity_on_context_and_no_trace_message_header_and_no_ambient_activity()
+ public void Should_not_propagate_header_trace_state_when_starting_new_trace()
+ {
+ var sendActivity = CreateCompletedActivity("send activity");
+
+ var messageHeaders = new Dictionary
+ {
+ { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! },
+ { Headers.StartNewTrace, bool.TrueString },
+ { Headers.DiagnosticsTraceState, "vendor=value" }
+ };
+
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
+
+ Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(activity.Parent, Is.Null, "a new trace has no parent");
+ Assert.That(activity.TraceStateString, Is.Null, "trace state belongs to the trace it was recorded in");
+ }
+ }
+
+ [Test]
+ public void Should_return_started_activity()
{
var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext());
Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
using (Assert.EnterMultipleScope())
{
- Assert.That(activity.ParentId, Is.Null, "should start a new trace");
- Assert.That(activity.IdFormat, Is.EqualTo(ActivityIdFormat.W3C));
+ Assert.That(activity.Id, Is.Not.Null, "an id is only assigned to a started activity");
+ Assert.That(Activity.Current, Is.SameAs(activity));
}
}
[Test]
- public void Should_start_new_trace_when_trace_header_contains_invalid_data()
+ public void Should_propagate_header_baggage_when_attached_to_ambient_activity()
{
- var messageHeaders = new Dictionary { { Headers.DiagnosticsTraceParent, "Some invalid traceparent format" } };
+ var sendActivity = CreateCompletedActivity("send activity");
+
+ using var ambientActivity = new Activity("transport sdk receive activity");
+ ambientActivity.Start();
+
+ var messageHeaders = new Dictionary
+ {
+ { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! },
+ { Headers.DiagnosticsBaggage, "tenant=acme" }
+ };
var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
using (Assert.EnterMultipleScope())
{
- Assert.That(activity.ParentId, Is.Null, "should start new trace");
- Assert.That(activity.Links.Count(), Is.EqualTo(0), "should not link to logical send span");
+ Assert.That(activity.Parent, Is.SameAs(ambientActivity));
+ Assert.That(activity.GetBaggageItem("tenant"), Is.EqualTo("acme"), "transport SDKs do not propagate baggage, so NServiceBus propagates it even with an ambient parent");
}
}
+ [Test]
+ public void Should_not_add_header_baggage_already_carried_by_ambient_parent()
+ {
+ var sendActivity = CreateCompletedActivity("send activity");
+
+ using var ambientActivity = new Activity("transport sdk receive activity that extracted baggage");
+ ambientActivity.AddBaggage("tenant", "acme");
+ ambientActivity.Start();
+
+ var messageHeaders = new Dictionary
+ {
+ { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! },
+ { Headers.DiagnosticsBaggage, "tenant=acme,region=eu" }
+ };
+
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
+
+ Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(activity.Parent, Is.SameAs(ambientActivity));
+ Assert.That(activity.GetBaggageItem("tenant"), Is.EqualTo("acme"));
+ Assert.That(activity.GetBaggageItem("region"), Is.EqualTo("eu"));
+ Assert.That(activity.Baggage.Count(item => item.Key == "tenant"), Is.EqualTo(1), "a key inherited from the parent must not be added again or it doubles on every hop");
+ }
+ }
+
+ [Test]
+ public void Should_propagate_header_baggage_but_not_ambient_baggage_when_starting_new_trace()
+ {
+ var sendActivity = CreateCompletedActivity("send activity");
+
+ using var ambientActivity = new Activity("transport sdk receive activity");
+ ambientActivity.AddBaggage("ambient-only", "value");
+ ambientActivity.Start();
+
+ var messageHeaders = new Dictionary
+ {
+ { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! },
+ { Headers.StartNewTrace, bool.TrueString },
+ { Headers.DiagnosticsBaggage, "tenant=acme" }
+ };
+
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
+
+ Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(activity.Parent, Is.Null, "a new trace has no parent");
+ Assert.That(activity.GetBaggageItem("tenant"), Is.EqualTo("acme"), "baggage from the message is propagated regardless of the trace shape");
+ Assert.That(activity.GetBaggageItem("ambient-only"), Is.Null, "the ambient activity is not part of the new trace");
+ }
+ }
+
+ [Test]
+ public void Should_ignore_baggage_header_when_no_trace_header()
+ {
+ var messageHeaders = new Dictionary { { Headers.DiagnosticsBaggage, "tenant=acme" } };
+
+ var activity = activityFactory.StartIncomingPipelineActivity(CreateMessageContext(messageHeaders));
+
+ Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
+ Assert.That(activity.GetBaggageItem("tenant"), Is.Null, "baggage is only meaningful together with a trace parent");
+ }
+
[Test]
public void Should_add_native_message_id_tag()
{
@@ -209,14 +344,14 @@ static Activity CreateCompletedActivity(string activityName, ActivityIdFormat id
return activity;
}
- static MessageContext CreateMessageContext(Dictionary? messageHeaders = null, ContextBag? contextBag = null) =>
+ static MessageContext CreateMessageContext(Dictionary? messageHeaders = null) =>
new(
Guid.NewGuid().ToString(),
messageHeaders ?? [],
Array.Empty(),
new TransportTransaction(),
"receiver",
- contextBag ?? new ContextBag());
+ new ContextBag());
}
class StartOutgoingPipelineActivity : ActivityFactoryTests
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityTagsTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityTagsTests.cs
index 5503c58bbef..6231825097e 100644
--- a/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityTagsTests.cs
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ActivityTagsTests.cs
@@ -14,14 +14,19 @@ public void Verify_ActivityTags()
var activityTags = typeof(ActivityTags)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(fi => fi.IsLiteral && !fi.IsInitOnly)
- .Select(x => $"{x.Name} => {x.GetRawConstantValue()}")
- .ToList();
+ .Select(x => $"{x.Name} => {x.GetRawConstantValue()}");
Approver.Verify(new
{
Note = "Changes to activity tags should result in ActivitySource version updates",
- ActivitySourceVersion = ActivitySources.Main.Version,
- Tags = activityTags
+ Tags = activityTags,
+ // All sources share one version. The constants are listed instead of ActivitySource.Version because the
+ // sources pick their version from the V11 switch once per process, see ActivitySources.cs.
+ ActivitySourceVersion = new
+ {
+ ActivitySources.PreV11Version,
+ ActivitySources.Version
+ }
});
}
}
\ No newline at end of file
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs
new file mode 100644
index 00000000000..950a847047c
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationCompatibilityTests.cs
@@ -0,0 +1,126 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using NUnit.Framework;
+
+[TestFixture]
+[OpenTelemetryV11Defaults]
+public class ContextPropagationCompatibilityTests
+{
+ delegate void Writer(Activity activity, Dictionary headers);
+
+ delegate void Reader(Activity activity, IDictionary 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|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);
+ 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 New_receiver_skips_baggage_the_parent_already_carries()
+ {
+ using var parent = new Activity("transport sdk receive activity");
+ parent.AddBaggage("tenant", "acme");
+ parent.Start();
+
+ // Adopts the parent from Activity.Current; the propagation reads through that chain
+ using var incoming = new Activity(ActivityNames.IncomingMessageActivityName);
+ incoming.Start();
+ var headers = new Dictionary { { Headers.DiagnosticsBaggage, "tenant=acme,region=eu" } };
+
+ ContextPropagation.PropagateBaggageFromHeaders(incoming, headers);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(incoming.Parent, Is.SameAs(parent));
+ Assert.That(incoming.GetBaggageItem("region"), Is.EqualTo("eu"));
+ Assert.That(incoming.Baggage.Count(item => item.Key == "tenant"), Is.EqualTo(1), "should only be the inherited one");
+ }
+ }
+
+ [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");
+ }
+ }
+}
\ 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..af9b6897042
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationDefaultBehaviorTests.cs
@@ -0,0 +1,104 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using NUnit.Framework;
+
+[TestFixture]
+public class ContextPropagationDefaultBehaviorTests
+{
+ // Without the OpenTelemetryV11Defaults attribute, the endpoint default must remain the
+ // backwards-compatible legacy propagator (percent-encoded, comma-separated, whitespace preserved).
+ [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);
+
+ 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));
+ 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);
+
+ 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 "));
+ }
+
+ [Test]
+ public void Default_skips_baggage_the_parent_already_carries()
+ {
+ using var parent = new Activity("transport sdk receive activity");
+ parent.AddBaggage("tenant", "acme");
+ parent.Start();
+
+ // Adopts the parent from Activity.Current; the propagation reads through that chain
+ using var incoming = new Activity(ActivityNames.IncomingMessageActivityName);
+ incoming.Start();
+ var headers = new Dictionary { { Headers.DiagnosticsBaggage, "tenant=acme,region=eu" } };
+
+ ContextPropagation.PropagateBaggageFromHeaders(incoming, headers);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(incoming.Parent, Is.SameAs(parent));
+ Assert.That(incoming.GetBaggageItem("region"), Is.EqualTo("eu"));
+ Assert.That(incoming.Baggage.Count(item => item.Key == "tenant"), Is.EqualTo(1), "should only be the inherited one");
+ }
+ }
+
+ [Test]
+ public void Default_writes_the_nservicebus_trace_header_alongside_the_w3c_header()
+ {
+ using var activity = new Activity(ActivityNames.OutgoingMessageActivityName);
+ activity.SetIdFormat(ActivityIdFormat.W3C);
+ activity.Start();
+
+ var headers = new Dictionary();
+ ContextPropagation.PropagateContextToHeaders(activity, headers);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(headers[Headers.NServiceBusDiagnosticsTraceParent], Is.EqualTo(activity.Id));
+ Assert.That(headers[Headers.DiagnosticsTraceParent], Is.EqualTo(activity.Id), "the W3C header stays for older receivers");
+ }
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/HandlerActivitySourceTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/HandlerActivitySourceTests.cs
new file mode 100644
index 00000000000..a8f34524e03
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/HandlerActivitySourceTests.cs
@@ -0,0 +1,86 @@
+#nullable enable
+
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Linq;
+using Helpers;
+using NServiceBus.Pipeline;
+using NUnit.Framework;
+
+[TestFixture]
+public class HandlerActivitySourceTests
+{
+ const string HandlerSourceName = "NServiceBus.Core.Handler";
+
+ readonly ActivityFactory activityFactory = new(new InstrumentationOptions());
+
+ TestingActivityListener mainListener;
+
+ [SetUp]
+ public void SetUp() => mainListener = TestingActivityListener.SetupNServiceBusDiagnosticListener();
+
+ [TearDown]
+ public void TearDown() => mainListener.Dispose();
+
+ [Test]
+ public void Should_emit_handler_activity_from_main_source()
+ {
+ using var ambientActivity = new Activity("ambient activity");
+ ambientActivity.Start();
+
+ var activity = activityFactory.StartHandlerActivity(new MessageHandler { HandlerType = typeof(HandlerActivitySourceTests) });
+
+ Assert.That(activity, Is.Not.Null);
+ Assert.That(activity!.Source.Name, Is.EqualTo("NServiceBus.Core"));
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public void Should_emit_handler_activity_from_handler_source()
+ {
+ using var handlerListener = TestingActivityListener.SetupDiagnosticListener(HandlerSourceName);
+
+ using var ambientActivity = new Activity("ambient activity");
+ ambientActivity.Start();
+
+ var activity = activityFactory.StartHandlerActivity(new MessageHandler { HandlerType = typeof(HandlerActivitySourceTests) });
+
+ Assert.That(activity, Is.Not.Null);
+ Assert.That(activity!.Source.Name, Is.EqualTo(HandlerSourceName));
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public void Should_preserve_display_name_and_handler_type_tag()
+ {
+ using var handlerListener = TestingActivityListener.SetupDiagnosticListener(HandlerSourceName);
+
+ using var ambientActivity = new Activity("ambient activity");
+ ambientActivity.Start();
+
+ Type handlerType = typeof(HandlerActivitySourceTests);
+ var activity = activityFactory.StartHandlerActivity(new MessageHandler { HandlerType = handlerType });
+
+ Assert.That(activity, Is.Not.Null);
+ Assert.That(activity!.DisplayName, Is.EqualTo(handlerType.Name));
+ var tags = activity.Tags.ToImmutableDictionary();
+ Assert.That(tags[ActivityTags.HandlerType], Is.EqualTo(handlerType.FullName));
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public void Should_not_create_handler_activity_without_handler_source_listener()
+ {
+
+ using var ambientActivity = new Activity("ambient activity");
+ ambientActivity.Start();
+
+ var activity = activityFactory.StartHandlerActivity(new MessageHandler { HandlerType = typeof(HandlerActivitySourceTests) });
+
+ Assert.That(activity, Is.Null, "handler activity must not be created when the dedicated source has no listeners");
+ Assert.That(Activity.Current, Is.SameAs(ambientActivity), "user tags must land on the parent (process message) activity");
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/Helpers/TestingMetricListener.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/Helpers/TestingMetricListener.cs
index cbae1435e61..783004cd7f2 100644
--- a/src/NServiceBus.Core.Tests/OpenTelemetry/Helpers/TestingMetricListener.cs
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/Helpers/TestingMetricListener.cs
@@ -10,7 +10,6 @@ namespace NServiceBus.AcceptanceTests.Core.OpenTelemetry.Metrics;
class TestingMetricListener : IDisposable
{
readonly MeterListener meterListener;
- readonly ConcurrentDictionary subscribedInstruments = new(StringComparer.Ordinal);
public readonly List metrics = [];
public string version = "";
public string metricsSourceName = "";
@@ -26,12 +25,6 @@ class TestingMetricListener : IDisposable
return;
}
- var instrumentKey = $"{instrument.Meter.Name}|{instrument.Name}|{instrument.GetType().FullName}";
- if (!subscribedInstruments.TryAdd(instrumentKey, 0))
- {
- return;
- }
-
TestContext.Out.WriteLine($"Subscribing to {instrument.Meter.Name}\\{instrument.Name}");
listener.EnableMeasurementEvents(instrument);
metrics.Add(instrument);
@@ -41,17 +34,19 @@ class TestingMetricListener : IDisposable
}
};
+ // Counters measure in long, the histograms in the incoming pipeline meter measure in double. Both callbacks
+ // have to be registered or the measurements (and therefore the tags) of one kind are never observed. A
+ // counter reports the measurement itself, a histogram reports how many times it recorded.
meterListener.SetMeasurementEventCallback((Instrument instrument,
long measurement,
- ReadOnlySpan> t,
- object _) =>
- {
- TestContext.Out.WriteLine($"{instrument.Meter.Name}\\{instrument.Name}:{measurement}");
+ ReadOnlySpan> tags,
+ object _) => RecordMeasurement(instrument, measurement, measurement, tags));
+
+ meterListener.SetMeasurementEventCallback((Instrument instrument,
+ double measurement,
+ ReadOnlySpan> tags,
+ object _) => RecordMeasurement(instrument, measurement, 1, tags));
- var tags = t.ToArray();
- ReportedMeters.AddOrUpdate(instrument.Name, measurement, (_, val) => val + measurement);
- Tags.AddOrUpdate(instrument.Name, _ => tags, (_, _) => tags);
- });
meterListener.Start();
}
@@ -66,6 +61,15 @@ public static TestingMetricListener SetupMetricsListener(string sourceName)
public void Dispose() => meterListener?.Dispose();
+ void RecordMeasurement(Instrument instrument, T measurement, long reportedValue, ReadOnlySpan> measurementTags)
+ {
+ TestContext.Out.WriteLine($"{instrument.Meter.Name}\\{instrument.Name}:{measurement}");
+
+ var tags = measurementTags.ToArray();
+ ReportedMeters.AddOrUpdate(instrument.Name, reportedValue, (_, val) => val + reportedValue);
+ Tags.AddOrUpdate(instrument.Name, _ => tags, (_, _) => tags);
+ }
+
public ConcurrentDictionary ReportedMeters { get; } = new();
public ConcurrentDictionary[]> Tags { get; } = new();
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/InstrumentationOptionsTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/InstrumentationOptionsTests.cs
new file mode 100644
index 00000000000..2b1847401bb
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/InstrumentationOptionsTests.cs
@@ -0,0 +1,21 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using NUnit.Framework;
+
+[TestFixture]
+public class InstrumentationOptionsTests
+{
+ [Test]
+ public void Should_default_trace_connectors_to_current_behavior()
+ {
+ var options = new InstrumentationOptions();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(options.SendTraceMode, Is.EqualTo(TraceMode.ContinueExisting), "sends continue the trace by default");
+ // Set by ApplyPreV11Defaults in obsoletes-v10.cs. When that file is deleted in v11 this
+ // assertion fails: flip it to TraceMode.ContinueExisting, which is the initializer's value.
+ Assert.That(options.PublishTraceMode, Is.EqualTo(TraceMode.StartNew), "publishes start a new linked trace by default until v11");
+ }
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs
similarity index 70%
rename from src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs
rename to src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs
index 6de0d1ade7a..4e90b6b3e1b 100644
--- a/src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/LegacyContextPropagationTests.cs
@@ -5,11 +5,10 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
-using Extensibility;
using NUnit.Framework;
[TestFixture]
-public class ContextPropagationTests
+public class LegacyContextPropagationTests
{
[Test]
public void Propagate_activity_id_to_header()
@@ -20,7 +19,7 @@ public void Propagate_activity_id_to_header()
var headers = new Dictionary();
- ContextPropagation.PropagateContextToHeaders(activity, headers, new ContextBag());
+ ContextPropagation.PropagateContextToHeaders(activity, headers);
Assert.That(activity.Id, Is.EqualTo(headers[Headers.DiagnosticsTraceParent]));
}
@@ -30,56 +29,42 @@ public void Should_not_set_header_without_activity()
{
var headers = new Dictionary();
- ContextPropagation.PropagateContextToHeaders(null, headers, new ContextBag());
+ ContextPropagation.PropagateContextToHeaders(null, headers);
Assert.That(headers, Is.Empty);
}
[Test]
- public void Should_set_start_new_trace_header_when_adding_trace_parent_header()
+ public void Overwrites_existing_propagation_header()
{
using var activity = new Activity("test");
activity.SetIdFormat(ActivityIdFormat.W3C);
activity.Start();
- var headers = new Dictionary();
- var contextBag = new ContextBag();
- contextBag.Set(Headers.StartNewTrace, bool.TrueString);
- ContextPropagation.PropagateContextToHeaders(activity, headers, contextBag);
-
- using (Assert.EnterMultipleScope())
+ var headers = new Dictionary()
{
- Assert.That(headers.ContainsKey(Headers.StartNewTrace), Is.True, bool.TrueString);
- Assert.That(bool.TrueString, Is.EqualTo(headers[Headers.StartNewTrace]));
- }
- }
+ { Headers.DiagnosticsTraceParent, "some existing id" }
+ };
- [Test]
- public void Should_not_set_start_new_trace_header_when_no_trace_parent_header_is_added()
- {
- var headers = new Dictionary();
- var contextBag = new ContextBag();
- contextBag.Set(Headers.StartNewTrace, bool.TrueString);
- ContextPropagation.PropagateContextToHeaders(null, headers, contextBag);
+ ContextPropagation.PropagateContextToHeaders(activity, headers);
- Assert.That(headers.ContainsKey(Headers.StartNewTrace), Is.False);
+ Assert.That(activity.Id, Is.EqualTo(headers[Headers.DiagnosticsTraceParent]));
}
[Test]
- public void Overwrites_existing_propagation_header()
+ public void Should_not_throw_when_baggage_value_is_null()
{
- using var activity = new Activity("test");
+ // 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()
- {
- { Headers.DiagnosticsTraceParent, "some existing id" }
- };
-
- ContextPropagation.PropagateContextToHeaders(activity, headers, new ContextBag());
+ var headers = new Dictionary();
- Assert.That(activity.Id, Is.EqualTo(headers[Headers.DiagnosticsTraceParent]));
+ Assert.DoesNotThrow(() => ContextPropagation.PropagateContextToHeaders(activity, headers));
}
[TestCaseSource(nameof(TestCases))]
@@ -94,7 +79,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,14 +101,16 @@ 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())
{
activity.AddBaggage(baggageItem.Key, baggageItem.Value);
}
- ContextPropagation.PropagateContextToHeaders(activity, headers, new ContextBag());
+ ContextPropagation.PropagateContextToHeaders(activity, headers);
var baggageHeaderSet = headers.TryGetValue(Headers.DiagnosticsBaggage, out var baggageValue);
@@ -131,7 +120,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,18 +135,22 @@ 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())
{
outgoingActivity.AddBaggage(baggageItem.Key, baggageItem.Value);
}
- ContextPropagation.PropagateContextToHeaders(outgoingActivity, outgoingHeaders, new ContextBag());
+ ContextPropagation.PropagateContextToHeaders(outgoingActivity, outgoingHeaders);
// 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);
@@ -176,49 +169,48 @@ public void Can_roundtrip_baggage(ContextPropagationTestCase testCase)
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%3DpropertyValue"),
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/MeterTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/MeterTests.cs
index b59ee8b556e..14bd2817b1f 100644
--- a/src/NServiceBus.Core.Tests/OpenTelemetry/MeterTests.cs
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/MeterTests.cs
@@ -20,9 +20,9 @@ public void Verify_MeterAPI()
.ToList();
using var meterFactory = new TestMeterFactory();
- //The IncomingPipelineMetrics constructor creates the meters, therefore a new instance before collecting the metrics.
+ //The IncomingPipelineMeter constructor creates the meters, therefore a new instance before collecting the metrics.
#pragma warning disable CA1806
- new IncomingPipelineMetrics(meterFactory, "queue", "disc");
+ new PipelineMetrics(meterFactory, "queue", "disc");
#pragma warning restore CA1806
using var metricsListener = TestingMetricListener.SetupNServiceBusMetricsListener();
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetryExtensionsTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetryExtensionsTests.cs
new file mode 100644
index 00000000000..7428d6e660f
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetryExtensionsTests.cs
@@ -0,0 +1,139 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System.Collections.Generic;
+using NUnit.Framework;
+using Settings;
+
+[TestFixture]
+public class OpenTelemetryExtensionsTests
+{
+ [Test]
+ public void StartNewTraceOnReceive_should_set_span_link_override_on_send_options()
+ {
+ var options = new SendOptions();
+
+ options.StartNewTraceOnReceive();
+
+ Assert.That(options.Context.TryGet(OpenTelemetryExtensions.TraceConnectorOverrideKey, out TraceMode connector), Is.True);
+ Assert.That(connector, Is.EqualTo(TraceMode.StartNew));
+ }
+
+ [Test]
+ public void ContinueExistingTraceOnReceive_should_set_child_span_override_on_send_options()
+ {
+ var options = new SendOptions();
+
+ options.ContinueExistingTraceOnReceive();
+
+ Assert.That(options.Context.TryGet(OpenTelemetryExtensions.TraceConnectorOverrideKey, out TraceMode connector), Is.True);
+ Assert.That(connector, Is.EqualTo(TraceMode.ContinueExisting));
+ }
+
+ [Test]
+ public void StartNewTraceOnReceive_should_set_span_link_override_on_publish_options()
+ {
+ var options = new PublishOptions();
+
+ options.StartNewTraceOnReceive();
+
+ Assert.That(options.Context.TryGet(OpenTelemetryExtensions.TraceConnectorOverrideKey, out TraceMode connector), Is.True);
+ Assert.That(connector, Is.EqualTo(TraceMode.StartNew));
+ }
+
+ [Test]
+ public void ContinueExistingTraceOnReceive_should_set_child_span_override_on_publish_options()
+ {
+ var options = new PublishOptions();
+
+ options.ContinueExistingTraceOnReceive();
+
+ Assert.That(options.Context.TryGet(OpenTelemetryExtensions.TraceConnectorOverrideKey, out TraceMode connector), Is.True);
+ Assert.That(connector, Is.EqualTo(TraceMode.ContinueExisting));
+ }
+
+ [Test]
+ public void Last_override_call_wins()
+ {
+ var options = new PublishOptions();
+
+ options.ContinueExistingTraceOnReceive();
+ options.StartNewTraceOnReceive();
+
+ Assert.That(options.Context.TryGet(OpenTelemetryExtensions.TraceConnectorOverrideKey, out TraceMode connector), Is.True);
+ Assert.That(connector, Is.EqualTo(TraceMode.StartNew));
+ }
+
+ [Test]
+ public void Defaults_to_span_and_logs_when_opt_in_environment_variable_is_not_set()
+ {
+ var settingsHolder = new SettingsHolder();
+ settingsHolder.Set(new FakeEnvironment { ValueToReturn = [] });
+
+ InstrumentationOptions.SetExceptionRecordingModeDefault(settingsHolder);
+
+ Assert.That(settingsHolder.Get().ExceptionRecordingMode, Is.EqualTo(ExceptionRecordingMode.SpanAndLogs));
+ }
+
+ [Test]
+ public void Uses_logs_only_when_opt_in_environment_variable_is_logs()
+ {
+ var settingsHolder = new SettingsHolder();
+ settingsHolder.Set(new FakeEnvironment
+ {
+ ValueToReturn = new Dictionary { { InstrumentationOptions.ExceptionSignalOptInEnvironmentVariableKey, "logs" } }
+ });
+
+ InstrumentationOptions.SetExceptionRecordingModeDefault(settingsHolder);
+
+ Assert.That(settingsHolder.Get().ExceptionRecordingMode, Is.EqualTo(ExceptionRecordingMode.Logs));
+ }
+
+ [Test]
+ public void Uses_span_and_logs_when_opt_in_environment_variable_is_logs_dup()
+ {
+ var settingsHolder = new SettingsHolder();
+ settingsHolder.Set(new FakeEnvironment
+ {
+ ValueToReturn = new Dictionary { { InstrumentationOptions.ExceptionSignalOptInEnvironmentVariableKey, "logs/dup" } }
+ });
+
+ InstrumentationOptions.SetExceptionRecordingModeDefault(settingsHolder);
+
+ Assert.That(settingsHolder.Get().ExceptionRecordingMode, Is.EqualTo(ExceptionRecordingMode.SpanAndLogs));
+ }
+
+ [Test]
+ public void Environment_variable_takes_precedence_over_explicit_configuration()
+ {
+ var settingsHolder = new SettingsHolder();
+ settingsHolder.Set(new FakeEnvironment
+ {
+ ValueToReturn = new Dictionary { { InstrumentationOptions.ExceptionSignalOptInEnvironmentVariableKey, "logs" } }
+ });
+
+ // explicitly configured to something other than what the environment variable resolves to
+ settingsHolder.Set(new InstrumentationOptions { ExceptionRecordingMode = ExceptionRecordingMode.SpanAndLogs });
+ InstrumentationOptions.SetExceptionRecordingModeDefault(settingsHolder);
+
+ Assert.That(settingsHolder.Get().ExceptionRecordingMode, Is.EqualTo(ExceptionRecordingMode.Logs));
+ }
+
+ [Test]
+ public void Explicit_configuration_is_preserved_when_environment_variable_is_not_set()
+ {
+ var settingsHolder = new SettingsHolder();
+ settingsHolder.Set(new FakeEnvironment { ValueToReturn = [] });
+
+ settingsHolder.Set(new InstrumentationOptions { ExceptionRecordingMode = ExceptionRecordingMode.Logs });
+ InstrumentationOptions.SetExceptionRecordingModeDefault(settingsHolder);
+
+ Assert.That(settingsHolder.Get().ExceptionRecordingMode, Is.EqualTo(ExceptionRecordingMode.Logs));
+ }
+
+ class FakeEnvironment : SystemEnvironment
+ {
+ public Dictionary ValueToReturn { get; set; }
+
+ public override string GetEnvironmentVariable(string variable) => ValueToReturn.GetValueOrDefault(variable);
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetryPublishBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetryPublishBehaviorTests.cs
new file mode 100644
index 00000000000..3cc9e44ee06
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetryPublishBehaviorTests.cs
@@ -0,0 +1,55 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Testing;
+
+[TestFixture]
+public class OpenTelemetryPublishBehaviorTests
+{
+ [Test]
+ public async Task Should_start_new_trace_on_receive_by_default()
+ {
+ var behavior = new OpenTelemetryPublishBehavior(new InstrumentationOptions());
+ var context = new TestableOutgoingPublishContext();
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Headers[Headers.StartNewTrace], Is.EqualTo(bool.TrueString));
+ }
+
+ [Test]
+ public async Task Should_continue_trace_on_receive_when_endpoint_connector_is_child_span()
+ {
+ var behavior = new OpenTelemetryPublishBehavior(new InstrumentationOptions { PublishTraceMode = TraceMode.ContinueExisting });
+ var context = new TestableOutgoingPublishContext();
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Headers[Headers.StartNewTrace], Is.EqualTo(bool.FalseString));
+ }
+
+ [Test]
+ public async Task Should_prefer_child_span_option_over_endpoint_connector()
+ {
+ var behavior = new OpenTelemetryPublishBehavior(new InstrumentationOptions { PublishTraceMode = TraceMode.StartNew });
+ var context = new TestableOutgoingPublishContext();
+ context.Extensions.Set(OpenTelemetryExtensions.TraceConnectorOverrideKey, TraceMode.ContinueExisting);
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Headers[Headers.StartNewTrace], Is.EqualTo(bool.FalseString));
+ }
+
+ [Test]
+ public async Task Should_prefer_span_link_option_over_endpoint_connector()
+ {
+ var behavior = new OpenTelemetryPublishBehavior(new InstrumentationOptions { PublishTraceMode = TraceMode.ContinueExisting });
+ var context = new TestableOutgoingPublishContext();
+ context.Extensions.Set(OpenTelemetryExtensions.TraceConnectorOverrideKey, TraceMode.StartNew);
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Headers[Headers.StartNewTrace], Is.EqualTo(bool.TrueString));
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetrySendBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetrySendBehaviorTests.cs
new file mode 100644
index 00000000000..8e26d213544
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/OpenTelemetrySendBehaviorTests.cs
@@ -0,0 +1,55 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Testing;
+
+[TestFixture]
+public class OpenTelemetrySendBehaviorTests
+{
+ [Test]
+ public async Task Should_continue_trace_on_receive_by_default()
+ {
+ var behavior = new OpenTelemetrySendBehavior(new InstrumentationOptions());
+ var context = new TestableOutgoingSendContext();
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Headers[Headers.StartNewTrace], Is.EqualTo(bool.FalseString));
+ }
+
+ [Test]
+ public async Task Should_start_new_trace_on_receive_when_endpoint_connector_is_span_link()
+ {
+ var behavior = new OpenTelemetrySendBehavior(new InstrumentationOptions { SendTraceMode = TraceMode.StartNew });
+ var context = new TestableOutgoingSendContext();
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Headers[Headers.StartNewTrace], Is.EqualTo(bool.TrueString));
+ }
+
+ [Test]
+ public async Task Should_prefer_span_link_option_over_endpoint_connector()
+ {
+ var behavior = new OpenTelemetrySendBehavior(new InstrumentationOptions { SendTraceMode = TraceMode.ContinueExisting });
+ var context = new TestableOutgoingSendContext();
+ context.Extensions.Set(OpenTelemetryExtensions.TraceConnectorOverrideKey, TraceMode.StartNew);
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Headers[Headers.StartNewTrace], Is.EqualTo(bool.TrueString));
+ }
+
+ [Test]
+ public async Task Should_prefer_child_span_option_over_endpoint_connector()
+ {
+ var behavior = new OpenTelemetrySendBehavior(new InstrumentationOptions { SendTraceMode = TraceMode.StartNew });
+ var context = new TestableOutgoingSendContext();
+ context.Extensions.Set(OpenTelemetryExtensions.TraceConnectorOverrideKey, TraceMode.ContinueExisting);
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Headers[Headers.StartNewTrace], Is.EqualTo(bool.FalseString));
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/PipelineMetricsExecutionResultTagTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/PipelineMetricsExecutionResultTagTests.cs
new file mode 100644
index 00000000000..0c676641ebd
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/PipelineMetricsExecutionResultTagTests.cs
@@ -0,0 +1,43 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AcceptanceTests.Core.OpenTelemetry.Metrics;
+using NServiceBus.Pipeline;
+using NUnit.Framework;
+
+// In v11 the execution.result tag is gone: delete this file together with the V11BehaviorSwitch block in
+// obsoletes-v10.cs.
+[TestFixture]
+public class PipelineMetricsExecutionResultTagTests
+{
+ [Test]
+ public void Should_emit_execution_result_tag()
+ {
+ var tags = RecordFailure();
+
+ Assert.That(tags.Select(t => t.Key), Does.Contain("execution.result"));
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public void Should_not_emit_execution_result_tag()
+ {
+ var tags = RecordFailure();
+
+ Assert.That(tags.Select(t => t.Key), Does.Not.Contain("execution.result"));
+ }
+
+ static KeyValuePair[] RecordFailure()
+ {
+ using var meterFactory = new TestMeterFactory();
+ using var listener = TestingMetricListener.SetupNServiceBusMetricsListener();
+
+ var metrics = new PipelineMetrics(meterFactory, "queue", "disc");
+ metrics.RecordMessageProcessingFailure(new PipelineMetricTags(), new InvalidOperationException("boom"));
+
+ Assert.That(listener.Tags, Has.Count.EqualTo(1), "exactly one metric is expected to be recorded");
+ return listener.Tags.Values.Single();
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/PopulateRecoverabilityTraceMetadataBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/PopulateRecoverabilityTraceMetadataBehaviorTests.cs
index 6b4850fabdd..ec5d0c067ea 100644
--- a/src/NServiceBus.Core.Tests/OpenTelemetry/PopulateRecoverabilityTraceMetadataBehaviorTests.cs
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/PopulateRecoverabilityTraceMetadataBehaviorTests.cs
@@ -14,7 +14,7 @@ public class PopulateRecoverabilityTraceMetadataBehaviorTests
[Test]
public async Task Should_not_write_metadata_when_trace_not_present()
{
- var behavior = new PopulateRecoverabilityTraceMetadataBehavior();
+ var behavior = new PopulateRecoverabilityTraceMetadataBehavior(new InstrumentationOptions());
var context = new TestableRecoverabilityContext();
await behavior.Invoke(context, _ => Task.CompletedTask);
@@ -27,10 +27,10 @@ public async Task Should_not_write_metadata_when_trace_not_present()
}
[Test]
- [TestCaseSource(nameof(Actions))]
+ [TestCaseSource(nameof(ActionsThatWriteMetadata))]
public async Task Should_write_metadata_when_trace_present(RecoverabilityAction recoverabilityAction)
{
- var behavior = new PopulateRecoverabilityTraceMetadataBehavior();
+ var behavior = new PopulateRecoverabilityTraceMetadataBehavior(new InstrumentationOptions());
var context = new TestableRecoverabilityContext
{
@@ -47,9 +47,63 @@ public async Task Should_write_metadata_when_trace_present(RecoverabilityAction
}
}
- static IEnumerable Actions()
+ [Test]
+ public async Task Should_not_write_metadata_for_immediate_retry()
+ {
+ var behavior = new PopulateRecoverabilityTraceMetadataBehavior(new InstrumentationOptions());
+
+ var context = new TestableRecoverabilityContext
+ {
+ Headers = { { Headers.DiagnosticsTraceParent, "traceparent" } },
+ RecoverabilityAction = new ImmediateRetry()
+ };
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(context.Headers, Does.Not.ContainKey(Headers.StartNewTrace));
+ Assert.That(context.Metadata, Does.Not.ContainKey(Headers.StartNewTrace));
+ }
+ }
+
+ [Test]
+ public async Task Should_always_start_new_trace_for_move_to_error()
+ {
+ var behavior = new PopulateRecoverabilityTraceMetadataBehavior(new InstrumentationOptions());
+
+ var context = new TestableRecoverabilityContext
+ {
+ Headers = { { Headers.DiagnosticsTraceParent, "traceparent" } },
+ RecoverabilityAction = new MoveToError("errorqueue")
+ };
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Metadata[Headers.StartNewTrace], Is.EqualTo(bool.TrueString));
+ }
+
+ [Test]
+ public async Task Should_honor_delayed_retry_trace_mode()
+ {
+ var behavior = new PopulateRecoverabilityTraceMetadataBehavior(new InstrumentationOptions
+ {
+ Recoverability = { DelayedRetryTraceMode = TraceMode.ContinueExisting }
+ });
+
+ var context = new TestableRecoverabilityContext
+ {
+ Headers = { { Headers.DiagnosticsTraceParent, "traceparent" } },
+ RecoverabilityAction = new DelayedRetry(TimeSpan.FromSeconds(10))
+ };
+
+ await behavior.Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(context.Metadata[Headers.StartNewTrace], Is.EqualTo(bool.FalseString));
+ }
+
+ static IEnumerable ActionsThatWriteMetadata()
{
- yield return new ImmediateRetry();
yield return new DelayedRetry(TimeSpan.FromSeconds(10));
yield return new MoveToError("errorqueue");
}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/SubscribeDiagnosticsBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/SubscribeDiagnosticsBehaviorTests.cs
new file mode 100644
index 00000000000..3ef69ec42c9
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/SubscribeDiagnosticsBehaviorTests.cs
@@ -0,0 +1,49 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Testing;
+
+[TestFixture]
+public class SubscribeDiagnosticsBehaviorTests
+{
+ const string EventTypesTag = "nservicebus.event_types";
+
+ [Test]
+ public async Task Should_tag_event_types_as_comma_separated_string()
+ {
+ var context = CreateContext(out var activity);
+
+ await new SubscribeDiagnosticsBehavior().Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(activity.TagObjects.ToImmutableDictionary()[EventTypesTag], Is.EqualTo($"{typeof(FirstEvent)},{typeof(SecondEvent)}"));
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_tag_event_types_as_array_of_full_names()
+ {
+ var context = CreateContext(out var activity);
+
+ await new SubscribeDiagnosticsBehavior().Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(activity.TagObjects.ToImmutableDictionary()[EventTypesTag], Is.EqualTo(new[] { typeof(FirstEvent).FullName, typeof(SecondEvent).FullName }));
+ }
+
+ static TestableSubscribeContext CreateContext(out Activity activity)
+ {
+ var context = new TestableSubscribeContext { EventTypes = [typeof(FirstEvent), typeof(SecondEvent)] };
+
+ activity = new Activity("subscribe");
+ activity.Start();
+ context.Extensions.SetOutgoingPipelineActivity(activity);
+
+ return context;
+ }
+
+ class FirstEvent;
+
+ class SecondEvent;
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/TraceContextPreservationTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/TraceContextPreservationTests.cs
new file mode 100644
index 00000000000..e7e578a599d
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/TraceContextPreservationTests.cs
@@ -0,0 +1,144 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using NServiceBus.Audit;
+using NServiceBus.Pipeline;
+using NServiceBus.Routing;
+using NServiceBus.Transport;
+using NUnit.Framework;
+using Testing;
+
+///
+/// Messages forwarded from the incoming side (recoverability, audit, ForwardCurrentMessageTo) enter the pipeline at
+/// the routing stage and never pass through the outgoing pipeline. They must keep the trace headers of the message
+/// as it was received and must not be re-parented to whatever activity happens to be current at that time.
+///
+[TestFixture]
+public class TraceContextPreservationTests
+{
+ const string OriginalTraceParent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
+
+ [Test]
+ public async Task Should_preserve_trace_headers_when_moving_to_error_queue()
+ {
+ var recoverabilityContext = CreateRecoverabilityContext();
+ var routingContext = new MoveToError("error-queue")
+ .GetRoutingContexts(recoverabilityContext)
+ .Single();
+
+ var headers = await DispatchWithAmbientActivity(routingContext);
+
+ AssertTraceHeadersPreserved(headers);
+ }
+
+ [Test]
+ public async Task Should_preserve_trace_headers_when_delayed_retrying()
+ {
+ var recoverabilityContext = CreateRecoverabilityContext();
+ var routingContext = new DelayedRetry(TimeSpan.FromSeconds(1))
+ .GetRoutingContexts(recoverabilityContext)
+ .Single();
+
+ var headers = await DispatchWithAmbientActivity(routingContext);
+
+ AssertTraceHeadersPreserved(headers);
+ }
+
+ [Test]
+ public async Task Should_preserve_trace_headers_when_auditing()
+ {
+ var auditContext = new TestableAuditContext
+ {
+ Message = new OutgoingMessage("message-id", CreateReceivedHeaders(), ReadOnlyMemory.Empty)
+ };
+ var routingContext = RouteToAudit.Instance
+ .GetRoutingContexts(auditContext)
+ .Single();
+
+ var headers = await DispatchWithAmbientActivity(routingContext);
+
+ AssertTraceHeadersPreserved(headers);
+ }
+
+ [Test]
+ public async Task Should_preserve_trace_headers_when_forwarding_current_message()
+ {
+ var receivedHeaders = CreateReceivedHeaders();
+ var incomingMessage = new IncomingMessage("message-id", receivedHeaders, ReadOnlyMemory.Empty);
+ var incomingContext = new TestableIncomingLogicalMessageContext();
+ incomingContext.Extensions.Set(incomingMessage);
+
+ Dictionary headers = null;
+ var routingPipelineCache = new RoutingPipelineCache(async routingContext =>
+ headers = await DispatchWithAmbientActivity(routingContext));
+ incomingContext.Extensions.Set(routingPipelineCache);
+
+ await IncomingMessageOperations.ForwardCurrentMessageTo(incomingContext, "destination");
+
+ AssertTraceHeadersPreserved(headers);
+ // The forwarded message shares the header dictionary of the message being processed
+ AssertTraceHeadersPreserved(receivedHeaders);
+ }
+
+ static TestableRecoverabilityContext CreateRecoverabilityContext() =>
+ new()
+ {
+ MessageId = "message-id",
+ Headers = CreateReceivedHeaders()
+ };
+
+ static Dictionary CreateReceivedHeaders() =>
+ new()
+ {
+ [Headers.NServiceBusDiagnosticsTraceParent] = OriginalTraceParent
+ };
+
+ static async Task> DispatchWithAmbientActivity(IRoutingContext routingContext)
+ {
+ using var ambientActivity = new Activity("ambient activity");
+ ambientActivity.SetIdFormat(ActivityIdFormat.W3C);
+ ambientActivity.AddBaggage("ambient-key", "ambient-value");
+ ambientActivity.Start();
+
+ var connector = new RoutingToDispatchConnector(NoOpActivityFactory.Instance);
+ TransportOperation transportOperation = null;
+
+ await connector.Invoke(routingContext, dispatchContext =>
+ {
+ transportOperation = dispatchContext.Operations.Single();
+ return Task.CompletedTask;
+ });
+
+ return transportOperation.Message.Headers;
+ }
+
+ static void AssertTraceHeadersPreserved(Dictionary headers)
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(
+ headers[Headers.NServiceBusDiagnosticsTraceParent],
+ Is.EqualTo(OriginalTraceParent),
+ "traceparent of the received message must be preserved");
+ Assert.That(
+ headers,
+ Does.Not.ContainKey(Headers.DiagnosticsBaggage),
+ "baggage of the current activity must not leak into the forwarded message");
+ }
+ }
+
+ sealed class RoutingPipelineCache(Func routingPipeline) : IPipelineCache
+ {
+ public IPipeline Pipeline() where TContext : IBehaviorContext =>
+ (IPipeline)(object)new RoutingPipeline(routingPipeline);
+
+ sealed class RoutingPipeline(Func invoke) : IPipeline
+ {
+ public Task Invoke(IRoutingContext context) => invoke(context);
+ }
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/TracingExtensionsTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/TracingExtensionsTests.cs
deleted file mode 100644
index 64c1947dbf5..00000000000
--- a/src/NServiceBus.Core.Tests/OpenTelemetry/TracingExtensionsTests.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-namespace NServiceBus.Core.Tests.OpenTelemetry;
-
-using System;
-using System.Collections.Immutable;
-using System.Diagnostics;
-using System.Linq;
-using System.Threading.Tasks;
-using NServiceBus.Pipeline;
-using NUnit.Framework;
-
-[TestFixture]
-public class TracingExtensionsTests
-{
- [Test]
- public async Task Invoke_should_invoke_pipeline_when_activity_null()
- {
- bool invokedPipeline = false;
- var pipeline = new FakePipeline(() =>
- {
- invokedPipeline = true;
- return Task.CompletedTask;
- });
-
- await pipeline.Invoke(new FakeRootContext(), null);
-
- Assert.That(invokedPipeline, Is.True);
- }
-
- [Test]
- public async Task Invoke_should_set_success_status_when_no_exception()
- {
- var pipeline = new FakePipeline(() => Task.CompletedTask);
- using var activity = new Activity("test activity");
- activity.Start();
-
- await pipeline.Invoke(new FakeRootContext(), activity);
-
- Assert.That(activity.Status, Is.EqualTo(ActivityStatusCode.Ok));
- }
-
- [Test]
- public void Invoke_should_set_error_status_and_tags_when_exception()
- {
- var exception = new Exception("test exception");
- var pipeline = new FakePipeline(() => throw exception);
- using var activity = new Activity("test activity");
- activity.Start();
-
- Assert.ThrowsAsync(() => pipeline.Invoke(new FakeRootContext(), activity));
-
- Assert.That(activity.Status, Is.EqualTo(ActivityStatusCode.Error));
-
- var tags = activity.Tags.ToImmutableDictionary();
- using (Assert.EnterMultipleScope())
- {
- Assert.That(tags["otel.status_code"], Is.EqualTo("ERROR"));
- Assert.That(tags["otel.status_description"], Is.EqualTo(exception.Message));
- }
-
- var errorEvent = activity.Events.Single();
- Assert.That(errorEvent.Name, Is.EqualTo("exception"));
- }
-
- class FakePipeline : IPipeline
- {
- readonly Func pipelineAction;
-
-#pragma warning disable PS0013 // A Func used as a method parameter with a Task, ValueTask, or ValueTask return type argument should have at least one CancellationToken parameter type argument unless it has a parameter type argument implementing ICancellableContext
- public FakePipeline(Func pipelineAction)
-#pragma warning restore PS0013 // A Func used as a method parameter with a Task, ValueTask, or ValueTask return type argument should have at least one CancellationToken parameter type argument unless it has a parameter type argument implementing ICancellableContext
- {
- this.pipelineAction = pipelineAction;
- }
-
- public Task Invoke(IBehaviorContext context) => pipelineAction();
- }
-}
\ No newline at end of file
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/TransportParentSpanDefaultBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/TransportParentSpanDefaultBehaviorTests.cs
new file mode 100644
index 00000000000..81bad803712
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/TransportParentSpanDefaultBehaviorTests.cs
@@ -0,0 +1,89 @@
+#nullable enable
+
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using Helpers;
+using NServiceBus.Extensibility;
+using NServiceBus.Transport;
+using NUnit.Framework;
+
+// Covers the pre-v11 default of V11BehaviorSwitch: without the OpenTelemetryV11Defaults attribute the incoming
+// message span stays a child of the NServiceBus sender span even when a transport SDK span is
+// ambient. In v11 that default is gone, so delete this file together with the V11BehaviorSwitch block in obsoletes-v10.cs.
+[TestFixture]
+public class TransportParentSpanDefaultBehaviorTests
+{
+ readonly ActivityFactory activityFactory = new(new InstrumentationOptions());
+
+ TestingActivityListener nsbActivityListener;
+
+ [SetUp]
+ public void SetUp()
+ {
+ nsbActivityListener = TestingActivityListener.SetupNServiceBusDiagnosticListener();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ nsbActivityListener.Dispose();
+ }
+
+ [Test]
+ public void Default_attaches_to_header_trace_when_even_id_ambient_activity_exists()
+ {
+ var sendActivity = new Activity("send activity");
+ sendActivity.SetIdFormat(ActivityIdFormat.W3C);
+ sendActivity.Start();
+ sendActivity.Stop();
+
+ using var ambientActivity = new Activity("transport sdk receive activity");
+ ambientActivity.Start();
+
+ var messageHeaders = new Dictionary { { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! } };
+ var messageContext = new MessageContext(Guid.NewGuid().ToString(), messageHeaders, Array.Empty(), new TransportTransaction(), "receiver", new ContextBag());
+
+ var activity = activityFactory.StartIncomingPipelineActivity(messageContext);
+
+ Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(activity.ParentId, Is.EqualTo(sendActivity.Id), "should use the sender span as parent, ignoring the ambient activity");
+ Assert.That(activity.Links.Count(), Is.EqualTo(0), "should not link to logical send span");
+ }
+ }
+
+ [Test]
+ public void Default_propagates_header_baggage_but_not_ambient_baggage()
+ {
+ var sendActivity = new Activity("send activity");
+ sendActivity.SetIdFormat(ActivityIdFormat.W3C);
+ sendActivity.Start();
+ sendActivity.Stop();
+
+ using var ambientActivity = new Activity("transport sdk receive activity");
+ ambientActivity.AddBaggage("ambient-only", "value");
+ ambientActivity.Start();
+
+ var messageHeaders = new Dictionary
+ {
+ { Headers.NServiceBusDiagnosticsTraceParent, sendActivity.Id! },
+ { Headers.DiagnosticsBaggage, "tenant=acme" }
+ };
+ var messageContext = new MessageContext(Guid.NewGuid().ToString(), messageHeaders, Array.Empty(), new TransportTransaction(), "receiver", new ContextBag());
+
+ var activity = activityFactory.StartIncomingPipelineActivity(messageContext);
+
+ Assert.That(activity, Is.Not.Null, "should create activity for receive pipeline");
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(activity.Parent, Is.Null, "the sender span is the parent, so the ambient activity is not in the parent chain");
+ Assert.That(activity.GetBaggageItem("tenant"), Is.EqualTo("acme"), "baggage from the message is propagated");
+ Assert.That(activity.GetBaggageItem("ambient-only"), Is.Null, "baggage of an activity that is not the parent is not NServiceBus' concern");
+ }
+ }
+}
diff --git a/src/NServiceBus.Core.Tests/OpenTelemetry/UnsubscribeDiagnosticsBehaviorTests.cs b/src/NServiceBus.Core.Tests/OpenTelemetry/UnsubscribeDiagnosticsBehaviorTests.cs
new file mode 100644
index 00000000000..c5502e5b8b5
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/OpenTelemetry/UnsubscribeDiagnosticsBehaviorTests.cs
@@ -0,0 +1,47 @@
+namespace NServiceBus.Core.Tests.OpenTelemetry;
+
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Testing;
+
+[TestFixture]
+public class UnsubscribeDiagnosticsBehaviorTests
+{
+ const string EventTypesTag = "nservicebus.event_types";
+
+ [Test]
+ public async Task Should_tag_event_type_as_string()
+ {
+ var context = CreateContext(out var activity);
+
+ await new UnsubscribeDiagnosticsBehavior().Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(activity.TagObjects.ToImmutableDictionary()[EventTypesTag], Is.EqualTo(typeof(DemoEvent).FullName));
+ }
+
+ [Test]
+ [OpenTelemetryV11Defaults]
+ public async Task Should_tag_event_type_as_single_element_array()
+ {
+ var context = CreateContext(out var activity);
+
+ await new UnsubscribeDiagnosticsBehavior().Invoke(context, _ => Task.CompletedTask);
+
+ Assert.That(activity.TagObjects.ToImmutableDictionary()[EventTypesTag], Is.EqualTo(new[] { typeof(DemoEvent).FullName }));
+ }
+
+ static TestableUnsubscribeContext CreateContext(out Activity activity)
+ {
+ var context = new TestableUnsubscribeContext { EventType = typeof(DemoEvent) };
+
+ activity = new Activity("unsubscribe");
+ activity.Start();
+ context.Extensions.SetOutgoingPipelineActivity(activity);
+
+ return context;
+ }
+
+ class DemoEvent;
+}
diff --git a/src/NServiceBus.Core.Tests/Pipeline/Incoming/InvokeHandlerTerminatorTest.cs b/src/NServiceBus.Core.Tests/Pipeline/Incoming/InvokeHandlerTerminatorTest.cs
index f36af8a855a..2b74cf4a74d 100644
--- a/src/NServiceBus.Core.Tests/Pipeline/Incoming/InvokeHandlerTerminatorTest.cs
+++ b/src/NServiceBus.Core.Tests/Pipeline/Incoming/InvokeHandlerTerminatorTest.cs
@@ -11,7 +11,7 @@
[TestFixture]
public class InvokeHandlerTerminatorTest
{
- InvokeHandlerTerminator terminator = new(new IncomingPipelineMetrics(new TestMeterFactory(), "queue", "disc"));
+ readonly InvokeHandlerTerminator terminator = new(new PipelineMetrics(new TestMeterFactory(), "queue", "disc"));
[Test]
public async Task When_saga_found_and_handler_is_saga_should_invoke_handler()
diff --git a/src/NServiceBus.Core.Tests/Pipeline/Incoming/SerializeMessageConnectorTests.cs b/src/NServiceBus.Core.Tests/Pipeline/Incoming/SerializeMessageConnectorTests.cs
index 12b7ed19bc6..9aeddeaec52 100644
--- a/src/NServiceBus.Core.Tests/Pipeline/Incoming/SerializeMessageConnectorTests.cs
+++ b/src/NServiceBus.Core.Tests/Pipeline/Incoming/SerializeMessageConnectorTests.cs
@@ -4,6 +4,8 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
+using NServiceBus.AcceptanceTests.Core.OpenTelemetry.Metrics;
+using NServiceBus.Core.Tests.OpenTelemetry;
using NServiceBus.Pipeline;
using NUnit.Framework;
using Serialization;
@@ -16,26 +18,54 @@ public class SerializeMessageConnectorTests
[Test]
public async Task Should_set_content_type_header()
{
- var registry = new MessageMetadataRegistry();
- registry.Initialize(new Conventions().IsMessageType, true);
+ var context = CreateContext();
+
+ await InvokeSerializer(context, queueName: "queue", discriminator: "disc");
+
+ Assert.That(context.Headers[Headers.ContentType], Is.EqualTo("myContentType"));
+ }
+
+ [Test]
+ public async Task Should_prefer_queue_and_discriminator_from_the_incoming_pipeline_tags()
+ {
+ // A send from a message handler chains its context to the incoming pipeline, whose tags win over the
+ // endpoint configuration defaults. Seeding both tags from the configuration and leaving them off for
+ // send-only endpoints is covered by the When_serializing_outgoing_messages acceptance tests.
+ using var metricsListener = TestingMetricListener.SetupNServiceBusMetricsListener();
- registry.RegisterMessageTypes(
- [
- typeof(MyMessage)
- ]);
+ var context = CreateContext();
+ var incomingTags = context.Extensions.GetOrCreate();
+ incomingTags.Add("nservicebus.queue", "queue-from-incoming-pipeline");
+ incomingTags.Add("nservicebus.discriminator", "disc-from-incoming-pipeline");
- var context = new TestableOutgoingLogicalMessageContext
+ await InvokeSerializer(context, queueName: "queue", discriminator: "disc");
+
+ using (Assert.EnterMultipleScope())
{
- Message = new OutgoingLogicalMessage(typeof(MyMessage), new MyMessage())
- };
+ Assert.That(metricsListener.AssertTagKeyExists(MessageSerializeTime, "nservicebus.queue"), Is.EqualTo("queue-from-incoming-pipeline"));
+ Assert.That(metricsListener.AssertTagKeyExists(MessageSerializeTime, "nservicebus.discriminator"), Is.EqualTo("disc-from-incoming-pipeline"));
+ }
+ }
- var behavior = new SerializeMessageConnector(new FakeSerializer("myContentType"), registry);
+ static TestableOutgoingLogicalMessageContext CreateContext() =>
+ new() { Message = new OutgoingLogicalMessage(typeof(MyMessage), new MyMessage()) };
- await behavior.Invoke(context, c => Task.CompletedTask);
+ static Task InvokeSerializer(TestableOutgoingLogicalMessageContext context, string queueName, string discriminator)
+ {
+ var registry = new MessageMetadataRegistry();
+ registry.Initialize(new Conventions().IsMessageType, true);
+ registry.RegisterMessageTypes([typeof(MyMessage)]);
- Assert.That(context.Headers[Headers.ContentType], Is.EqualTo("myContentType"));
+ var behavior = new SerializeMessageConnector(
+ new FakeSerializer("myContentType"),
+ registry,
+ new PipelineMetrics(new TestMeterFactory(), queueName, discriminator));
+
+ return behavior.Invoke(context, _ => Task.CompletedTask);
}
+ const string MessageSerializeTime = "nservicebus.messaging.serialize_time";
+
class FakeSerializer(string contentType) : IMessageSerializer
{
public void Serialize(object message, Stream stream)
diff --git a/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs b/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs
index f6223a25eac..f78b9f7261b 100644
--- a/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs
+++ b/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs
@@ -123,14 +123,14 @@ static MessageContext CreateMessageContext() =>
static MainPipelineExecutor CreateMainPipelineExecutor(ServiceProvider serviceProvider, IPipeline receivePipeline)
{
- var incomingPipelineMetrics = new IncomingPipelineMetrics(new TestMeterFactory(), "queue", "disc");
+ var incomingPipelineMetrics = new PipelineMetrics(new TestMeterFactory(), "queue", "disc");
var executor = new MainPipelineExecutor(
serviceProvider,
new PipelineCache(serviceProvider, new PipelineModifications()),
new TestableMessageOperations(),
new Notification(),
receivePipeline,
- new ActivityFactory(),
+ new ActivityFactory(new InstrumentationOptions()),
incomingPipelineMetrics,
new EnvelopeUnwrapper([], incomingPipelineMetrics));
diff --git a/src/NServiceBus.Core.Tests/Pipeline/IncomingPipelineMetricTagsTests.cs b/src/NServiceBus.Core.Tests/Pipeline/PipelineMetricTagsTests.cs
similarity index 89%
rename from src/NServiceBus.Core.Tests/Pipeline/IncomingPipelineMetricTagsTests.cs
rename to src/NServiceBus.Core.Tests/Pipeline/PipelineMetricTagsTests.cs
index 52576e26016..aa3353a319c 100644
--- a/src/NServiceBus.Core.Tests/Pipeline/IncomingPipelineMetricTagsTests.cs
+++ b/src/NServiceBus.Core.Tests/Pipeline/PipelineMetricTagsTests.cs
@@ -5,6 +5,7 @@ namespace NServiceBus.Core.Tests.Pipeline.Incoming;
using System.IO;
using System.Threading.Tasks;
using MessageInterfaces.MessageMapper.Reflection;
+using NServiceBus.Core.Tests.OpenTelemetry;
using NServiceBus.Pipeline;
using NUnit.Framework;
using Serialization;
@@ -13,7 +14,7 @@ namespace NServiceBus.Core.Tests.Pipeline.Incoming;
using Unicast.Messages;
[TestFixture]
-public class IncomingPipelineMetricTagsTests
+public class PipelineMetricTagsTests
{
[Test]
public void Should_not_fail_when_handling_more_than_one_logical_message()
@@ -35,11 +36,11 @@ public void Should_not_fail_when_handling_more_than_one_logical_message()
};
var messageMapper = new MessageMapper();
- var behavior = new DeserializeMessageConnector(new MessageDeserializerResolver(new FakeSerializer(), []), new LogicalMessageFactory(registry, messageMapper), registry, messageMapper, false);
+ var behavior = new DeserializeMessageConnector(new MessageDeserializerResolver(new FakeSerializer(), []), new LogicalMessageFactory(registry, messageMapper), registry, messageMapper, false, new PipelineMetrics(new TestMeterFactory(), "queue", "disc"));
Assert.DoesNotThrowAsync(async () => await behavior.Invoke(context, c =>
{
- c.Extensions.Get().Add("Same", "Same");
+ c.PipelineMetricTags.Add("Same", "Same");
return Task.CompletedTask;
}));
}
diff --git a/src/NServiceBus.Core.Tests/Pipeline/TestableMessageOperations.cs b/src/NServiceBus.Core.Tests/Pipeline/TestableMessageOperations.cs
index ddb58d3a66e..e4e76b40f14 100644
--- a/src/NServiceBus.Core.Tests/Pipeline/TestableMessageOperations.cs
+++ b/src/NServiceBus.Core.Tests/Pipeline/TestableMessageOperations.cs
@@ -5,7 +5,15 @@
using System.Threading.Tasks;
using MessageInterfaces.MessageMapper.Reflection;
-class TestableMessageOperations : MessageOperations
+class TestableMessageOperations() : MessageOperations(
+ new MessageMapper(),
+ new Pipeline(),
+ new Pipeline(),
+ new Pipeline(),
+ new Pipeline(),
+ new Pipeline(),
+ new ActivityFactory(new InstrumentationOptions())
+ )
{
public Pipeline PublishPipeline => (Pipeline)publishPipeline;
public Pipeline SendPipeline => (Pipeline)sendPipeline;
@@ -13,10 +21,6 @@ class TestableMessageOperations : MessageOperations
public Pipeline SubscribePipeline => (Pipeline)subscribePipeline;
public Pipeline UnsubscribePipeline => (Pipeline