Skip to content

feat: auto-align DistributedContextPropagator to W3C#5599

Merged
thomhurst merged 4 commits intomainfrom
feat/auto-align-w3c-propagator
Apr 17, 2026
Merged

feat: auto-align DistributedContextPropagator to W3C#5599
thomhurst merged 4 commits intomainfrom
feat/auto-align-w3c-propagator

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

Closes #5592.

  • Add [ModuleInitializer] in TUnit.Core/PropagatorAlignment.cs that swaps .NET's default LegacyPropagator (emits Correlation-Context) for DistributedContextPropagator.CreateW3CPropagator() (emits W3C baggage). Any user-customised propagator is left alone. Opt out via TUNIT_KEEP_LEGACY_PROPAGATOR=1.
  • TestWebApplicationFactory.ConfigureWebHost re-applies alignment so SUT startup code cannot accidentally revert it.
  • Docs refreshed — manual Sdk.SetDefaultTextMapPropagator(...) snippet retained only for out-of-process SUTs that don't reference TUnit.Core.

Why

The OpenTelemetry SDK's BaggagePropagator reads W3C baggage; .NET's default writes Correlation-Context. tunit.test.id silently drops between processes, so cross-process correlation (real HTTP servers, Aspire hosts, out-of-proc SUTs) never resolves the originating test.

Test plan

  • PropagatorAlignmentTests — 3 tests: module init replaces default Legacy; AlignIfDefault preserves user custom propagator; AlignIfDefault leaves an existing W3C propagator reference-equal.
  • ActivityBaggageCorrelationTests (7) + ActivityPropagationHandlerTests (3) still pass — existing propagation paths unaffected.
  • TUnit.PublicAPI (3) still pass — no public API surface change (PropagatorAlignment is internal).
  • Builds clean on net10.0 for TUnit.Core, TUnit.AspNetCore.Core, TUnit.UnitTests.
  • Verify on a real Aspire/WebApplicationFactory scenario that tunit.test.id now reaches SUT spans without manual Sdk.SetDefaultTextMapPropagator wiring.

.NET's default LegacyPropagator emits Correlation-Context; the OpenTelemetry
SDK's BaggagePropagator only reads W3C baggage. The mismatch silently drops
tunit.test.id across processes so test correlation breaks on the SUT side.

Add a module initializer in TUnit.Core that swaps the runtime-default
LegacyPropagator for CreateW3CPropagator(), leaving user-customised
propagators untouched. TestWebApplicationFactory.ConfigureWebHost re-applies
the same alignment so SUT startup code cannot accidentally revert it. Opt
out via TUNIT_KEEP_LEGACY_PROPAGATOR=1.

Docs updated to reflect automatic alignment; manual OTel SetDefaultTextMapPropagator
snippet retained only for out-of-process SUTs that don't reference TUnit.Core.
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 clean, well-motivated change. The [ModuleInitializer] approach is the right tool for auto-alignment, the opt-out mechanism is correct, and the tests are solid. A few architectural observations worth considering:


Concern: AlignIfDefault() in ConfigureWebHost may fire too early

In TestWebApplicationFactory.ConfigureWebHost, the alignment is called before SUT services are configured:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    base.ConfigureWebHost(builder);
    PropagatorAlignment.AlignIfDefault();   // ← fires here
    builder.ConfigureServices(services => { ... });
}

The SUT's Program.cs / Startup.cs (including any OpenTelemetry SDK Sdk.SetDefaultTextMapPropagator(...) calls) executes during the host's Build() phase — after all ConfigureWebHost callbacks have registered. So if user startup code resets DistributedContextPropagator.Current, this call won't counter it.

A more robust placement would be an IStartupFilter registered via builder.ConfigureServices:

builder.ConfigureServices(services =>
{
    services.AddSingleton<IStartupFilter, PropagatorAlignmentStartupFilter>();
    services.AddCorrelatedTUnitLogging();
});

// In a separate internal class:
internal sealed class PropagatorAlignmentStartupFilter : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
        => app => { PropagatorAlignment.AlignIfDefault(); next(app); };
}

