Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
85 changes: 85 additions & 0 deletions src/core/gated-span-exporter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
59 changes: 59 additions & 0 deletions src/core/gated-span-exporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ExportResultCode, type ExportResult } from '@opentelemetry/core';

interface GatableExporter {
export(items: unknown, callback: (result: ExportResult) => void): void;
shutdown?(): Promise<void>;
forceFlush?(): Promise<void>;
}

/**
* 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<E extends GatableExporter> {
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<void> {
return this.inner.shutdown?.() ?? Promise.resolve();
}

forceFlush(): Promise<void> {
return this.inner.forceFlush?.() ?? Promise.resolve();
}
}
2 changes: 1 addition & 1 deletion src/core/scope.ts
Original file line number Diff line number Diff line change
@@ -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';
50 changes: 45 additions & 5 deletions src/core/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => 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 =
Expand Down Expand Up @@ -288,11 +307,28 @@ export class Scout {
}
private _anonymousId: string | null = null;
private _webViewBridgeSend?: (payload: Record<string, unknown>) => void;
setWebViewBridge(bridge: {
sessionId?: string;
anonymousId?: string;
send?: (payload: Record<string, unknown>) => 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);
}
Expand All @@ -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;
}
Expand Down
Loading
Loading