Skip to content

Commit 16ce2e0

Browse files
thomhurstclaude
andauthored
+semver:minor - feat: add Activity tracing for OpenTelemetry support (thomhurst#4844)
* feat: add System.Diagnostics.Activity tracing for OpenTelemetry-compatible instrumentation Add first-party trace spans at every level of the test lifecycle (session, assembly, class, test) using System.Diagnostics.ActivitySource. Users get OpenTelemetry-compatible tracing automatically when they configure an exporter. Zero cost when no listener is attached. - Add TUnitActivitySource singleton with StartActivity/RecordException/StopActivity helpers - Add Activity property to Context base class (inherited by all context types) - Start/stop session, assembly, class spans in HookExecutor - Start/stop test spans in TestExecutor with result/skip/retry tags - Stop failed attempt Activity on retry in RetryHelper - All Activity code behind #if NET (ActivitySource requires .NET 5+) - Explicit ActivityContext parenting for parallel safety Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: align activity tracing with OpenTelemetry semantic conventions - Span names: use verb+object pattern (test session, test assembly, test suite, test case) - Use OTel test.* namespace: test.case.name, test.case.result.status, test.suite.name - Result values: lowercase per OTel (pass/fail/skipped instead of Passed/Failed/Skipped) - Status codes: leave Unset on success (not Ok) per instrumentation library conventions - Add error.type tag alongside exception events per OTel recording-errors spec - Categories: pass as string[] array instead of comma-joined string - Remove redundant HasListeners() guard (StartActivity returns null natively) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add OpenTelemetry tracing integration guide Covers ActivitySource subscription, span hierarchy, attribute reference tables, status conventions, retry behavior, and exporter setup examples. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * perf: guard StartActivity call sites with HasListeners() check Wrap all 4 StartActivity call sites with TUnitActivitySource.Source.HasListeners() so that tag collection expressions, string.Join, and ToArray allocations are never evaluated when no listener is attached. This ensures true zero-cost when tracing is disabled, per CLAUDE.md Rule 4 (Performance First). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b148ff2 commit 16ce2e0

7 files changed

Lines changed: 403 additions & 0 deletions

File tree

TUnit.Core/Context.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ internal Context(Context? parent)
5858
}
5959

6060
#if NET
61+
internal System.Diagnostics.Activity? Activity { get; set; }
6162
internal ExecutionContext? ExecutionContext { get; private set; }
6263
#endif
6364

