From 3c613a406802ccfa5829de4575844ca439096cbd Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Wed, 5 Aug 2026 16:22:08 +0530 Subject: [PATCH 1/2] fix(web): make shutdown() actually stop web vitals and startup re-emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installWebVitalsTracker returned a no-op disposer while registering web-vitals observers that have no unsubscribe API, so vitals kept reporting after Scout.shutdown() and every re-initialize() stacked another live callback on a possibly dead instance. Register once per document and route to whichever instance is installed. installStartupTracker read PerformanceNavigationTiming, which describes the document rather than the installation, and fired whenever readyState was already complete — so re-initializing on the same page reported a second cold start with identical timings. Together these let a host mount and unmount the SDK on its own lifecycle instead of leaking capture across the rest of the page. --- CHANGELOG.md | 19 +++ package.json | 2 +- src/core/scope.ts | 2 +- src/web/instrumentations/startup.test.ts | 111 ++++++++++++++++++ src/web/instrumentations/startup.ts | 15 +++ src/web/instrumentations/web-vitals.test.ts | 121 ++++++++++++++++++++ src/web/instrumentations/web-vitals.ts | 45 ++++++-- 7 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 src/web/instrumentations/startup.test.ts create mode 100644 src/web/instrumentations/web-vitals.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ab4f3..4183477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ 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. + +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/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; + }; } From a5fb7e0e91617f741d2fca0625ba2760391e5233 Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Wed, 5 Aug 2026 19:36:49 +0530 Subject: [PATCH 2/2] fix(web): restore the real history.pushState on dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installRouteTracker captured history.pushState.bind(history) and restored that bound wrapper rather than the original function, so an install/uninstall cycle left the page altered and each subsequent cycle stacked another bind layer — unbounded for a host that mounts the SDK on every visit. Adds the first tests for Scout.initialize/Scout.shutdown, which had none: shutdown must hand back every patched page global, and repeated cycles must neither stack patches nor wedge re-initialization. That is the contract a Grafana app plugin or micro-frontend relies on to keep its telemetry scoped to itself. --- CHANGELOG.md | 11 +++ src/web/instrumentations/route.ts | 12 ++- src/web/lifecycle.test.ts | 128 ++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 src/web/lifecycle.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4183477..38a85a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 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/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(); + }); +});