Skip to content

refactor(opentelemetry): depend on TUnit.Core instead of umbrella TUnit#5774

Merged
thomhurst merged 2 commits intomainfrom
otel-depend-on-core
Apr 27, 2026
Merged

refactor(opentelemetry): depend on TUnit.Core instead of umbrella TUnit#5774
thomhurst merged 2 commits intomainfrom
otel-depend-on-core

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • TUnit.OpenTelemetry now references TUnit.Core directly instead of the umbrella TUnit package, dropping the transitive TUnit.Engine + TUnit.Assertions + Microsoft.Testing.Extensions footprint from its dependency chain.
  • Cross-assembly contract for external span ingestion is now expressed via a small ExternalSpanSink hook in TUnit.Core (dependency inversion). ActivityCollector (Engine) registers itself as the sink at session start; OtlpReceiver (OpenTelemetry) pushes through the sink without taking a hard dependency on Engine.
  • Moved 4 internal POCOs (SpanData, SpanEvent, SpanLink, ReportKeyValue) from TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs to a new TUnit.Core/SpanData.cs so both ends of the contract share the same shape.

Test plan

  • TUnit.Engine builds clean (all TFMs).
  • TUnit.OpenTelemetry builds clean against TUnit.Core only.
  • TUnit.OpenTelemetry.Tests 30/30 pass.
  • TUnit.Aspire.Tests 92/92 pass.

Inverts the cross-assembly contract for external span ingestion so
TUnit.OpenTelemetry no longer pulls in TUnit.Engine + TUnit.Assertions
+ Microsoft.Testing.Extensions transitively.

- Move SpanData/SpanEvent/SpanLink/ReportKeyValue POCOs to TUnit.Core.
- Add ExternalSpanSink hook in TUnit.Core (Action<SpanData>? slot,
  Interlocked.CompareExchange register/unregister, first-wins).
- ActivityCollector registers IngestExternalSpan as the sink during
  Start/Stop. OtlpReceiver pushes through the sink instead of calling
  ActivityCollector.Current directly.
- Drop dead InternalsVisibleTo for TUnit.OpenTelemetry from
  TUnit.Engine.csproj.
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 27, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

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

This is a well-motivated refactor. Inverting the dependency so TUnit.OpenTelemetry references TUnit.Core instead of the umbrella package is the right architectural direction — it removes a transitive blob of Engine + Assertions from the OpenTelemetry package's footprint, which is the correct application of the Dependency Inversion Principle here.

A few observations:


1. ExternalSpanSink.Current is not a volatile read

Interlocked.CompareExchange provides a full memory barrier at write-time, but reading _sink via the plain property Current does not get that guarantee:

public static Action<SpanData>? Current => _sink;  // plain read

On ARM or with aggressive JIT reordering, a thread calling ProcessTraces could see a stale (cached) null even after Register has already stored a non-null delegate. The fix is trivially cheap:

public static Action<SpanData>? Current => Volatile.Read(ref _sink);

ActivityCollector._current has the same non-volatile read pattern — both should be consistent.


2. ExternalSpanSink is internal but lives in TUnit.Core — that boundary is leaky

Both ExternalSpanSink and the four data types (SpanData, SpanEvent, SpanLink, ReportKeyValue) are internal and bridged via InternalsVisibleTo. That works, but it exposes TUnit.Core's InternalsVisibleTo list to what are essentially engine-level concerns. Any future third-party package that wants to do the same thing cannot.

A forward-looking alternative is to make the two small files — ExternalSpanSink and the data model — public but [clearly documented as infrastructure-level]. The data classes are pure POCOs with no behaviour; making them public has negligible risk and removes the InternalsVisibleTo coupling. The ExternalSpanSink static itself could stay internal while the delegate signature (Action<SpanData>) becomes public through a named delegate type if desired.

This is not a blocker for the PR as-is; the InternalsVisibleTo approach is well-understood and the set of trusted assemblies is small and stable.


3. ReportKeyValue is a reporting artefact — its name is misleading in TUnit.Core

ReportKeyValue is a JSON serialisation DTO whose name implies it belongs to the HTML reporter. Now that it lives in TUnit.Core and is shared with the OpenTelemetry package, a name like SpanAttribute or KeyValueData would communicate its purpose more clearly at the Core layer.

This is a cosmetic concern and entirely optional.


4. SpanLink is not registered in HtmlReportJsonContext

HtmlReportJsonContext explicitly registers SpanEvent and ReportKeyValue but not SpanLink:

[JsonSerializable(typeof(ReportKeyValue))]
[JsonSerializable(typeof(SpanData))]
[JsonSerializable(typeof(SpanEvent))]

SpanLink is reachable through SpanData.Links so the source-generated context would include it transitively via SpanData, but an explicit registration is consistent with how the other peer types are listed. Worth adding for clarity.


