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..0062173 --- /dev/null +++ b/src/core/webview-bridge.test.ts @@ -0,0 +1,133 @@ +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); + }); +});