Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 13 additions & 136 deletions TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,147 +1,24 @@
using System.Diagnostics;
using System.Net.Http.Headers;
using TUnit.Core;

namespace TUnit.AspNetCore;

/// <summary>
/// DelegatingHandler that creates Activity spans for HTTP requests and propagates
/// trace context via the W3C traceparent header. This bridges the gap where
/// <see cref="Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory{TEntryPoint}"/>
/// creates an HttpClient with an in-memory handler, bypassing .NET's built-in
/// DiagnosticsHandler that normally creates HTTP Activity spans.
/// DelegatingHandler that injects W3C <c>traceparent</c> and <c>baggage</c> headers into
/// outgoing requests so the SUT can correlate them to the originating test.
/// </summary>
/// <remarks>
/// No client Activity is created here. For in-memory <c>WebApplicationFactory</c> traffic the
/// ASP.NET Core server span becomes a direct child of the ambient test Activity — no synthetic
/// client span is needed to stitch the trace. For SUT-initiated <c>IHttpClientFactory</c>
/// pipelines, the runtime's <c>System.Net.Http</c> ActivitySource already emits a properly-shaped
/// client span.
/// </remarks>
internal sealed class ActivityPropagationHandler : DelegatingHandler
{
// Intentionally process-scoped: lives for the test process lifetime and is
// cleaned up on process exit. Not disposed explicitly because multiple handler
// instances share this source across concurrent tests.
private static readonly ActivitySource HttpActivitySource = new("TUnit.AspNetCore.Http");
private readonly Func<HttpRequestMessage, Activity?> _startActivity;

public ActivityPropagationHandler()
{
_startActivity = StartHttpActivity;
}

internal ActivityPropagationHandler(Func<HttpRequestMessage, Activity?> startActivity)
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
_startActivity = startActivity;
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var ambientActivity = Activity.Current;
using var activity = _startActivity(request);

if (activity is not null)
{
activity.SetTag("http.request.method", request.Method.Method);
activity.SetTag("url.full", request.RequestUri?.ToString());
activity.SetTag("server.address", request.RequestUri?.Host);

// WebApplicationFactory bypasses DiagnosticsHandler, so when we synthesize
// a client span we also need to flow the ambient baggage onto it explicitly.
// Child Activities do not reliably surface parent baggage across all target
// frameworks, but correlation relies on the test's baggage being propagated.
CopyBaggage(ambientActivity, activity);
}

// Propagate the current distributed trace even when the helper span is not
// created (for example, when no listener is attached to TUnit.AspNetCore.Http).
var propagationActivity = activity ?? ambientActivity;
InjectTraceContext(propagationActivity, request.Headers);
InjectBaggage(propagationActivity, request.Headers);

HttpResponseMessage response;
try
{
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Only the synthesized client span gets exception metadata. When no listener is
// attached, propagation falls back to the ambient activity and there is no extra
// HTTP client span to annotate.
TUnit.Core.TUnitActivitySource.RecordException(activity, ex);
throw;
}

if (activity is not null)
{
var statusCode = (int)response.StatusCode;
activity.SetTag("http.response.status_code", statusCode);
if (statusCode >= 400)
{
activity.SetStatus(ActivityStatusCode.Error);
activity.SetTag("error.type", statusCode.ToString());
}
}

return response;
}

private static Activity? StartHttpActivity(HttpRequestMessage request)
{
var method = string.IsNullOrWhiteSpace(request.Method.Method)
? "HTTP"
: request.Method.Method;
return HttpActivitySource.StartActivity(
method,
ActivityKind.Client);
}

private static void InjectTraceContext(Activity? activity, HttpRequestHeaders headers)
{
if (activity is null)
{
return;
}

// Inject trace context headers (traceparent + tracestate) so the server
// creates child activities under the same trace. Respect pre-existing headers
// so callers who explicitly set their own context win.
DistributedContextPropagator.Current.Inject(activity, headers,
static (targetHeaders, key, value) =>
{
if (targetHeaders is HttpRequestHeaders h && key is not null && !h.Contains(key))
{
h.TryAddWithoutValidation(key, value);
}
});
}

private static void CopyBaggage(Activity? source, Activity destination)
{
if (source is null || ReferenceEquals(source, destination))
{
return;
}

foreach (var (key, value) in source.Baggage)
{
// Preserve baggage already attached to the synthesized client span itself.
if (key is null || destination.GetBaggageItem(key) is not null)
{
continue;
}

destination.SetBaggage(key, value);
}
}

private static void InjectBaggage(Activity? activity, HttpRequestHeaders headers)
{
// If a propagator already emitted W3C baggage (e.g. OTel SDK's BaggagePropagator),
// preserve it; otherwise emit our own so LegacyPropagator-based stacks still
// propagate test correlation baggage.
if (activity is null || headers.Contains(TUnit.Core.TUnitActivitySource.BaggageHeader))
{
return;
}

if (TUnit.Core.TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage)
{
headers.TryAddWithoutValidation(TUnit.Core.TUnitActivitySource.BaggageHeader, baggage);
}
HttpActivityPropagator.Inject(Activity.Current, request.Headers);
return base.SendAsync(request, cancellationToken);
}
}
13 changes: 6 additions & 7 deletions TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)

/// <summary>
/// Adds TUnit's default OpenTelemetry tracing configuration to <paramref name="services"/>:
/// the <c>TUnit.AspNetCore.Http</c> activity source, the
/// <see cref="TUnitTestCorrelationProcessor"/>, and ASP.NET Core + HttpClient instrumentation.
/// Safe to call even if the SUT already registers these — OpenTelemetry de-duplicates them.
/// Also safe when combined with the <c>TUnit.OpenTelemetry</c> zero-config package: the
/// SUT and test-runner <c>TracerProvider</c>s each carry their own processor, but the
/// processor's idempotent <c>OnStart</c> guard prevents duplicate <c>tunit.test.id</c> tags.
/// the <see cref="TUnitTestCorrelationProcessor"/> and ASP.NET Core + HttpClient
/// instrumentation. Safe to call even if the SUT already registers these — OpenTelemetry
/// de-duplicates them. Also safe when combined with the <c>TUnit.OpenTelemetry</c>
/// zero-config package: the SUT and test-runner <c>TracerProvider</c>s each carry their
/// own processor, and the processor's idempotent tagging guard prevents duplicate
/// <c>tunit.test.id</c> tags across its <c>OnStart</c>/<c>OnEnd</c> hooks.
/// </summary>
private static void AddTUnitOpenTelemetry(IServiceCollection services)
{
services.AddOpenTelemetry().WithTracing(tracing => tracing
.AddSource(TUnitActivitySource.AspNetCoreHttpSourceName)
.AddProcessor(new TUnitTestCorrelationProcessor())
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation());
Expand Down
Loading
Loading