diff --git a/dictionary.txt b/dictionary.txt
index 24817ca59b2..92a3f1e8348 100644
--- a/dictionary.txt
+++ b/dictionary.txt
@@ -214,3 +214,5 @@ Wilhuff
Wunder
xunit
Expando
+traceparent
+tracestate
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxProcessor.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxProcessor.cs
index a405b4d2dd2..2a3164b5a63 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxProcessor.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxProcessor.cs
@@ -262,24 +262,20 @@ private async ValueTask SendAsync(
CancellationToken cancellationToken)
{
Activity? activity = null;
- var traceId = envelope.Headers?.Get(MessageHeaders.TraceId);
- var traceState = envelope.Headers?.Get(MessageHeaders.TraceState);
- var spanId = envelope.Headers?.Get(MessageHeaders.SpanId);
+ var traceparent = envelope.Headers?.Get(MessageHeaders.Traceparent);
- if (!string.IsNullOrEmpty(traceId) && !string.IsNullOrEmpty(spanId))
+ if (!string.IsNullOrEmpty(traceparent))
{
- var parentContext = new ActivityContext(
- ActivityTraceId.CreateFromString(traceId),
- ActivitySpanId.CreateFromString(spanId),
- ActivityTraceFlags.Recorded,
- traceState);
-
- activity = OpenTelemetry.Source.CreateActivity(
- $"outbox send {envelope.MessageId}",
- ActivityKind.Client,
- parentContext);
-
- activity?.Start();
+ var tracestate = envelope.Headers?.Get(MessageHeaders.Tracestate);
+ if (ActivityContext.TryParse(traceparent, tracestate, out var parentContext))
+ {
+ activity = OpenTelemetry.Source.CreateActivity(
+ $"outbox send {envelope.MessageId}",
+ ActivityKind.Client,
+ parentContext);
+
+ activity?.Start();
+ }
}
var context = _contextPool.Get();
diff --git a/src/Mocha/src/Mocha/Headers/MessageHeaders.cs b/src/Mocha/src/Mocha/Headers/MessageHeaders.cs
index 35af0cc4318..6b22af15f64 100644
--- a/src/Mocha/src/Mocha/Headers/MessageHeaders.cs
+++ b/src/Mocha/src/Mocha/Headers/MessageHeaders.cs
@@ -6,24 +6,14 @@ namespace Mocha;
internal static class MessageHeaders
{
///
- /// The distributed trace identifier, propagated from .
+ /// The W3C Trace Context traceparent header (version-traceId-spanId-traceFlags).
///
- public static readonly ContextDataKey TraceId = new("trace-id");
+ public static readonly ContextDataKey Traceparent = new("traceparent");
///
- /// The span identifier, propagated from .
+ /// The W3C Trace Context tracestate header carrying vendor-specific trace data.
///
- public static readonly ContextDataKey SpanId = new("span-id");
-
- ///
- /// The W3C trace state string, propagated from .
- ///
- public static readonly ContextDataKey TraceState = new("trace-state");
-
- ///
- /// The parent activity identifier, propagated from .
- ///
- public static readonly ContextDataKey ParentId = new("parent-id");
+ public static readonly ContextDataKey Tracestate = new("tracestate");
///
/// Indicates the kind of message it is.
diff --git a/src/Mocha/src/Mocha/Observability/OpenTelemetry.cs b/src/Mocha/src/Mocha/Observability/OpenTelemetry.cs
index 44a9090485c..6411ccc62f4 100644
--- a/src/Mocha/src/Mocha/Observability/OpenTelemetry.cs
+++ b/src/Mocha/src/Mocha/Observability/OpenTelemetry.cs
@@ -15,10 +15,16 @@ public static IHeaders WithActivity(this IHeaders headers)
return headers;
}
- headers.TryAdd(MessageHeaders.TraceId, activity.TraceId.ToHexString());
- headers.TryAdd(MessageHeaders.SpanId, activity.SpanId.ToHexString());
- headers.TryAdd(MessageHeaders.TraceState, activity.TraceStateString);
- headers.TryAdd(MessageHeaders.ParentId, activity.ParentId);
+ var traceparent = TraceparentHelper.FormatTraceparent(activity);
+ if (traceparent is not null)
+ {
+ headers.TryAdd(MessageHeaders.Traceparent, traceparent);
+ }
+
+ if (!string.IsNullOrEmpty(activity.TraceStateString))
+ {
+ headers.TryAdd(MessageHeaders.Tracestate, activity.TraceStateString);
+ }
return headers;
}
diff --git a/src/Mocha/src/Mocha/Observability/OpenTelemetryDiagnosticObserver.cs b/src/Mocha/src/Mocha/Observability/OpenTelemetryDiagnosticObserver.cs
index c0d2957826a..fce581b839c 100644
--- a/src/Mocha/src/Mocha/Observability/OpenTelemetryDiagnosticObserver.cs
+++ b/src/Mocha/src/Mocha/Observability/OpenTelemetryDiagnosticObserver.cs
@@ -61,26 +61,22 @@ private ReceiveActivity(IReceiveContext context)
{
_context = context;
- var traceId = context.Headers.Get(MessageHeaders.TraceId);
- var traceState = context.Headers.Get(MessageHeaders.TraceState);
- var spanId = context.Headers.Get(MessageHeaders.SpanId);
+ var traceparent = context.Headers.Get(MessageHeaders.Traceparent);
Activity? activity = null;
- if (!string.IsNullOrEmpty(traceId) && !string.IsNullOrEmpty(spanId))
+ if (!string.IsNullOrEmpty(traceparent))
{
- var parentContext = new ActivityContext(
- ActivityTraceId.CreateFromString(traceId),
- ActivitySpanId.CreateFromString(spanId),
- ActivityTraceFlags.Recorded,
- traceState);
-
- activity = OpenTelemetry.Source.CreateActivity(
- $"receive {context.Endpoint.Address}",
- ActivityKind.Client,
- parentContext);
-
- activity?.Start();
+ var traceState = context.Headers.Get(MessageHeaders.Tracestate);
+ if (ActivityContext.TryParse(traceparent, traceState, out var parentContext))
+ {
+ activity = OpenTelemetry.Source.CreateActivity(
+ $"receive {context.Endpoint.Address}",
+ ActivityKind.Client,
+ parentContext);
+
+ activity?.Start();
+ }
}
activity ??= OpenTelemetry.Source.StartActivity($"receive {context.Endpoint.Address}", ActivityKind.Client);
diff --git a/src/Mocha/src/Mocha/Observability/TraceparentHelper.cs b/src/Mocha/src/Mocha/Observability/TraceparentHelper.cs
new file mode 100644
index 00000000000..fd5ee28342e
--- /dev/null
+++ b/src/Mocha/src/Mocha/Observability/TraceparentHelper.cs
@@ -0,0 +1,62 @@
+using System.Diagnostics;
+
+namespace Mocha;
+
+internal static class TraceparentHelper
+{
+ private const int TraceparentLength = 55;
+
+ ///
+ /// Formats a W3C traceparent header value from the given activity.
+ /// Returns null if the activity has no valid trace or span ID.
+ /// Format: "00-{traceId 32 hex}-{spanId 16 hex}-{flags 2 hex}"
+ ///
+ internal static string? FormatTraceparent(Activity activity)
+ {
+ var traceId = activity.TraceId;
+ var spanId = activity.SpanId;
+
+ if (traceId == default || spanId == default)
+ {
+ return null;
+ }
+
+ return FormatTraceparent(traceId, spanId, activity.ActivityTraceFlags);
+ }
+
+ internal static string FormatTraceparent(
+ ActivityTraceId traceId,
+ ActivitySpanId spanId,
+ ActivityTraceFlags flags)
+ {
+ Span traceIdBytes = stackalloc byte[16];
+ Span spanIdBytes = stackalloc byte[8];
+ traceId.CopyTo(traceIdBytes);
+ spanId.CopyTo(spanIdBytes);
+
+ Span buffer = stackalloc char[TraceparentLength];
+ buffer[0] = '0';
+ buffer[1] = '0';
+ buffer[2] = '-';
+ HexEncode(traceIdBytes, buffer[3..]);
+ buffer[35] = '-';
+ HexEncode(spanIdBytes, buffer[36..]);
+ buffer[52] = '-';
+ ((byte)flags).TryFormat(buffer[53..], out _, "x2");
+
+ return new string(buffer);
+ }
+
+ private static void HexEncode(ReadOnlySpan bytes, Span destination)
+ {
+ for (var i = 0; i < bytes.Length; i++)
+ {
+ var b = bytes[i];
+ destination[i * 2] = ToHexChar(b >> 4);
+ destination[i * 2 + 1] = ToHexChar(b & 0xF);
+ }
+ }
+
+ private static char ToHexChar(int value)
+ => (char)(value < 10 ? '0' + value : 'a' + value - 10);
+}
diff --git a/src/Mocha/test/Mocha.Tests/Telemetry/OpenTelemetryTests.cs b/src/Mocha/test/Mocha.Tests/Telemetry/OpenTelemetryTests.cs
index 19f8f35e038..b4d5d605455 100644
--- a/src/Mocha/test/Mocha.Tests/Telemetry/OpenTelemetryTests.cs
+++ b/src/Mocha/test/Mocha.Tests/Telemetry/OpenTelemetryTests.cs
@@ -148,7 +148,7 @@ public async Task MessageBus_Should_ProcessMessages_When_NoListenerRegistered()
}
[Fact]
- public void WithActivity_Sets_Trace_Headers_From_Current_Activity()
+ public void WithActivity_Sets_Traceparent_Header_From_Current_Activity()
{
// arrange
using var source = new ActivitySource("test-source");
@@ -166,20 +166,17 @@ public void WithActivity_Sets_Trace_Headers_From_Current_Activity()
headers.WithActivity();
// assert
- Assert.True(headers.ContainsKey(MessageHeaders.TraceId.Key));
- Assert.True(headers.ContainsKey(MessageHeaders.SpanId.Key));
+ Assert.True(headers.ContainsKey(MessageHeaders.Traceparent.Key));
- var traceId = headers.GetValue(MessageHeaders.TraceId.Key) as string;
- var spanId = headers.GetValue(MessageHeaders.SpanId.Key) as string;
+ var traceparent = headers.GetValue(MessageHeaders.Traceparent.Key) as string;
+ Assert.NotNull(traceparent);
- Assert.NotNull(traceId);
- Assert.NotNull(spanId);
- Assert.Equal(activity.TraceId.ToHexString(), traceId);
- Assert.Equal(activity.SpanId.ToHexString(), spanId);
+ var expected = $"00-{activity.TraceId.ToHexString()}-{activity.SpanId.ToHexString()}-01";
+ Assert.Equal(expected, traceparent);
}
[Fact]
- public void WithActivity_Sets_TraceState_When_Activity_Has_TraceState()
+ public void WithActivity_Sets_Tracestate_When_Activity_Has_TraceState()
{
// arrange
using var source = new ActivitySource("test-source");
@@ -198,13 +195,13 @@ public void WithActivity_Sets_TraceState_When_Activity_Has_TraceState()
headers.WithActivity();
// assert
- Assert.True(headers.ContainsKey(MessageHeaders.TraceState.Key));
- var traceState = headers.GetValue(MessageHeaders.TraceState.Key) as string;
- Assert.Equal("key1=value1,key2=value2", traceState);
+ Assert.True(headers.ContainsKey(MessageHeaders.Tracestate.Key));
+ var tracestate = headers.GetValue(MessageHeaders.Tracestate.Key) as string;
+ Assert.Equal("key1=value1,key2=value2", tracestate);
}
[Fact]
- public void WithActivity_Sets_ParentId_When_Activity_Has_Parent()
+ public void WithActivity_Does_Not_Set_Tracestate_When_Activity_Has_No_TraceState()
{
// arrange
using var source = new ActivitySource("test-source");
@@ -213,9 +210,8 @@ public void WithActivity_Sets_ParentId_When_Activity_Has_Parent()
listener.Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded;
ActivitySource.AddActivityListener(listener);
- using var parentActivity = source.StartActivity("parent-operation");
- using var childActivity = source.StartActivity("child-operation");
- Assert.NotNull(childActivity);
+ using var activity = source.StartActivity("test-operation");
+ Assert.NotNull(activity);
var headers = new Headers();
@@ -223,12 +219,8 @@ public void WithActivity_Sets_ParentId_When_Activity_Has_Parent()
headers.WithActivity();
// assert
- if (childActivity.ParentId != null)
- {
- Assert.True(headers.ContainsKey(MessageHeaders.ParentId.Key));
- var parentId = headers.GetValue(MessageHeaders.ParentId.Key) as string;
- Assert.Equal(childActivity.ParentId, parentId);
- }
+ Assert.True(headers.ContainsKey(MessageHeaders.Traceparent.Key));
+ Assert.False(headers.ContainsKey(MessageHeaders.Tracestate.Key));
}
[Fact]
@@ -246,13 +238,13 @@ public void WithActivity_With_No_Current_Activity_Returns_Headers_Unchanged()
// assert
Assert.Same(headers, result);
- Assert.False(headers.ContainsKey(MessageHeaders.TraceId.Key));
- Assert.False(headers.ContainsKey(MessageHeaders.SpanId.Key));
+ Assert.False(headers.ContainsKey(MessageHeaders.Traceparent.Key));
+ Assert.False(headers.ContainsKey(MessageHeaders.Tracestate.Key));
Assert.Equal("existing-value", headers.GetValue("existing-header"));
}
[Fact]
- public void WithActivity_Does_Not_Overwrite_Existing_Trace_Headers()
+ public void WithActivity_Does_Not_Overwrite_Existing_Traceparent_Header()
{
// arrange
using var source = new ActivitySource("test-source");
@@ -265,14 +257,38 @@ public void WithActivity_Does_Not_Overwrite_Existing_Trace_Headers()
Assert.NotNull(activity);
var headers = new Headers();
- headers.Set(MessageHeaders.TraceId.Key, "existing-trace-id");
+ headers.Set(MessageHeaders.Traceparent.Key, "00-existing-trace-id-span-01");
// act
headers.WithActivity();
// assert - TryAdd should not overwrite existing value
- var traceId = headers.GetValue(MessageHeaders.TraceId.Key) as string;
- Assert.Equal("existing-trace-id", traceId);
+ var traceparent = headers.GetValue(MessageHeaders.Traceparent.Key) as string;
+ Assert.Equal("00-existing-trace-id-span-01", traceparent);
+ }
+
+ [Fact]
+ public void WithActivity_Sets_Unsampled_TraceFlags_When_Activity_Not_Recorded()
+ {
+ // arrange
+ using var source = new ActivitySource("test-source");
+ using var listener = new ActivityListener();
+ listener.ShouldListenTo = _ => true;
+ listener.Sample = (ref _) => ActivitySamplingResult.PropagationData;
+ ActivitySource.AddActivityListener(listener);
+
+ using var activity = source.StartActivity("test-operation");
+ Assert.NotNull(activity);
+
+ var headers = new Headers();
+
+ // act
+ headers.WithActivity();
+
+ // assert
+ var traceparent = headers.GetValue(MessageHeaders.Traceparent.Key) as string;
+ Assert.NotNull(traceparent);
+ Assert.EndsWith("-00", traceparent);
}
[Fact]
diff --git a/src/Mocha/test/Mocha.Tests/Telemetry/TraceparentHelperTests.cs b/src/Mocha/test/Mocha.Tests/Telemetry/TraceparentHelperTests.cs
new file mode 100644
index 00000000000..2bf13f803d6
--- /dev/null
+++ b/src/Mocha/test/Mocha.Tests/Telemetry/TraceparentHelperTests.cs
@@ -0,0 +1,173 @@
+using System.Diagnostics;
+
+namespace Mocha.Tests;
+
+public class TraceparentHelperTests
+{
+ [Fact]
+ public void FormatTraceparent_Activity_Returns_Correct_Format()
+ {
+ // arrange
+ using var source = new ActivitySource("test");
+ using var listener = new ActivityListener();
+ listener.ShouldListenTo = _ => true;
+ listener.Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded;
+ ActivitySource.AddActivityListener(listener);
+
+ using var activity = source.StartActivity("op");
+ Assert.NotNull(activity);
+
+ // act
+ var result = TraceparentHelper.FormatTraceparent(activity);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(55, result.Length);
+
+ var parts = result.Split('-');
+ Assert.Equal(4, parts.Length);
+ Assert.Equal("00", parts[0]);
+ Assert.Equal(32, parts[1].Length);
+ Assert.Equal(16, parts[2].Length);
+ Assert.Equal(2, parts[3].Length);
+ }
+
+ [Fact]
+ public void FormatTraceparent_Activity_Matches_TraceId_And_SpanId()
+ {
+ // arrange
+ using var source = new ActivitySource("test");
+ using var listener = new ActivityListener();
+ listener.ShouldListenTo = _ => true;
+ listener.Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded;
+ ActivitySource.AddActivityListener(listener);
+
+ using var activity = source.StartActivity("op");
+ Assert.NotNull(activity);
+
+ // act
+ var result = TraceparentHelper.FormatTraceparent(activity);
+
+ // assert
+ Assert.NotNull(result);
+ var expected = $"00-{activity.TraceId.ToHexString()}-{activity.SpanId.ToHexString()}-01";
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void FormatTraceparent_Activity_Returns_Null_When_Default_TraceId()
+ {
+ // arrange - activity not started, has default trace/span IDs
+ var activity = new Activity("not-started");
+
+ // act
+ var result = TraceparentHelper.FormatTraceparent(activity);
+
+ // assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void FormatTraceparent_Recorded_Flag_Sets_01()
+ {
+ // arrange
+ var traceId = ActivityTraceId.CreateRandom();
+ var spanId = ActivitySpanId.CreateRandom();
+
+ // act
+ var result = TraceparentHelper.FormatTraceparent(
+ traceId, spanId, ActivityTraceFlags.Recorded);
+
+ // assert
+ Assert.EndsWith("-01", result);
+ }
+
+ [Fact]
+ public void FormatTraceparent_None_Flag_Sets_00()
+ {
+ // arrange
+ var traceId = ActivityTraceId.CreateRandom();
+ var spanId = ActivitySpanId.CreateRandom();
+
+ // act
+ var result = TraceparentHelper.FormatTraceparent(
+ traceId, spanId, ActivityTraceFlags.None);
+
+ // assert
+ Assert.EndsWith("-00", result);
+ }
+
+ [Fact]
+ public void FormatTraceparent_Ids_Roundtrip_Correctly()
+ {
+ // arrange
+ var traceId = ActivityTraceId.CreateRandom();
+ var spanId = ActivitySpanId.CreateRandom();
+
+ // act
+ var result = TraceparentHelper.FormatTraceparent(
+ traceId, spanId, ActivityTraceFlags.Recorded);
+
+ // assert
+ var parts = result.Split('-');
+ Assert.Equal(traceId.ToHexString(), parts[1]);
+ Assert.Equal(spanId.ToHexString(), parts[2]);
+ }
+
+ [Fact]
+ public void FormatTraceparent_Always_Returns_Lowercase_Hex()
+ {
+ // arrange
+ var traceId = ActivityTraceId.CreateRandom();
+ var spanId = ActivitySpanId.CreateRandom();
+
+ // act
+ var result = TraceparentHelper.FormatTraceparent(
+ traceId, spanId, ActivityTraceFlags.Recorded);
+
+ // assert - all hex chars should be lowercase
+ foreach (var c in result)
+ {
+ if (char.IsLetter(c))
+ {
+ Assert.True(char.IsLower(c), $"Expected lowercase but found '{c}' in: {result}");
+ }
+ }
+ }
+
+ [Fact]
+ public void FormatTraceparent_Is_Parseable_By_ActivityContext()
+ {
+ // arrange
+ var traceId = ActivityTraceId.CreateRandom();
+ var spanId = ActivitySpanId.CreateRandom();
+
+ // act
+ var result = TraceparentHelper.FormatTraceparent(
+ traceId, spanId, ActivityTraceFlags.Recorded);
+
+ // assert - ActivityContext.TryParse should accept our output
+ Assert.True(
+ ActivityContext.TryParse(result, null, out var parsed),
+ $"ActivityContext.TryParse failed for: {result}");
+ Assert.Equal(traceId, parsed.TraceId);
+ Assert.Equal(spanId, parsed.SpanId);
+ Assert.Equal(ActivityTraceFlags.Recorded, parsed.TraceFlags);
+ }
+
+ [Fact]
+ public void FormatTraceparent_Length_Is_Always_55()
+ {
+ // run multiple times to test with different random IDs
+ for (var i = 0; i < 100; i++)
+ {
+ var traceId = ActivityTraceId.CreateRandom();
+ var spanId = ActivitySpanId.CreateRandom();
+
+ var result = TraceparentHelper.FormatTraceparent(
+ traceId, spanId, ActivityTraceFlags.None);
+
+ Assert.Equal(55, result.Length);
+ }
+ }
+}
diff --git a/website/src/docs/mocha/v1/observability.md b/website/src/docs/mocha/v1/observability.md
index ceda2bf7221..e914df2986d 100644
--- a/website/src/docs/mocha/v1/observability.md
+++ b/website/src/docs/mocha/v1/observability.md
@@ -63,7 +63,7 @@ In the Aspire Dashboard, you will see the `publish` span from the publishing ser
# How trace context propagates
-When the dispatch instrumentation middleware runs, it writes the current `Activity`'s trace context into the outgoing message headers. The receive instrumentation middleware on the other side reads those headers and restores the parent context, linking the two spans into a single trace.
+When the dispatch instrumentation middleware runs, it writes the current `Activity`'s trace context into the outgoing message headers using the [W3C Trace Context](https://www.w3.org/TR/trace-context/) standard (`traceparent` and `tracestate`). The receive instrumentation middleware on the other side reads those headers and restores the parent context, linking the two spans into a single trace. This is the same propagation format used by ASP.NET Core, HttpClient, and the broader OpenTelemetry ecosystem, so Mocha traces connect seamlessly with spans from other frameworks.
```mermaid
sequenceDiagram
@@ -72,8 +72,8 @@ sequenceDiagram
participant B as Service B
A->>A: dispatch span (Producer)
- Note over A: Writes trace-id, span-id to headers
- A->>Broker: Message + trace headers
+ Note over A: Writes traceparent, tracestate to headers
+ A->>Broker: Message + W3C trace headers
Broker->>B: Deliver message
B->>B: receive span (Client, linked to parent)
B->>B: consumer span (Consumer)