Skip to content

Commit bba80f7

Browse files
authored
Using DistributedContextPropagator instead of hand-written baggage propagator (#7820)
* initial migration to the DistributedContextPropagator for W3C compatibility * new propagator with backwards compatiblity tests * fixing the startnewtrace header progagation * refactor ContextPropagation.cs * small tweak * warning fixes * att tests reflect supported baggage and tracestate formats
1 parent af64227 commit bba80f7

9 files changed

Lines changed: 269 additions & 78 deletions

File tree

src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Metrics/When_envelope_handler_succeeds.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Linq;
77
using System.Threading.Tasks;
8+
using Microsoft.ApplicationInsights.Extensibility;
89
using NServiceBus;
910
using NServiceBus.AcceptanceTesting;
1011
using NServiceBus.AcceptanceTests.Core.OpenTelemetry;

src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_ambient_trace_in_message_session.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public async Task Should_attach_to_ambient_trace()
1515
using var externalActivitySource = new ActivitySource("external trace source");
1616
using var _ = TestingActivityListener.SetupDiagnosticListener(externalActivitySource.Name); // need to have a registered listener for activities to be created
1717

18-
const string wrapperActivityTraceState = "test trace state";
18+
const string wrapperActivityTraceState = "tracekey=traceValue";
1919

2020
var context = await Scenario.Define<Context>()
2121
.WithEndpoint<EndpointWithAmbientActivity>(b => b

src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_incoming_message_has_baggage_header.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public async Task Should_propagate_baggage_to_activity()
1717
{
1818
var sendOptions = new SendOptions();
1919
sendOptions.RouteToThisEndpoint();
20-
sendOptions.SetHeader(Headers.DiagnosticsBaggage, "key1=value1,key2=value2,key3=");
20+
sendOptions.SetHeader(Headers.DiagnosticsBaggage, "key1=value1,key2=value2,key3=value3");
2121
await session.Send(new SomeMessage(), sendOptions);
2222
})
2323
)
@@ -29,7 +29,7 @@ public async Task Should_propagate_baggage_to_activity()
2929

3030
VerifyBaggageItem("key1", "value1");
3131
VerifyBaggageItem("key2", "value2");
32-
VerifyBaggageItem("key3", "");
32+
VerifyBaggageItem("key3", "value3");
3333
return;
3434

3535
void VerifyBaggageItem(string key, string expectedValue)

src/NServiceBus.AcceptanceTests/Core/OpenTelemetry/Traces/When_outgoing_activity_has_baggage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public async Task Should_propagate_baggage_to_headers()
3333
)
3434
.Run();
3535

36-
Assert.That(context.BaggageHeader, Is.EqualTo("key3=,key2=value2,key1=value1"));
36+
Assert.That(context.BaggageHeader, Is.EqualTo("key3 = , key2 = value2, key1 = value1"));
3737
}
3838

