Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cde8bc2
Track initialization and disposal times with OpenTelemetry activity s…
Copilot Apr 1, 2026
d8ee5eb
Address code review: rename activity, add explanatory comments
Copilot Apr 1, 2026
7657adf
Address PR review: extract disposal helper, add error recording, fix …
Copilot Apr 1, 2026
f4e70bb
Move init/disposal spans outside test case, add type names, restore T…
Copilot Apr 1, 2026
f905adf
Address latest review: fix dead code, add missing tags, set Activity.…
Copilot Apr 1, 2026
fa7d05b
Add initialization and disposal spans to HTML report execution timeline
Copilot Apr 2, 2026
d966236
Remove full name from class type in TestInitializer
thomhurst Apr 2, 2026
6f8e89b
Restore FullName ?? Name in TestInitializer.cs to fix regression
Copilot Apr 2, 2026
3c3097b
Implement scope-aware init/dispose tracing based on SharedType
Copilot Apr 2, 2026
a074b30
Address code review: extract GetScopeTag helper, fix null safety, doc…
Copilot Apr 2, 2026
c8167bd
Fix TraceScopeRegistry memory leak and redundant registration
thomhurst Apr 3, 2026
1e5d49d
Move TraceScopeRegistry registration from ClassDataSources to engine …
thomhurst Apr 3, 2026
fe8e35f
Simplify: remove dead code, fix stringly-typed tag, cache type lookups
thomhurst Apr 3, 2026
b291d2f
Address PR review: fix memory leak, add robustness, tests, and snapshots
thomhurst Apr 4, 2026
165d800
Address code review: unify class init path, fix keyed scope tag
thomhurst Apr 4, 2026
3009a26
Improve docs: fix misleading TOCTOU comment, document ITraceScopeProv…
thomhurst Apr 4, 2026
8f33ecf
Return defensive copies from GetSharedTypes() and GetKeys()
thomhurst Apr 4, 2026
1cfa90f
Use simple type names in init/dispose span labels for readability
thomhurst Apr 4, 2026
b98e7f2
Deduplicate init spans: only the first caller for a shared object cre…
thomhurst Apr 4, 2026
700733f
Simplify: extract RunWithSpanAsync helper, add tag constants, remove …
thomhurst Apr 4, 2026
4540966
Parent session/keyed init spans under assembly, not session
thomhurst Apr 4, 2026
91d2fa8
Make ITraceScopeProvider internal, revert defensive copies
thomhurst Apr 4, 2026
e10249c
Use session span duration for header when available
thomhurst Apr 4, 2026
031e748
Address review: span name constants, disposal error recording, memory…
thomhurst Apr 4, 2026
a3e504d
Fix Activity.Current comment: AsyncLocal, not ThreadStatic
thomhurst Apr 4, 2026
6e15c91
Use ConditionalWeakTable for span dedup gate, fix Activity.Current co…
thomhurst Apr 4, 2026
03354ce
Polish: readable type names, omit test ID from shared spans, add disp…
thomhurst Apr 4, 2026
643516e
Wrap GetReadableTypeNameTests in #if NET for net472 compatibility
thomhurst Apr 4, 2026
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
4 changes: 2 additions & 2 deletions TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ namespace TUnit.Core;
/// </code>
/// </example>
[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;

Expand Down Expand Up @@ -180,7 +180,7 @@ public ClassDataSourceAttribute(params Type[] types)
/// </example>
[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<T>
: DataSourceGeneratorAttribute<T>, ITraceScopeProvider
{
public SharedType Shared { get; set; } = SharedType.None;
public string Key { get; set; } = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T1, T2>
: DataSourceGeneratorAttribute<T1, T2>, ITraceScopeProvider
where T1 : new()
where T2 : new()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T1, T2, T3>
: DataSourceGeneratorAttribute<T1, T2, T3>, ITraceScopeProvider
where T1 : new()
where T2 : new()
where T3 : new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T1, T2, T3, T4>
: DataSourceGeneratorAttribute<T1, T2, T3, T4>, ITraceScopeProvider
where T1 : new()
where T2 : new()
where T3 : new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T1, T2, T3, T4, T5>
: DataSourceGeneratorAttribute<T1, T2, T3, T4, T5>, ITraceScopeProvider
where T1 : new()
where T2 : new()
where T3 : new()
Expand Down
13 changes: 13 additions & 0 deletions TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace TUnit.Core;

/// <summary>
/// Implemented by data source attributes that expose <see cref="SharedType"/> information.
/// Used by the engine to parent initialization spans under the correct activity.
/// </summary>
internal interface ITraceScopeProvider
{
/// <summary>
/// Returns the <see cref="SharedType"/> for each generated object, in parameter order.
/// </summary>
IEnumerable<SharedType> GetSharedTypes();
}
96 changes: 94 additions & 2 deletions TUnit.Core/TUnitActivitySource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>
/// 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&lt;T&gt;).
/// </summary>
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<KeyValuePair<string, object?>>? 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);
}

/// <summary>
/// Runs <paramref name="action"/> inside an OpenTelemetry span, handling
/// Activity.Current save/restore, error recording, and span stop/dispose.
/// </summary>
internal static async Task RunWithSpanAsync(
string name,
ActivityContext parentContext,
IEnumerable<KeyValuePair<string, object?>> tags,
Func<Task> 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)
Expand Down Expand Up @@ -51,6 +129,20 @@ internal static void StopActivity(Activity? activity)
activity.Stop();
activity.Dispose();
}

