Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions .github/workflows/cloudshop-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ jobs:
restore-keys: |
nuget-cloudshop-

- name: Expose GitHub Actions Runtime
uses: actions/github-script@v7
with:
script: |
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env['ACTIONS_RUNTIME_TOKEN']);
core.exportVariable('ACTIONS_RESULTS_URL', process.env['ACTIONS_RESULTS_URL']);

- name: Build
run: dotnet build examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj -c Release

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ public static HttpClient CreateClientWithTestContext<TEntryPoint>(
this WebApplicationFactory<TEntryPoint> factory)
where TEntryPoint : class
{
return factory.CreateDefaultClient(new TUnitTestIdHandler());
return factory.CreateDefaultClient(new ActivityPropagationHandler(), new TUnitTestIdHandler());
}
}
59 changes: 59 additions & 0 deletions TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Diagnostics;
using System.Net.Http.Headers;

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.
/// </summary>
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");

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? request.RequestUri?.ToString() ?? "unknown";
using var activity = HttpActivitySource.StartActivity(
$"HTTP {request.Method} {path}",
ActivityKind.Client);

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);

// Inject trace context headers (traceparent + tracestate) so the server
// creates child activities under the same trace
DistributedContextPropagator.Current.Inject(activity, request.Headers,
static (headers, key, value) =>
{
if (headers is HttpRequestHeaders h)
{
h.Remove(key);
h.TryAddWithoutValidation(key, value);
}
});
}

var response = await base.SendAsync(request, cancellationToken);

if (activity is not null)
{
activity.SetTag("http.response.status_code", (int)response.StatusCode);
if (!response.IsSuccessStatusCode)
{
activity.SetStatus(ActivityStatusCode.Error);
}
}

return response;
}
}
84 changes: 84 additions & 0 deletions TUnit.AspNetCore/TracedWebApplicationFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;

namespace TUnit.AspNetCore;

/// <summary>
/// Wrapper around <see cref="WebApplicationFactory{TEntryPoint}"/> that automatically injects
/// <see cref="ActivityPropagationHandler"/> and <see cref="TUnitTestIdHandler"/> into all
/// created <see cref="HttpClient"/> instances.
/// <para>
/// HTTP requests made through clients created by this factory will:
/// <list type="bullet">
/// <item><description>Appear as spans in the HTML report's trace timeline</description></item>
/// <item><description>Propagate W3C <c>traceparent</c> headers for server-side span correlation</description></item>
/// <item><description>Propagate the current test's context ID for log correlation</description></item>
/// </list>
/// </para>
/// </summary>
/// <typeparam name="TEntryPoint">The entry point class of the web application.</typeparam>
public sealed class TracedWebApplicationFactory<TEntryPoint> : IAsyncDisposable, IDisposable
where TEntryPoint : class
{
private readonly WebApplicationFactory<TEntryPoint> _inner;

public TracedWebApplicationFactory(WebApplicationFactory<TEntryPoint> inner)
{
_inner = inner;
}

/// <summary>
/// Gets the <see cref="TestServer"/> instance.
/// </summary>
public TestServer Server => _inner.Server;

/// <summary>
/// Gets the application's <see cref="IServiceProvider"/>.
/// </summary>
public IServiceProvider Services => _inner.Services;

/// <summary>
/// Creates an <see cref="HttpClient"/> with activity tracing and test context propagation.
/// </summary>
public HttpClient CreateClient() =>
_inner.CreateDefaultClient(new ActivityPropagationHandler(), new TUnitTestIdHandler());

/// <summary>
/// Creates an <see cref="HttpClient"/> with the specified delegating handlers, plus
/// activity tracing and test context propagation (prepended before custom handlers).
/// </summary>
public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
{
var all = new DelegatingHandler[handlers.Length + 2];
all[0] = new ActivityPropagationHandler();
all[1] = new TUnitTestIdHandler();
Array.Copy(handlers, 0, all, 2, handlers.Length);
return _inner.CreateDefaultClient(all);
}

/// <summary>
/// Creates an <see cref="HttpClient"/> with the specified base address and delegating handlers,
/// plus activity tracing and test context propagation (prepended before custom handlers).
/// </summary>
public HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers)
{
var all = new DelegatingHandler[handlers.Length + 2];
all[0] = new ActivityPropagationHandler();
all[1] = new TUnitTestIdHandler();
Array.Copy(handlers, 0, all, 2, handlers.Length);
return _inner.CreateDefaultClient(baseAddress, all);
}

/// <summary>
/// Gets the underlying <see cref="WebApplicationFactory{TEntryPoint}"/> for advanced scenarios
/// that need direct access (e.g., calling <c>WithWebHostBuilder</c>).
/// Clients created from the inner factory will NOT have automatic tracing.
/// </summary>
public WebApplicationFactory<TEntryPoint> Inner => _inner;

/// <inheritdoc />
public async ValueTask DisposeAsync() => await _inner.DisposeAsync();

/// <inheritdoc />
public void Dispose() => _inner.Dispose();
}
24 changes: 13 additions & 11 deletions TUnit.AspNetCore/WebApplicationTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TUnit.AspNetCore.Interception;
Expand Down Expand Up @@ -76,15 +75,17 @@ public abstract class WebApplicationTest<TFactory, TEntryPoint> : WebApplication
[ClassDataSource(Shared = [SharedType.PerTestSession])]
public TFactory GlobalFactory { get; set; } = null!;