This runs after all ConfigureServices callbacks (including the SUT's) so alignment wins even when SUT startup code has its own propagator assignment.


Minor: String-based internal type detection

private const string LegacyPropagatorTypeName = "System.Diagnostics.LegacyPropagator";

if (DistributedContextPropagator.Current.GetType().FullName == LegacyPropagatorTypeName)

LegacyPropagator is an internal .NET type with no public accessor, so this is the only realistic approach — it's pragmatic. Worth noting in a code comment that this is intentional (the System.Diagnostics namespace makes an internal rename very unlikely, but a reader unfamiliar with .NET internals will wonder why a magic string is used). The existing XML doc on the class partially covers this, but a brief inline note at the constant would make it obvious at the point of use.


Tests look good

The test class is well-structured:

  • [NotInParallel] correctly guards against global-state races.
  • try/finally restores DistributedContextPropagator.Current in each test.
  • The three cases (module-init ran, custom propagator untouched, W3C idempotent) cover the key invariants.

One minor note: ModuleInitializer_Replaces_Default_Legacy_Propagator implicitly depends on the TUnit.Core module having been loaded. Because the test project references TUnit.Core types, this will always be true in practice — but a short comment explaining the assumption would help future readers.


Documentation

The docs are clear and accurate. The one thing to address: "TUnit 0.x (issue #5592)" in opentelemetry.md contains a placeholder version number (0.x) that should be updated to the actual release version before merging, or replaced with a format like "since [version]" once the release version is known.


Summary

The core implementation is correct and the approach is sound. The main actionable item is the ConfigureWebHost placement — the IStartupFilter pattern would make the ASP.NET Core side robust against SUT startup code that resets the propagator. Everything else is minor polish.

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.

Test review post

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 17, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 27 complexity

Metric Results
Complexity 27

View in Codacy

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

DistributedContextPropagator.CreateW3CPropagator() was added in .NET 10;
on net8/net9 supply a minimal in-library W3CBaggagePropagator that delegates
traceparent/tracestate to the runtime default and emits/parses W3C baggage.
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 6da2043)

The new commit adds the W3CBaggagePropagator fallback for .NET 8/9 — good catch, since DistributedContextPropagator.CreateW3CPropagator() only exists on .NET 10+. The implementation is correct: LegacyPropagator (used via CreateDefaultPropagator()) does inject traceparent for W3C-format activities in .NET 8/9, so delegating trace-context to it and handling baggage separately is sound.

Open from previous review

ConfigureWebHost timing — the IStartupFilter suggestion was not addressed. If user Program.cs/Startup.cs code calls Sdk.SetDefaultTextMapPropagator(...) after host configuration, the current AlignIfDefault() call (placed before builder.ConfigureServices) won't win. The prior review explains this with a code example; it's still worth considering for robustness.

"TUnit 0.x" placeholder in docs/docs/examples/opentelemetry.md still reads "TUnit 0.x (issue #5592)" — needs the real version number before shipping.

New code analysis

W3CBaggagePropagator — implementation looks correct. The wrapping callback that strips Correlation-Context from DefaultPropagator.Inject is a clean approach. One minor note: FieldNames is typed as IReadOnlyCollection<string> but initialized with new[] (array). This works fine, but new[] { ... } creates a mutable array that the caller could cast and mutate. Using Array.AsReadOnly(new[] { ... }) or (IReadOnlyCollection<string>)new[] { ... } is inconsequential here but the former is more expressive of the intent.

DefaultPropagator static captureCreateDefaultPropagator() is called at class initialisation, capturing a fresh LegacyPropagator rather than snapshotting Current. This is deliberate and correct: it avoids circular references regardless of propagator swap ordering.

Test update — changing the test to use PropagatorAlignment.CreateW3CPropagator() instead of DistributedContextPropagator.CreateW3CPropagator() directly is the right fix; it means the test exercises the same code path as the production flow on all target frameworks.

Summary

The new commit is solid and the .NET 8/9 fallback is well-implemented. Two items from the previous review remain unaddressed: the ConfigureWebHost timing concern (architectural) and the version-placeholder in docs (cosmetic). Otherwise this is ready.

- Promote BaggageHeader constant onto TUnitActivitySource; consume from
  W3CBaggagePropagator and the ASP.NET Core / Aspire propagation handlers.
- Reuse TUnitActivitySource.TryBuildBaggageHeader in W3CBaggagePropagator
  instead of duplicating the URI-escape/comma-join logic.
- Rename PropagatorAlignment.CreateW3CPropagator -> CreateAlignedPropagator
  to avoid shadowing the BCL static.
- Parse baggage via span-walker (no Split allocation), lazy-init the result
  list so empty headers return null.
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 6c1eff9)

