feat(webview): add relay mode so bridged spans are not duplicated - #5
Merged
Conversation
`setWebViewBridge({ send })` only ever mirrored: the page exported the
span and passed a copy to the native host, so every bridged interaction
reached the backend twice, once under the web service name and once
re-emitted natively. Passing `relay: true` now stops the page POSTing
spans and makes `send` the sole delivery path. Mirroring stays available
but becomes opt-in rather than unavoidable.
Gating lives in a new GatedSpanExporter wrapper rather than on the emit
path, so spans are still created, sampled and parented in relay mode and
firstPartyHosts traceparent injection keeps working. Logs and metrics
keep exporting over HTTP in every mode, because the host-side re-emit
accepts spans only.
Also adds Scout.isExportingSpans for diagnosing a mis-wired bridge
(otherwise silently lossy), exports WebViewBridgeOptions, documents the
three bridge modes, and adds the first test coverage for the bridge,
which previously had none.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
setWebViewBridge({ send })only ever mirrored.emitSpancreated the span via the tracer (exported over OTLP by the batch processor) and handed a copy to the host'ssend. So every bridged interaction reached the backend twice — once under the webservice.name, once re-emitted natively by the host.scout_flutter's bridge docs have claimed the opposite since it shipped ("the web SDK stops POSTing to its own OTLP endpoint"), so anyone following them got silent double-counting.Found while reviewing the scout-flutter ↔ scout-react seam for a customer embedding a React checkout flow in a Flutter app.
What
GatedSpanExporter(src/core/gated-span-exporter.ts) — wraps the trace exporter with a mutable gate.setWebViewBridge({ send, relay: true })closes it, so the page stops POSTing spans andsendbecomes the sole delivery path.Gating sits at the exporter, not the emit path, deliberately: spans are still created, sampled and parented in relay mode, so
startTrackedSpan'straceparentinjection keeps working and backend spans still parent under the browser request. Only the network write is suppressed. Dropped batches reportSUCCESSso the offline buffer doesn't hoard spans that were never meant to go out over HTTP.Three explicit modes, documented on
WebViewBridgeOptions(now exported):sessionId,anonymousId+ send,relay: true+ sendScout.isExportingSpans— a mis-wired bridge is otherwise silently lossy in both directions (relay that didn't take = double reporting; adoption that closed the gate = data loss). This makes it assertable in a smoke test.README section covering the modes and when to pick which.
Tests
The bridge had zero test coverage. Adds 19 tests:
src/core/gated-span-exporter.test.ts(8) — gating, pass-through, reopen, flush/shutdown delegation.src/core/webview-bridge.test.ts(11) — session + anonymous id adoption, forced sampling, forwarding from bothemitSpanandstartTrackedSpan, throwing-sendresilience, sampled-out spans not forwarded.src/web/webview-bridge.test.ts(8) — end-to-end over the real web entry, counting OTLPfetchcalls to prove relay actually stops the page exporting; covers pre-initialize()injection and confirms logs still ship in relay mode.One note on the web-entry tests: the OTel API keeps global providers outside the module graph, so
vi.resetModules()alone leaks providers between tests. They explicitlytrace.disable()/metrics.disable()/logs.disable()in teardown.Verification
vitest run— 253 passtsc --noEmit— cleaneslint src— cleannpm run build— cleanCompatibility
Non-breaking.
relayis opt-in; omitting it preserves today's mirroring behaviour exactly. Hosts on olderscout_flutterthat passrelay: trueagainst a pre-0.1.16 page get mirroring, same as before.Pairs with base-14/scout-flutter#6, which sends
relay: truefrom the Flutter side.