@@ -133,6 +134,8 @@ public DefaultLogger GetDefaultLogger()
133134
public void Dispose()
134135
{
135136
#if NET
137+
TUnitActivitySource.StopActivity(Activity);
138+
Activity = null;
136139
ExecutionContext?.Dispose();
137140
#endif
138141
_outputLock?.Dispose();

TUnit.Core/TUnitActivitySource.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#if NET
2+
3+
using System.Diagnostics;
4+
5+
namespace TUnit.Core;
6+
7+
internal static class TUnitActivitySource
8+
{
9+
private static readonly string Version =
10+
typeof(TUnitActivitySource).Assembly.GetName().Version?.ToString() ?? "0.0.0";
11+
12+
internal static readonly ActivitySource Source = new("TUnit", Version);
13+
14+
internal static Activity? StartActivity(
15+
string name,
16+
ActivityKind kind = ActivityKind.Internal,
17+
ActivityContext parentContext = default,
18+
IEnumerable<KeyValuePair<string, object?>>? tags = null)
19+
{
20+
// StartActivity returns null when no listener is sampling this source,
21+
// so the HasListeners() check is implicit. We rely on the framework behavior.
22+
return Source.StartActivity(name, kind, parentContext, tags);
23+
}
24+
25+
internal static void RecordException(Activity? activity, Exception exception)
26+
{
27+
if (activity is null)
28+
{
29+
return;
30+
}
31+
32+
var tagsCollection = new ActivityTagsCollection
33+
{
34+
{ "exception.type", exception.GetType().FullName },
35+
{ "exception.message", exception.Message },
36+
{ "exception.stacktrace", exception.ToString() }
37+
};
38+
39+
activity.AddEvent(new ActivityEvent("exception", tags: tagsCollection));
40+
activity.SetTag("error.type", exception.GetType().FullName);
41+
activity.SetStatus(ActivityStatusCode.Error, exception.Message);
42+
}
43+
44+
internal static void StopActivity(Activity? activity)
45+
{
46+
if (activity is null)
47+
{
48+
return;
49+
}
50+
51+
activity.Stop();
52+
activity.Dispose();
53+
}
54+
}
55+
56+
#endif

TUnit.Engine/Services/HookExecutor.cs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ public HookExecutor(
3131

3232
public async ValueTask ExecuteBeforeTestSessionHooksAsync(CancellationToken cancellationToken)
3333
{
34+
var sessionContext = _contextProvider.TestSessionContext;
35+
36+
#if NET
37+
if (TUnitActivitySource.Source.HasListeners())
38+
{
39+
sessionContext.Activity = TUnitActivitySource.StartActivity(
40+
"test session",
41+
System.Diagnostics.ActivityKind.Internal,
42+
default,
43+
[
44+
new("tunit.session.id", sessionContext.Id),
45+
new("tunit.filter", sessionContext.TestFilter)
46+
]);
47+
}
48+
#endif
49+
3450
var hooks = await _hookCollectionService.CollectBeforeTestSessionHooksAsync().ConfigureAwait(false);
3551

3652
if (hooks.Count == 0)
@@ -68,6 +84,9 @@ public async ValueTask<List<Exception>> ExecuteAfterTestSessionHooksAsync(Cancel
6884

6985
if (hooks.Count == 0)
7086
{
87+
#if NET
88+
FinishSessionActivity(hasErrors: false);
89+
#endif
7190
return [];
7291
}
7392

@@ -90,11 +109,54 @@ public async ValueTask<List<Exception>> ExecuteAfterTestSessionHooksAsync(Cancel
90109
}
91110
}
92111

112+
#if NET
113+
FinishSessionActivity(hasErrors: exceptions is { Count: > 0 });
114+
#endif
115+
93116
return exceptions ?? [];
94117
}
95118

119+
#if NET
120+
private void FinishSessionActivity(bool hasErrors)
121+
{
122+
var sessionContext = _contextProvider.TestSessionContext;
123+
var activity = sessionContext.Activity;
124+
125+
if (activity is null)
126+
{
127+
return;
128+
}
129+
130+
activity.SetTag("tunit.test.count", sessionContext.AllTests.Count);
131+
132+
if (hasErrors)
133+
{
134+
activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error);
135+
}
136+
137+
TUnitActivitySource.StopActivity(activity);
138+
sessionContext.Activity = null;
139+
}
140+
#endif
141+
96142
public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken)
97143
{
144+
var assemblyContext = _contextProvider.GetOrCreateAssemblyContext(assembly);
145+
146+
#if NET
147+
if (TUnitActivitySource.Source.HasListeners())
148+
{
149+
var sessionActivity = _contextProvider.TestSessionContext.Activity;
150+
assemblyContext.Activity = TUnitActivitySource.StartActivity(
151+
"test assembly",
152+
System.Diagnostics.ActivityKind.Internal,
153+
sessionActivity?.Context ?? default,
154+
[
155+
new("tunit.assembly.name", assembly.GetName().Name)
156+
]);
157+
}
158+
#endif
159+
98160
var hooks = await _hookCollectionService.CollectBeforeAssemblyHooksAsync(assembly).ConfigureAwait(false);
99161

100162
if (hooks.Count == 0)
@@ -133,6 +195,9 @@ public async ValueTask<List<Exception>> ExecuteAfterAssemblyHooksAsync(Assembly
133195

134196
if (hooks.Count == 0)
135197
{
198+
#if NET
199+
FinishAssemblyActivity(assembly, hasErrors: false);
200+
#endif
136201
return [];
137202
}
138203

@@ -156,13 +221,57 @@ public async ValueTask<List<Exception>> ExecuteAfterAssemblyHooksAsync(Assembly
156221
}
157222
}
158223

224+
#if NET
225+
FinishAssemblyActivity(assembly, hasErrors: exceptions is { Count: > 0 });
226+
#endif
227+
159228
return exceptions ?? [];
160229
}
161230

