Skip to content

feat: add Activity tracing for OpenTelemetry support#4844

Merged
thomhurst merged 4 commits intomainfrom
feat/activity-tracing
Feb 18, 2026
Merged

feat: add Activity tracing for OpenTelemetry support#4844
thomhurst merged 4 commits intomainfrom
feat/activity-tracing

Conversation

@thomhurst
Copy link
Owner

Summary

  • Adds first-party System.Diagnostics.Activity trace spans at every level of the test lifecycle (session, assembly, class, test) for OpenTelemetry-compatible distributed tracing
  • Zero cost when no ActivityListener is attached — ActivitySource.HasListeners() short-circuits all instrumentation
  • All Activity code gated behind #if NET (ActivitySource requires .NET 5+; TUnit targets netstandard2.0)

What's included

Span Parent Key Tags
tunit.session (root) tunit.session.id, tunit.filter
tunit.assembly session tunit.assembly.name
tunit.class assembly tunit.class.name, tunit.class.namespace
tunit.test class tunit.test.name, tunit.test.class, tunit.test.method, tunit.test.id, tunit.test.categories
  • Test spans include result status (Passed/Failed/Skipped), retry attempt, skip reason
  • Failed tests record OTel-standard exception events (exception.type, exception.message, exception.stacktrace)
  • Retry attempts properly stop the failed span before creating a fresh one
  • Uses explicit ActivityContext parenting (not ambient Activity.Current) for parallel safety

Files changed

File Type Description
TUnit.Core/TUnitActivitySource.cs NEW ActivitySource singleton + helpers
TUnit.Core/Context.cs MODIFY Activity? property + disposal on base class
TUnit.Engine/Services/HookExecutor.cs MODIFY Start/stop session, assembly, class spans
TUnit.Engine/TestExecutor.cs MODIFY Start/stop test spans with result tags
TUnit.Engine/Services/TestExecution/RetryHelper.cs MODIFY Stop Activity on retry

Test plan

  • dotnet build TUnit.Core — succeeds on all TFMs (netstandard2.0, net8.0, net9.0, net10.0)
  • dotnet build TUnit.Engine — succeeds on all TFMs
  • dotnet test --project TUnit.UnitTests — all 556 tests pass (no behavioral change when no listener attached)
  • Manual: configure OTel console exporter, subscribe to "TUnit" source, verify span hierarchy in output

🤖 Generated with Claude Code

@claude
Copy link
Contributor

claude bot commented Feb 18, 2026

Code Review

I found a performance issue that violates CLAUDE.md requirements.


⚠️ Performance Issue: Hot-path Allocations Bypass HasListeners() Check

Location: TUnit.Engine/TestExecutor.cs, TUnit.Engine/Services/HookExecutor.cs

Issue: The current implementation allocates collection expressions and evaluates string.Join() before the HasListeners() check can short-circuit, violating CLAUDE.md Rule 4: "Performance First - Minimize allocations in hot paths."

Problem

In C#, method arguments are evaluated before method invocation. This means:

executableTest.Context.Activity = TUnitActivitySource.StartActivity(
    "tunit.test",
    ActivityKind.Internal,
    classActivity?.Context ?? default,
    [  // ← Collection expression evaluated FIRST
        new("tunit.test.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", string.Join(",", testDetails.Categories))
    ]);

Execution order:

  1. ✗ 5 KeyValuePair objects created
  2. string.Join() allocates string
  3. ✗ Collection materialized into array/span
  4. StartActivity() called → checks HasListeners() → returns null
  5. Result: Allocations happened but Activity was never created

This happens on every test execution (hot path), even when tracing is disabled. The PR description claims "zero-cost when no ActivityListener is attached", but this is false.

Architectural Solution

Recommended: Guard at call site (simple, matches PR intent)

#if NET
if (TUnitActivitySource.Source.HasListeners())
{
    executableTest.Context.Activity = TUnitActivitySource.StartActivity(
        "tunit.test",
        ActivityKind.Internal,
        classActivity?.Context ?? default,
        [
            new("tunit.test.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", string.Join(",", testDetails.Categories))
        ]);
}
#endif

Why this approach is better:

  • True zero-cost when disabled: Guard short-circuits before any allocations
  • Simple: Minimal change to existing code
  • Consistent: Apply same pattern to session/assembly/class/test spans
  • Clear intent: Explicitly documents the performance optimization
  • Scalable: A project with 10,000 tests avoids 50,000+ unnecessary allocations

Alternative Approach

Lazy tag builder (more complex, better encapsulation)
Create a ref struct ActivityTagBuilder that only materializes tags if HasListeners() returns true. More invasive but cleaner separation of concerns. This would be over-engineering for this use case.

Why This Matters

Test execution is explicitly defined as a hot path in .claude/docs/mandatory-rules.md (lines 68-70). Even small allocations per test compound across thousands of tests. The current implementation defeats the intended zero-cost optimization.

Apply this fix to all StartActivity() call sites:

  • #if NET
    var classActivity = executableTest.Context.ClassContext.Activity;
    var testDetails = executableTest.Context.Metadata.TestDetails;
    executableTest.Context.Activity = TUnitActivitySource.StartActivity(
    "tunit.test",
    ActivityKind.Internal,
    classActivity?.Context ?? default,
    [
    new("tunit.test.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", string.Join(",", testDetails.Categories))
    ]);
  • var sessionContext = _contextProvider.TestSessionContext;
    #if NET
    sessionContext.Activity = TUnitActivitySource.StartActivity(
    "tunit.session",
    System.Diagnostics.ActivityKind.Internal,
    default,
    [
    new("tunit.session.id", sessionContext.Id),
    new("tunit.filter", sessionContext.TestFilter)
    ]);
  • }
    catch (Exception ex)
    {
    if (ex is SkipTestException)
    {
    throw;
    }
  • // Defer exception list allocation until actually needed
    List<Exception>? exceptions = null;
    foreach (var hook in hooks)
    {
    try
    {

CLAUDE.md Reference: Performance First - Minimize allocations in hot paths

thomhurst and others added 4 commits February 18, 2026 18:19
…tible 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>
- 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>
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments