Skip to content

feat(playwright): propagate W3C trace context into browser contexts#5636

Merged
thomhurst merged 3 commits intomainfrom
feat/playwright-trace-propagation
Apr 20, 2026
Merged

feat(playwright): propagate W3C trace context into browser contexts#5636
thomhurst merged 3 commits intomainfrom
feat/playwright-trace-propagation

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • BrowserTest.NewContext now seeds each IBrowserContext with W3C traceparent/baggage headers derived from the per-test Activity started by the engine, so backend spans/logs correlate to the originating test (mirrors the TUnit.AspNetCore and TUnit.Aspire propagation handlers).
  • New public virtual bool PropagateTraceContext => true on BrowserTest lets users opt out per-class (e.g. when hitting third-party domains where leaking a trace id is undesirable).
  • User-supplied ExtraHTTPHeaders always win on key conflict; options are cloned (not mutated) only when we actually inject headers.
  • No-op on netstandard2.0 (engine activity plumbing is #if NET only).

Test plan

  • TUnit.Playwright builds clean on net8.0/net9.0/net10.0/netstandard2.0
  • TUnit.PublicAPI Playwright_Library_Has_No_API_Changes snapshot regenerated and passes on all four TFMs
  • Manual: run a test hitting a locally-instrumented backend, confirm server spans parent under the test activity in the collector

Seed each BrowserContext created via BrowserTest.NewContext with
traceparent/baggage HTTP headers from the current test's Activity so
backend spans and logs correlate to the originating test. Opt-out via
the new PropagateTraceContext virtual property. User-supplied
ExtraHTTPHeaders win on key conflict.
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 20, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 5 complexity

Metric Results
Complexity 5

View in Codacy

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

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

Good implementation overall — the design follows the established TUnit.AspNetCore and TUnit.Aspire propagation patterns closely and handles edge cases correctly.

Strengths

  • User headers correctly win on conflict: seeding merged with user headers using = (overwrite), then injecting trace headers via TryAdd (no-overwrite), is the right precedence.
  • Clone-on-write: BrowserNewContextOptions is only cloned when headers are actually added. The before/after count trick efficiently avoids an allocation when there is nothing to inject.
  • Correct #if NET guards throughout — netstandard2.0 is a clean no-op.
  • InternalsVisibleTo addition is consistent with the existing TUnit.Aspire and TUnit.OpenTelemetry pattern.

Issues

1. Unnecessary defensive cast in the propagator callback

In PlaywrightActivityPropagator.cs lines 22-28, the carrier is IDictionary<string, string> dict pattern match will always succeed — the carrier is the exact Dictionary<string, string> we passed in. The check adds a type-test per injected header for no reason:

DistributedContextPropagator.Current.Inject(activity, headers, static (carrier, key, value) =>
{
    if (key is not null && value is not null)
        ((IDictionary<string, string>)carrier!).TryAdd(key, value);
});

This is more honest about the contract and marginally faster when many baggage entries are present.

2. Redundant TryBuildBaggageHeader call on .NET 8/9

PlaywrightActivityPropagator.InjectInto calls:

  1. DistributedContextPropagator.Current.Inject(...) — on .NET 8/9 this resolves to PropagatorAlignment.W3CBaggagePropagator, which already calls TUnitActivitySource.TryBuildBaggageHeader internally and injects the baggage header.
  2. Then TUnitActivitySource.TryBuildBaggageHeader(activity) is called again explicitly.

The second call is a no-op thanks to TryAdd, but it is redundant on .NET 8/9 (it IS needed on .NET 10+ where CreateW3CPropagator() handles only standard activity baggage).

Compare with ActivityPropagationHandler.InjectBaggage which guards with headers.Contains(BaggageHeader) before calling TryBuildBaggageHeader — that pattern makes the guard explicit. Either approach is correct, but a comment explaining why the explicit call is needed for .NET 10+ would help future maintainers avoid "cleaning up" what looks like duplication.

3. PropagateTraceContext docstring omits the netstandard2.0 caveat

The property is visible on all TFMs including netstandard2.0, but has no effect there (the #if NET guard in MergeTelemetryHeaders means it is silently ignored). Worth adding a <remarks> note that this property has no effect on netstandard2.0 targets.


Summary

The design is correct and the core logic is solid. The three points above are minor — (1) is a style/clarity issue, (2) is harmless redundancy that would benefit from a comment, (3) is a docs gap. None are blocking.

- Drop defensive is-pattern in propagator callback (carrier type is known).
- Document why TryBuildBaggageHeader is called explicitly after Inject —
  matches the rationale in ActivityPropagationHandler / TUnitBaggagePropagationHandler.
- Document netstandard2.0 no-op behavior on PropagateTraceContext.
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 (Follow-up)

Previous review raised three issues. Checking against the current HEAD (548fe7b0):

Previous Issues — Status

Issue #1 (Unnecessary defensive cast)Resolved. The callback now uses the direct cast ((IDictionary<string, string>)carrier!).TryAdd(key, value) as recommended.

Issue #2 (Redundant TryBuildBaggageHeader call)Addressed with a comment. The belt-and-braces comment clearly explains the TUNIT_KEEP_LEGACY_PROPAGATOR=1 scenario where DistributedContextPropagator.Current emits only Correlation-Context and the explicit W3C baggage emission is still needed. This is the right approach.

Issue #3 (Missing netstandard2.0 caveat in docs)Resolved. The <remarks> block is present on PropagateTraceContext.


Fresh Look at Current Code

Strengths:

  • Header precedence is correct: user headers seeded with = (overwrite), trace headers injected with TryAdd (no-overwrite). User values always win.
  • Clone-on-write is efficient: the count diff trick (before/after) avoids allocating a new BrowserNewContextOptions when no trace headers are injected (e.g. user already had a traceparent).
  • Activity.Current guard in both MergeTelemetryHeaders and InjectInto handles the race correctly.
  • InternalsVisibleTo pattern is consistent with TUnit.Aspire and TUnit.OpenTelemetry.

One minor note (non-blocking):

Trace headers are only injected through the NewContext(BrowserNewContextOptions) overload. Callers who bypass BrowserTest.NewContext and call Browser.NewContextAsync() directly won't get propagation. This is arguably fine — it's not a regression and the doc comment on NewContext makes the contract clear — but worth a note in user-facing docs if you have any.


Summary

All previous issues have been addressed. The implementation is clean, consistent with the existing AspNetCore/Aspire propagation patterns, and handles edge cases correctly.

Adding TUnit.Playwright to InternalsVisibleTo is a visible assembly-level
attribute change in the Core library snapshot tests.
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

All three issues raised in the previous review have been addressed in the follow-up commits.

Previous Feedback — Status

Issue 1 — Defensive is-pattern in propagator callback
Resolved. The pattern match was replaced with a direct cast ((IDictionary<string, string>)carrier!), which is correct since the caller always passes the concrete Dictionary<string, string>.

Issue 2 — Redundant TryBuildBaggageHeader call
Resolved. A comment was added explaining the intent: this is a belt-and-braces guard for the TUNIT_KEEP_LEGACY_PROPAGATOR=1 code path where the ambient propagator only emits Correlation-Context, not W3C baggage. The TryAdd makes it a no-op when W3C baggage was already injected.

Issue 3 — PropagateTraceContext docstring missing netstandard2.0 caveat
Resolved. A <remarks> block now explicitly states the property has no effect on netstandard2.0 targets.


New Observations

Logic correctness: The merged.Count == before early-return in MergeTelemetryHeaders handles all cases correctly:

  • No activity → returned early before before is even assigned.
  • Activity present, user headers conflict with trace headers → TryAdd silences the duplicate, count unchanged, original options returned untouched (user wins, no allocation).
  • Activity present, new trace headers added → clone issued with merged dict.

Parallelism: Activity.Current flows with the async execution context, so concurrent tests each capture their own activity correctly when NewContext is called.


Summary

Clean, well-structured implementation that mirrors the TUnit.AspNetCore and TUnit.Aspire propagation patterns faithfully. All prior feedback addressed. No new concerns. LGTM.

BenjaminMichaelis pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 25, 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>
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