Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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