diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs index 2d87437f55..38e22bfbb3 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs @@ -31,7 +31,7 @@ namespace TUnit.Core; /// /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)] -public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute +public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute, ITraceScopeProvider { private Type[] _types; @@ -180,7 +180,7 @@ public ClassDataSourceAttribute(params Type[] types) /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)] public sealed class ClassDataSourceAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider { public SharedType Shared { get; set; } = SharedType.None; public string Key { get; set; } = string.Empty; diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs index b6a06a98d9..020d613a37 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs @@ -7,7 +7,7 @@ namespace TUnit.Core; public sealed class ClassDataSourceAttribute< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T1, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T2> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider where T1 : new() where T2 : new() { diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs index 010a7dbde6..1c46daab48 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs @@ -8,7 +8,7 @@ public sealed class ClassDataSourceAttribute< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T1, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T2, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T3> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs index ed4c73fa1c..fb7cb9dd94 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs @@ -9,7 +9,7 @@ public sealed class ClassDataSourceAttribute< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T2, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T3, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T4> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs index f65b362fd5..0e2c67075b 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs @@ -10,7 +10,7 @@ public sealed class ClassDataSourceAttribute< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T3, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T4, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T5> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() diff --git a/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs b/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs new file mode 100644 index 0000000000..b3042515ae --- /dev/null +++ b/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs @@ -0,0 +1,13 @@ +namespace TUnit.Core; + +/// +/// Implemented by data source attributes that expose information. +/// Used by the engine to parent initialization spans under the correct activity. +/// +internal interface ITraceScopeProvider +{ + /// + /// Returns the for each generated object, in parameter order. + /// + IEnumerable GetSharedTypes(); +} diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 2505f814d3..d0c0d6448b 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -11,17 +11,95 @@ internal static class TUnitActivitySource internal static readonly ActivitySource Source = new("TUnit", Version); + // Span names used across the engine and HTML report. + internal const string SpanTestSession = "test session"; + internal const string SpanTestAssembly = "test assembly"; + internal const string SpanTestSuite = "test suite"; + internal const string SpanTestCase = "test case"; + + // Tag keys used across init/dispose spans and the HTML report. + internal const string TagTestId = "tunit.test.id"; + internal const string TagTestClass = "tunit.test.class"; + internal const string TagTraceScope = "tunit.trace.scope"; + + /// + /// Returns a human-readable type name suitable for span labels. + /// Strips the namespace but preserves nesting (Outer.Inner) and cleans up + /// generic arity suffixes (MySource`1 → MySource<T>). + /// + internal static string GetReadableTypeName(Type type) + { + var name = type.FullName ?? type.Name; + + // Strip namespace: take everything after the last '.' that isn't part of nesting + var nsEnd = name.LastIndexOf('.'); + if (nsEnd >= 0 && type.Namespace is { Length: > 0 }) + { + name = name[type.Namespace.Length..].TrimStart('.'); + } + + // Replace '+' nesting separator with '.' + name = name.Replace('+', '.'); + + // Clean up generic arity suffixes: MySource`1 → MySource + var backtick = name.IndexOf('`'); + if (backtick >= 0) + { + name = name[..backtick]; + } + + return name; + } + 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); } + /// + /// Runs inside an OpenTelemetry span, handling + /// Activity.Current save/restore, error recording, and span stop/dispose. + /// + internal static async Task RunWithSpanAsync( + string name, + ActivityContext parentContext, + IEnumerable> tags, + Func action) + { + Activity? activity = null; + var previousActivity = Activity.Current; + + if (Source.HasListeners()) + { + activity = Source.StartActivity(name, ActivityKind.Internal, parentContext, tags); + if (activity is not null) + { + Activity.Current = activity; + } + } + + try + { + await action(); + } + catch (Exception ex) + { + RecordException(activity, ex); + throw; + } + finally + { + // Activity.Current is AsyncLocal — restore so the ambient context on this + // execution path is not permanently set to the span we just stopped. + StopActivity(activity); + Activity.Current = previousActivity; + } + } + internal static void RecordException(Activity? activity, Exception exception) { if (activity is null) @@ -51,6 +129,20 @@ internal static void StopActivity(Activity? activity) activity.Stop(); activity.Dispose(); } + + /// + /// Maps a to its trace scope tag value. + /// Keyed objects map to "session" because their lifetime is session-scoped + /// (shared across classes and assemblies via matching keys). + /// + internal static string GetScopeTag(SharedType? sharedType) => sharedType switch + { + SharedType.PerTestSession => "session", + SharedType.PerAssembly => "assembly", + SharedType.PerClass => "class", + SharedType.Keyed => "session", + _ => "test" + }; } #endif diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs new file mode 100644 index 0000000000..3bfdb774bb --- /dev/null +++ b/TUnit.Core/TraceScopeRegistry.cs @@ -0,0 +1,64 @@ +#if NET +using System.Runtime.CompilerServices; + +namespace TUnit.Core; + +/// +/// Thread-safe registry that maps data source objects to their trace scope. +/// Used by the engine to parent initialization and disposal OpenTelemetry spans +/// under the correct activity (session, assembly, class, or test). +/// +/// +/// Objects are registered by the engine (TestBuilder, PropertyInjector) when data source +/// attributes implement . The engine reads the scope +/// during initialization to determine the parent activity for each object's trace span. +/// Uses so that per-test data source objects +/// can be garbage-collected after their tests complete, rather than being held alive for the +/// entire session. ConditionalWeakTable inherently uses reference equality. +/// +internal static class TraceScopeRegistry +{ + private static readonly ConditionalWeakTable> Scopes = new(); + + /// + /// Registers trace scopes for data objects produced by a data source attribute. + /// If the attribute implements , each object is + /// paired with the corresponding from the provider. + /// First registration wins — subsequent calls for the same instance are no-ops. + /// + internal static void RegisterFromDataSource(IDataSourceAttribute dataSource, object?[]? objects) + { + if (objects is null || objects.Length == 0) + { + return; + } + + if (dataSource is not ITraceScopeProvider traceScopeProvider) + { + return; + } + + using var enumerator = traceScopeProvider.GetSharedTypes().GetEnumerator(); + for (var i = 0; i < objects.Length; i++) + { + var sharedType = enumerator.MoveNext() ? enumerator.Current : SharedType.None; + if (objects[i] is not null && !Scopes.TryGetValue(objects[i]!, out _)) + { + // TryGetValue above is a fast-path to avoid allocating the closure below + // on re-registration. GetValue is the atomic operation that enforces + // first-registration-wins semantics. + Scopes.GetValue(objects[i]!, _ => new StrongBox(sharedType)); + } + } + } + + /// + /// Returns the for an object, or null if unregistered. + /// Unregistered objects (e.g., the test class instance) default to per-test scope. + /// + internal static SharedType? GetSharedType(object obj) + { + return Scopes.TryGetValue(obj, out var box) ? box.Value : null; + } +} +#endif diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index c58e163fed..50098c8a6e 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -220,6 +220,9 @@ private async ValueTask UntrackObject(object? obj) if (shouldDispose) { + // TODO: Add disposal span for shared objects here to match the init spans + // created in ObjectLifecycleService.InitializeWithSpanAsync. Currently only + // per-test disposal gets a span (via TestCoordinator.DisposeTestInstanceWithSpanAsync). await disposer.DisposeAsync(obj).ConfigureAwait(false); } } diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index f6f0a859fb..0154b3301b 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -210,6 +210,12 @@ public async Task> BuildTestsFromMetadataAsy var classDataResult = await classDataFactory() ?? []; var classData = DataUnwrapper.Unwrap(classDataResult); +#if NET + // Register scope for class data objects from the simple path. + // Also registered in the repeat loop below for re-fetched instances. + TraceScopeRegistry.RegisterFromDataSource(classDataSource, classData); +#endif + // Initialize objects before method data sources are evaluated. // ObjectInitializer is phase-aware and will only initialize IAsyncDiscoveryInitializer during Discovery. await InitializeClassDataAsync(classData); @@ -293,6 +299,14 @@ await _objectLifecycleService.RegisterObjectAsync( classData = classDataUnwrapped; var (methodData, methodRowMetadata) = DataUnwrapper.UnwrapWithTypesAndMetadata(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); +#if NET + // Re-register: classDataFactory() was called again above and may return + // a different instance for non-shared objects. First-registration-wins + // semantics make this a no-op for the same instance. + TraceScopeRegistry.RegisterFromDataSource(classDataSource, classData); + TraceScopeRegistry.RegisterFromDataSource(methodDataSource, methodData); +#endif + // Extract and merge metadata from data source attributes and TestDataRow wrappers var classAttrMetadata = DataSourceMetadataExtractor.ExtractFromAttribute(classDataSource); var methodAttrMetadata = DataSourceMetadataExtractor.ExtractFromAttribute(methodDataSource); diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs index cae6be94c1..f1b99e8d0a 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -169,9 +169,9 @@ private static string EnrichSpanName(Activity activity) // Look up the semantic name tag to produce a more descriptive label var tagKey = displayName switch { - "test case" => "test.case.name", - "test suite" => "test.suite.name", - "test assembly" => "tunit.assembly.name", + TUnitActivitySource.SpanTestCase => "test.case.name", + TUnitActivitySource.SpanTestSuite => "test.suite.name", + TUnitActivitySource.SpanTestAssembly => "tunit.assembly.name", _ => null }; diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 887df5d4a2..6527439c14 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -1476,9 +1476,19 @@ function renderSuiteTrace(className) { return '
' + tlArrow + 'Class Timeline
' + renderSpanRows(filtered, 'suite-' + className) + '
'; } -// Global timeline: session + assembly + suite spans +// Global timeline: session + assembly + suite + shared init/dispose spans (not per-test) +function isGlobalTimelineSpan(s) { + var t = s.spanType; + if (t === 'test session' || t === 'test assembly' || t === 'test suite') return true; + // Include init/dispose spans that are NOT per-test scope (shared prerequisites) + if (t && (t.startsWith('initialize ') || t.startsWith('dispose '))) { + var scopeTag = (s.tags||[]).find(function(tag){ return tag.key === 'tunit.trace.scope'; }); + return scopeTag && scopeTag.value !== 'test'; + } + return false; +} function renderGlobalTimeline() { - const topSpans = spans.filter(s => s.spanType === 'test session' || s.spanType === 'test assembly' || s.spanType === 'test suite'); + const topSpans = spans.filter(isGlobalTimelineSpan); if (!topSpans.length) return ''; return '
' + tlArrow + 'Execution Timeline
' + renderSpanRows(topSpans, 'global') + '
'; } diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index f96a59f392..a5961ed86d 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -335,6 +335,15 @@ private ReportData BuildReportData() if (_activityCollector != null) { spans = _activityCollector.GetAllSpans(); + + // Use the session span duration as the header duration when available, + // since it captures the full wall-clock time including initialization. + // The test-timing-based duration only covers test execution. + var sessionSpan = spans?.FirstOrDefault(s => s.SpanType == TUnitActivitySource.SpanTestSession); + if (sessionSpan != null) + { + totalDurationMs = sessionSpan.DurationMs; + } } #endif diff --git a/TUnit.Engine/Services/HookExecutor.cs b/TUnit.Engine/Services/HookExecutor.cs index 7dcab8564c..dc90566995 100644 --- a/TUnit.Engine/Services/HookExecutor.cs +++ b/TUnit.Engine/Services/HookExecutor.cs @@ -69,7 +69,7 @@ public async ValueTask ExecuteBeforeTestSessionHooksAsync(CancellationToken canc if (TUnitActivitySource.Source.HasListeners()) { sessionContext.Activity = TUnitActivitySource.StartActivity( - "test session", + TUnitActivitySource.SpanTestSession, System.Diagnostics.ActivityKind.Internal, default, [ @@ -153,7 +153,7 @@ public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, Cancel { var sessionActivity = _contextProvider.TestSessionContext.Activity; assemblyContext.Activity = TUnitActivitySource.StartActivity( - "test assembly", + TUnitActivitySource.SpanTestAssembly, System.Diagnostics.ActivityKind.Internal, sessionActivity?.Context ?? default, [ @@ -311,7 +311,7 @@ public async ValueTask ExecuteBeforeClassHooksAsync( { var assemblyActivity = classContext.AssemblyContext.Activity; classContext.Activity = TUnitActivitySource.StartActivity( - "test suite", + TUnitActivitySource.SpanTestSuite, System.Diagnostics.ActivityKind.Internal, assemblyActivity?.Context ?? default, [ diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index a582ffa897..55b45bda5c 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -5,6 +5,11 @@ using TUnit.Core.Interfaces; using TUnit.Core.PropertyInjection; using TUnit.Core.Tracking; +#if NET +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading; +#endif namespace TUnit.Engine.Services; @@ -32,6 +37,14 @@ internal sealed class ObjectLifecycleService : IObjectRegistry, IInitializationC private readonly ConcurrentDictionary> _initializationTasks = new(Core.Helpers.ReferenceEqualityComparer.Instance); +#if NET + // Gates span creation so only the first caller for a given object creates a trace span. + // Subsequent callers (concurrent tests sharing the same object) skip span creation + // and just await ObjectInitializer's deduplicated Lazy. + // Uses ConditionalWeakTable so per-test objects can be GC'd after their test completes. + private readonly ConditionalWeakTable> _spannedObjects = new(); +#endif + public ObjectLifecycleService( Lazy propertyInjector, ObjectGraphDiscoveryService objectGraphDiscoveryService, @@ -220,6 +233,8 @@ private void SetCachedPropertiesOnInstance(object instance, TestContext testCont /// Initializes all tracked objects depth-first (deepest objects first). /// This is called during test execution (after BeforeClass hooks) to initialize IAsyncInitializer objects. /// Objects at the same level are initialized in parallel. + /// Each object gets its own OpenTelemetry span parented under the appropriate scope activity + /// based on its (registered in ). /// private async Task InitializeTrackedObjectsAsync(TestContext testContext, CancellationToken cancellationToken) { @@ -235,7 +250,7 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel var tasks = new List(objectsAtLevel.Count); foreach (var obj in objectsAtLevel) { - tasks.Add(InitializeObjectWithNestedAsync(obj, cancellationToken)); + tasks.Add(InitializeObjectWithSpanAsync(obj, testContext, cancellationToken)); } if (tasks.Count > 0) @@ -244,23 +259,101 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel } } - // Finally initialize the test class and its nested objects + // Finally initialize the test class and its nested objects. + // The test class instance is unregistered in TraceScopeRegistry, so + // GetSharedType returns null → defaults to per-test scope automatically. var classInstance = testContext.Metadata.TestDetails.ClassInstance; - await InitializeNestedObjectsForExecutionAsync(classInstance, cancellationToken); - await ObjectInitializer.InitializeAsync(classInstance, cancellationToken); + await InitializeObjectWithSpanAsync(classInstance, testContext, cancellationToken); } /// - /// Initializes an object and its nested objects. + /// Initializes an object and its nested objects, wrapped in a scope-aware OpenTelemetry span. /// - private async Task InitializeObjectWithNestedAsync(object obj, CancellationToken cancellationToken) + private async Task InitializeObjectWithSpanAsync(object obj, TestContext testContext, CancellationToken cancellationToken) { // First initialize nested objects depth-first await InitializeNestedObjectsForExecutionAsync(obj, cancellationToken); - // Then initialize the object itself +#if NET + // Only the first caller for a given object creates a trace span. + // GetValue is atomic — exactly one concurrent caller's factory runs. + // Interlocked.Exchange ensures exactly one caller wins the gate even if + // multiple threads pass the TryGetValue fast-path simultaneously. + var isFirstCaller = false; + if (obj is IAsyncInitializer && TUnitActivitySource.Source.HasListeners() + && !_spannedObjects.TryGetValue(obj, out _)) + { + var box = _spannedObjects.GetValue(obj, _ => new StrongBox(1)); + isFirstCaller = Interlocked.Exchange(ref box.Value, 0) == 1; + } + + if (isFirstCaller) + { + var sharedType = TraceScopeRegistry.GetSharedType(obj); + await InitializeWithSpanAsync(obj, testContext, sharedType, cancellationToken); + } + else + { + await ObjectInitializer.InitializeAsync(obj, cancellationToken); + } +#else await ObjectInitializer.InitializeAsync(obj, cancellationToken); +#endif + } + +#if NET + /// + /// Initializes an object within an OpenTelemetry span parented under the appropriate scope activity. + /// + private static async Task InitializeWithSpanAsync( + object obj, + TestContext testContext, + SharedType? sharedType, + CancellationToken cancellationToken) + { + var parentContext = GetParentActivityContext(testContext, sharedType); + + var scopeTag = TUnitActivitySource.GetScopeTag(sharedType); + var isShared = sharedType is not null and not SharedType.None; + + KeyValuePair[] tags = isShared + ? + [ + new(TUnitActivitySource.TagTestClass, testContext.Metadata.TestDetails.ClassType.FullName), + new(TUnitActivitySource.TagTraceScope, scopeTag) + ] + : + [ + new(TUnitActivitySource.TagTestId, testContext.Id), + new(TUnitActivitySource.TagTestClass, testContext.Metadata.TestDetails.ClassType.FullName), + new(TUnitActivitySource.TagTraceScope, scopeTag) + ]; + + await TUnitActivitySource.RunWithSpanAsync( + $"initialize {TUnitActivitySource.GetReadableTypeName(obj.GetType())}", + parentContext, + tags, + () => ObjectInitializer.InitializeAsync(obj, cancellationToken).AsTask()); + } + + /// + /// Returns the parent activity context for an initialization span. + /// Session/keyed/assembly-scoped objects are parented under the assembly activity so they + /// appear as siblings of suite spans in both OTel backends and the HTML timeline. + /// The tunit.trace.scope tag carries the precise lifetime semantics. + /// + private static ActivityContext GetParentActivityContext(TestContext testContext, SharedType? sharedType) + { + return sharedType switch + { + SharedType.PerTestSession or SharedType.PerAssembly or SharedType.Keyed + => testContext.ClassContext.AssemblyContext.Activity?.Context ?? default, + SharedType.PerClass => testContext.ClassContext.Activity?.Context ?? default, + // Per-test objects and the test class instance go under the test case activity + _ => testContext.Activity?.Context ?? testContext.ClassContext.Activity?.Context ?? default + }; } +#endif /// /// Initializes nested objects during execution phase - all IAsyncInitializer objects. @@ -468,5 +561,8 @@ public Task CleanupTestAsync(TestContext testContext, List cleanupExc public void ClearCache() { _initializationTasks.Clear(); +#if NET + _spannedObjects.Clear(); +#endif } } diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs index f2df872ea6..58a63a5450 100644 --- a/TUnit.Engine/Services/PropertyInjector.cs +++ b/TUnit.Engine/Services/PropertyInjector.cs @@ -537,6 +537,9 @@ private async Task ResolveAndCacheReflectionPropertyAsync( if (value != null) { +#if NET + TraceScopeRegistry.RegisterFromDataSource(dataSource, args); +#endif // EnsureInitializedAsync handles property injection and initialization. // ObjectInitializer is phase-aware: during Discovery phase, only IAsyncDiscoveryInitializer // objects are initialized; regular IAsyncInitializer objects are deferred to Execution phase. diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index cc79d87fb7..0bfc503c51 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -301,32 +301,66 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C } finally { - // Dispose test instance and fire OnDispose after each attempt - // This ensures each retry gets a fresh instance - var onDispose = test.Context.InternalEvents.OnDispose; - if (onDispose?.InvocationList != null) + await DisposeTestInstanceWithSpanAsync(test).ConfigureAwait(false); + } + } + + /// + /// Disposes the test instance and fires OnDispose callbacks, wrapped in an OpenTelemetry + /// activity span for trace timeline visibility. + /// Parented under the class activity because the test case activity has already been stopped + /// by this point (disposal runs after TestExecutor.ExecuteAsync completes). + /// + private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest test) + { +#if NET + var classType = test.Context.Metadata.TestDetails.ClassType; + await TUnitActivitySource.RunWithSpanAsync( + $"dispose {TUnitActivitySource.GetReadableTypeName(classType)}", + test.Context.ClassContext.Activity?.Context ?? default, + [ + new(TUnitActivitySource.TagTestId, test.Context.Id), + new(TUnitActivitySource.TagTestClass, classType.FullName), + new(TUnitActivitySource.TagTraceScope, TUnitActivitySource.GetScopeTag(SharedType.None)) + ], + () => DisposeTestInstanceCoreAsync(test)); +#else + await DisposeTestInstanceCoreAsync(test); +#endif + } + + private async Task DisposeTestInstanceCoreAsync(AbstractExecutableTest test) + { + // Fire OnDispose callbacks — each retry gets a fresh instance + var onDispose = test.Context.InternalEvents.OnDispose; + if (onDispose?.InvocationList != null) + { + foreach (var invocation in onDispose.InvocationList) { - foreach (var invocation in onDispose.InvocationList) + try + { + await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false); + } + catch (Exception disposeEx) { - try - { - await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false); - } - catch (Exception disposeEx) - { - await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false); - } +#if NET + TUnitActivitySource.RecordException(System.Diagnostics.Activity.Current, disposeEx); +#endif + await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false); } } + } - try - { - await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false); - } - catch (Exception disposeEx) - { - await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); - } + try + { + await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false); + } + catch (Exception disposeEx) + { +#if NET + TUnitActivitySource.RecordException(System.Diagnostics.Activity.Current, disposeEx); +#endif + await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); } } } diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 4f1336b6ab..59adb506cd 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -116,17 +116,17 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( executableTest.Context.ClassContext.RestoreExecutionContext(); - // 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); - #if NET + // Start the test case activity BEFORE initialization so that per-test + // object init spans can be parented under the test case. Shared objects + // (PerSession/PerAssembly/PerClass) are parented under their respective + // scope activities via explicit parentContext in ObjectLifecycleService. if (TUnitActivitySource.Source.HasListeners()) { var classActivity = executableTest.Context.ClassContext.Activity; var testDetails = executableTest.Context.Metadata.TestDetails; executableTest.Context.Activity = TUnitActivitySource.StartActivity( - "test case", + TUnitActivitySource.SpanTestCase, ActivityKind.Internal, classActivity?.Context ?? default, [ @@ -140,6 +140,11 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( } #endif + // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks + // and after the test case activity starts. Per-test objects are traced + // under the test case; shared objects under session/assembly/class. + await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); + executableTest.Context.RestoreExecutionContext(); // Early stage test start receivers run before instance-level hooks diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index ad6c1f161b..7aa378a844 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -1,8 +1,5 @@ using TUnit.Core; using TUnit.Engine.Services; -#if NET -using System.Diagnostics; -#endif namespace TUnit.Engine; @@ -35,27 +32,10 @@ public void PrepareTest(AbstractExecutableTest test, CancellationToken cancellat public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { - // Data source initialization runs before the test case span starts, so any spans it - // creates (container startup, auth calls, connection pools, etc.) do not appear nested - // inside the individual test's trace timeline. We briefly set Activity.Current to the - // session span so those spans are parented there instead. -#if NET - var sessionActivity = test.Context.ClassContext.AssemblyContext.TestSessionContext.Activity; - var previousActivity = Activity.Current; - if (sessionActivity is not null) - { - Activity.Current = sessionActivity; - } - try - { - await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); - } - finally - { - Activity.Current = previousActivity; - } -#else + // ObjectLifecycleService creates per-object initialization spans with scope-aware + // parent activity selection. Shared objects (PerSession/PerAssembly/PerClass) are + // parented under session/assembly/class activities; per-test objects and the test + // class itself are parented under the test case activity. await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); -#endif } } diff --git a/TUnit.UnitTests/GetReadableTypeNameTests.cs b/TUnit.UnitTests/GetReadableTypeNameTests.cs new file mode 100644 index 0000000000..769d4d6a12 --- /dev/null +++ b/TUnit.UnitTests/GetReadableTypeNameTests.cs @@ -0,0 +1,45 @@ +#if NET +using TUnit.Core; + +namespace TUnit.UnitTests; + +public class GetReadableTypeNameTests +{ + [Test] + public async Task Simple_Type_Returns_Name() + { + var name = TUnitActivitySource.GetReadableTypeName(typeof(string)); + await Assert.That(name).IsEqualTo("String"); + } + + [Test] + public async Task Nested_Type_Uses_Dot_Separator() + { + var name = TUnitActivitySource.GetReadableTypeName(typeof(OuterClass.InnerClass)); + await Assert.That(name).IsEqualTo("OuterClass.InnerClass"); + } + + [Test] + public async Task Generic_Type_Strips_Arity_Suffix() + { + var name = TUnitActivitySource.GetReadableTypeName(typeof(List)); + await Assert.That(name).IsEqualTo("List"); + } + + [Test] + public async Task Deeply_Nested_Type_Preserves_Chain() + { + var name = TUnitActivitySource.GetReadableTypeName(typeof(OuterClass.InnerClass.DeeplyNested)); + await Assert.That(name).IsEqualTo("OuterClass.InnerClass.DeeplyNested"); + } +} + +// Test fixtures for nested type tests +public class OuterClass +{ + public class InnerClass + { + public class DeeplyNested; + } +} +#endif diff --git a/TUnit.UnitTests/TraceScopeRegistryTests.cs b/TUnit.UnitTests/TraceScopeRegistryTests.cs new file mode 100644 index 0000000000..e56bf4920b --- /dev/null +++ b/TUnit.UnitTests/TraceScopeRegistryTests.cs @@ -0,0 +1,169 @@ +#if NET +using System.Runtime.CompilerServices; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +namespace TUnit.UnitTests; + +public class TraceScopeRegistryTests +{ + [Test] + public async Task RegisterFromDataSource_WithTraceScopeProvider_RegistersSharedTypes() + { + var obj1 = new object(); + var obj2 = new object(); + var dataSource = new FakeTraceScopeDataSource( + [SharedType.PerAssembly, SharedType.PerClass]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [obj1, obj2]); + + await Assert.That(TraceScopeRegistry.GetSharedType(obj1)).IsEqualTo(SharedType.PerAssembly); + await Assert.That(TraceScopeRegistry.GetSharedType(obj2)).IsEqualTo(SharedType.PerClass); + } + + [Test] + public async Task RegisterFromDataSource_WithNonTraceScopeProvider_DoesNotRegister() + { + var obj = new object(); + var dataSource = new FakeNonTraceScopeDataSource(); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [obj]); + + await Assert.That(TraceScopeRegistry.GetSharedType(obj)).IsNull(); + } + + [Test] + public async Task GetSharedType_UnregisteredObject_ReturnsNull() + { + var unregistered = new object(); + + await Assert.That(TraceScopeRegistry.GetSharedType(unregistered)).IsNull(); + } + + [Test] + public async Task RegisterFromDataSource_WithNullObjectsArray_DoesNotThrow() + { + var dataSource = new FakeTraceScopeDataSource([SharedType.None]); + + // Should not throw — just a no-op + TraceScopeRegistry.RegisterFromDataSource(dataSource, null); + + // Verify no side effects — a new object should still be unregistered + var probe = new object(); + await Assert.That(TraceScopeRegistry.GetSharedType(probe)).IsNull(); + } + + [Test] + public async Task RegisterFromDataSource_WithEmptyObjectsArray_DoesNotThrow() + { + var dataSource = new FakeTraceScopeDataSource([SharedType.None]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, []); + + // Verify no side effects + var probe = new object(); + await Assert.That(TraceScopeRegistry.GetSharedType(probe)).IsNull(); + } + + [Test] + public async Task RegisterFromDataSource_WithNullElementsInArray_SkipsNulls() + { + var realObj = new object(); + var dataSource = new FakeTraceScopeDataSource( + [SharedType.PerTestSession, SharedType.PerAssembly]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [null, realObj]); + + // null element is skipped, realObj gets the second SharedType + await Assert.That(TraceScopeRegistry.GetSharedType(realObj)).IsEqualTo(SharedType.PerAssembly); + } + + [Test] + public async Task RegisterFromDataSource_DuplicateObject_KeepsFirstRegistration() + { + var obj = new object(); + var firstSource = new FakeTraceScopeDataSource([SharedType.PerTestSession]); + var secondSource = new FakeTraceScopeDataSource([SharedType.PerClass]); + + TraceScopeRegistry.RegisterFromDataSource(firstSource, [obj]); + TraceScopeRegistry.RegisterFromDataSource(secondSource, [obj]); + + // First registration wins + await Assert.That(TraceScopeRegistry.GetSharedType(obj)).IsEqualTo(SharedType.PerTestSession); + } + + [Test] + public async Task RegisterFromDataSource_FewerSharedTypesThanObjects_DefaultsToNone() + { + var obj1 = new object(); + var obj2 = new object(); + // Only one SharedType for two objects + var dataSource = new FakeTraceScopeDataSource([SharedType.PerAssembly]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [obj1, obj2]); + + await Assert.That(TraceScopeRegistry.GetSharedType(obj1)).IsEqualTo(SharedType.PerAssembly); + await Assert.That(TraceScopeRegistry.GetSharedType(obj2)).IsEqualTo(SharedType.None); + } + + [Test] + public async Task RegisterFromDataSource_UsesReferenceEquality() + { + var obj1 = new EquatableObject(42); + var obj2 = new EquatableObject(42); // Same value but different reference + var dataSource = new FakeTraceScopeDataSource( + [SharedType.PerTestSession, SharedType.PerClass]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [obj1, obj2]); + + // Should track separately despite Equals returning true + await Assert.That(TraceScopeRegistry.GetSharedType(obj1)).IsEqualTo(SharedType.PerTestSession); + await Assert.That(TraceScopeRegistry.GetSharedType(obj2)).IsEqualTo(SharedType.PerClass); + } + + /// + /// Fake data source that implements both IDataSourceAttribute and ITraceScopeProvider. + /// + private sealed class FakeTraceScopeDataSource : IDataSourceAttribute, ITraceScopeProvider + { + private readonly SharedType[] _sharedTypes; + + public FakeTraceScopeDataSource(SharedType[] sharedTypes) + { + _sharedTypes = sharedTypes; + } + + public bool SkipIfEmpty { get; set; } + + public IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) + => throw new NotSupportedException("Not needed for registry tests"); + + public IEnumerable GetSharedTypes() => _sharedTypes; + } + + /// + /// Fake data source that does NOT implement ITraceScopeProvider. + /// + private sealed class FakeNonTraceScopeDataSource : IDataSourceAttribute + { + public bool SkipIfEmpty { get; set; } + + public IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) + => throw new NotSupportedException("Not needed for registry tests"); + } + + /// + /// Object with value-based equality to test that the registry uses reference equality. + /// + private sealed class EquatableObject : IEquatable + { + private readonly int _value; + + public EquatableObject(int value) => _value = value; + + public bool Equals(EquatableObject? other) => other is not null && _value == other._value; + public override bool Equals(object? obj) => Equals(obj as EquatableObject); + public override int GetHashCode() => _value; + } +} +#endif