/// <summary>
/// Maps a <see cref="SharedType"/> 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).
/// </summary>
internal static string GetScopeTag(SharedType? sharedType) => sharedType switch
{
SharedType.PerTestSession => "session",
SharedType.PerAssembly => "assembly",
SharedType.PerClass => "class",
SharedType.Keyed => "session",
_ => "test"
};
}

#endif
64 changes: 64 additions & 0 deletions TUnit.Core/TraceScopeRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#if NET
using System.Runtime.CompilerServices;

namespace TUnit.Core;

/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// Objects are registered by the engine (TestBuilder, PropertyInjector) when data source
/// attributes implement <see cref="ITraceScopeProvider"/>. The engine reads the scope
/// during initialization to determine the parent activity for each object's trace span.
/// Uses <see cref="ConditionalWeakTable{TKey,TValue}"/> 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.
/// </remarks>
internal static class TraceScopeRegistry
{
private static readonly ConditionalWeakTable<object, StrongBox<SharedType>> Scopes = new();

/// <summary>
/// Registers trace scopes for data objects produced by a data source attribute.
/// If the attribute implements <see cref="ITraceScopeProvider"/>, each object is
/// paired with the corresponding <see cref="SharedType"/> from the provider.
/// First registration wins — subsequent calls for the same instance are no-ops.
/// </summary>
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>(sharedType));
}
}
}

/// <summary>
/// Returns the <see cref="SharedType"/> for an object, or <c>null</c> if unregistered.
/// Unregistered objects (e.g., the test class instance) default to per-test scope.
/// </summary>
internal static SharedType? GetSharedType(object obj)
{
return Scopes.TryGetValue(obj, out var box) ? box.Value : null;
}
}
#endif
3 changes: 3 additions & 0 deletions TUnit.Core/Tracking/ObjectTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
14 changes: 14 additions & 0 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ public async Task<IEnumerable<AbstractExecutableTest>> 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);
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions TUnit.Engine/Reporters/Html/ActivityCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down
14 changes: 12 additions & 2 deletions TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1476,9 +1476,19 @@ function renderSuiteTrace(className) {
return '<div class="suite-trace"><div class="tl-toggle">' + tlArrow + 'Class Timeline</div><div class="tl-content"><div class="tl-content-inner"><div class="tl-content-pad">' + renderSpanRows(filtered, 'suite-' + className) + '</div></div></div></div>';
}

// 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 '<div class="global-trace"><div class="tl-toggle">' + tlArrow + 'Execution Timeline</div><div class="tl-content"><div class="tl-content-inner"><div class="tl-content-pad">' + renderSpanRows(topSpans, 'global') + '</div></div></div></div>';
}
Expand Down
9 changes: 9 additions & 0 deletions TUnit.Engine/Reporters/Html/HtmlReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions TUnit.Engine/Services/HookExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
[
Expand Down Expand Up @@ -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,
[
Expand Down Expand Up @@ -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,
[
Expand Down
Loading
Loading