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 @@
-
+