Skip to content

fix: start session activity before discovery so discovery spans parent correctly#5534

Merged
thomhurst merged 1 commit intomainfrom
fix/otel-session-activity-before-discovery
Apr 14, 2026
Merged

fix: start session activity before discovery so discovery spans parent correctly#5534
thomhurst merged 1 commit intomainfrom
fix/otel-session-activity-before-discovery

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • Fixes orphaned "test discovery" OpenTelemetry span reported in [Bug]: OpenTelemetry - Missing root span #5244
  • Extracts session activity creation into an idempotent TryStartSessionActivity() method on HookExecutor
  • Calls it after Before(TestDiscovery) hooks (for execution requests) so users who set up their TracerProvider in Before(TestDiscovery) get a single unified trace with discovery properly nested under the session

Root cause

The "test discovery" span tried to parent under the session activity, but the session activity didn't exist yet — it was only created after Before(TestSession) hooks, which run after discovery. So sessionActivity?.Context ?? default always evaluated to default, producing an orphaned root span.

How it works

TryStartSessionActivity() is called from two sites:

  1. After Before(TestDiscovery) hooks — catches users who set up their TracerProvider early
  2. After Before(TestSession) hooks — catches users who set up their TracerProvider in the traditional location

The method is idempotent: it checks sessionContext.Activity is not null and HasListeners() before creating anything, so whichever call site runs first with an active listener wins.

Files changed

File Change
TUnit.Engine/Services/HookExecutor.cs Extract TryStartSessionActivity(), replace inline creation
TUnit.Engine/TestExecutor.cs Thin pass-through to HookExecutor
TUnit.Engine/TestDiscoveryService.cs Call TryStartSessionActivity() after discovery hooks
TUnit.UnitTests/SessionActivityLifecycleTests.cs 10 new tests for activity lifecycle

Test plan

  • All 177 unit tests pass (TUnit.UnitTests)
  • All 13 ASP.NET Core OTel correlation tests pass (TUnit.AspNetCore.Tests)
  • Builds on both net8.0 and net10.0
  • Manual verification with Jaeger/Grafana LGTM: user sets up TracerProvider in Before(TestDiscovery) and sees a single unified trace

Closes #5244

