OpenTelemetry Tracing
TUnit emits System.Diagnostics.Activity trace spans at every level of the test lifecycle. When you configure an OpenTelemetry exporter (or any ActivityListener), you get distributed tracing for your test runs automatically. When no listener is attached, the cost is zero.
Activity tracing requires .NET 8 or later. It is not available on .NET Framework or .NET Standard targets.
Setupâ
Add the OpenTelemetry packages to your test project:
dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Exporter.Console
Then subscribe to the "TUnit" ActivitySource in your test setup:
using System.Diagnostics;
using OpenTelemetry;
using OpenTelemetry.Trace;
using OpenTelemetry.Resources;
public class TraceSetup
{
private static TracerProvider? _tracerProvider;
[Before(TestSession)]
public static void SetupTracing()
{
_tracerProvider = Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("MyTests"))
.AddSource("TUnit")
.AddConsoleExporter()
.Build();
}
[After(TestSession)]
public static void TeardownTracing()
{
_tracerProvider?.Dispose();
}
}
Replace AddConsoleExporter() with your preferred exporter (Jaeger, Zipkin, OTLP, etc.).
Span Hierarchyâ
TUnit creates a nested span tree that mirrors the test lifecycle:
test session
âââ test assembly
âââ test suite (one per test class)
âââ test case (one per test method invocation)
Attributesâ
Each span carries tags that follow OpenTelemetry semantic conventions where applicable.
Standard OTel Attributesâ
| Attribute | Span | Description |
|---|---|---|
test.case.name | test case | Test method name |
test.case.result.status | test case | pass, fail, or skipped |
test.suite.name | test suite | Test class name |
error.type | test case | Exception type (on failure) |
exception.type | test case | Exception type (on exception event) |
exception.message | test case | Exception message (on exception event) |
exception.stacktrace | test case | Full stack trace (on exception event) |
TUnit-Specific Attributesâ
| Attribute | Span | Description |
|---|---|---|
tunit.session.id | test session | Unique session identifier |
tunit.filter | test session | Active test filter expression |
tunit.assembly.name | test assembly | Assembly name |
tunit.class.namespace | test suite | Class namespace |
tunit.test.class | test case | Fully qualified class name |
tunit.test.method | test case | Method name |
tunit.test.id | test case | Unique test instance ID |
tunit.test.categories | test case | Test categories (string array) |
tunit.test.count | session/assembly/suite | Total test count |
tunit.test.retry_attempt | test case | Current retry attempt (when retrying) |
tunit.test.skip_reason | test case | Reason the test was skipped |
Span Statusâ
Following OTel instrumentation conventions:
- Passed tests: status is left as
Unset(the default â success is implicit) - Failed tests: status is set to
Errorwith an exception event recorded - Skipped tests: status is left as
Unsetwithtest.case.result.status=skipped
Retriesâ
When a test is configured with [Retry], each failed attempt produces its own span with Error status and the recorded exception. The retry attempt that finally passes (or the last failing attempt) is the final span for that test.
Using with Jaeger, Zipkin, or OTLPâ
Swap the exporter in the setup:
// OTLP (works with Jaeger, Tempo, Honeycomb, etc.)
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
.AddOtlpExporter(opts => opts.Endpoint = new Uri("http://localhost:4317"))
// Zipkin
dotnet add package OpenTelemetry.Exporter.Zipkin
.AddZipkinExporter(opts => opts.Endpoint = new Uri("http://localhost:9411/api/v2/spans"))