diff --git a/TUnit.Core/ExternalSpanSink.cs b/TUnit.Core/ExternalSpanSink.cs new file mode 100644 index 0000000000..de58392a89 --- /dev/null +++ b/TUnit.Core/ExternalSpanSink.cs @@ -0,0 +1,26 @@ +namespace TUnit.Core; + +/// +/// Process-wide hook letting out-of-process span feeders (e.g. TUnit.OpenTelemetry's +/// OTLP receiver) push into TUnit.Engine's collector without OpenTelemetry referencing +/// Engine. Engine claims the slot at session start; first-wins semantics. +/// +internal static class ExternalSpanSink +{ + private static Action? _sink; + + // Volatile.Read pairs with the full fence in Interlocked.CompareExchange on the + // Register/Unregister side; without it, weak memory models (ARM) could observe + // a stale null after another thread has published a sink. + public static Action? Current => Volatile.Read(ref _sink); + + public static void Register(Action sink) + { + Interlocked.CompareExchange(ref _sink, sink, null); + } + + public static void Unregister(Action sink) + { + Interlocked.CompareExchange(ref _sink, null, sink); + } +} diff --git a/TUnit.Core/SpanData.cs b/TUnit.Core/SpanData.cs new file mode 100644 index 0000000000..35f4ddc46e --- /dev/null +++ b/TUnit.Core/SpanData.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; + +namespace TUnit.Core; + +internal sealed class SpanData +{ + [JsonPropertyName("traceId")] + public required string TraceId { get; init; } + + [JsonPropertyName("spanId")] + public required string SpanId { get; init; } + + [JsonPropertyName("parentSpanId")] + public string? ParentSpanId { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("spanType")] + public string? SpanType { get; init; } + + [JsonPropertyName("source")] + public required string Source { get; init; } + + [JsonPropertyName("kind")] + public required string Kind { get; init; } + + [JsonPropertyName("startTimeMs")] + public double StartTimeMs { get; init; } + + [JsonPropertyName("durationMs")] + public double DurationMs { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("statusMessage")] + public string? StatusMessage { get; init; } + + [JsonPropertyName("tags")] + public ReportKeyValue[]? Tags { get; init; } + + [JsonPropertyName("events")] + public SpanEvent[]? Events { get; init; } + + [JsonPropertyName("links")] + public SpanLink[]? Links { get; init; } +} + +internal sealed class SpanEvent +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("timestampMs")] + public double TimestampMs { get; init; } + + [JsonPropertyName("tags")] + public ReportKeyValue[]? Tags { get; init; } +} + +internal sealed class SpanLink +{ + [JsonPropertyName("traceId")] + public required string TraceId { get; init; } + + [JsonPropertyName("spanId")] + public required string SpanId { get; init; } +} + +internal sealed class ReportKeyValue +{ + [JsonPropertyName("key")] + public required string Key { get; init; } + + [JsonPropertyName("value")] + public required string Value { get; init; } +} diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs index 83d3d1d5fe..f57ee760a2 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -67,6 +67,7 @@ private static void WarnCapHitOnce() // so that subsequent activities on the same trace avoid cross-class dictionary checks. private readonly ConcurrentDictionary _knownTraceIds = new(StringComparer.OrdinalIgnoreCase); private ActivityListener? _listener; + private Action? _externalSinkDelegate; public void Start() { @@ -74,6 +75,8 @@ public void Start() // test runs, so this slot is claimed for the rest of the session. Later ad-hoc // collectors (e.g. created from a test) don't race-steal the global pointer. Interlocked.CompareExchange(ref _current, this, null); + _externalSinkDelegate = IngestExternalSpan; + ExternalSpanSink.Register(_externalSinkDelegate); // Listen to ALL sources so we can capture child spans from HttpClient, ASP.NET Core, // EF Core, etc. The Sample callback uses smart filtering to avoid overhead: only spans // belonging to known test traces are fully recorded; everything else gets PropagationData @@ -155,6 +158,11 @@ private ActivitySamplingResult SampleActivityUsingParentId(ref ActivityCreationO public void Stop() { Interlocked.CompareExchange(ref _current, null, this); + if (_externalSinkDelegate is { } sink) + { + ExternalSpanSink.Unregister(sink); + _externalSinkDelegate = null; + } _listener?.Dispose(); _listener = null; } diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index f8d24264f5..5589f26169 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using TUnit.Core; namespace TUnit.Engine.Reporters.Html; @@ -173,77 +174,3 @@ internal sealed class ReportExceptionData public ReportExceptionData? InnerException { get; init; } } -internal sealed class ReportKeyValue -{ - [JsonPropertyName("key")] - public required string Key { get; init; } - - [JsonPropertyName("value")] - public required string Value { get; init; } -} - -internal sealed class SpanData -{ - [JsonPropertyName("traceId")] - public required string TraceId { get; init; } - - [JsonPropertyName("spanId")] - public required string SpanId { get; init; } - - [JsonPropertyName("parentSpanId")] - public string? ParentSpanId { get; init; } - - [JsonPropertyName("name")] - public required string Name { get; init; } - - [JsonPropertyName("spanType")] - public string? SpanType { get; init; } - - [JsonPropertyName("source")] - public required string Source { get; init; } - - [JsonPropertyName("kind")] - public required string Kind { get; init; } - - [JsonPropertyName("startTimeMs")] - public double StartTimeMs { get; init; } - - [JsonPropertyName("durationMs")] - public double DurationMs { get; init; } - - [JsonPropertyName("status")] - public required string Status { get; init; } - - [JsonPropertyName("statusMessage")] - public string? StatusMessage { get; init; } - - [JsonPropertyName("tags")] - public ReportKeyValue[]? Tags { get; init; } - - [JsonPropertyName("events")] - public SpanEvent[]? Events { get; init; } - - [JsonPropertyName("links")] - public SpanLink[]? Links { get; init; } -} - -internal sealed class SpanLink -{ - [JsonPropertyName("traceId")] - public required string TraceId { get; init; } - - [JsonPropertyName("spanId")] - public required string SpanId { get; init; } -} - -internal sealed class SpanEvent -{ - [JsonPropertyName("name")] - public required string Name { get; init; } - - [JsonPropertyName("timestampMs")] - public double TimestampMs { get; init; } - - [JsonPropertyName("tags")] - public ReportKeyValue[]? Tags { get; init; } -} diff --git a/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs b/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs index 233059c1b0..c6274ce2b3 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using TUnit.Core; namespace TUnit.Engine.Reporters.Html; @@ -10,6 +11,7 @@ namespace TUnit.Engine.Reporters.Html; [JsonSerializable(typeof(ReportKeyValue))] [JsonSerializable(typeof(SpanData))] [JsonSerializable(typeof(SpanEvent))] +[JsonSerializable(typeof(SpanLink))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/TUnit.Engine/TUnit.Engine.csproj b/TUnit.Engine/TUnit.Engine.csproj index ff217cb492..1da56c3721 100644 --- a/TUnit.Engine/TUnit.Engine.csproj +++ b/TUnit.Engine/TUnit.Engine.csproj @@ -9,7 +9,6 @@ - diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs index bade9a1532..2f26ce7f4b 100644 --- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs +++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs @@ -3,7 +3,6 @@ using System.Net; using System.Net.Sockets; using TUnit.Core; -using TUnit.Engine.Reporters.Html; namespace TUnit.OpenTelemetry.Receiver; @@ -212,8 +211,8 @@ private async Task ProcessRequestAsync(HttpListenerContext context) private static void ProcessTraces(byte[] body) { - var collector = ActivityCollector.Current; - if (collector is null) + var sink = ExternalSpanSink.Current; + if (sink is null) { return; } @@ -231,7 +230,7 @@ private static void ProcessTraces(byte[] body) foreach (var span in spans) { - collector.IngestExternalSpan(ToSpanData(span)); + sink(ToSpanData(span)); } } diff --git a/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj b/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj index 742894f576..55c213c5c5 100644 --- a/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj +++ b/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj @@ -19,7 +19,7 @@ - +