231+
#if NET
232+
private void FinishAssemblyActivity(Assembly assembly, bool hasErrors)
233+
{
234+
var assemblyContext = _contextProvider.GetOrCreateAssemblyContext(assembly);
235+
var activity = assemblyContext.Activity;
236+
237+
if (activity is null)
238+
{
239+
return;
240+
}
241+
242+
activity.SetTag("tunit.test.count", assemblyContext.TestCount);
243+
244+
if (hasErrors)
245+
{
246+
activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error);
247+
}
248+
249+
TUnitActivitySource.StopActivity(activity);
250+
assemblyContext.Activity = null;
251+
}
252+
#endif
253+
162254
public async ValueTask ExecuteBeforeClassHooksAsync(
163255
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
164256
Type testClass, CancellationToken cancellationToken)
165257
{
258+
var classContext = _contextProvider.GetOrCreateClassContext(testClass);
259+
260+
#if NET
261+
if (TUnitActivitySource.Source.HasListeners())
262+
{
263+
var assemblyActivity = classContext.AssemblyContext.Activity;
264+
classContext.Activity = TUnitActivitySource.StartActivity(
265+
"test suite",
266+
System.Diagnostics.ActivityKind.Internal,
267+
assemblyActivity?.Context ?? default,
268+
[
269+
new("test.suite.name", testClass.Name),
270+
new("tunit.class.namespace", testClass.Namespace)
271+
]);
272+
}
273+
#endif
274+
166275
var hooks = await _hookCollectionService.CollectBeforeClassHooksAsync(testClass).ConfigureAwait(false);
167276

168277
if (hooks.Count == 0)
@@ -203,6 +312,9 @@ public async ValueTask<List<Exception>> ExecuteAfterClassHooksAsync(
203312

204313
if (hooks.Count == 0)
205314
{
315+
#if NET
316+
FinishClassActivity(testClass, hasErrors: false);
317+
#endif
206318
return [];
207319
}
208320

@@ -226,9 +338,38 @@ public async ValueTask<List<Exception>> ExecuteAfterClassHooksAsync(
226338
}
227339
}
228340

341+
#if NET
342+
FinishClassActivity(testClass, hasErrors: exceptions is { Count: > 0 });
343+
#endif
344+
229345
return exceptions ?? [];
230346
}
231347

