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.14] - 2026-08-05

### Fixed

- `Scout.shutdown()` now genuinely stops Web Vitals. `installWebVitalsTracker`
returned a no-op disposer, so the `web-vitals` observers — which have no
unsubscribe API — kept reporting after shutdown, and every re-`initialize()`
stacked another live callback. Registration is now once per document, routed
to whichever instance is currently installed.
- `installStartupTracker` no longer re-emits a `cold` `app_startup` span when
the SDK is re-initialized on the same document. Navigation timing describes
the document, so the repeat carried byte-identical timings and skewed startup
percentiles.
- `installRouteTracker` now restores the real `history.pushState` /
`history.replaceState` on dispose. It captured `history.pushState.bind(history)`
and restored that wrapper instead of the original, so every install/uninstall
cycle left another `bind` layer on `history` — an unbounded chain for a host
that mounts the SDK on every visit.

### Added

- `src/web/lifecycle.test.ts` covers `Scout.initialize` / `Scout.shutdown`,
which had no tests: that shutdown restores every patched page global, and that
install/uninstall cycles neither stack patches nor wedge re-initialization.

Both matter to hosts that mount and unmount the SDK rather than initializing
once per page load — Grafana app plugins and micro-frontends, where scoping
capture to the host's own lifetime is the only way to keep `service.name`
meaning what it says.

## [0.1.13] - 2026-08-05

Web interaction coverage and a distributed-tracing correctness fix. No default
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.13",
"version": "0.1.14",
"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
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.13';
export const SCOPE_VERSION = '0.1.14';
12 changes: 8 additions & 4 deletions src/web/instrumentations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,19 @@ export function installRouteTracker(scout: Scout): () => void {
});
});
}
const origPush = history.pushState.bind(history);
const origReplace = history.replaceState.bind(history);
// Captured unbound, and called with `apply`, so the disposer can put the
// original function back. Restoring a `.bind()` wrapper instead would leave
// the page subtly altered and stack another layer on every reinstall — which
// hosts that mount and unmount the SDK do on every visit.
const origPush = history.pushState;
const origReplace = history.replaceState;
history.pushState = function (...args: Parameters<typeof history.pushState>) {
const r = origPush(...args);
const r = origPush.apply(history, args);
queueMicrotask(handleChange);
return r;
};
history.replaceState = function (...args: Parameters<typeof history.replaceState>) {
const r = origReplace(...args);
const r = origReplace.apply(history, args);
queueMicrotask(handleChange);
return r;
};
Expand Down
111 changes: 111 additions & 0 deletions src/web/instrumentations/startup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Scout } from '../../core/scout';
import { ATTR } from '../../core/attributes';
import { SPAN } from '../../core/spans';
import { makeRecorder, memoryPlatform, type Recorder } from '../../test/recorder';
import { installStartupTracker, __resetStartupStateForTests } from './startup';

async function newScout(): Promise<Scout> {
const scout = new Scout(
{
serviceName: 't',
endpoint: 'http://localhost',
secure: false,
sessionSampleRate: 100,
},
memoryPlatform(),
);
await scout.bootstrap();
return scout;
}

/** `emitCold` runs in a microtask when the document is already complete. */
const settle = () => new Promise<void>((r) => queueMicrotask(() => r()));

describe('installStartupTracker', () => {
let recorder: Recorder;
const disposers: Array<() => void> = [];
beforeEach(() => {
recorder = makeRecorder();
__resetStartupStateForTests();
// jsdom exposes no navigation entry, which is the one input `emitCold`
// refuses to run without.
vi.spyOn(performance, 'getEntriesByType').mockImplementation((type: string) =>
type === 'navigation'
? ([
{
loadEventEnd: 1200,
domContentLoadedEventEnd: 900,
domComplete: 1100,
domInteractive: 700,
responseStart: 120,
},
] as unknown as PerformanceEntryList)
: [],
);
});
afterEach(() => {
for (const d of disposers.splice(0)) d();
vi.restoreAllMocks();
});

function install(scout: Scout) {
const d = installStartupTracker(scout);
disposers.push(d);
return d;
}

it('emits a cold app_startup span on first install', async () => {
install(await newScout());
await settle();
const spans = recorder.spans().filter((s) => s.name === SPAN.APP_STARTUP);
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes[ATTR.APP_STARTUP_TYPE]).toBe('cold');
});

// A host that mounts and unmounts the SDK (a Grafana app plugin, a
// micro-frontend) reinstalls this tracker on every entry. The navigation
// timing it reads belongs to the document, not to the install, so a second
// emission would report a page load that never happened — and would report
// it with byte-identical timings, which silently skews startup percentiles.
it('does not re-emit a cold start when reinstalled on the same document', async () => {
const first = install(await newScout());
await settle();
first();
recorder.reset();

install(await newScout());
await settle();
expect(recorder.spans().filter((s) => s.name === SPAN.APP_STARTUP)).toHaveLength(0);
});

it('still reports a warm start from bfcache after a reinstall', async () => {
const first = install(await newScout());
await settle();
first();
recorder.reset();

install(await newScout());
await settle();
const evt = new Event('pageshow') as PageTransitionEvent;
Object.defineProperty(evt, 'persisted', { value: true });
window.dispatchEvent(evt);

const spans = recorder.spans().filter((s) => s.name === SPAN.APP_STARTUP);
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes[ATTR.APP_STARTUP_TYPE]).toBe('warm');
});

it('stops reporting warm starts once disposed', async () => {
const dispose = install(await newScout());
await settle();
dispose();
recorder.reset();

const evt = new Event('pageshow') as PageTransitionEvent;
Object.defineProperty(evt, 'persisted', { value: true });
window.dispatchEvent(evt);
expect(recorder.spans()).toHaveLength(0);
});
});
15 changes: 15 additions & 0 deletions src/web/instrumentations/startup.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { ATTR } from '../../core/attributes';
import { SPAN, BREADCRUMB_TYPE } from '../../core/spans';
import type { Scout } from '../../core/scout';
// Navigation timing describes the document, not this installation. A host that
// mounts and unmounts the SDK (a Grafana app plugin, a micro-frontend) would
// otherwise re-report the very same page load on every entry, with identical
// timings, quietly skewing every startup percentile downstream.
let coldStartEmitted = false;

/** Test-only: clears the per-document cold-start latch. */
export function __resetStartupStateForTests(): void {
coldStartEmitted = false;
}

export function installStartupTracker(scout: Scout): () => void {
if (typeof performance === 'undefined') return () => {};
const emitCold = () => {
if (coldStartEmitted) return;
try {
const entries = performance.getEntriesByType(
'navigation',
) as PerformanceNavigationTiming[];
const nav = entries[0];
// Latch only on a real emission: a document with no navigation entry
// yet must stay eligible rather than burn its one cold start on a no-op.
if (!nav) return;
coldStartEmitted = true;
const duration = (nav.loadEventEnd || nav.domContentLoadedEventEnd) / 1000;
scout.emitSpan(SPAN.APP_STARTUP, {
[ATTR.APP_STARTUP_TYPE]: 'cold',
Expand Down
121 changes: 121 additions & 0 deletions src/web/instrumentations/web-vitals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Metric } from 'web-vitals';
import { Scout } from '../../core/scout';
import { SPAN } from '../../core/spans';
import { makeRecorder, memoryPlatform, type Recorder } from '../../test/recorder';

// `web-vitals` v5 offers no way to unsubscribe: `onCLS(cb)` registers a
// PerformanceObserver for the life of the document. These fakes stand in for
// that so a test can count registrations and fire a metric on demand.
const registered: Record<string, Array<(m: Metric) => void>> = {};
function register(name: string) {
return (cb: (m: Metric) => void) => {
(registered[name] ??= []).push(cb);
};
}
vi.mock('web-vitals', () => ({
onCLS: register('CLS'),
onFCP: register('FCP'),
onINP: register('INP'),
onLCP: register('LCP'),
onTTFB: register('TTFB'),
}));

const { installWebVitalsTracker, __resetWebVitalsStateForTests } =
await import('./web-vitals');

function fire(name: string, value = 1.5) {
const metric = {
name,
value,
rating: 'good',
id: `v1-${name}`,
delta: value,
entries: [],
navigationType: 'navigate',
} as unknown as Metric;
for (const cb of registered[name] ?? []) cb(metric);
}

async function newScout(): Promise<Scout> {
const scout = new Scout(
{
serviceName: 't',
endpoint: 'http://localhost',
secure: false,
sessionSampleRate: 100,
},
memoryPlatform(),
);
await scout.bootstrap();
return scout;
}

describe('installWebVitalsTracker', () => {
let recorder: Recorder;
const disposers: Array<() => void> = [];
beforeEach(() => {
recorder = makeRecorder();
for (const k of Object.keys(registered)) delete registered[k];
__resetWebVitalsStateForTests();
});
afterEach(() => {
for (const d of disposers.splice(0)) d();
});

async function install() {
const d = installWebVitalsTracker(await newScout());
disposers.push(d);
return d;
}

it('emits a web_vital span when a metric settles', async () => {
await install();
fire('LCP', 2400);
const spans = recorder.spans().filter((s) => s.name === SPAN.WEB_VITAL);
expect(spans).toHaveLength(1);
});

// The observers cannot be torn down, so reinstalling must reuse the existing
// registration rather than stacking another one. Without this, a host that
// mounts the SDK n times reports every subsequent vital n times over.
it('registers its observers once per document, however often it is installed', async () => {
await install();
await install();
await install();
expect(registered.CLS).toHaveLength(1);
expect(registered.LCP).toHaveLength(1);
});

it('reports a vital exactly once after repeated installs', async () => {
const first = await install();
first();
await install();
recorder.reset();

fire('CLS', 0.05);
expect(recorder.spans().filter((s) => s.name === SPAN.WEB_VITAL)).toHaveLength(1);
});

// The whole point of scoping the SDK to a host's lifetime: once disposed,
// a vital that settles later must not reach a shut-down provider.
it('stops emitting once disposed', async () => {
const dispose = await install();
dispose();
recorder.reset();

fire('INP', 180);
expect(recorder.spans()).toHaveLength(0);
});

it('routes vitals to the most recent instance after a reinstall', async () => {
const first = await install();
first();
recorder.reset();
await install();

fire('TTFB', 120);
expect(recorder.spans().filter((s) => s.name === SPAN.WEB_VITAL)).toHaveLength(1);
});
});
Loading
Loading