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)