private WebApplicationFactory<TEntryPoint>? _factory;
private TracedWebApplicationFactory<TEntryPoint>? _factory;

private readonly WebApplicationTestOptions _options = new();

/// <summary>
/// Gets the per-test delegating factory. This factory is isolated to the current test.
/// Gets the per-test factory, isolated to the current test.
/// All <see cref="TracedWebApplicationFactory{TEntryPoint}.CreateClient()"/> calls on this factory
/// automatically inject activity tracing and test context propagation handlers.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if accessed before test setup.</exception>
public WebApplicationFactory<TEntryPoint> Factory => _factory ?? throw new InvalidOperationException(
public TracedWebApplicationFactory<TEntryPoint> Factory => _factory ?? throw new InvalidOperationException(
"Factory is not initialized. Ensure the test has started and the BeforeTest hook has run. " +
"Do not access Factory during test discovery or in data source methods.");

Expand All @@ -109,13 +110,14 @@ public async Task InitializeFactoryAsync(TestContext testContext)
await SetupAsync();

// Then create factory with sync configuration (required by ASP.NET Core hosting)
_factory = GlobalFactory.GetIsolatedFactory(
testContext,
_options,
ConfigureTestServices,
ConfigureTestConfiguration,
(_, config) => ConfigureTestConfiguration(config),
ConfigureWebHostBuilder);
_factory = new TracedWebApplicationFactory<TEntryPoint>(
GlobalFactory.GetIsolatedFactory(
testContext,
_options,
ConfigureTestServices,
ConfigureTestConfiguration,
(_, config) => ConfigureTestConfiguration(config),
ConfigureWebHostBuilder));

// Eagerly start the test server to catch configuration errors early
_ = _factory.Server;
Expand Down
25 changes: 25 additions & 0 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,31 @@ internal override void SetAsyncLocalContext()
/// </summary>
public object Lock { get; } = new();

#if NET
/// <summary>
/// Gets the <see cref="System.Diagnostics.Activity"/> associated with this test's execution,
/// or <c>null</c> if no activity is active.
/// Use <c>Activity.Context</c> to parent external work (e.g., HttpClient calls) under this test's trace.
/// </summary>
public new System.Diagnostics.Activity? Activity
{
get => base.Activity;
internal set => base.Activity = value;
}

/// <summary>
/// Registers an external trace ID to be associated with this test.
/// Registered traces will be captured by the activity collector and displayed
/// in the HTML report as linked traces.
/// </summary>
/// <param name="traceId">The trace ID of the external trace to associate with this test.</param>
public void RegisterTrace(System.Diagnostics.ActivityTraceId traceId)
{
// TestDetails.TestId is the stable test node UID (e.g. "MyNs.MyClass.MyTest:0")
// used as the key in GetTestSpanLookup and HtmlReporter's BuildReportData loop.
TraceRegistry.Register(traceId.ToString(), TestDetails.TestId);
}
#endif

internal IClassConstructor? ClassConstructor => _testBuilderContext.ClassConstructor;

Expand Down
60 changes: 60 additions & 0 deletions TUnit.Core/TraceRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#if NET
using System.Collections.Concurrent;

namespace TUnit.Core;

/// <summary>
/// Provides cross-project communication between TUnit.Core (where tests run)
/// and TUnit.Engine (where activities are collected) for distributed trace correlation.
/// Accessible to TUnit.Engine via InternalsVisibleTo.
/// </summary>
internal static class TraceRegistry
{
// traceId → testNodeUids (uses ConcurrentDictionary as a set to prevent duplicates)
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> TraceToTests =
new(StringComparer.OrdinalIgnoreCase);

// testNodeUid → traceIds (uses ConcurrentDictionary as a set to prevent duplicates)
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> TestToTraces =
new(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Registers a trace ID as associated with a test node UID.
/// Called by <see cref="TestContext.RegisterTrace"/>.
/// </summary>
internal static void Register(string traceId, string testNodeUid)
{
TraceToTests.GetOrAdd(traceId, static _ => new(StringComparer.OrdinalIgnoreCase)).TryAdd(testNodeUid, 0);
TestToTraces.GetOrAdd(testNodeUid, static _ => new(StringComparer.OrdinalIgnoreCase)).TryAdd(traceId, 0);
}

/// <summary>
/// Returns <c>true</c> if the given trace ID has been registered by any test.
/// Used by ActivityCollector's sampling callback.
/// </summary>
internal static bool IsRegistered(string traceId)
{
return TraceToTests.ContainsKey(traceId);
}

/// <summary>
/// Gets all trace IDs registered for the given test node UID.
/// Used by HtmlReporter to populate additional trace IDs on test results.
/// </summary>
internal static string[] GetTraceIds(string testNodeUid)
{
return TestToTraces.TryGetValue(testNodeUid, out var set)
? set.Keys.ToArray()
: [];
}

/// <summary>
/// Clears all registered trace associations. Called at the end of a test run.
/// </summary>
internal static void Clear()
{
TraceToTests.Clear();
TestToTraces.Clear();
}
}
#endif
Loading
Loading