Skip to content

fix(web): make shutdown() a real uninstall so hosts can scope capture - #3

Merged
nitinstp23 merged 2 commits into
mainfrom
fix/scope-sdk-to-host-lifetime
Aug 5, 2026
Merged

fix(web): make shutdown() a real uninstall so hosts can scope capture#3
nitinstp23 merged 2 commits into
mainfrom
fix/scope-sdk-to-host-lifetime

Conversation

@nitinstp23

Copy link
Copy Markdown
Contributor

Makes Scout.shutdown() a genuine uninstall, so a host can scope RUM capture to its own lifetime instead of the page's.

Why

The SDK installs itself by patching page-globals — history.pushState/replaceState, fetch, XMLHttpRequest.prototype. That is fine when the SDK is initialized once per page load, which is what every existing consumer does. It breaks down for a host that mounts and unmounts: a Grafana app plugin, a micro-frontend, anything embedded in a larger SPA.

Concretely: logX is a Grafana app plugin. Once a user opened it, the SDK kept reporting every dashboard and Explore page they visited afterwards, all tagged service.name = logx. shutdown() exists and looked like the answer, but three defects meant calling it did not actually give the page back.

What was wrong

installWebVitalsTracker returned a no-op disposer. onCLS/onFCP/onINP/onLCP/onTTFB register PerformanceObservers that web-vitals v5 gives no way to unsubscribe, so vitals kept reporting after shutdown(), and every re-initialize() stacked another live callback holding a torn-down instance. Now registered once per document and routed through a module-level slot pointing at whichever instance is currently installed.

installStartupTracker re-emitted a cold app_startup span on every install. It reads performance.getEntriesByType('navigation'), which describes the document, and fires immediately whenever readyState === 'complete' — true for every install after the first. So re-entering the host reported a page load that never happened, carrying byte-identical timings, quietly skewing startup percentiles. Latched per document, and only on a real emission so a document with no navigation entry yet stays eligible.

installRouteTracker never restored history. It captured history.pushState.bind(history) and restored that bound wrapper rather than the original function. Navigation still worked, so nothing looked broken — but the page was left altered after dispose, and each subsequent install/uninstall cycle stacked another bind layer. Unbounded, for a host that mounts on every visit. Now captures the unbound originals and calls through with apply.

Tests

Scout.initialize and Scout.shutdown had no test coverage at all, which is why none of the above surfaced. src/web/lifecycle.test.ts snapshots the page globals, installs, and asserts shutdown restores every one of them by identity — plus that repeated cycles neither stack patches nor wedge re-initialization.

That test is what found the history bug; it was not on the list when this branch started.

Also adds startup.test.ts and web-vitals.test.ts, neither of which existed. 210 → 225 tests, make ci green.

Compatibility

No API change and no behaviour change for a consumer that initializes once per page load, which is all of them today. Callers that never call shutdown() are unaffected.

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.
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.
@nitinstp23 nitinstp23 self-assigned this Aug 5, 2026
@nitinstp23
nitinstp23 requested a review from nimishgj August 5, 2026 14:16
@nitinstp23
nitinstp23 merged commit bf191d9 into main Aug 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant