diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index 6f49de6e97..ed7a2a53f3 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -58,6 +58,7 @@ internal Context(Context? parent) } #if NET + internal System.Diagnostics.Activity? Activity { get; set; } internal ExecutionContext? ExecutionContext { get; private set; } #endif @@ -133,6 +134,8 @@ public DefaultLogger GetDefaultLogger() public void Dispose() { #if NET + TUnitActivitySource.StopActivity(Activity); + Activity = null; ExecutionContext?.Dispose(); #endif _outputLock?.Dispose(); diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs new file mode 100644 index 0000000000..2505f814d3 --- /dev/null +++ b/TUnit.Core/TUnitActivitySource.cs @@ -0,0 +1,56 @@ +#if NET + +using System.Diagnostics; + +namespace TUnit.Core; + +internal static class TUnitActivitySource +{ + private static readonly string Version = + typeof(TUnitActivitySource).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + internal static readonly ActivitySource Source = new("TUnit", Version); + + internal static Activity? StartActivity( + string name, + ActivityKind kind = ActivityKind.Internal, + ActivityContext parentContext = default, + IEnumerable>? tags = null) + { + // StartActivity returns null when no listener is sampling this source, + // so the HasListeners() check is implicit. We rely on the framework behavior. + return Source.StartActivity(name, kind, parentContext, tags); + } + + internal static void RecordException(Activity? activity, Exception exception) + { + if (activity is null) + { + return; + } + + var tagsCollection = new ActivityTagsCollection + { + { "exception.type", exception.GetType().FullName }, + { "exception.message", exception.Message }, + { "exception.stacktrace", exception.ToString() } + }; + + activity.AddEvent(new ActivityEvent("exception", tags: tagsCollection)); + activity.SetTag("error.type", exception.GetType().FullName); + activity.SetStatus(ActivityStatusCode.Error, exception.Message); + } + + internal static void StopActivity(Activity? activity) + { + if (activity is null) + { + return; + } + + activity.Stop(); + activity.Dispose(); + } +} + +#endif diff --git a/TUnit.Engine/Services/HookExecutor.cs b/TUnit.Engine/Services/HookExecutor.cs index 1fb083b56f..6a24647154 100644 --- a/TUnit.Engine/Services/HookExecutor.cs +++ b/TUnit.Engine/Services/HookExecutor.cs @@ -31,6 +31,22 @@ public HookExecutor( public async ValueTask ExecuteBeforeTestSessionHooksAsync(CancellationToken cancellationToken) { + var sessionContext = _contextProvider.TestSessionContext; + +#if NET + if (TUnitActivitySource.Source.HasListeners()) + { + sessionContext.Activity = TUnitActivitySource.StartActivity( + "test session", + System.Diagnostics.ActivityKind.Internal, + default, + [ + new("tunit.session.id", sessionContext.Id), + new("tunit.filter", sessionContext.TestFilter) + ]); + } +#endif + var hooks = await _hookCollectionService.CollectBeforeTestSessionHooksAsync().ConfigureAwait(false); if (hooks.Count == 0) @@ -68,6 +84,9 @@ public async ValueTask> ExecuteAfterTestSessionHooksAsync(Cancel if (hooks.Count == 0) { +#if NET + FinishSessionActivity(hasErrors: false); +#endif return []; } @@ -90,11 +109,54 @@ public async ValueTask> ExecuteAfterTestSessionHooksAsync(Cancel } } +#if NET + FinishSessionActivity(hasErrors: exceptions is { Count: > 0 }); +#endif + return exceptions ?? []; } +#if NET + private void FinishSessionActivity(bool hasErrors) + { + var sessionContext = _contextProvider.TestSessionContext; + var activity = sessionContext.Activity; + + if (activity is null) + { + return; + } + + activity.SetTag("tunit.test.count", sessionContext.AllTests.Count); + + if (hasErrors) + { + activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error); + } + + TUnitActivitySource.StopActivity(activity); + sessionContext.Activity = null; + } +#endif + public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken) { + var assemblyContext = _contextProvider.GetOrCreateAssemblyContext(assembly); + +#if NET + if (TUnitActivitySource.Source.HasListeners()) + { + var sessionActivity = _contextProvider.TestSessionContext.Activity; + assemblyContext.Activity = TUnitActivitySource.StartActivity( + "test assembly", + System.Diagnostics.ActivityKind.Internal, + sessionActivity?.Context ?? default, + [ + new("tunit.assembly.name", assembly.GetName().Name) + ]); + } +#endif + var hooks = await _hookCollectionService.CollectBeforeAssemblyHooksAsync(assembly).ConfigureAwait(false); if (hooks.Count == 0) @@ -133,6 +195,9 @@ public async ValueTask> ExecuteAfterAssemblyHooksAsync(Assembly if (hooks.Count == 0) { +#if NET + FinishAssemblyActivity(assembly, hasErrors: false); +#endif return []; } @@ -156,13 +221,57 @@ public async ValueTask> ExecuteAfterAssemblyHooksAsync(Assembly } } +#if NET + FinishAssemblyActivity(assembly, hasErrors: exceptions is { Count: > 0 }); +#endif + return exceptions ?? []; } +#if NET + private void FinishAssemblyActivity(Assembly assembly, bool hasErrors) + { + var assemblyContext = _contextProvider.GetOrCreateAssemblyContext(assembly); + var activity = assemblyContext.Activity; + + if (activity is null) + { + return; + } + + activity.SetTag("tunit.test.count", assemblyContext.TestCount); + + if (hasErrors) + { + activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error); + } + + TUnitActivitySource.StopActivity(activity); + assemblyContext.Activity = null; + } +#endif + public async ValueTask ExecuteBeforeClassHooksAsync( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] Type testClass, CancellationToken cancellationToken) { + var classContext = _contextProvider.GetOrCreateClassContext(testClass); + +#if NET + if (TUnitActivitySource.Source.HasListeners()) + { + var assemblyActivity = classContext.AssemblyContext.Activity; + classContext.Activity = TUnitActivitySource.StartActivity( + "test suite", + System.Diagnostics.ActivityKind.Internal, + assemblyActivity?.Context ?? default, + [ + new("test.suite.name", testClass.Name), + new("tunit.class.namespace", testClass.Namespace) + ]); + } +#endif + var hooks = await _hookCollectionService.CollectBeforeClassHooksAsync(testClass).ConfigureAwait(false); if (hooks.Count == 0) @@ -203,6 +312,9 @@ public async ValueTask> ExecuteAfterClassHooksAsync( if (hooks.Count == 0) { +#if NET + FinishClassActivity(testClass, hasErrors: false); +#endif return []; } @@ -226,9 +338,38 @@ public async ValueTask> ExecuteAfterClassHooksAsync( } } +#if NET + FinishClassActivity(testClass, hasErrors: exceptions is { Count: > 0 }); +#endif + return exceptions ?? []; } +#if NET + private void FinishClassActivity( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] + Type testClass, bool hasErrors) + { + var classContext = _contextProvider.GetOrCreateClassContext(testClass); + var activity = classContext.Activity; + + if (activity is null) + { + return; + } + + activity.SetTag("tunit.test.count", classContext.TestCount); + + if (hasErrors) + { + activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error); + } + + TUnitActivitySource.StopActivity(activity); + classContext.Activity = null; + } +#endif + public async ValueTask ExecuteBeforeTestHooksAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { var testClassType = test.Metadata.TestClassType; diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 75e6448ac7..fb571167f5 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -26,6 +26,19 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func ac if (await ShouldRetry(testContext, ex, attempt)) { +#if NET + // Stop the failed attempt's activity before retrying + var activity = testContext.Activity; + if (activity is not null) + { + activity.SetTag("test.case.result.status", "fail"); + activity.SetTag("tunit.test.retry_attempt", attempt); + TUnitActivitySource.RecordException(activity, ex); + TUnitActivitySource.StopActivity(activity); + testContext.Activity = null; + } +#endif + // Clear the previous result before retrying testContext.Execution.Result = null; testContext.TestStart = null; diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 425160c71d..0533484e53 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -8,6 +8,9 @@ using TUnit.Core.Services; using TUnit.Engine.Helpers; using TUnit.Engine.Services; +#if NET +using System.Diagnostics; +#endif namespace TUnit.Engine; @@ -113,6 +116,25 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( executableTest.Context.ClassContext.RestoreExecutionContext(); +#if NET + if (TUnitActivitySource.Source.HasListeners()) + { + var classActivity = executableTest.Context.ClassContext.Activity; + var testDetails = executableTest.Context.Metadata.TestDetails; + executableTest.Context.Activity = TUnitActivitySource.StartActivity( + "test case", + ActivityKind.Internal, + classActivity?.Context ?? default, + [ + new("test.case.name", testDetails.TestName), + new("tunit.test.class", testDetails.ClassType.FullName), + new("tunit.test.method", testDetails.MethodName), + new("tunit.test.id", executableTest.Context.Id), + new("tunit.test.categories", testDetails.Categories.ToArray()) + ]); + } +#endif + // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks // This ensures resources like Docker containers are not started until needed await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); @@ -189,6 +211,10 @@ await Timings.Record("AfterTest", executableTest.Context, (Func)(async () { hookException = new TestExecutionException(null, hookExceptions, eventReceiverExceptions); } + +#if NET + FinishTestActivity(executableTest, capturedException); +#endif } if (capturedException is SkipTestException) @@ -216,6 +242,50 @@ await Timings.Record("AfterTest", executableTest.Context, (Func)(async () } } +#if NET + private static void FinishTestActivity(AbstractExecutableTest executableTest, Exception? capturedException) + { + var activity = executableTest.Context.Activity; + + if (activity is null) + { + return; + } + + var result = executableTest.Context.Execution.Result; + + // Use OTel test semantic convention values: pass, fail, skipped + var statusValue = result?.State switch + { + TestState.Passed => "pass", + TestState.Failed => "fail", + TestState.Skipped => "skipped", + _ => "unknown" + }; + activity.SetTag("test.case.result.status", statusValue); + + if (executableTest.Context.CurrentRetryAttempt > 0) + { + activity.SetTag("tunit.test.retry_attempt", executableTest.Context.CurrentRetryAttempt); + } + + if (capturedException is SkipTestException skipEx) + { + // Skipped tests are not errors — leave status as Unset + activity.SetTag("tunit.test.skip_reason", skipEx.Reason); + } + else if (capturedException is not null) + { + // RecordException sets Error status and error.type tag + TUnitActivitySource.RecordException(activity, capturedException); + } + // Success: leave status as Unset per OTel instrumentation library conventions + + TUnitActivitySource.StopActivity(activity); + executableTest.Context.Activity = null; + } +#endif + private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executableTest, CancellationToken cancellationToken) { // Skip the actual test invocation for skipped tests diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md new file mode 100644 index 0000000000..3b267992cc --- /dev/null +++ b/docs/docs/examples/opentelemetry.md @@ -0,0 +1,119 @@ +# 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: + +```bash +dotnet add package OpenTelemetry +dotnet add package OpenTelemetry.Exporter.Console +``` + +Then subscribe to the `"TUnit"` ActivitySource in your test setup: + +```csharp +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](https://opentelemetry.io/docs/specs/semconv/) 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 `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: + +```csharp +// 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")) +``` diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 91fedef350..68a3566467 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -196,6 +196,7 @@ const sidebars: SidebarsConfig = { 'examples/aspnet', 'examples/aspire', 'examples/playwright', + 'examples/opentelemetry', 'examples/fscheck', 'examples/complex-test-infrastructure', ],