diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ab4f3..38a85a7 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.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 diff --git a/package.json b/package.json index 9b96bfd..77d52f0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/scope.ts b/src/core/scope.ts index c65dcb3..5d02358 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.13'; +export const SCOPE_VERSION = '0.1.14'; diff --git a/src/web/instrumentations/route.ts b/src/web/instrumentations/route.ts index c5f5d4a..e6f68d9 100644 --- a/src/web/instrumentations/route.ts +++ b/src/web/instrumentations/route.ts @@ -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) { - const r = origPush(...args); + const r = origPush.apply(history, args); queueMicrotask(handleChange); return r; }; history.replaceState = function (...args: Parameters) { - const r = origReplace(...args); + const r = origReplace.apply(history, args); queueMicrotask(handleChange); return r; }; diff --git a/src/web/instrumentations/startup.test.ts b/src/web/instrumentations/startup.test.ts new file mode 100644 index 0000000..d3eb7f9 --- /dev/null +++ b/src/web/instrumentations/startup.test.ts @@ -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 { + 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((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); + }); +}); diff --git a/src/web/instrumentations/startup.ts b/src/web/instrumentations/startup.ts index 5964fec..9d82f83 100644 --- a/src/web/instrumentations/startup.ts +++ b/src/web/instrumentations/startup.ts @@ -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', diff --git a/src/web/instrumentations/web-vitals.test.ts b/src/web/instrumentations/web-vitals.test.ts new file mode 100644 index 0000000..06033da --- /dev/null +++ b/src/web/instrumentations/web-vitals.test.ts @@ -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 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 { + 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); + }); +}); diff --git a/src/web/instrumentations/web-vitals.ts b/src/web/instrumentations/web-vitals.ts index 254e074..09a2074 100644 --- a/src/web/instrumentations/web-vitals.ts +++ b/src/web/instrumentations/web-vitals.ts @@ -106,8 +106,26 @@ function extractLCP(m: Metric): Attributes { } return out; } +// `web-vitals` registers PerformanceObservers that live as long as the document +// — `onCLS(cb)` has no unsubscribe. So the callbacks are registered once and +// permanently, and this holds whichever Scout should receive them. A host that +// mounts the SDK repeatedly would otherwise stack one live closure per mount, +// each reporting the same vital again against a provider that may already have +// been shut down. +let activeScout: Scout | null = null; +let observersRegistered = false; + +/** Test-only: drops the registration latch and the active instance. */ +export function __resetWebVitalsStateForTests(): void { + activeScout = null; + observersRegistered = false; +} + export function installWebVitalsTracker(scout: Scout): () => void { + activeScout = scout; const send = (m: Metric) => { + const target = activeScout; + if (!target) return; try { const metricName = NAME_TO_METRIC[m.name] ?? `web.vital.${m.name.toLowerCase()}`; const base: Attributes = { @@ -120,14 +138,14 @@ export function installWebVitalsTracker(scout: Scout): () => void { if (m.name === 'CLS') extras = extractCLS(m); else if (m.name === 'INP') extras = extractINP(m); else if (m.name === 'LCP') extras = extractLCP(m); - scout.emitHistogram(metricName, m.value, { ...base, ...extras }); - scout.emitSpan(SPAN.WEB_VITAL, { + target.emitHistogram(metricName, m.value, { ...base, ...extras }); + target.emitSpan(SPAN.WEB_VITAL, { ...base, ...extras, - ...scout.commonAttributes(), + ...target.commonAttributes(), }); try { - const root = scout.rootSpan; + const root = target.rootSpan; if (root) { const prefix = `web.vital.${m.name.toLowerCase()}`; const screenAttrs: Record = { [`${prefix}.value`]: m.value }; @@ -144,10 +162,17 @@ export function installWebVitalsTracker(scout: Scout): () => void { } catch {} } catch {} }; - onCLS(send); - onFCP(send); - onINP(send); - onLCP(send); - onTTFB(send); - return () => {}; + if (!observersRegistered) { + observersRegistered = true; + onCLS(send); + onFCP(send); + onINP(send); + onLCP(send); + onTTFB(send); + } + // Only detach if this instance is still the active one; a reinstall that + // already replaced it owns the slot now and must keep receiving vitals. + return () => { + if (activeScout === scout) activeScout = null; + }; } diff --git a/src/web/lifecycle.test.ts b/src/web/lifecycle.test.ts new file mode 100644 index 0000000..d64d781 --- /dev/null +++ b/src/web/lifecycle.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +/** + * The SDK installs itself by patching page-global APIs. Hosts that mount and + * unmount it — Grafana app plugins, micro-frontends — depend on `shutdown()` + * handing the page back exactly as it found it, because anything left patched + * keeps reporting under a `service.name` that no longer applies. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Scout from './index'; + +const ENDPOINT = 'http://collector.test:4318'; + +function config() { + return { + serviceName: 'host-app', + endpoint: ENDPOINT, + secure: false, + sessionSampleRate: 100, + }; +} + +describe('Scout lifecycle — install and uninstall', () => { + let pristine: { + fetch: typeof globalThis.fetch; + pushState: typeof history.pushState; + replaceState: typeof history.replaceState; + xhrOpen: typeof XMLHttpRequest.prototype.open; + xhrSend: typeof XMLHttpRequest.prototype.send; + xhrSetHeader: typeof XMLHttpRequest.prototype.setRequestHeader; + }; + + beforeEach(() => { + // Stubbed before the snapshot so exporters never reach the network and the + // restoration check compares against a known reference. + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 200 })), + ); + pristine = { + fetch: globalThis.fetch, + pushState: history.pushState, + replaceState: history.replaceState, + xhrOpen: XMLHttpRequest.prototype.open, + xhrSend: XMLHttpRequest.prototype.send, + xhrSetHeader: XMLHttpRequest.prototype.setRequestHeader, + }; + }); + + afterEach(async () => { + if (Scout.isInitialized) await Scout.shutdown(); + vi.unstubAllGlobals(); + }); + + function patchedGlobals(): string[] { + const changed: string[] = []; + if (globalThis.fetch !== pristine.fetch) changed.push('fetch'); + if (history.pushState !== pristine.pushState) changed.push('history.pushState'); + if (history.replaceState !== pristine.replaceState) { + changed.push('history.replaceState'); + } + if (XMLHttpRequest.prototype.open !== pristine.xhrOpen) changed.push('xhr.open'); + if (XMLHttpRequest.prototype.send !== pristine.xhrSend) changed.push('xhr.send'); + if (XMLHttpRequest.prototype.setRequestHeader !== pristine.xhrSetHeader) { + changed.push('xhr.setRequestHeader'); + } + return changed; + } + + it('patches the page globals it instruments through', async () => { + await Scout.initialize(config()); + expect(Scout.isInitialized).toBe(true); + expect(patchedGlobals()).toEqual( + expect.arrayContaining([ + 'fetch', + 'history.pushState', + 'history.replaceState', + 'xhr.open', + 'xhr.send', + ]), + ); + }); + + // The guarantee a host relies on: after teardown the page carries no trace of + // the SDK, so navigation and requests outside the host go unreported. + it('restores every patched global on shutdown', async () => { + await Scout.initialize(config()); + await Scout.shutdown(); + expect(patchedGlobals()).toEqual([]); + expect(Scout.isInitialized).toBe(false); + }); + + it('can be reinstalled after shutdown, and torn down again cleanly', async () => { + await Scout.initialize(config()); + await Scout.shutdown(); + + await Scout.initialize(config()); + expect(Scout.isInitialized).toBe(true); + expect(patchedGlobals().length).toBeGreaterThan(0); + + await Scout.shutdown(); + expect(patchedGlobals()).toEqual([]); + expect(Scout.isInitialized).toBe(false); + }); + + it('survives repeated install/uninstall cycles without stacking patches', async () => { + for (let i = 0; i < 3; i++) { + await Scout.initialize(config()); + await Scout.shutdown(); + } + expect(patchedGlobals()).toEqual([]); + }); + + it('ignores a redundant shutdown', async () => { + await Scout.initialize(config()); + await Scout.shutdown(); + await expect(Scout.shutdown()).resolves.toBeUndefined(); + expect(patchedGlobals()).toEqual([]); + }); + + it('stops reporting events once shut down', async () => { + await Scout.initialize(config()); + await Scout.shutdown(); + // The façade drops through to a null instance rather than throwing, so a + // late callback in the host cannot resurrect a torn-down SDK. + expect(() => Scout.logEvent('late.event', { a: 1 })).not.toThrow(); + expect(Scout.sessionId).toBeNull(); + }); +});