3939
public class TestEndpoint : EndpointConfigurationBuilder
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
namespace NServiceBus.Core.Tests.OpenTelemetry;
2+
3+
using System.Collections.Generic;
4+
using System.Diagnostics;
5+
using Extensibility;
6+
using NUnit.Framework;
7+
8+
[TestFixture]
9+
public class ContextPropagationIncompatibilityTests
10+
{
11+
delegate void Writer(Activity activity, Dictionary<string, string> headers, ContextBag context);
12+
delegate void Reader(Activity activity, IDictionary<string, string> headers);
13+
14+
static readonly Writer LegacyWrite = LegacyContextPropagator.PropagateContextToHeaders;
15+
static readonly Reader LegacyRead = LegacyContextPropagator.PropagateContextFromHeaders;
16+
static readonly Writer NewWrite = ContextPropagation.PropagateContextToHeaders;
17+
static readonly Reader NewRead = ContextPropagation.PropagateContextFromHeaders;
18+
19+
// A value exercising every class of special character: structural baggage delimiters
20+
// (',' ';' '='), the escape char '%', quotes, brackets, slashes, ampersand, Unicode and
21+
// an emoji, plus interior spaces. Deliberately has NO leading/trailing whitespace, so this
22+
// value isolates "what happens to special characters" from the separate edge-whitespace
23+
// issue covered by New_propagation_loses_leading_whitespace_in_a_value.
24+
// This already includes property-like syntax (the ';' and '=' delimiters), so a value such as
25+
// "zone=eu;sensitive" is just a subset and needs no separate case here.
26+
const string AllSpecialCharacters = "a b,c;d=e&f'g\"h\\i(j)k{l}m[n]o%p/q?r:s@t~u|v<w>x é ü 😀 z";
27+
28+
static Dictionary<string, string> Send(string value, Writer write)
29+
{
30+
using var sender = new Activity(ActivityNames.OutgoingMessageActivityName);
31+
sender.SetIdFormat(ActivityIdFormat.W3C);
32+
sender.Start();
33+
sender.AddBaggage("key", value);
34+
35+
var headers = new Dictionary<string, string>();
36+
write(sender, headers, new ContextBag());
37+
sender.Stop();
38+
return headers;
39+
}
40+
41+
static string Receive(Dictionary<string, string> headers, Reader read)
42+
{
43+
using var receiver = new Activity(ActivityNames.IncomingMessageActivityName);
44+
receiver.SetIdFormat(ActivityIdFormat.W3C);
45+
receiver.Start();
46+
read(receiver, headers);
47+
return receiver.GetBaggageItem("key");
48+
}
49+
50+
static string Transmit(string value, Writer write, Reader read) => Receive(Send(value, write), read);
51+
52+
[Test]
53+
public void Legacy_sender_to_new_receiver_preserves_the_value()
54+
{
55+
var received = Transmit(AllSpecialCharacters, LegacyWrite, NewRead);
56+
Assert.That(received, Is.EqualTo(AllSpecialCharacters));
57+
}
58+
59+
[Test]
60+
public void New_sender_to_legacy_receiver_prepends_a_leading_space_but_keeps_the_special_characters()
61+
{
62+
var received = Transmit(AllSpecialCharacters, NewWrite, LegacyRead);
63+
64+
Assert.That(received, Is.EqualTo(" " + AllSpecialCharacters),
65+
"ignoring the leading space, every special character round-trips correctly");
66+
}
67+
68+
[Test]
69+
public void New_propagation_loses_leading_whitespace_in_a_value()
70+
{
71+
const string valueWithLeadingSpace = " hasLeadingSpace";
72+
73+
var legacyRoundTrip = Transmit(valueWithLeadingSpace, LegacyWrite, LegacyRead);
74+
var newRoundTrip = Transmit(valueWithLeadingSpace, NewWrite, NewRead);
75+
76+
using (Assert.EnterMultipleScope())
77+
{
78+
Assert.That(legacyRoundTrip, Is.EqualTo(valueWithLeadingSpace),
79+
"legacy propagation preserves leading whitespace via percent-encoding");
80+
Assert.That(newRoundTrip, Is.EqualTo("hasLeadingSpace"),
81+
"new propagation strips the leading whitespace from the value");
82+
}
83+
}
84+
}

src/NServiceBus.Core.Tests/OpenTelemetry/ContextPropagationTests.cs

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
namespace NServiceBus.Core.Tests.OpenTelemetry;
22

3-
using System;
43
using System.Collections;
54
using System.Collections.Generic;
65
using System.Diagnostics;
@@ -94,7 +93,9 @@ public void Can_propagate_baggage_from_header_to_activity(ContextPropagationTest
9493
headers[Headers.DiagnosticsBaggage] = testCase.BaggageHeaderValue;
9594
}
9695

97-
var activity = new Activity(ActivityNames.IncomingMessageActivityName);
96+
using var activity = new Activity(ActivityNames.IncomingMessageActivityName);
97+
activity.SetIdFormat(ActivityIdFormat.W3C);
98+
activity.Start();
9899

99100
ContextPropagation.PropagateContextFromHeaders(activity, headers);
100101