348+
#if NET
349+
private void FinishClassActivity(
350+
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
351+
Type testClass, bool hasErrors)
352+
{
353+
var classContext = _contextProvider.GetOrCreateClassContext(testClass);
354+
var activity = classContext.Activity;
355+
356+
if (activity is null)
357+
{
358+
return;
359+
}
360+
361+
activity.SetTag("tunit.test.count", classContext.TestCount);
362+
363+
if (hasErrors)
364+
{
365+
activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error);
366+
}
367+
368+
TUnitActivitySource.StopActivity(activity);
369+
classContext.Activity = null;
370+
}
371+
#endif
372+
232373
public async ValueTask ExecuteBeforeTestHooksAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
233374
{
234375
var testClassType = test.Metadata.TestClassType;

TUnit.Engine/Services/TestExecution/RetryHelper.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func<Task> ac
2626

2727
if (await ShouldRetry(testContext, ex, attempt))
2828
{
29+
#if NET
30+
// Stop the failed attempt's activity before retrying
31+
var activity = testContext.Activity;
32+
if (activity is not null)
33+
{
34+
activity.SetTag("test.case.result.status", "fail");
35+
activity.SetTag("tunit.test.retry_attempt", attempt);
36+
TUnitActivitySource.RecordException(activity, ex);
37+
TUnitActivitySource.StopActivity(activity);
38+
testContext.Activity = null;
39+
}
40+
#endif
41+
2942
// Clear the previous result before retrying
3043
testContext.Execution.Result = null;
3144
testContext.TestStart = null;

TUnit.Engine/TestExecutor.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
using TUnit.Core.Services;
99
using TUnit.Engine.Helpers;
1010
using TUnit.Engine.Services;
11+
#if NET
12+
using System.Diagnostics;
13+
#endif
1114

1215
namespace TUnit.Engine;
1316

@@ -113,6 +116,25 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
113116

114117
executableTest.Context.ClassContext.RestoreExecutionContext();
115118

119+
#if NET
120+
if (TUnitActivitySource.Source.HasListeners())
121+
{
122+
var classActivity = executableTest.Context.ClassContext.Activity;
123+
var testDetails = executableTest.Context.Metadata.TestDetails;
124+
executableTest.Context.Activity = TUnitActivitySource.StartActivity(
125+
"test case",
126+
ActivityKind.Internal,
127+
classActivity?.Context ?? default,
128+
[
129+
new("test.case.name", testDetails.TestName),
130+
new("tunit.test.class", testDetails.ClassType.FullName),
131+
new("tunit.test.method", testDetails.MethodName),
132+
new("tunit.test.id", executableTest.Context.Id),
133+
new("tunit.test.categories", testDetails.Categories.ToArray())
134+
]);
135+
}
136+
#endif
137+
116138
// Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks
117139
// This ensures resources like Docker containers are not started until needed
118140
await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false);
@@ -189,6 +211,10 @@ await Timings.Record("AfterTest", executableTest.Context, (Func<Task>)(async ()
189211
{
190212
hookException = new TestExecutionException(null, hookExceptions, eventReceiverExceptions);
191213
}
214+
215+
#if NET
216+
FinishTestActivity(executableTest, capturedException);
217+
#endif
192218
}
193219

194220
if (capturedException is SkipTestException)
@@ -216,6 +242,50 @@ await Timings.Record("AfterTest", executableTest.Context, (Func<Task>)(async ()
216242
}
217243
}
218244

245+
#if NET
246+
private static void FinishTestActivity(AbstractExecutableTest executableTest, Exception? capturedException)
247+
{
248+
var activity = executableTest.Context.Activity;
249+
250+
if (activity is null)
251+
{
252+
return;
253+
}
254+
255+
var result = executableTest.Context.Execution.Result;
256+
257+
// Use OTel test semantic convention values: pass, fail, skipped
258+
var statusValue = result?.State switch
259+
{
260+
TestState.Passed => "pass",
261+
TestState.Failed => "fail",
262+
TestState.Skipped => "skipped",
263+
_ => "unknown"
264+
};
265+
activity.SetTag("test.case.result.status", statusValue);
266+
267+
if (executableTest.Context.CurrentRetryAttempt > 0)
268+
{
269+
activity.SetTag("tunit.test.retry_attempt", executableTest.Context.CurrentRetryAttempt);
270+
}
271+
272+
if (capturedException is SkipTestException skipEx)
273+
{
274+
// Skipped tests are not errors — leave status as Unset
275+
activity.SetTag("tunit.test.skip_reason", skipEx.Reason);
276+
}
277+
else if (capturedException is not null)
278+
{
279+
// RecordException sets Error status and error.type tag
280+
TUnitActivitySource.RecordException(activity, capturedException);
281+
}
282+
// Success: leave status as Unset per OTel instrumentation library conventions
283+
284+
TUnitActivitySource.StopActivity(activity);
285+
executableTest.Context.Activity = null;
286+
}
287+
#endif
288+
219289
private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executableTest, CancellationToken cancellationToken)
220290
{
221291
// Skip the actual test invocation for skipped tests

0 commit comments

Comments
 (0)