Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions TUnit.Core/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ internal Context(Context? parent)
}

#if NET
internal System.Diagnostics.Activity? Activity { get; set; }
internal ExecutionContext? ExecutionContext { get; private set; }
#endif

Expand Down Expand Up @@ -133,6 +134,8 @@ public DefaultLogger GetDefaultLogger()
public void Dispose()
{
#if NET
TUnitActivitySource.StopActivity(Activity);
Activity = null;
ExecutionContext?.Dispose();
#endif
_outputLock?.Dispose();
Expand Down
56 changes: 56 additions & 0 deletions TUnit.Core/TUnitActivitySource.cs
Original file line number Diff line number Diff line change
@@ -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<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);
}

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
141 changes: 141 additions & 0 deletions TUnit.Engine/Services/HookExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -68,6 +84,9 @@ public async ValueTask<List<Exception>> ExecuteAfterTestSessionHooksAsync(Cancel

if (hooks.Count == 0)
{
#if NET
FinishSessionActivity(hasErrors: false);
#endif
return [];
}

Expand All @@ -90,11 +109,54 @@ public async ValueTask<List<Exception>> 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)
Expand Down Expand Up @@ -133,6 +195,9 @@ public async ValueTask<List<Exception>> ExecuteAfterAssemblyHooksAsync(Assembly

if (hooks.Count == 0)
{
#if NET
FinishAssemblyActivity(assembly, hasErrors: false);
#endif
return [];
}

Expand All @@ -156,13 +221,57 @@ public async ValueTask<List<Exception>> 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)
Expand Down Expand Up @@ -203,6 +312,9 @@ public async ValueTask<List<Exception>> ExecuteAfterClassHooksAsync(

if (hooks.Count == 0)
{
#if NET
FinishClassActivity(testClass, hasErrors: false);
#endif
return [];
}

Expand All @@ -226,9 +338,38 @@ public async ValueTask<List<Exception>> 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;
Expand Down
13 changes: 13 additions & 0 deletions TUnit.Engine/Services/TestExecution/RetryHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func<Task> 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;
Expand Down
70 changes: 70 additions & 0 deletions TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -189,6 +211,10 @@ await Timings.Record("AfterTest", executableTest.Context, (Func<Task>)(async ()
{
hookException = new TestExecutionException(null, hookExceptions, eventReceiverExceptions);
}

#if NET
FinishTestActivity(executableTest, capturedException);
#endif
}

if (capturedException is SkipTestException)
Expand Down Expand Up @@ -216,6 +242,50 @@ await Timings.Record("AfterTest", executableTest.Context, (Func<Task>)(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
Expand Down
Loading
Loading