@@ -114,7 +115,9 @@ public void Can_propagate_baggage_from_activity_to_header(ContextPropagationTest
114115

115116
var headers = new Dictionary<string, string>();
116117

117-
var activity = new Activity(ActivityNames.OutgoingMessageActivityName);
118+
using var activity = new Activity(ActivityNames.OutgoingMessageActivityName);
119+
activity.SetIdFormat(ActivityIdFormat.W3C);
120+
activity.Start();
118121

119122
foreach (var baggageItem in testCase.ExpectedBaggageItems.Reverse())
120123
{
@@ -131,7 +134,7 @@ public void Can_propagate_baggage_from_activity_to_header(ContextPropagationTest
131134
{
132135
Assert.That(baggageHeaderSet, Is.True, "Should have a baggage header if there is baggage");
133136

134-
Assert.That(baggageValue, Is.EqualTo(testCase.BaggageHeaderValueWithoutOptionalWhitespace), "baggage header is set but is not correct");
137+
Assert.That(baggageValue, Is.EqualTo(testCase.BaggageHeaderValue), "baggage header is set but is not correct");
135138
}
136139
}
137140
else
@@ -146,7 +149,9 @@ public void Can_roundtrip_baggage(ContextPropagationTestCase testCase)
146149
TestContext.Out.WriteLine($"Baggage header: {testCase.BaggageHeaderValue}");
147150

148151
var outgoingHeaders = new Dictionary<string, string>();
149-
var outgoingActivity = new Activity(ActivityNames.OutgoingMessageActivityName);
152+
using var outgoingActivity = new Activity(ActivityNames.OutgoingMessageActivityName);
153+
outgoingActivity.SetIdFormat(ActivityIdFormat.W3C);
154+
outgoingActivity.Start();
150155

151156
foreach (var baggageItem in testCase.ExpectedBaggageItems.Reverse())
152157
{
@@ -157,7 +162,9 @@ public void Can_roundtrip_baggage(ContextPropagationTestCase testCase)
157162

158163
// Simulate wire transfer
159164
var incomingHeaders = outgoingHeaders;
160-
var incomingActivity = new Activity(ActivityNames.IncomingMessageActivityName);
165+
using var incomingActivity = new Activity(ActivityNames.IncomingMessageActivityName);
166+
incomingActivity.SetIdFormat(ActivityIdFormat.W3C);
167+
incomingActivity.Start();
161168

162169
ContextPropagation.PropagateContextFromHeaders(incomingActivity, incomingHeaders);
163170

@@ -170,55 +177,87 @@ public void Can_roundtrip_baggage(ContextPropagationTestCase testCase)
170177
}
171178
}
172179

180+
[Test]
181+
public void Can_not_roundtrip_baggage_value_with_optional_whitespaces()
182+
{
183+
var outgoingHeaders = new Dictionary<string, string>();
184+
using var outgoingActivity = new Activity(ActivityNames.OutgoingMessageActivityName);
185+
outgoingActivity.SetIdFormat(ActivityIdFormat.W3C);
186+
outgoingActivity.Start();
187+
188+
outgoingActivity.AddBaggage("key1", " value1");
189+
outgoingActivity.AddBaggage("key2", "value2 ");
190+
191+
ContextPropagation.PropagateContextToHeaders(outgoingActivity, outgoingHeaders, new ContextBag());
192+
193+
// Simulate wire transfer
194+
var incomingHeaders = outgoingHeaders;
195+
using var incomingActivity = new Activity(ActivityNames.IncomingMessageActivityName);
196+
incomingActivity.SetIdFormat(ActivityIdFormat.W3C);
197+
incomingActivity.Start();
198+
199+
ContextPropagation.PropagateContextFromHeaders(incomingActivity, incomingHeaders);
200+
201+
using (Assert.EnterMultipleScope())
202+
{
203+
foreach (var baggageItem in outgoingActivity.Baggage)
204+
{
205+
var key = baggageItem.Key;
206+
var actualValue = incomingActivity.GetBaggageItem(key);
207+
Assert.That(actualValue, Is.Not.Null, $"Baggage is missing item with key |{key}|");
208+
Assert.That(actualValue, Is.EqualTo(baggageItem.Value.Trim()), $"Baggage item |{key}| has the wrong value");
209+
}
210+
}
211+
}
212+
173213
// HINT: Many of these test cases are given as examples in the spec https://www.w3.org/TR/baggage/#example
174214
static IEnumerable TestCases => new object[]
175215
{
176216
new ContextPropagationTestCase("without any baggage"),
177217

178218
new ContextPropagationTestCase("with a single key")
179-
.WithBaggage("key1", "value1"),
219+
.WithBaggage("key1", "value1")
220+
.WithHeaderValue("key1 = value1"),
180221

181222
new ContextPropagationTestCase("with multiple keys")
182223
.WithBaggage("key1", "value1")
183-
.WithBaggage("key2", "value2"),
184-
185-
new ContextPropagationTestCase("with whitespace")
186-
.WithBaggage("key1 ", " value1")
187-
.WithBaggage(" key2", "value2 ")
188-
.WithBaggage(" key3 ", " value3 "),
224+
.WithBaggage("key2", "value2")
225+
.WithHeaderValue("key1 = value1, key2 = value2"),
189226

190227
new ContextPropagationTestCase("with properties that do not have keys")
191-
.WithBaggage("key1", "value1;property1;property2"),
228+
.WithBaggage("key1", "value1;property1;property2")
229+
.WithHeaderValue("key1 = value1%3Bproperty1%3Bproperty2"),
192230

193231
new ContextPropagationTestCase("with properties that have keys")
194-
.WithBaggage("key3", "value3; propertyKey=propertyValue"),
232+
.WithBaggage("key3", "value3; propertyKey=propertyValue")
233+
.WithHeaderValue("key3 = value3%3B%20propertyKey=propertyValue"),
195234

196235
new ContextPropagationTestCase("with values containing whitespace")
197-
.WithBaggage("serverNode", "DF 28"),
236+
.WithBaggage("serverNode", "DF 28")
237+
.WithHeaderValue("serverNode = DF%2028"),
198238

199239
new ContextPropagationTestCase("with values containing unicode")
200240
.WithBaggage("userId", "Amélie")
241+
.WithHeaderValue("userId = Am%C3%A9lie")
201242
};
202243

203-
public class ContextPropagationTestCase
244+
public class ContextPropagationTestCase(string caseName)
204245
{
205-
string caseName;
206-
Dictionary<string, string> baggageItems = [];
246+
readonly Dictionary<string, string> baggageItems = [];
207247

208-
public ContextPropagationTestCase(string caseName)
248+
public ContextPropagationTestCase WithBaggage(string key, string value)
209249
{
210-
this.caseName = caseName;
250+
baggageItems.Add(key, value);
251+
return this;
211252
}
212253

213-
public ContextPropagationTestCase WithBaggage(string key, string value)
254+
public ContextPropagationTestCase WithHeaderValue(string headerValue)
214255
{
215-
baggageItems.Add(key, value);
256+
BaggageHeaderValue = headerValue;
216257
return this;
217258
}
218259

219-
public string BaggageHeaderValue => string.Join(",", from kvp in baggageItems select $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}");
220-
public string BaggageHeaderValueWithoutOptionalWhitespace
221-
=> string.Join(",", from kvp in baggageItems select $"{kvp.Key.Trim()}={Uri.EscapeDataString(kvp.Value)}");
260+
public string BaggageHeaderValue { get; private set; }
222261
public IEnumerable<KeyValuePair<string, string>> ExpectedBaggageItems => from kvp in baggageItems
223262
select new KeyValuePair<string, string>(
224263
kvp.Key.Trim(),
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#nullable enable
2+
3+
namespace NServiceBus.Core.Tests.OpenTelemetry;
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Diagnostics;
8+
using System.Linq;
9+
using Extensibility;
10+
11+
static class LegacyContextPropagator
12+
{
13+
public static void PropagateContextToHeaders(Activity activity, Dictionary<string, string> headers, ContextBag contextBag)
14+
{
15+
if (activity is null)
16+
{
17+
return;
18+
}
19+
20+
if (activity.Id is not null)
21+
{
22+
headers[Headers.DiagnosticsTraceParent] = activity.Id;
23+
}
24+
25+
if (activity.TraceStateString is not null)
26+
{
27+
headers[Headers.DiagnosticsTraceState] = activity.TraceStateString;
28+
}
29+
30+
// Check whether the startnewtrace setting was set in the context, if so, add it to the headers now the trace parent was added
31+
if (contextBag.TryGet<string>(Headers.StartNewTrace, out var headerContent))
32+
{
33+
headers[Headers.StartNewTrace] = headerContent;
34+
}
35+
36+
var baggage = string.Join(",", activity.Baggage.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value ?? string.Empty)}"));
37+
if (!string.IsNullOrEmpty(baggage))
38+
{
39+
headers[Headers.DiagnosticsBaggage] = baggage;
40+
}
41+
}
42+
43+
public static void PropagateContextFromHeaders(Activity? activity, IDictionary<string, string> headers)
44+
{
45+
if (activity is null)
46+
{
47+
return;
48+
}
49+
50+
if (headers.TryGetValue(Headers.DiagnosticsTraceState, out var traceState))
51+
{
52+
activity.TraceStateString = traceState;
53+
}
54+
55+
if (headers.TryGetValue(Headers.DiagnosticsBaggage, out var baggageValue))
56+
{
57+
var baggageSpan = baggageValue.AsSpan();
58+
// HINT: Iterate in reverse order because Activity baggage is LIFO
59+
while (!baggageSpan.IsEmpty)
60+
{
61+
var lastComma = baggageSpan.LastIndexOf(',');
62+
ReadOnlySpan<char> baggageItem;
63+
64+
if (lastComma >= 0)
65+
{
66+
baggageItem = baggageSpan[(lastComma + 1)..];
67+
baggageSpan = baggageSpan[..lastComma];
68+
}
69+
else
70+
{
71+
baggageItem = baggageSpan;
72+
baggageSpan = [];
73+
}
74+
75+
var firstEquals = baggageItem.IndexOf('=');
76+
if (firstEquals < 0 || firstEquals >= baggageItem.Length)
77+
{
78+
continue;
79+
}
80+
81+
var key = baggageItem[..firstEquals].Trim();
82+
var value = baggageItem[(firstEquals + 1)..];
83+
activity.AddBaggage(key.ToString(), Uri.UnescapeDataString(value));
84+
}
85+
}
86+
}
87+
}

src/NServiceBus.Core/OpenTelemetry/Tracing/ActivityExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ public static void SetErrorStatus(this Activity activity, Exception ex)
4141
activity.SetStatus(ActivityStatusCode.Error, ex.Message);
4242
activity.SetTag("otel.status_code", "ERROR");
4343
activity.SetTag("otel.status_description", ex.Message);
44+
45+
4446
activity.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow,
4547
[
4648
new KeyValuePair<string, object?>("exception.escaped", true),

0 commit comments

Comments
 (0)