The latest refactor commit is solid — extracting BaggageHeader to TUnitActivitySource, reusing TryBuildBaggageHeader inside W3CBaggagePropagator, renaming CreateAlignedPropagator to avoid shadowing the BCL static, and the span-walker baggage parser all reflect good discipline. The allocation saving on the parse path aligns well with TUnit's performance focus.


Still open from previous reviews

1. ConfigureWebHost timing (architectural — raised twice, still unaddressed)

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    base.ConfigureWebHost(builder);
    PropagatorAlignment.AlignIfDefault();   // runs before SUT services are registered
    builder.ConfigureServices(services => { ... });
}

Any SUT that configures OpenTelemetry via Sdk.SetDefaultTextMapPropagator(...) in ConfigureServices (which runs during Build()) will silently revert the alignment, leaving tunit.test.id dropped again. The IStartupFilter pattern suggested in the first review runs after Build(), so it wins in the typical OTel-SDK setup:

builder.ConfigureServices(services =>
{
    services.Insert(0, ServiceDescriptor.Transient<IStartupFilter, PropagatorAlignmentStartupFilter>());
    services.AddCorrelatedTUnitLogging();
});

internal sealed class PropagatorAlignmentStartupFilter : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
        => app => { PropagatorAlignment.AlignIfDefault(); next(app); };
}

The Insert(0, ...) ensures TUnit's filter runs first in the pipeline, after all ConfigureServices callbacks have completed. This is the correct fix and it is worthwhile — without it the WebApplicationFactory alignment is unreliable.

2. "TUnit 0.x" version placeholder (cosmetic, but would ship as-is)

docs/docs/examples/opentelemetry.md still reads:

Since TUnit 0.x (issue #5592), the test process auto-aligns...

This needs the real version number (or a link-only reference) before the PR merges.


Minor: FieldNames backed by mutable array

private static readonly IReadOnlyCollection<string> FieldNames = new[] { "traceparent", "tracestate", TUnitActivitySource.BaggageHeader };

A caller who casts to string[] can mutate it. Non-exploitable here, but using Array.AsReadOnly(new[] { ... }) or a FrozenSet makes the intent unambiguous and prevents the cast.


Summary

The refactoring work in the latest commit is clean and well-targeted. Two carry-over items remain before this is fully ready: the IStartupFilter fix for ConfigureWebHost (which would make the ASP.NET Core alignment actually reliable) and the version placeholder in the docs.

ConfigureWebHost callbacks register builder actions that run before user
Program.cs/Startup code; calling AlignIfDefault() there lets SUT startup
clobber the propagator again. IStartupFilter runs when the request
pipeline is built, after all service registration and startup
assignments, so alignment wins.

Also drop the "TUnit 0.x (issue #5592)" placeholder from the
OpenTelemetry docs — the auto-alignment is just how it works now.
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 3a18a4b)

All two carry-over items from the previous reviews are now resolved. This PR is in good shape.


RESOLVED: ConfigureWebHost timing (architectural concern raised 3× previously)

The direct AlignIfDefault() call has been replaced with PropagatorAlignmentStartupFilter registered via services.AddSingleton<IStartupFilter, PropagatorAlignmentStartupFilter>().

