From f0e37a9f0cbef92dd82cf8029ac6c7fec17bcfad Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Wed, 12 Aug 2026 11:26:09 +0530 Subject: [PATCH 1/2] feat(webview): add relay mode so bridged spans are not duplicated `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. --- CHANGELOG.md | 30 ++++++ README.md | 33 +++++++ package.json | 2 +- src/core/gated-span-exporter.test.ts | 85 +++++++++++++++++ src/core/gated-span-exporter.ts | 59 ++++++++++++ src/core/scope.ts | 2 +- src/core/scout.ts | 50 +++++++++- src/core/webview-bridge.test.ts | 135 +++++++++++++++++++++++++++ src/web/index.ts | 54 +++++++++-- src/web/webview-bridge.test.ts | 130 ++++++++++++++++++++++++++ 10 files changed, 563 insertions(+), 17 deletions(-) create mode 100644 src/core/gated-span-exporter.test.ts create mode 100644 src/core/gated-span-exporter.ts create mode 100644 src/core/webview-bridge.test.ts create mode 100644 src/web/webview-bridge.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a3bb8..bf2006f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.16] - 2026-08-11 + +### Added + +- `setWebViewBridge({ relay: true })` — a native host embedding this page (for + example a Flutter app using `scout_flutter`'s `ScoutWebViewBridge`) can now + take over span delivery. With `relay` set the page stops POSTing spans to the + collector and hands them to the host's `send` callback instead. + + Previously `send` only ever *mirrored*: the page exported the span **and** + passed a copy to the host, so every bridged interaction reached the backend + twice — once under the web service name, once re-emitted natively. That + duplication is now opt-in (`send` without `relay`) rather than unavoidable. + + Gating happens at the span exporter, not the emit path, so spans are still + created, sampled and parented in relay mode and `firstPartyHosts` + `traceparent` injection keeps working. Logs and metrics continue to export + over HTTP in every mode, because the host-side re-emit accepts spans only. + +- `Scout.isExportingSpans` — reports whether the page still owns span delivery. + Useful for diagnosing a mis-wired bridge, which is otherwise silently lossy. + +- `WebViewBridgeOptions` is now an exported type, and `setWebViewBridge` + documents its three modes: session-adoption-only (recommended), relay, and + mirror. + +- Test coverage for the WebView bridge, which previously had none: session and + anonymous-id adoption, sampling behaviour, forwarding from both `emitSpan` + and `startTrackedSpan`, pre-`initialize()` injection, and relay gating. + ## [0.1.15] - 2026-08-10 ### Fixed diff --git a/README.md b/README.md index 6631f00..f2e7e88 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,39 @@ buffer](docs/configuration.md#offline-buffer). --- +## Embedded in a native app? — the WebView bridge + +When this page runs inside a native app that also reports RUM (a Flutter app +using [`scout_flutter`](https://pub.dev/packages/scout_flutter), say), the two +SDKs would otherwise open two unrelated sessions for one user flow. The host +fixes that by handing the page its identity: + +```ts +Scout.setWebViewBridge({ + sessionId: 'native-session-id', + anonymousId: 'native-anonymous-id', +}); +``` + +Safe to call before `initialize()` — the call is parked and applied once the +SDK is up, which is what lets a host inject it as the page starts loading. + +Three modes, selected by which fields you pass: + +| Mode | Fields | Behaviour | +|---|---|---| +| **Session adoption** (recommended) | `sessionId`, `anonymousId` | Page keeps exporting to the collector, tagged with the host's session. One copy of every signal, full fidelity. | +| **Relay** | `+ send`, `relay: true` | Page stops POSTing spans; the host delivers them. Use when the WebView can't reach the collector. **Spans only** — logs and metrics still go over HTTP. | +| **Mirror** | `+ send` | Page exports *and* hands the host a copy. Both reach the backend, so expect duplicate spans. Only useful if the host needs to observe web events locally. | + +`Scout.isExportingSpans` reports whether the page still owns delivery — worth +asserting in a smoke test, since a mis-wired relay is silently lossy. + +Prefer session adoption unless the WebView genuinely can't reach the +collector; it needs no host-side relay code and loses nothing. + +--- + ## Out of scope (for now) | Signal | Why not | diff --git a/package.json b/package.json index 2516660..d59a65c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@base-14/scout-react", - "version": "0.1.15", + "version": "0.1.16", "description": "Zero-config OpenTelemetry RUM for React and React Native. Auto-captures clicks, navigation, errors, lifecycle, network, performance, and web vitals.", "license": "MIT", "author": "base-14", diff --git a/src/core/gated-span-exporter.test.ts b/src/core/gated-span-exporter.test.ts new file mode 100644 index 0000000..a27deb8 --- /dev/null +++ b/src/core/gated-span-exporter.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ExportResultCode, type ExportResult } from '@opentelemetry/core'; +import { GatedSpanExporter } from './gated-span-exporter'; + +function mockExporter() { + return { + export: vi.fn((_items: unknown, cb: (r: ExportResult) => void) => + cb({ code: ExportResultCode.SUCCESS }), + ), + shutdown: vi.fn(() => Promise.resolve()), + forceFlush: vi.fn(() => Promise.resolve()), + }; +} + +describe('GatedSpanExporter', () => { + it('passes exports through while open', () => { + const inner = mockExporter(); + const gated = new GatedSpanExporter(inner); + const cb = vi.fn(); + gated.export([{}], cb); + expect(inner.export).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + }); + + it('starts open', () => { + expect(new GatedSpanExporter(mockExporter()).isOpen).toBe(true); + }); + + it('drops exports once closed, without touching the inner exporter', () => { + const inner = mockExporter(); + const gated = new GatedSpanExporter(inner); + gated.setOpen(false); + const cb = vi.fn(); + gated.export([{}], cb); + expect(inner.export).not.toHaveBeenCalled(); + expect(gated.isOpen).toBe(false); + }); + + it('reports SUCCESS for dropped batches so the buffer does not hoard them', () => { + const gated = new GatedSpanExporter(mockExporter()); + gated.setOpen(false); + const cb = vi.fn(); + gated.export([{}, {}], cb); + expect(cb).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + }); + + it('resumes exporting when reopened', () => { + const inner = mockExporter(); + const gated = new GatedSpanExporter(inner); + gated.setOpen(false); + gated.export([{}], vi.fn()); + gated.setOpen(true); + gated.export([{}], vi.fn()); + expect(inner.export).toHaveBeenCalledTimes(1); + }); + + it('forwards shutdown and forceFlush to the inner exporter', async () => { + const inner = mockExporter(); + const gated = new GatedSpanExporter(inner); + await gated.shutdown(); + await gated.forceFlush(); + expect(inner.shutdown).toHaveBeenCalledTimes(1); + expect(inner.forceFlush).toHaveBeenCalledTimes(1); + }); + + it('flushes and shuts down even while closed', async () => { + const inner = mockExporter(); + const gated = new GatedSpanExporter(inner); + gated.setOpen(false); + await expect(gated.forceFlush()).resolves.toBeUndefined(); + await expect(gated.shutdown()).resolves.toBeUndefined(); + expect(inner.shutdown).toHaveBeenCalledTimes(1); + }); + + it('tolerates an inner exporter with no shutdown/forceFlush', async () => { + const bare = { + export: vi.fn((_i: unknown, cb: (r: ExportResult) => void) => + cb({ code: ExportResultCode.SUCCESS }), + ), + }; + const gated = new GatedSpanExporter(bare); + await expect(gated.shutdown()).resolves.toBeUndefined(); + await expect(gated.forceFlush()).resolves.toBeUndefined(); + }); +}); diff --git a/src/core/gated-span-exporter.ts b/src/core/gated-span-exporter.ts new file mode 100644 index 0000000..1aad23c --- /dev/null +++ b/src/core/gated-span-exporter.ts @@ -0,0 +1,59 @@ +import { ExportResultCode, type ExportResult } from '@opentelemetry/core'; + +interface GatableExporter { + export(items: unknown, callback: (result: ExportResult) => void): void; + shutdown?(): Promise; + forceFlush?(): Promise; +} + +/** + * A gate the WebView bridge can close at runtime. + * + * When a Flutter/native host relays this page's spans through its own + * pipeline (`setWebViewBridge({ send, relay: true })`), the browser must + * stop POSTing the same spans to the collector — otherwise every + * interaction lands in the backend twice, once under the web service name + * and once re-emitted natively. + * + * The gate sits at the exporter rather than at the emit path on purpose: + * spans are still created, sampled, parented and assigned trace + span + * ids, so `startTrackedSpan`'s `traceparent` injection keeps working and + * backend spans still parent under the browser request. Only the network + * write is suppressed. + * + * Closed exports report SUCCESS. The batch processor treats a dropped + * batch as delivered, which is correct here — the host owns delivery — and + * keeps the offline buffer from hoarding spans that were never meant to go + * out over HTTP. + */ +export class GatedSpanExporter { + private _open = true; + + constructor(private readonly inner: E) {} + + /** Whether spans are still being written to the collector. */ + get isOpen(): boolean { + return this._open; + } + + /** Close the gate (host relays) or reopen it (host detached). */ + setOpen(open: boolean): void { + this._open = open; + } + + export(items: unknown, callback: (result: ExportResult) => void): void { + if (!this._open) { + callback({ code: ExportResultCode.SUCCESS }); + return; + } + this.inner.export(items, callback); + } + + shutdown(): Promise { + return this.inner.shutdown?.() ?? Promise.resolve(); + } + + forceFlush(): Promise { + return this.inner.forceFlush?.() ?? Promise.resolve(); + } +} diff --git a/src/core/scope.ts b/src/core/scope.ts index 5485638..f5f6d28 100644 --- a/src/core/scope.ts +++ b/src/core/scope.ts @@ -1,2 +1,2 @@ export const SCOPE_NAME = 'base14.scout.react'; -export const SCOPE_VERSION = '0.1.15'; +export const SCOPE_VERSION = '0.1.16'; diff --git a/src/core/scout.ts b/src/core/scout.ts index 216774d..847cdbb 100644 --- a/src/core/scout.ts +++ b/src/core/scout.ts @@ -33,6 +33,25 @@ export interface TrackedSpan { /** Merges `extra` attributes, applies status, ends the span and records it. */ end(extra?: Attributes, opts?: { status?: SpanStatusCode }): void; } +/** + * Identity and transport a native host hands to a page it embeds. See + * {@link Scout.setWebViewBridge} for the modes these fields select. + */ +export interface WebViewBridgeOptions { + /** Host session id the page adopts, so both sides report one session. */ + sessionId?: string; + /** Host's stable per-install anonymous user id. */ + anonymousId?: string; + /** Receives a copy of every span the page emits, for the host to re-emit. */ + send?: (payload: Record) => void; + /** + * Stop POSTing spans from the page — the host's `send` becomes the only + * delivery path. Ignored unless `send` is supplied. Leave unset when the + * WebView can reach the collector itself; setting it without a working + * host-side relay silently drops the page's spans. + */ + relay?: boolean; +} const MAX_STACK_LEN = 8000; function errorFingerprint(type: string, message: string, stack: string): string { const firstFrame = @@ -288,11 +307,28 @@ export class Scout { } private _anonymousId: string | null = null; private _webViewBridgeSend?: (payload: Record) => void; - setWebViewBridge(bridge: { - sessionId?: string; - anonymousId?: string; - send?: (payload: Record) => void; - }): void { + /** + * Adopts a native host's RUM identity so an embedded WebView and the app + * around it report as one session instead of two disconnected ones. + * + * Every field is optional, and the useful modes come from which ones you + * pass: + * + * - **Session adoption only** (`sessionId` + `anonymousId`, no `send`) — + * the page keeps exporting to the collector itself, tagged with the + * host's session. One copy of every signal, full fidelity. This is the + * recommended default whenever the WebView can reach the collector. + * - **Relay** (`send` + `relay: true`) — the page stops POSTing spans and + * hands them to the host instead. Use when the WebView cannot reach the + * collector directly. Note that only spans travel the bridge: logs and + * metrics keep exporting over HTTP, because the host-side re-emit + * accepts spans only. + * - **Mirror** (`send`, `relay` false/omitted) — the page exports *and* + * hands a copy to the host. Both copies reach the backend, so expect + * duplicate spans. Opt into this only when you want the host to observe + * web events locally. + */ + setWebViewBridge(bridge: WebViewBridgeOptions): void { if (typeof bridge?.sessionId === 'string' && bridge.sessionId) { this.session.adoptExternalSessionId(bridge.sessionId); } @@ -303,6 +339,10 @@ export class Scout { this._webViewBridgeSend = bridge.send; } } + /** Whether a host has taken over span delivery for this page. */ + static isRelaying(bridge: WebViewBridgeOptions): boolean { + return bridge?.relay === true && typeof bridge?.send === 'function'; + } timeSinceAppStartMs(): number { return Date.now() - this._appStartedAt; } diff --git a/src/core/webview-bridge.test.ts b/src/core/webview-bridge.test.ts new file mode 100644 index 0000000..7633fbf --- /dev/null +++ b/src/core/webview-bridge.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Scout } from './scout'; +import { ATTR } from './attributes'; +import { makeRecorder, memoryPlatform, type Recorder } from '../test/recorder'; + +async function makeScout(overrides: Record = {}) { + const s = new Scout( + { + serviceName: 'test-svc', + endpoint: 'http://localhost:4318', + secure: false, + sessionSampleRate: 100, + ...overrides, + }, + memoryPlatform(), + ); + await s.bootstrap(); + return s; +} + +describe('setWebViewBridge — session adoption', () => { + let rec: Recorder; + beforeEach(() => { + rec = makeRecorder(); + }); + + it('adopts the host session id onto subsequent spans', async () => { + const s = await makeScout(); + const own = s.sessionId; + s.setWebViewBridge({ sessionId: 'native-session-42' }); + expect(s.sessionId).toBe('native-session-42'); + expect(s.sessionId).not.toBe(own); + s.logEvent('after_adopt', {}); + const span = rec.spans().find((x) => x.name === 'after_adopt'); + expect(span?.attributes[ATTR.SESSION_ID]).toBe('native-session-42'); + }); + + it('adopts the host anonymous id onto subsequent spans', async () => { + const s = await makeScout(); + s.setWebViewBridge({ anonymousId: 'anon-abc' }); + s.logEvent('after_adopt', {}); + const span = rec.spans().find((x) => x.name === 'after_adopt'); + expect(span?.attributes[ATTR.USER_ANONYMOUS_ID]).toBe('anon-abc'); + }); + + it('forces the adopted session to be sampled', async () => { + const s = await makeScout({ sessionSampleRate: 0 }); + s.setWebViewBridge({ sessionId: 'native-session-42' }); + s.logEvent('sampled_in', {}); + expect(rec.spans().some((x) => x.name === 'sampled_in')).toBe(true); + }); + + it('ignores empty and non-string identity fields', async () => { + const s = await makeScout(); + const own = s.sessionId; + s.setWebViewBridge({ sessionId: '', anonymousId: '' }); + s.setWebViewBridge({ sessionId: 42 as unknown as string }); + expect(s.sessionId).toBe(own); + }); + + it('tolerates an empty bridge object', async () => { + const s = await makeScout(); + expect(() => s.setWebViewBridge({})).not.toThrow(); + }); +}); + +describe('setWebViewBridge — span forwarding', () => { + beforeEach(() => { + makeRecorder(); + }); + + it('hands every emitted span to send()', async () => { + const send = vi.fn(); + const s = await makeScout(); + s.setWebViewBridge({ send }); + s.logEvent('checkout_started', { sku: 'SKU-1' }); + expect(send).toHaveBeenCalledTimes(1); + const payload = send.mock.calls[0][0]; + expect(payload.type).toBe('checkout_started'); + expect(payload.attributes.sku).toBe('SKU-1'); + expect(typeof payload.timestamp_ms).toBe('number'); + }); + + it('forwards the host session id on bridged payloads', async () => { + const send = vi.fn(); + const s = await makeScout(); + s.setWebViewBridge({ sessionId: 'native-session-42', send }); + s.logEvent('tap', {}); + expect(send.mock.calls[0][0].attributes[ATTR.SESSION_ID]).toBe( + 'native-session-42', + ); + }); + + it('does not let a throwing send() break span emission', async () => { + const s = await makeScout(); + s.setWebViewBridge({ + send: () => { + throw new Error('channel closed'); + }, + }); + expect(() => s.logEvent('still_emits', {})).not.toThrow(); + }); + + it('forwards spans ended through startTrackedSpan', async () => { + const send = vi.fn(); + const s = await makeScout(); + s.setWebViewBridge({ send }); + const tracked = s.startTrackedSpan('http.request', { 'http.method': 'GET' }); + expect(tracked).not.toBeNull(); + tracked!.end({ 'http.status_code': 200 }); + const payload = send.mock.calls.at(-1)![0]; + expect(payload.type).toBe('http.request'); + expect(payload.attributes['http.status_code']).toBe(200); + }); + + it('does not forward spans dropped by sampling', async () => { + const send = vi.fn(); + const s = await makeScout({ sessionSampleRate: 0, alwaysCaptureErrors: false }); + s.setWebViewBridge({ send }); + s.logEvent('dropped', {}); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe('Scout.isRelaying', () => { + it('is true only when relay and send are both present', () => { + const send = vi.fn(); + expect(Scout.isRelaying({ send, relay: true })).toBe(true); + expect(Scout.isRelaying({ send })).toBe(false); + expect(Scout.isRelaying({ send, relay: false })).toBe(false); + expect(Scout.isRelaying({ relay: true })).toBe(false); + expect(Scout.isRelaying({ sessionId: 'x' })).toBe(false); + expect(Scout.isRelaying({})).toBe(false); + }); +}); diff --git a/src/web/index.ts b/src/web/index.ts index 2fde5be..0da1e09 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -1,4 +1,8 @@ -import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web'; +import { + WebTracerProvider, + BatchSpanProcessor, + type SpanExporter, +} from '@opentelemetry/sdk-trace-web'; import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { LoggerProvider, BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'; import { @@ -13,9 +17,10 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from '@opentelemetry/semantic-conventions'; -import { Scout as ScoutCore } from '../core/scout'; +import { Scout as ScoutCore, type WebViewBridgeOptions } from '../core/scout'; import { resolveConfig, resolveEndpoint, type ScoutConfig } from '../core/config'; import { wrapWithRetry } from '../core/retry-exporter'; +import { GatedSpanExporter } from '../core/gated-span-exporter'; import { buildOfflineWiring } from '../core/offline-wiring'; import type { Attributes, AttributeValue } from '../core/types'; import { ATTR } from '../core/attributes'; @@ -54,8 +59,12 @@ export type { SeverityText, } from '../core/types'; export type { ScoutConfig } from '../core/config'; +export type { WebViewBridgeOptions } from '../core/scout'; let _instance: ScoutCore | null = null; const _disposers: Array<() => void> = []; +// Closed while a native host relays this page's spans, so the same span is +// not delivered twice. Assigned during initialize(); null before that. +let _traceGate: GatedSpanExporter | null = null; export const Scout = { async initialize(config: ScoutConfig): Promise { if (_instance) return; @@ -88,10 +97,14 @@ export const Scout = { resolved.exportRetry, { ...offline.hooks.traces, debug: !!resolved.debug, label: 'traces' }, ); + // Wrapped outside the retry layer: a relayed span should be dropped + // outright, not retried or spilled into the offline buffer. + const gatedTraceExporter = new GatedSpanExporter(traceExporter); + _traceGate = gatedTraceExporter; const traceProvider = new WebTracerProvider({ resource, spanProcessors: [ - new BatchSpanProcessor(traceExporter, { + new BatchSpanProcessor(gatedTraceExporter, { scheduledDelayMillis: resolved.traceExportIntervalMs, maxQueueSize: resolved.traceMaxQueueSize, maxExportBatchSize: resolved.traceMaxExportBatchSize, @@ -148,13 +161,16 @@ export const Scout = { const core = new ScoutCore(config, platform); await core.bootstrap(); _instance = core; + // A host that injected its bridge before the page finished booting had + // its call parked; apply it now, gate included. const pending = ( Scout as unknown as { - _pendingWebViewBridge?: Parameters[0]; + _pendingWebViewBridge?: WebViewBridgeOptions; } )._pendingWebViewBridge; if (pending) { core.setWebViewBridge(pending); + gatedTraceExporter.setOpen(!ScoutCore.isRelaying(pending)); ( Scout as unknown as { _pendingWebViewBridge?: unknown; @@ -228,21 +244,38 @@ export const Scout = { if (_instance) emitScoutUsageOnce(_instance, 'logEvent'); _instance?.logEvent(name, attributes); }, - setWebViewBridge(bridge: { - sessionId?: string; - anonymousId?: string; - send?: (payload: Record) => void; - }): void { + /** + * Adopt a native host's session so an embedded WebView and the app around + * it report as one session. Safe to call before `initialize()` — the call + * is parked and applied once the SDK is up, which is what lets a host + * inject its bridge the moment the page starts loading. + * + * See {@link WebViewBridgeOptions} for the three modes. In short: pass + * `sessionId` + `anonymousId` alone for unified sessions with no + * duplication, and add `send` + `relay: true` only when the WebView cannot + * reach the collector on its own. + */ + setWebViewBridge(bridge: WebViewBridgeOptions): void { if (_instance) { _instance.setWebViewBridge(bridge); + _traceGate?.setOpen(!ScoutCore.isRelaying(bridge)); } else { ( Scout as unknown as { - _pendingWebViewBridge?: typeof bridge; + _pendingWebViewBridge?: WebViewBridgeOptions; } )._pendingWebViewBridge = bridge; } }, + /** + * Whether the page is still exporting spans over HTTP. False once a host + * has taken over delivery via `setWebViewBridge({ send, relay: true })`. + * Exposed for host-side diagnostics — the bridge is easy to mis-wire and + * silently lossy when it is. + */ + get isExportingSpans(): boolean { + return _traceGate?.isOpen ?? true; + }, addBreadcrumb(type: string, message: string): void { _instance?.addBreadcrumb(type, message); }, @@ -333,6 +366,7 @@ export const Scout = { } await _instance?.shutdown(); _instance = null; + _traceGate = null; }, }; export default Scout; diff --git a/src/web/webview-bridge.test.ts b/src/web/webview-bridge.test.ts new file mode 100644 index 0000000..f4ea684 --- /dev/null +++ b/src/web/webview-bridge.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { trace, metrics } from '@opentelemetry/api'; +import { logs } from '@opentelemetry/api-logs'; + +// Every OTLP write goes out over fetch, so counting trace POSTs is the only +// honest way to assert that relay mode actually stops the page exporting. +function tracePosts(fetchMock: ReturnType): number { + return fetchMock.mock.calls.filter((c) => String(c[0]).endsWith('/v1/traces')).length; +} + +describe('web WebView bridge', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(async () => { + const { Scout } = await import('./index'); + await Scout.shutdown(); + // The OTel API keeps its global providers outside the module graph, so + // resetModules alone leaves the next test emitting into this test's + // (already shut down) exporters. Disable them explicitly. + trace.disable(); + metrics.disable(); + logs.disable(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + async function initialize(config: Record = {}) { + const { Scout } = await import('./index'); + await Scout.initialize({ + serviceName: 'test-svc', + endpoint: 'http://localhost:4318', + secure: false, + sessionSampleRate: 100, + ...config, + } as never); + return Scout; + } + + it('exposes Scout on window so a host shim can find it', async () => { + await import('./index'); + expect( + (window as unknown as { Scout?: { setWebViewBridge?: unknown } }).Scout + ?.setWebViewBridge, + ).toBeTypeOf('function'); + }); + + it('keeps exporting spans in session-adoption-only mode', async () => { + const Scout = await initialize(); + Scout.setWebViewBridge({ sessionId: 'native-1', anonymousId: 'anon-1' }); + expect(Scout.isExportingSpans).toBe(true); + expect(Scout.sessionId).toBe('native-1'); + Scout.logEvent('adopted', {}); + await Scout.flush(); + expect(tracePosts(fetchMock)).toBeGreaterThan(0); + }); + + it('keeps exporting spans in mirror mode (send without relay)', async () => { + const Scout = await initialize(); + const send = vi.fn(); + Scout.setWebViewBridge({ sessionId: 'native-1', send }); + expect(Scout.isExportingSpans).toBe(true); + Scout.logEvent('mirrored', {}); + await Scout.flush(); + expect(send).toHaveBeenCalled(); + expect(tracePosts(fetchMock)).toBeGreaterThan(0); + }); + + it('stops exporting spans in relay mode, and forwards them instead', async () => { + const Scout = await initialize(); + const send = vi.fn(); + Scout.setWebViewBridge({ sessionId: 'native-1', send, relay: true }); + expect(Scout.isExportingSpans).toBe(false); + Scout.logEvent('relayed', {}); + await Scout.flush(); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0].type).toBe('relayed'); + expect(tracePosts(fetchMock)).toBe(0); + }); + + it('reopens the gate when the host detaches the relay', async () => { + const Scout = await initialize(); + const send = vi.fn(); + Scout.setWebViewBridge({ send, relay: true }); + expect(Scout.isExportingSpans).toBe(false); + Scout.setWebViewBridge({ sessionId: 'native-1' }); + expect(Scout.isExportingSpans).toBe(true); + Scout.logEvent('after_detach', {}); + await Scout.flush(); + expect(tracePosts(fetchMock)).toBeGreaterThan(0); + }); + + it('applies a bridge injected before initialize(), gate included', async () => { + const { Scout } = await import('./index'); + const send = vi.fn(); + Scout.setWebViewBridge({ sessionId: 'native-early', send, relay: true }); + await Scout.initialize({ + serviceName: 'test-svc', + endpoint: 'http://localhost:4318', + secure: false, + sessionSampleRate: 100, + } as never); + expect(Scout.sessionId).toBe('native-early'); + expect(Scout.isExportingSpans).toBe(false); + Scout.logEvent('early', {}); + await Scout.flush(); + expect(tracePosts(fetchMock)).toBe(0); + }); + + it('reports spans as exporting before initialize()', async () => { + const { Scout } = await import('./index'); + expect(Scout.isExportingSpans).toBe(true); + }); + + it('leaves logs exporting in relay mode — the bridge carries spans only', async () => { + const Scout = await initialize(); + Scout.setWebViewBridge({ send: vi.fn(), relay: true }); + Scout.logInfo('still shipped over http'); + await Scout.flush(); + const logPosts = fetchMock.mock.calls.filter((c) => + String(c[0]).endsWith('/v1/logs'), + ).length; + expect(logPosts).toBeGreaterThan(0); + }); +}); From 7a3a63b9d9e16df07d9ed062c03ced3f206e93a6 Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Wed, 12 Aug 2026 11:40:38 +0530 Subject: [PATCH 2/2] style: apply prettier formatting to webview bridge tests --- src/core/webview-bridge.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/webview-bridge.test.ts b/src/core/webview-bridge.test.ts index 7633fbf..0062173 100644 --- a/src/core/webview-bridge.test.ts +++ b/src/core/webview-bridge.test.ts @@ -86,9 +86,7 @@ describe('setWebViewBridge — span forwarding', () => { const s = await makeScout(); s.setWebViewBridge({ sessionId: 'native-session-42', send }); s.logEvent('tap', {}); - expect(send.mock.calls[0][0].attributes[ATTR.SESSION_ID]).toBe( - 'native-session-42', - ); + expect(send.mock.calls[0][0].attributes[ATTR.SESSION_ID]).toBe('native-session-42'); }); it('does not let a throwing send() break span emission', async () => {