refactor(opentelemetry): depend on TUnit.Core instead of umbrella TUnit#5774
refactor(opentelemetry): depend on TUnit.Core instead of umbrella TUnit#5774
Conversation
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.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
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.
There was a problem hiding this comment.
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 readOn 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).
There was a problem hiding this comment.
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+InternalsVisibleToapproach 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.
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> [](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>
Summary
TUnit.Coredirectly instead of the umbrellaTUnitpackage, dropping the transitiveTUnit.Engine+TUnit.Assertions+Microsoft.Testing.Extensionsfootprint from its dependency chain.ExternalSpanSinkhook inTUnit.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.SpanData,SpanEvent,SpanLink,ReportKeyValue) fromTUnit.Engine/Reporters/Html/HtmlReportDataModel.csto a newTUnit.Core/SpanData.csso both ends of the contract share the same shape.Test plan
TUnit.Enginebuilds clean (all TFMs).TUnit.OpenTelemetrybuilds clean againstTUnit.Coreonly.TUnit.OpenTelemetry.Tests30/30 pass.TUnit.Aspire.Tests92/92 pass.