feat(playwright): propagate W3C trace context into browser contexts#5636
feat(playwright): propagate W3C trace context into browser contexts#5636
Conversation
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.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 5 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
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
mergedwith user headers using=(overwrite), then injecting trace headers viaTryAdd(no-overwrite), is the right precedence. - Clone-on-write:
BrowserNewContextOptionsis only cloned when headers are actually added. Thebefore/aftercount trick efficiently avoids an allocation when there is nothing to inject. - Correct
#if NETguards throughout — netstandard2.0 is a clean no-op. InternalsVisibleToaddition 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:
DistributedContextPropagator.Current.Inject(...)— on .NET 8/9 this resolves toPropagatorAlignment.W3CBaggagePropagator, which already callsTUnitActivitySource.TryBuildBaggageHeaderinternally and injects the baggage header.- 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.
There was a problem hiding this comment.
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 withTryAdd(no-overwrite). User values always win. - Clone-on-write is efficient: the count diff trick (
before/after) avoids allocating a newBrowserNewContextOptionswhen no trace headers are injected (e.g. user already had atraceparent). Activity.Currentguard in bothMergeTelemetryHeadersandInjectIntohandles the race correctly.InternalsVisibleTopattern 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.
There was a problem hiding this comment.
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
beforeis even assigned. - Activity present, user headers conflict with trace headers →
TryAddsilences the duplicate, count unchanged, originaloptionsreturned 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.
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> [](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
BrowserTest.NewContextnow seeds eachIBrowserContextwith W3Ctraceparent/baggageheaders derived from the per-testActivitystarted by the engine, so backend spans/logs correlate to the originating test (mirrors theTUnit.AspNetCoreandTUnit.Aspirepropagation handlers).public virtual bool PropagateTraceContext => trueonBrowserTestlets users opt out per-class (e.g. when hitting third-party domains where leaking a trace id is undesirable).ExtraHTTPHeadersalways win on key conflict; options are cloned (not mutated) only when we actually inject headers.netstandard2.0(engine activity plumbing is#if NETonly).Test plan
TUnit.Playwrightbuilds clean on net8.0/net9.0/net10.0/netstandard2.0TUnit.PublicAPIPlaywright_Library_Has_No_API_Changessnapshot regenerated and passes on all four TFMs