Skip to main content

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.

note

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 a [Before(TestDiscovery)] hook:

using System.Diagnostics;
using OpenTelemetry;
using OpenTelemetry.Trace;
using OpenTelemetry.Resources;

public class TraceSetup
{
private static TracerProvider? _tracerProvider;

[Before(TestDiscovery)]
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.).

Option B: Raw ActivityListener (no SDK dependency)

If you don't want the OpenTelemetry SDK, you can subscribe directly with a System.Diagnostics.ActivityListener:

using System.Diagnostics;

public class TraceSetup
{
private static ActivityListener? _listener;

[Before(TestDiscovery)]
public static void SetupTracing()
{
_listener = new ActivityListener
{
ShouldListenTo = source => source.Name == "TUnit",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStarted = activity => Console.WriteLine($"▶ {activity.OperationName}"),
ActivityStopped = activity => Console.WriteLine($"■ {activity.OperationName} ({activity.Duration.TotalMilliseconds:F1}ms)")
};
ActivitySource.AddActivityListener(_listener);
}

[After(TestSession)]
public static void TeardownTracing()
{
_listener?.Dispose();
}
}

Why [Before(TestDiscovery)]?

The listener must be registered in a [Before(TestDiscovery)] hook (or earlier, e.g. [Before(TestSession)]) so it is active before the discovery span begins. TUnit's hook execution order is:

  1. [Before(TestSession)] — session-level setup
  2. [Before(TestDiscovery)] — register your listener here
  3. Test discovery — the "test discovery" span is emitted here
  4. Test execution — assembly, suite, and test case spans are emitted
  5. [After(TestSession)] — dispose your listener here

If you register the listener later (e.g., in [Before(Assembly)]), the discovery span will not be captured.

Span Hierarchy

TUnit creates a nested span tree that mirrors the test lifecycle:

test session
├── test discovery
└── test assembly
└── test suite (one per test class)
└── test case (one per test method invocation)

The test discovery span captures the time spent finding, building, and resolving dependencies for all tests. It appears as a sibling of the assembly spans, giving you a clear view of discovery vs execution time.

Attributes

Each span carries tags that follow OpenTelemetry semantic conventions where applicable.

Standard OTel Attributes

AttributeSpanDescription
test.case.nametest caseTest method name
test.case.result.statustest casepass, fail, or skipped
test.suite.nametest suiteTest class name
error.typetest caseException type (on failure)
exception.typetest caseException type (on exception event)
exception.messagetest caseException message (on exception event)
exception.stacktracetest caseFull stack trace (on exception event)

TUnit-Specific Attributes

AttributeSpanDescription
tunit.session.idtest sessionUnique session identifier
tunit.filtertest sessionActive test filter expression
tunit.assembly.nametest assemblyAssembly name
tunit.class.namespacetest suiteClass namespace
tunit.test.classtest caseFully qualified class name
tunit.test.methodtest caseMethod name
tunit.test.idtest caseUnique test instance ID
tunit.test.categoriestest caseTest categories (string array)
tunit.test.countsession/assembly/suite/discoveryTotal test count
tunit.test.retry_attempttest caseCurrent retry attempt (when retrying)
tunit.test.skip_reasontest caseReason 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 Error with an exception event recorded
  • Skipped tests: status is left as Unset with test.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 code above. Each exporter needs its own NuGet package.

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

HTML Report Integration

TUnit's built-in HTML test report automatically captures activity spans and renders them as trace timelines — no OpenTelemetry SDK required. The report also captures spans from instrumented libraries like HttpClient, ASP.NET Core, and EF Core when they execute within a test's context.

For details on distributed trace collection, linking external traces, and accessing the test's Activity, see the Distributed Tracing section of the HTML report guide.