Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@
<ItemGroup>
<ProjectReference Include="..\TUnit.Core\TUnit.Core.csproj" />
<ProjectReference Include="..\TUnit.Logging.Microsoft\TUnit.Logging.Microsoft.csproj" />
<ProjectReference Include="..\TUnit.OpenTelemetry\TUnit.OpenTelemetry.csproj" />
<ProjectReference Include="..\TUnit.Core.SourceGenerator\TUnit.Core.SourceGenerator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
</ItemGroup>

<!-- Framework-specific package versions for Microsoft.AspNetCore.Mvc.Testing -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.6" VersionOverride="8.0.0" />
Expand Down
25 changes: 25 additions & 0 deletions TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Http;
using OpenTelemetry.Trace;
using TUnit.AspNetCore.Extensions;
using TUnit.AspNetCore.Http;
using TUnit.AspNetCore.Interception;
using TUnit.AspNetCore.Logging;
using TUnit.Core;
using TUnit.OpenTelemetry;

namespace TUnit.AspNetCore;

Expand Down Expand Up @@ -50,6 +52,11 @@ public WebApplicationFactory<TEntryPoint> GetIsolatedFactory(
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IHttpMessageHandlerBuilderFilter, TUnitHttpClientFilter>());
}

if (options.AutoConfigureOpenTelemetry)
{
AddTUnitOpenTelemetry(services);
}
});

if (options.EnableHttpExchangeCapture)
Expand Down Expand Up @@ -94,6 +101,24 @@ 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.
/// </summary>
private static void AddTUnitOpenTelemetry(IServiceCollection services)
{
services.AddOpenTelemetry().WithTracing(tracing => tracing
.AddSource(TUnitActivitySource.AspNetCoreHttpSourceName)
.AddProcessor(new TUnitTestCorrelationProcessor())
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation());
}

/// <summary>
/// Controls whether every registered <see cref="Microsoft.Extensions.Hosting.IHostedService"/>
/// has its <c>StartAsync</c> dispatched onto a thread-pool worker with a clean
Expand Down
18 changes: 18 additions & 0 deletions TUnit.AspNetCore.Core/WebApplicationTestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,22 @@ public record WebApplicationTestOptions
/// </para>
/// </summary>
public bool AutoPropagateHttpClientFactory { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether the SUT's <see cref="OpenTelemetry.Trace.TracerProvider"/>
/// should be automatically augmented with the TUnit HTTP activity source, the
/// <c>TUnitTestCorrelationProcessor</c>, and ASP.NET Core + HttpClient instrumentation.
/// Default is <c>true</c>.
/// <para>
/// When enabled, test spans emitted inside the SUT are tagged with the ambient
/// <c>tunit.test.id</c> baggage so they remain queryable per-test in backends like
/// Seq or Jaeger, even when third-party libraries break the parent-chain.
/// </para>
/// <para>
/// Set to <c>false</c> to leave the SUT's OpenTelemetry configuration untouched —
/// useful if the SUT configures its own processors and you do not want TUnit's
/// defaults layered on top.
/// </para>
/// </summary>
public bool AutoConfigureOpenTelemetry { get; set; } = true;
}
72 changes: 72 additions & 0 deletions TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Trace;
using TUnit.AspNetCore;
using TUnit.Core;

namespace TUnit.AspNetCore.Tests;

/// <summary>
/// Coverage for thomhurst/TUnit#5594 — <see cref="TestWebApplicationFactory{TEntryPoint}"/>
/// automatically augments the SUT's <see cref="TracerProvider"/> with TUnit's
/// correlation processor + ASP.NET Core instrumentation.
/// </summary>
/// <remarks>
/// Serialized against sibling auto-wire tests because <see cref="OpenTelemetry.Sdk"/>
/// attaches a process-global <see cref="ActivityListener"/> per <c>TracerProvider</c>,
/// so a parallel factory's correlation processor can tag activities created by another
/// factory's SUT. Serializing keeps assertions observing only their own factory's wiring.
/// </remarks>
[NotInParallel(nameof(AutoConfigureOpenTelemetryTests))]
public class AutoConfigureOpenTelemetryTests : WebApplicationTest<TestWebAppFactory, Program>
{
private readonly List<Activity> _exported = [];

protected override void ConfigureTestServices(IServiceCollection services)
{
services.AddOpenTelemetry().WithTracing(t => t.AddInMemoryExporter(_exported));
}

[Test]
public async Task AutoWires_TagsAspNetCoreSpans_WithTestId()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync("/ping");
response.EnsureSuccessStatusCode();

var testId = TestContext.Current!.Id;
var taggedSpan = _exported.FirstOrDefault(a => (a.GetTagItem(TUnitActivitySource.TagTestId) as string) == testId);
await Assert.That(taggedSpan).IsNotNull();
}
}

[NotInParallel(nameof(AutoConfigureOpenTelemetryTests))]
public class AutoConfigureOpenTelemetryOptOutTests : WebApplicationTest<TestWebAppFactory, Program>
{
private readonly List<Activity> _exported = [];

protected override void ConfigureTestOptions(WebApplicationTestOptions options)
{
options.AutoConfigureOpenTelemetry = false;
}

protected override void ConfigureTestServices(IServiceCollection services)
{
services.AddOpenTelemetry().WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddInMemoryExporter(_exported));
}