One thing worth noting about the AddSingleton choice: ASP.NET Core processes startup filters in reverse registration order when building the middleware chain (see WebHost.BuildApplication()startupFilters.Reverse()). This means the last-registered filter becomes the innermost wrapper, so its pre-next(app) code runs last among all startup filters — right before the actual pipeline is built. That's exactly what we want: TUnit's alignment wins over any SUT IStartupFilter that might set the propagator. The Insert(0, ...) pattern suggested in prior reviews would have been wrong here (it would have run TUnit's filter first/outermost, then SUT filters could revert it). AddSingleton is the correct choice.

RESOLVED: "TUnit 0.x" version placeholder

The docs no longer contain a version reference — the text reads generically "TUnit auto-aligns...", which is correct and won't go stale.


Still present (minor): FieldNames backed by mutable array

// TUnit.Core/PropagatorAlignment.cs:74
private static readonly IReadOnlyCollection<string> FieldNames = new[] { "traceparent", "tracestate", TUnitActivitySource.BaggageHeader };

A caller can cast Fields to string[] and mutate it. Non-exploitable and low-risk since the type is private sealed, but using Array.AsReadOnly(new[] { ... }) would make the intent unambiguous and prevent any accidental mutation:

private static readonly IReadOnlyCollection<string> FieldNames =
    Array.AsReadOnly(new[] { "traceparent", "tracestate", TUnitActivitySource.BaggageHeader });

This is genuinely minor — not a blocker.


Summary

Both significant items (IStartupFilter timing, version placeholder) are addressed. The AddSingleton placement is correct and better than what the prior reviews suggested. The only remaining note is the cosmetic FieldNames mutable-array issue. This PR is ready to merge.

intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 21, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.35.2 to
1.37.10.

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

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

## 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

## 1.37.0

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

## What's Changed
### Other Changes
* fix: stabilize flaky tests across analyzer, OTel, and engine suites by
@​thomhurst in thomhurst/TUnit#5609
* perf: engine hot-path allocation wins (#​5528 B) by @​thomhurst in
thomhurst/TUnit#5610
* feat(analyzers): detect collection IsEqualTo reference equality
(TUnitAssertions0016) by @​thomhurst in
thomhurst/TUnit#5615
* perf: consolidate test dedup + hook register guards (#​5528 A) by
@​thomhurst in thomhurst/TUnit#5612
* perf: engine discovery/init path cleanup (#​5528 C) by @​thomhurst in
thomhurst/TUnit#5611
* fix(assertions): render collection contents in IsEqualTo failure
messages (#​5613 B) by @​thomhurst in
thomhurst/TUnit#5619
* feat(analyzers): code-fix for TUnit0015 to insert CancellationToken
(#​5613 D) by @​thomhurst in
thomhurst/TUnit#5621
* fix(assertions): add Task reference forwarders on
AsyncDelegateAssertion by @​thomhurst in
thomhurst/TUnit#5618
* test(asp-net): fix race in FactoryMethodOrderTests by @​thomhurst in
thomhurst/TUnit#5623
* feat(analyzers): code-fix for TUnit0049 to insert [MatrixDataSource]
(#​5613 C) by @​thomhurst in
thomhurst/TUnit#5620
* fix(pipeline): isolate AOT publish outputs to stop clobbering pack
DLLs (#​5622) by @​thomhurst in
thomhurst/TUnit#5624
### Dependencies
* chore(deps): update tunit to 1.36.0 by @​thomhurst in
thomhurst/TUnit#5608
* chore(deps): update modularpipelines to 3.2.8 by @​thomhurst in
thomhurst/TUnit#5614


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

## 1.36.0

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

## What's Changed
### Other Changes
* fix: don't render test's own trace as Linked Trace in HTML report by
@​thomhurst in thomhurst/TUnit#5580
* fix(docs): benchmark index links 404 by @​thomhurst in
thomhurst/TUnit#5587
* docs: replace repeated benchmark link suffix with per-test
descriptions by @​thomhurst in
thomhurst/TUnit#5588
* docs: clearer distributed tracing setup and troubleshooting by
@​thomhurst in thomhurst/TUnit#5597
* fix: auto-suppress ExecutionContext flow for hosted services (#​5589)
by @​thomhurst in thomhurst/TUnit#5598
* feat: auto-align DistributedContextPropagator to W3C by @​thomhurst in
thomhurst/TUnit#5599
* feat: TUnit0064 analyzer + code fix for direct WebApplicationFactory
inheritance by @​thomhurst in
thomhurst/TUnit#5601
* feat: auto-propagate test trace context through IHttpClientFactory by
@​thomhurst in thomhurst/TUnit#5603
* feat: TUnit.OpenTelemetry zero-config tracing package by @​thomhurst
in thomhurst/TUnit#5602
* fix: restore [Obsolete] members removed in v1.27 (#​5539) by
@​thomhurst in thomhurst/TUnit#5605
* feat: generalize OTLP receiver for use outside TUnit.Aspire by
@​thomhurst in thomhurst/TUnit#5606
* feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT by
@​thomhurst in thomhurst/TUnit#5607
### Dependencies
* chore(deps): update tunit to 1.35.2 by @​thomhurst in
thomhurst/TUnit#5581
* chore(deps): update dependency typescript to ~6.0.3 by @​thomhurst in
thomhurst/TUnit#5582
* chore(deps): update dependency coverlet.collector to v10 by
@​thomhurst in thomhurst/TUnit#5600


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

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

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.35.2&new-version=1.37.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 21, 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.

Auto-align DistributedContextPropagator to W3C in test and SUT processes

1 participant