Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f311ff2
feat: cross-process test log correlation via OTLP receiver (#4818)
thomhurst Apr 13, 2026
a32e2ad
fix: address code review findings on OTLP correlation PR
thomhurst Apr 13, 2026
13ee150
refactor: simplify after code review
thomhurst Apr 13, 2026
e3109a9
fix: gate CreateHttpClient override on EnableTelemetryCollection
thomhurst Apr 13, 2026
c3f804a
fix: address third-round review findings
thomhurst Apr 13, 2026
1f9ee36
docs: add telemetry correlation docs, enable by default
thomhurst Apr 13, 2026
8959f79
docs: note field ordering assumption in ParseResourceLogs
thomhurst Apr 13, 2026
b373da4
test: add thorough tests for OTLP log-to-test correlation
thomhurst Apr 13, 2026
ab164b9
fix: update public API snapshots for new InternalsVisibleTo entry
thomhurst Apr 14, 2026
4a9231d
feat: add OTLP integration tests and auto-inject telemetry env vars
thomhurst Apr 14, 2026
3aed4c2
refactor: simplify integration tests per code review
thomhurst Apr 14, 2026
dc20e0c
docs: address review feedback on shared handler and TraceId scope
thomhurst Apr 14, 2026
28d44bb
fix: generate unique TraceId per HTTP request for reliable correlation
thomhurst Apr 14, 2026
c40e230
feat: give each test its own TraceId via root activity with class link
thomhurst Apr 14, 2026
4359189
test: add engine activity tests, fix test body parent chain
thomhurst Apr 14, 2026
0587668
fix: restore parent-child activity hierarchy, move correlation to han…
thomhurst Apr 14, 2026
3e6575c
refactor: replace raw tag key strings with TUnitActivitySource constants
thomhurst Apr 14, 2026
3221c82
fix: replace fixed Task.Delay with polling in OtlpReceiverTests
thomhurst Apr 14, 2026
3ddbf74
fix: address review round 4 findings
thomhurst Apr 14, 2026
477a319
fix: remove mutually exclusive RunOnLinuxOnly/RunOnWindowsOnly attrs
thomhurst Apr 14, 2026
870a6c5
fix: add missing HangDump package to Aspire and ASP.NET test projects
thomhurst Apr 14, 2026
e911de2
fix: skip Aspire, ASP.NET, and RPC test modules on macOS
thomhurst Apr 14, 2026
3dd2b20
fix: enable Docker setup on macOS CI runners
thomhurst Apr 14, 2026
0874d0e
fix: skip Docker-dependent test modules on macOS
thomhurst Apr 14, 2026
87cfffa
fix: skip RPC tests globally until fixed (#5540)
thomhurst Apr 14, 2026
98fc40f
fix: restrict Docker-dependent CI modules to Linux, add missing ApiSe…
thomhurst Apr 14, 2026
08b62fb
fix: fix PublicAPI snapshot ordering and flaky severity test
thomhurst Apr 14, 2026
8c5e115
fix: move mock tests AOT publish into pipeline module
thomhurst Apr 14, 2026
068cdb0
Refactor code structure for improved readability and maintainability
thomhurst Apr 14, 2026
8034a5b
fix: share IntegrationTestFixture in WaitForHealthy tests
thomhurst Apr 14, 2026
f55d181
fix: restart Docker after setup to initialize iptables chains
thomhurst Apr 14, 2026
97178be
fix: remove docker/setup-docker-action
thomhurst Apr 14, 2026
4c3b872
fix: correct ASP.NET execution order assertions
thomhurst Apr 14, 2026
9145da5
fix: use CreateTablesAsync to avoid EnsureCreatedAsync race in parall…
thomhurst Apr 14, 2026
cad9f86
fix: use absolute path for AOT mock test publish output
thomhurst Apr 14, 2026
db8c9f9
merge: resolve conflict with main (NuGet.Protocol 7.3.1)
thomhurst Apr 14, 2026
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
97 changes: 96 additions & 1 deletion TUnit.Aspire/AspireFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public class AspireFixture<TAppHost> : IAsyncInitializer, IAsyncDisposable
where TAppHost : class
{
private DistributedApplication? _app;
private Telemetry.OtlpReceiver? _otlpReceiver;
private HttpMessageHandler? _httpHandler;

/// <summary>
/// The running Aspire distributed application.
Expand All @@ -37,12 +39,35 @@ public class AspireFixture<TAppHost> : IAsyncInitializer, IAsyncDisposable

/// <summary>
/// Creates an <see cref="HttpClient"/> for the named resource.
/// When <see cref="EnableTelemetryCollection"/> is <c>true</c>, the returned client
/// automatically propagates W3C <c>traceparent</c> and <c>baggage</c> headers
/// (including <c>tunit.test.id</c>) to the SUT for cross-process correlation.
/// Otherwise, delegates to Aspire's default <c>CreateHttpClient</c>.
/// </summary>
/// <param name="resourceName">The name of the resource to connect to.</param>
/// <param name="endpointName">Optional endpoint name if the resource exposes multiple endpoints.</param>
/// <returns>An <see cref="HttpClient"/> configured to connect to the resource.</returns>
public HttpClient CreateHttpClient(string resourceName, string? endpointName = null)
=> App.CreateHttpClient(resourceName, endpointName);
{
if (!EnableTelemetryCollection)
{
return App.CreateHttpClient(resourceName, endpointName);
}

_httpHandler ??= new Http.TUnitBaggagePropagationHandler
{
InnerHandler = new SocketsHttpHandler
{
// Match Aspire's CreateHttpClient behavior: trust dev certs for HTTPS resources
SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => true },
},
};

return new HttpClient(_httpHandler, disposeHandler: false)
{
BaseAddress = App.GetEndpoint(resourceName, endpointName),
};
}

/// <summary>
/// Gets the connection string for the named resource.
Expand Down Expand Up @@ -140,6 +165,19 @@ protected virtual void ConfigureAppHost(DistributedApplicationOptions options, H
/// <param name="builder">The distributed application testing builder.</param>
protected virtual void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) { }

/// <summary>
/// When <c>true</c>, starts an OTLP receiver that collects telemetry from SUT resources
/// and routes correlated logs to the originating test's output. SUT resources must have
/// OpenTelemetry configured (e.g., via Aspire's standard ServiceDefaults) — no TUnit-specific
/// code is needed in the SUT.
/// </summary>
/// <remarks>
/// The receiver overrides <c>OTEL_EXPORTER_OTLP_ENDPOINT</c> on project resources. If the
/// Aspire dashboard is enabled, received telemetry is forwarded to the dashboard's original
/// OTLP endpoint so both TUnit and the dashboard receive data.
/// </remarks>
protected virtual bool EnableTelemetryCollection => false;

/// <summary>
/// Resource wait timeout. Default: 60 seconds.
/// </summary>
Expand Down Expand Up @@ -211,9 +249,24 @@ public virtual async Task InitializeAsync()
{
var sw = Stopwatch.StartNew();

// Start OTLP receiver before building the app so we can inject the endpoint
if (EnableTelemetryCollection)
{
LogProgress("Starting OTLP telemetry receiver...");
StartOtlpReceiver();
LogProgress($"OTLP receiver listening on port {_otlpReceiver!.Port}");
}

LogProgress($"Creating distributed application builder for {typeof(TAppHost).Name}...");
var builder = await DistributedApplicationTestingBuilder.CreateAsync<TAppHost>(Args, ConfigureAppHost);
ConfigureBuilder(builder);

// Configure OTLP endpoint on project resources AFTER user's ConfigureBuilder
if (_otlpReceiver is not null)
{
ConfigureOtlpEndpoints(builder);
}

LogProgress($"Builder created in {sw.Elapsed.TotalSeconds:0.0}s");

LogProgress("Building application...");
Expand Down Expand Up @@ -295,9 +348,51 @@ public virtual async ValueTask DisposeAsync()
LogProgress("Application disposed.");
}

if (_otlpReceiver is not null)
{
await _otlpReceiver.DisposeAsync();
_otlpReceiver = null;
}

_httpHandler?.Dispose();
_httpHandler = null;

GC.SuppressFinalize(this);
}

// --- OTLP Telemetry ---

private const string DashboardOtlpEndpointEnvVar = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL";
private const string OtelExporterEndpointEnvVar = "OTEL_EXPORTER_OTLP_ENDPOINT";
private const string OtelExporterProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL";

private void StartOtlpReceiver()
{
// Check if there's an existing upstream OTLP endpoint (e.g., Aspire dashboard)
// that we should forward to after processing.
var upstreamEndpoint = Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar);
_otlpReceiver = new Telemetry.OtlpReceiver(upstreamEndpoint);
_otlpReceiver.Start();
}

private void ConfigureOtlpEndpoints(IDistributedApplicationTestingBuilder builder)
{
var otlpEndpoint = $"http://127.0.0.1:{_otlpReceiver!.Port}";

foreach (var resource in builder.Resources)
{
if (resource is ProjectResource projectResource)
{
projectResource.Annotations.Add(
new EnvironmentCallbackAnnotation(context =>
{
context.EnvironmentVariables[OtelExporterEndpointEnvVar] = otlpEndpoint;
context.EnvironmentVariables[OtelExporterProtocolEnvVar] = "http/protobuf";
}));
}
}
}

/// <summary>
/// Monitors resource notification events during startup, logging state transitions
/// in real time. This provides immediate visibility into issues like health check
Expand Down
70 changes: 70 additions & 0 deletions TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Diagnostics;

namespace TUnit.Aspire.Http;

/// <summary>
/// DelegatingHandler that propagates <c>traceparent</c>, <c>tracestate</c>, and
/// <c>baggage</c> W3C headers from <see cref="Activity.Current"/> onto outgoing
/// HTTP requests. .NET's <c>DiagnosticsHandler</c> (which injects <c>traceparent</c>)
/// is only present in the <see cref="HttpClient"/> pipeline when using
/// <c>IHttpClientFactory</c> — it is NOT wired in when constructing
/// <c>new HttpClient(handler)</c> directly. This handler fills that gap so that both
/// trace context and <c>tunit.test.id</c> baggage reach the SUT process.
/// </summary>
internal sealed class TUnitBaggagePropagationHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (Activity.Current is { } activity)
{
// Inject traceparent and tracestate via the default propagator.
// This is necessary because DiagnosticsHandler is not in the pipeline
// when HttpClient is constructed with an explicit handler.
DistributedContextPropagator.Current.Inject(
activity,
request,
static (carrier, key, value) =>
{
if (carrier is HttpRequestMessage req)
{
req.Headers.TryAddWithoutValidation(key, value);
}
});

// Inject baggage header if the propagator hasn't already done so.
// When OTel SDK is configured with BaggagePropagator, the Inject call
// above may have already added a baggage header — avoid duplicates.
if (!request.Headers.Contains("baggage"))
{
var first = true;
var sb = new System.Text.StringBuilder();

foreach (var (key, value) in activity.Baggage)
{
if (key is null)
{
continue;
}

if (!first)
{
sb.Append(',');
}

sb.Append(Uri.EscapeDataString(key));
sb.Append('=');
sb.Append(Uri.EscapeDataString(value ?? string.Empty));
first = false;
}

if (!first)
{
request.Headers.TryAddWithoutValidation("baggage", sb.ToString());
}
}
}

return base.SendAsync(request, cancellationToken);
}
}
5 changes: 5 additions & 0 deletions TUnit.Aspire/TUnit.Aspire.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
</PropertyGroup>

<!-- Aspire is server-side only — remove browser platform support from Library.props -->
<ItemGroup>
<SupportedPlatform Remove="browser" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TUnit\TUnit.csproj" />
</ItemGroup>
Expand Down
Loading
Loading