[Test]
public async Task OptOut_DoesNotTag_AspNetCoreSpans()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync("/ping");
response.EnsureSuccessStatusCode();

foreach (var activity in _exported)
{
await Assert.That(activity.GetTagItem(TUnitActivitySource.TagTestId)).IsNull();
}
}
}
4 changes: 4 additions & 0 deletions TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
</ItemGroup>

<Import Project="..\TestProject.targets" />

</Project>
7 changes: 7 additions & 0 deletions TUnit.Core/TUnitActivitySource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ public static class TUnitActivitySource
internal const string SourceName = "TUnit";
internal const string LifecycleSourceName = "TUnit.Lifecycle";

/// <summary>
/// Activity source emitted by TUnit's ASP.NET Core HTTP propagation handlers.
/// Registered automatically on the SUT's <see cref="System.Diagnostics.ActivitySource"/>
/// listeners by <c>TestWebApplicationFactory</c>.
/// </summary>
public const string AspNetCoreHttpSourceName = "TUnit.AspNetCore.Http";

/// <summary>W3C baggage HTTP header name.</summary>
internal const string BaggageHeader = "baggage";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,7 @@ namespace
}
public static class TUnitActivitySource
{
public const string AspNetCoreHttpSourceName = ".Http";
public const string TagTestId = ".id";
}
public class TUnitAttribute : { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,7 @@ namespace
}
public static class TUnitActivitySource
{
public const string AspNetCoreHttpSourceName = ".Http";
public const string TagTestId = ".id";
}
public class TUnitAttribute : { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,7 @@ namespace
}
public static class TUnitActivitySource
{
public const string AspNetCoreHttpSourceName = ".Http";
public const string TagTestId = ".id";
}
public class TUnitAttribute : { }
Expand Down
1 change: 1 addition & 0 deletions docs/docs/examples/aspnet.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public class TodoApiTests : TestsBase

- **Client-side tracing**: `CreateClient()` / `CreateDefaultClient()` return an `HttpClient` that propagates `traceparent`, `baggage`, and `X-TUnit-TestId` headers to the SUT.
- **SUT `IHttpClientFactory` tracing**: Every pipeline built inside the SUT via `AddHttpClient<T>()`, named clients, or typed clients also gets those headers prepended — outbound calls from your app to downstream services correlate with the originating test. Opt out per-test with `WebApplicationTestOptions.AutoPropagateHttpClientFactory = false`.
- **SUT-side OpenTelemetry**: The SUT's `TracerProvider` is augmented with the `TUnit.AspNetCore.Http` activity source, the `TUnitTestCorrelationProcessor` (stamps the `tunit.test.id` baggage item onto every span as a tag), and ASP.NET Core + HttpClient instrumentation. Spans emitted inside the SUT stay queryable per-test in backends like Jaeger or Seq, even when third-party libraries break the parent-chain. Opt out per-test with `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false`.
- **Correlated logging**: Server-side `ILogger` output is routed to the test that triggered the request.
- **Hosted-service context hygiene**: `IHostedService.StartAsync` runs under `ExecutionContext.SuppressFlow()` so background work doesn't inherit the first test's `Activity.Current`.

Expand Down
11 changes: 9 additions & 2 deletions docs/docs/examples/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,15 @@ dotnet add package OpenTelemetry.Exporter.Zipkin
If you use `TestWebApplicationFactory` or `TracedWebApplicationFactory`, outgoing requests
automatically propagate the current test trace via W3C `traceparent` and `baggage` headers.

Add `"TUnit.AspNetCore.Http"` as a source only if you also want TUnit's synthetic client spans
to appear in your exporter. Header propagation works either way.
The factory also augments the SUT's `TracerProvider` automatically — no manual `services.AddOpenTelemetry().WithTracing(...)` wiring is needed for the basics:

- Registers the `TUnit.AspNetCore.Http` activity source.
- Adds the `TUnitTestCorrelationProcessor` so spans from libraries with broken parent chains are still tagged with `tunit.test.id`.
- Adds ASP.NET Core and HttpClient instrumentation.

Your own `WithTracing` callback on the SUT is preserved; TUnit's defaults are layered on top. If you configure your own exporter (OTLP, Jaeger, Zipkin, in-memory), test spans flow straight through it.

Set `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false` per-test to opt out — useful if the SUT owns its own processors and you don't want TUnit's defaults layered on top.

## Test Context Correlation via Activity Baggage

Expand Down
6 changes: 6 additions & 0 deletions docs/docs/guides/distributed-tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ protected override void ConfigureTestOptions(WebApplicationTestOptions options)
}
```

### SUT-side OpenTelemetry wiring

`TestWebApplicationFactory<T>` also augments the SUT's `TracerProvider` automatically — the `TUnit.AspNetCore.Http` activity source, the `TUnitTestCorrelationProcessor`, and ASP.NET Core + HttpClient instrumentation are layered on top of whatever `AddOpenTelemetry().WithTracing(...)` wiring the SUT already has. That means spans emitted inside the SUT stay queryable per-test (`tunit.test.id` tag) even when third-party libraries break the parent chain.

Opt out per-test with `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false` when the SUT owns its own processors and you don't want TUnit's defaults layered on top.

### Raw `HttpClient`

`new HttpClient()` can't be intercepted. Either route through `IHttpClientFactory` or set the `traceparent` header manually.
Expand Down
Loading