Skip to content

feat(webview): add relay mode so bridged spans are not duplicated - #5

Merged
nitinstp23 merged 2 commits into
mainfrom
feat/webview-bridge-relay-mode
Aug 12, 2026
Merged

feat(webview): add relay mode so bridged spans are not duplicated#5
nitinstp23 merged 2 commits into
mainfrom
feat/webview-bridge-relay-mode

Conversation

@nitinstp23

@nitinstp23 nitinstp23 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Why

setWebViewBridge({ send }) only ever mirrored. emitSpan created the span via the tracer (exported over OTLP by the batch processor) and handed a copy to the host's send. So every bridged interaction reached the backend twice — once under the web service.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 and send becomes 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's traceparent injection keeps working and backend spans still parent under the browser request. Only the network write is suppressed. Dropped batches report SUCCESS so the offline buffer doesn't hoard spans that were never meant to go out over HTTP.

  • Three explicit modes, documented on WebViewBridgeOptions (now exported):

    Mode Fields Behaviour
    Session adoption sessionId, anonymousId Page exports normally, tagged with host session. One copy, full fidelity. Recommended.
    Relay + send, relay: true Page stops POSTing spans; host delivers. Spans only — logs/metrics still go over HTTP.
    Mirror + send Previous behaviour. Both copies reach the backend. Now opt-in.
  • Scout.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 both emitSpan and startTrackedSpan, throwing-send resilience, sampled-out spans not forwarded.
  • src/web/webview-bridge.test.ts (8) — end-to-end over the real web entry, counting OTLP fetch calls 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 explicitly trace.disable() / metrics.disable() / logs.disable() in teardown.

Verification

  • vitest run — 253 pass
  • tsc --noEmit — clean
  • eslint src — clean
  • npm run build — clean

Compatibility

Non-breaking. relay is opt-in; omitting it preserves today's mirroring behaviour exactly. Hosts on older scout_flutter that pass relay: true against a pre-0.1.16 page get mirroring, same as before.

Pairs with base-14/scout-flutter#6, which sends relay: true from the Flutter side.

`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.
@nitinstp23
nitinstp23 merged commit aee21e4 into main Aug 12, 2026
6 checks passed
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