…t correctly (#5244)

The "test discovery" span was orphaned because the session activity didn't
exist yet during discovery — it was only created after Before(TestSession)
hooks, which run later. Extract session activity creation into an idempotent
TryStartSessionActivity() and call it after Before(TestDiscovery) hooks
(for execution requests) so users who set up their TracerProvider early
get a single unified trace.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Overall this is a well-targeted, clean fix for the orphaned discovery span issue. The root cause analysis in the PR description is accurate, the idempotency approach is solid, and the test suite is thorough. A few observations:


Architecture / Design

The two-phase idempotency pattern is the right call here.
Calling TryStartSessionActivity() from two sequential sites (after Before(TestDiscovery) and after Before(TestSession)) with an early-return guard is a simple, low-risk design. It doesn't require the caller to know which phase the listener was registered in—correct.

Thread-safety: low risk in practice, but worth noting.
TryStartSessionActivity has a classic TOCTOU shape:

if (sessionContext.Activity is not null) return;   // check
// ... gap ...
sessionContext.Activity = TUnitActivitySource.StartActivity(...); // act

In practice, both call sites are sequential within the TUnit session lifecycle so a real race is unlikely today. But if the method is ever called from parallel hook execution or a concurrent path in the future, two threads could both pass the null check and create duplicate activities (with the loser's activity leaking). A one-liner lock or Interlocked.CompareExchange would make the intent explicit and guard against future callers:

// Safer — makes no-double-create intent explicit
lock (_activityLock)
{
    if (sessionContext.Activity is not null) return;
    if (TUnitActivitySource.Source.HasListeners())
        sessionContext.Activity = TUnitActivitySource.StartActivity(...);
}

Not blocking the PR on this, but worth considering given how easy it is to protect.

isForExecution guard in TestDiscoveryService.
Correct — skipping activity creation during pure discovery (IDE listing, --list-tests) avoids creating a dangling session span that would never be finished. Good defensive coding.


Test File: SessionActivityLifecycleTests.cs

Good: The ActivityListenerScope RAII helper is clean, and the stubs are minimal (only implement what TryStartSessionActivity actually touches). The hierarchy tests in FullSpanHierarchy_SessionParentsAllChildren and DiscoveryAndAssembly_ShareSameTrace directly validate the fix for #5244.

Missing negative test — the silent failure mode.
There's no test for the case where HasListeners() is false when TryStartSessionActivity is called. The important failure mode here is: user sets up their TracerProvider after discovery hooks, TryStartSessionActivity runs, HasListeners() is false, activity stays null, and the discovery span silently orphans (same bug as before, different trigger). A test that calls TryStartSessionActivity without an ActivityListenerScope and asserts sessionContext.Activity is null would make this contract explicit. However, the comment in the test file warns that TUnit's HTML reporter may keep HasListeners() == true at all times during test execution, which would make that test unreliable. If that's the case, the comment should be promoted to a more prominent warning.

null! in test setup is a code smell.

var executor = new HookExecutor(hookDelegateBuilder, contextProvider, null!);

This works today because TryStartSessionActivity doesn't touch the third argument, but it's a maintenance trap — anyone who later adds a call to the orchestrator inside that method gets a NullReferenceException at runtime in a test rather than a compile-time signal. Consider extracting a StubEventReceiverOrchestrator (even if it only throw new NotSupportedException() like the other stubs), or adding an XML doc note on the constructor explaining which parameters are safe to null in tests.

Comment on HasListeners assumption.

// TUnit's own HTML reporter listener is active during test execution, so HasListeners() is always true here.

This assumption should be surfaced more prominently — ideally as a class-level doc comment — because it means these tests can never reliably test the "no listeners" path. It's not wrong, just worth calling out so future maintainers don't add a test for that path and wonder why it doesn't work.


Minor

  • The #if NET guards are consistent with the rest of the file — good.
  • The /// \<inheritdoc cref="HookExecutor.TryStartSessionActivity"/\> on the TestExecutor pass-through is a nice touch for discoverability.
  • The XML doc on TryStartSessionActivity accurately describes the contract.

Summary: Fix is correct and well-tested. The main actionable items are (in priority order):

  1. Add a StubEventReceiverOrchestrator or doc comment to remove the null! fragility.
  2. Consider a lock or note for thread safety (low urgency, high future-proofing value).
  3. Clarify the HasListeners test assumption at the class level.

@codacy-production
Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 20 complexity

Metric Results
Complexity 20

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

@thomhurst thomhurst merged commit 30b478f into main Apr 14, 2026
15 checks passed
@thomhurst thomhurst deleted the fix/otel-session-activity-before-discovery branch April 14, 2026 19:26
@claude claude bot mentioned this pull request Apr 15, 2026
1 task
This was referenced Apr 15, 2026
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 17, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.33.0 to
1.35.2.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.35.2

<!-- Release notes generated using configuration in .github/release.yml
at v1.35.2 -->

## What's Changed
### Other Changes
* fix: restore SourceLink and deterministic builds in published packages
by @​thomhurst in thomhurst/TUnit#5579
### Dependencies
* chore(deps): update tunit to 1.35.0 by @​thomhurst in
thomhurst/TUnit#5578


**Full Changelog**:
thomhurst/TUnit@v1.35.0...v1.35.2

## 1.35.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.35.0 -->

## What's Changed
### Other Changes
* fix: support open generic transitive auto-mocks by @​thomhurst in
thomhurst/TUnit#5568
* refactor: separate test and lifecycle tracing by @​thomhurst in
thomhurst/TUnit#5572
* fix: expand nested And/Or expectations in failure messages (#​5573) by
@​thomhurst in thomhurst/TUnit#5577
### Dependencies
* chore(deps): update tunit to 1.34.5 by @​thomhurst in
thomhurst/TUnit#5566
* chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5538
* chore(deps): update verify to 31.16.0 by @​thomhurst in
thomhurst/TUnit#5570
* chore(deps): update verify to 31.16.1 by @​thomhurst in
thomhurst/TUnit#5574
* chore(deps): update gittools/actions action to v4 by @​thomhurst in
thomhurst/TUnit#5575


**Full Changelog**:
thomhurst/TUnit@v1.34.5...v1.35.0

## 1.34.5

<!-- Release notes generated using configuration in .github/release.yml
at v1.34.5 -->

## What's Changed
### Other Changes
* fix: cap test output at 1M chars to prevent OOM by @​thomhurst in
thomhurst/TUnit#5561
* fix: handle explicit interface impl with different return types in
mock generator by @​thomhurst in
thomhurst/TUnit#5564
* fix: include XML documentation files in NuGet packages by @​thomhurst
in thomhurst/TUnit#5565
### Dependencies
* chore(deps): update tunit to 1.34.0 by @​thomhurst in
thomhurst/TUnit#5562


**Full Changelog**:
thomhurst/TUnit@v1.34.0...v1.34.5

## 1.34.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.34.0 -->

## What's Changed
### Other Changes
* refactor: move CorrelatedTUnitLogger to TUnit.Logging.Microsoft and
auto-inject handlers by @​thomhurst in
thomhurst/TUnit#5532
* feat: add Dev Drive setup for Windows in CI workflow by @​thomhurst in
thomhurst/TUnit#5544
* fix: start session activity before discovery so discovery spans parent
correctly by @​thomhurst in thomhurst/TUnit#5534
* feat: cross-process test log correlation via OTLP receiver by
@​thomhurst in thomhurst/TUnit#5533
* refactor: use natural OTEL trace propagation instead of synthetic
TraceIds by @​thomhurst in thomhurst/TUnit#5557
* fix: route ITestOutput writes through synchronized
ConcurrentStringWriter by @​thomhurst in
thomhurst/TUnit#5558
### Dependencies
* chore(deps): update tunit to 1.33.0 by @​thomhurst in
thomhurst/TUnit#5527
* chore(deps): update dependency dompurify to v3.4.0 by @​thomhurst in
thomhurst/TUnit#5537
* chore(deps): update dependency docusaurus-plugin-llms to ^0.3.1 by
@​thomhurst in thomhurst/TUnit#5541
* chore(deps): update dependency microsoft.sourcelink.github to 10.0.202
by @​thomhurst in thomhurst/TUnit#5543
* chore(deps): update dependency microsoft.entityframeworkcore to 10.0.6
by @​thomhurst in thomhurst/TUnit#5542
* chore(deps): update dependency
microsoft.templateengine.authoring.templateverifier to 10.0.202 by
@​thomhurst in thomhurst/TUnit#5546
* chore(deps): update dependency microsoft.templateengine.authoring.cli
to v10.0.202 by @​thomhurst in
thomhurst/TUnit#5545
* chore(deps): update dependency system.commandline to 2.0.6 by
@​thomhurst in thomhurst/TUnit#5547
* chore(deps): update microsoft.aspnetcore to 10.0.6 by @​thomhurst in
thomhurst/TUnit#5548
* chore(deps): update dependency nuget.protocol to 7.3.1 by @​thomhurst
in thomhurst/TUnit#5549
* chore(deps): update microsoft.extensions to 10.0.6 by @​thomhurst in
thomhurst/TUnit#5550
* chore(deps): update dependency dotnet-sdk to v10.0.202 by @​thomhurst
in thomhurst/TUnit#5551
* chore(deps): update opentelemetry by @​thomhurst in
thomhurst/TUnit#5552
* chore(deps): update microsoft.extensions to 10.5.0 by @​thomhurst in
thomhurst/TUnit#5554


**Full Changelog**:
thomhurst/TUnit@v1.33.0...v1.34.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.33.0...v1.35.2).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.33.0&new-version=1.35.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.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.

[Bug]: OpenTelemetry - Missing root span

1 participant