5. OtlpReceiverIngestionTests.Receiver_ParsedTrace_ReachesActivityCollector still references ActivityCollector directly

The stated goal of the refactor is that TUnit.OpenTelemetry no longer depends on TUnit.Engine. But TUnit.OpenTelemetry.Tests still references ActivityCollector.Current directly (line 17 of OtlpReceiverIngestionTests.cs). Tests are not the shipped package, so this doesn't break the stated goal, but it does mean the test file imports TUnit.Engine.Reporters.Html and therefore the test project still carries the Engine dependency.

A cleaner test would assert the outcome through ExternalSpanSink or would use ActivityCollector only for setup, which is arguably what ActivityCollectorIngestionTests already does well. Not a blocker.


Summary

The design — ExternalSpanSink as a single-slot, first-wins, Interlocked-guarded process-wide hook — is simple, low-allocation, and appropriate for the single-session lifecycle. The POCO movement and InternalsVisibleTo approach are pragmatic. The main actionable item is the missing Volatile.Read on the Current property (point 1); everything else is quality-of-life.

…in JSON context

Addresses PR #5774 review feedback:
- ExternalSpanSink.Current now uses Volatile.Read so weak memory models
  (ARM) cannot observe a stale null after Register publishes the sink.
- HtmlReportJsonContext explicitly registers SpanLink for consistency
  with the other peer DTOs (was reachable transitively via SpanData.Links).
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.

Follow-up Review (after commit ba7baec)

Both actionable items from the previous review have been addressed — thank you for the quick turnaround.

✅ Fixed: Volatile.Read on ExternalSpanSink.Current

The missing memory-ordering guarantee is now correct:

public static Action<SpanData>? Current => Volatile.Read(ref _sink);

This pairs properly with the full fence from Interlocked.CompareExchange on the write side and is safe on ARM/weak-memory-model architectures.

✅ Fixed: SpanLink registered in HtmlReportJsonContext

[JsonSerializable(typeof(SpanLink))]

Now explicitly listed alongside the other peer types — consistent and correct for source-generated JSON.


Remaining non-blocking items (unchanged by design, acknowledged as acceptable)

  • ExternalSpanSink internals / InternalsVisibleTo — The internal + InternalsVisibleTo approach is pragmatic and the trusted-assembly set is small and stable. Acceptable for the current scope.
  • ReportKeyValue naming — Still a cosmetic mismatch with its new home in TUnit.Core, but low risk. Could be a follow-up rename if desired.
  • OtlpReceiverIngestionTests references ActivityCollector directly — Tests still carry the Engine dependency, but the shipped package (TUnit.OpenTelemetry) does not. The stated goal of the refactor is achieved.

Overall

The design is clean — a single-slot, first-wins, Interlocked-guarded sink is exactly the right shape for this single-session lifecycle, and the POCO migration gives both sides of the contract a shared type without any public API surface exposure. With the two fixes in place, this PR is in good shape. LGTM.

@thomhurst thomhurst merged commit f9ed7e2 into main Apr 27, 2026
13 of 15 checks passed
@thomhurst thomhurst deleted the otel-depend-on-core branch April 27, 2026 16:11
@claude claude Bot mentioned this pull request Apr 27, 2026
1 task
github-actions Bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Apr 27, 2026
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.37.0 to
1.40.10.

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

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

## 1.40.10

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

## What's Changed
### Other Changes
* refactor(opentelemetry): depend on TUnit.Core instead of umbrella
TUnit by @​thomhurst in thomhurst/TUnit#5774
### Dependencies
* chore(deps): update tunit to 1.40.5 by @​thomhurst in
thomhurst/TUnit#5769


**Full Changelog**:
thomhurst/TUnit@v1.40.5...v1.40.10

## 1.40.5

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

## What's Changed
### Other Changes
* Fix reflection property injection reuse by @​thomhurst in
thomhurst/TUnit#5763
* fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+
(#​5765) by @​thomhurst in thomhurst/TUnit#5767
### Dependencies
* chore(deps): update tunit to 1.40.0 by @​thomhurst in
thomhurst/TUnit#5762


**Full Changelog**:
thomhurst/TUnit@v1.40.0...v1.40.5

## 1.40.0

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

## What's Changed
### Other Changes
* perf(engine): collapse async forwarding wrappers in test execution
(#​5714) by @​thomhurst in thomhurst/TUnit#5725
* perf(engine): skip Console.Out/Err FlushAsync when no output captured
(#​5712) by @​thomhurst in thomhurst/TUnit#5724
* perf(engine): collapse async state machines on hook cache-hit /
empty-hook path (#​5713) by @​thomhurst in
thomhurst/TUnit#5726
* perf: eliminate per-test closure + GetOrAdd factory alloc (#​5710) by
@​thomhurst in thomhurst/TUnit#5727
* perf(engine): replace global lock in EventReceiverRegistry with
lock-free CAS by @​thomhurst in
thomhurst/TUnit#5731
* perf(engine): batch per-test overhead cleanups (#​5719) by @​thomhurst
in thomhurst/TUnit#5730
* #​5733 handling all arguments for Fact and Theory by @​inyutin-maxim
in thomhurst/TUnit#5734
* fix(assertions): prefer string overload of Member() over
IEnumerable<char> (#​5702) by @​thomhurst in
thomhurst/TUnit#5721
* fix(migration): preserve comments/XML docs when removing sole
attributes (#​5698) by @​thomhurst in
thomhurst/TUnit#5739
* perf(build): trim test TFMs and skip viewer dump by default by
@​thomhurst in thomhurst/TUnit#5741
* fix(pipeline): skip TestBaseModule frameworks with missing binaries by
@​thomhurst in thomhurst/TUnit#5752
* feat(assertions): focused diff messages for IsEqualTo/IsEquivalentTo
(#​5732) by @​thomhurst in thomhurst/TUnit#5747
* fix(analyzers): remove incorrect AOT rules TUnit0300/0301/0302
(#​5722) by @​thomhurst in thomhurst/TUnit#5746
* perf(engine): lazy hook metadata registration (#​5448) by @​thomhurst
in thomhurst/TUnit#5750
* chore(templates): unify TUnit version pinning to 1.* (#​5709) by
@​thomhurst in thomhurst/TUnit#5743
* fix(templates): floating TUnit.Aspire version (#​5708) by @​thomhurst
in thomhurst/TUnit#5742
* fix(assertions): preserve specialised source in .Count(itemAssertion)
(#​5707) by @​thomhurst in thomhurst/TUnit#5749
* feat(assertions): IsEqualTo with implicitly-convertible wrappers
(#​5720) by @​thomhurst in thomhurst/TUnit#5751
* feat(aspire): add ability to manually remove resources by @​Odonno in
thomhurst/TUnit#5586
* fix(fscheck): register default CancellationToken arbitrary that
surfaces TestContext token by @​JohnVerheij in
thomhurst/TUnit#5758
* fix(engine): allow keyed NotInParallel tests to run alongside
unconstrained tests (#​5700) by @​thomhurst in
thomhurst/TUnit#5740
* perf: skip TimeoutHelper wrap when no explicit [Timeout] is set
(#​5711) by @​thomhurst in thomhurst/TUnit#5728
### Dependencies
* chore(deps): update tunit to 1.39.0 by @​thomhurst in
thomhurst/TUnit#5701
* chore(deps): update aspire to 13.2.4 by @​thomhurst in
thomhurst/TUnit#5735
* chore(deps): bump postcss from 8.5.6 to 8.5.10 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5736
* chore(deps): update dependency fscheck to 3.3.3 by @​thomhurst in
thomhurst/TUnit#5760

## New Contributors
* @​inyutin-maxim made their first contribution in
thomhurst/TUnit#5734
* @​Odonno made their first contribution in
thomhurst/TUnit#5586

**Full Changelog**:
thomhurst/TUnit@v1.39.0...v1.40.0

## 1.39.0

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

## What's Changed
### Other Changes
* perf(mocks): shrink MethodSetup + cache stateless matchers by
@​thomhurst in thomhurst/TUnit#5669
* fix(mocks): handle base classes with explicit interface impls (#​5673)
by @​thomhurst in thomhurst/TUnit#5674
* fix(mocks): implement indexer in generated mock (#​5676) by
@​thomhurst in thomhurst/TUnit#5683
* fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals
(#​5675) by @​thomhurst in thomhurst/TUnit#5680
* fix(mocks): escape C# keyword identifiers at all emit sites (#​5679)
by @​thomhurst in thomhurst/TUnit#5684
* fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#​5678)
by @​thomhurst in thomhurst/TUnit#5682
* fix(mocks): skip MockBridge for class targets with static-abstract
interfaces (#​5677) by @​thomhurst in
thomhurst/TUnit#5681
* chore(mocks): regenerate source generator snapshots by @​thomhurst in
thomhurst/TUnit#5691
* perf(engine): collapse async state-machine layers on hot test path
(#​5687) by @​thomhurst in thomhurst/TUnit#5690
* perf(engine): reduce lock contention in scheduling and hook caches
(#​5686) by @​thomhurst in thomhurst/TUnit#5693
* fix(assertions): prevent implicit-to-string op from NREing on null
(#​5692) by @​thomhurst in thomhurst/TUnit#5696
* perf(engine/core): reduce per-test allocations (#​5688) by @​thomhurst
in thomhurst/TUnit#5694
* perf(engine): reduce message-bus contention on test start (#​5685) by
@​thomhurst in thomhurst/TUnit#5695
### Dependencies
* chore(deps): update tunit to 1.37.36 by @​thomhurst in
thomhurst/TUnit#5667
* chore(deps): update verify to 31.16.2 by @​thomhurst in
thomhurst/TUnit#5699


**Full Changelog**:
thomhurst/TUnit@v1.37.36...v1.39.0

## 1.37.36

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

## What's Changed
### Other Changes
* fix(telemetry): remove duplicate HTTP client spans by @​thomhurst in
thomhurst/TUnit#5668


**Full Changelog**:
thomhurst/TUnit@v1.37.35...v1.37.36

## 1.37.35

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

## What's Changed
### Other Changes
* Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by
@​Zodt in thomhurst/TUnit#5655
* fix(aspire): preserve user-supplied OTLP endpoint (#​4818) by
@​thomhurst in thomhurst/TUnit#5665
* feat(aspire): emit client spans for HTTP by @​thomhurst in
thomhurst/TUnit#5666
### Dependencies
* chore(deps): update dependency dotnet-sdk to v10.0.203 by @​thomhurst
in thomhurst/TUnit#5656
* chore(deps): update microsoft.aspnetcore to 10.0.7 by @​thomhurst in
thomhurst/TUnit#5657
* chore(deps): update tunit to 1.37.24 by @​thomhurst in
thomhurst/TUnit#5659
* chore(deps): update microsoft.extensions to 10.0.7 by @​thomhurst in
thomhurst/TUnit#5658
* chore(deps): update aspire to 13.2.3 by @​thomhurst in
thomhurst/TUnit#5661
* chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by
@​thomhurst in thomhurst/TUnit#5664

## New Contributors
* @​Zodt made their first contribution in
thomhurst/TUnit#5655

**Full Changelog**:
thomhurst/TUnit@v1.37.24...v1.37.35

## 1.37.24

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

## What's Changed
### Other Changes
* docs: add Tluma Ask AI widget to Docusaurus site by @​thomhurst in
thomhurst/TUnit#5638
* Revert "chore(deps): update dependency docusaurus-plugin-llms to
^0.4.0 (#​5637)" by @​thomhurst in
thomhurst/TUnit#5640
* fix(asp-net): forward disposal in FlowSuppressingHostedService
(#​5651) by @​JohnVerheij in
thomhurst/TUnit#5652
### Dependencies
* chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by
@​thomhurst in thomhurst/TUnit#5637
* chore(deps): update tunit to 1.37.10 by @​thomhurst in
thomhurst/TUnit#5639
* chore(deps): update opentelemetry to 1.15.3 by @​thomhurst in
thomhurst/TUnit#5645
* chore(deps): update opentelemetry by @​thomhurst in
thomhurst/TUnit#5647
* chore(deps): update dependency dompurify to v3.4.1 by @​thomhurst in
thomhurst/TUnit#5648
* chore(deps): update dependency system.commandline to 2.0.7 by
@​thomhurst in thomhurst/TUnit#5650
* chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7
by @​thomhurst in thomhurst/TUnit#5649
* chore(deps): update dependency microsoft.templateengine.authoring.cli
to v10.0.203 by @​thomhurst in
thomhurst/TUnit#5653
* chore(deps): update dependency
microsoft.templateengine.authoring.templateverifier to 10.0.203 by
@​thomhurst in thomhurst/TUnit#5654


**Full Changelog**:
thomhurst/TUnit@v1.37.10...v1.37.24

## 1.37.10

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

## What's Changed
### Other Changes
* docs(test-filters): add migration callout for --filter →
--treenode-filter by @​johnkattenhorn in
thomhurst/TUnit#5628
* fix: re-enable RPC tests and modernize harness (#​5540) by @​thomhurst
in thomhurst/TUnit#5632
* fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch
(#​5626) by @​JohnVerheij in
thomhurst/TUnit#5631
* ci: use setup-dotnet built-in NuGet cache by @​thomhurst in
thomhurst/TUnit#5635
* feat(playwright): propagate W3C trace context into browser contexts by
@​thomhurst in thomhurst/TUnit#5636
### Dependencies
* chore(deps): update tunit to 1.37.0 by @​thomhurst in
thomhurst/TUnit#5625

## New Contributors
* @​johnkattenhorn made their first contribution in
thomhurst/TUnit#5628
* @​JohnVerheij made their first contribution in
thomhurst/TUnit#5631

**Full Changelog**:
thomhurst/TUnit@v1.37.0...v1.37.10

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

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.37.0&new-version=1.40.10)](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>
This was referenced Apr 28, 2026
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