From 7448cd0e2025b81d9f8a526e28a0ec0bd77e5a7c Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Tue, 8 Sep 2026 12:20:07 +0530 Subject: [PATCH 1/6] refactor(contract): split unclean exits and frustrations off their span names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two wire-contract additions the fixes that follow build on. app_unclean_exit exists because "the session ended without a clean shutdown signal" is not "the app crashed" — pagehide does not fire on force-quit, OS shutdown, tab discard or task-switcher eviction, so its absence is not evidence of a crash. It stays out of ERROR_CLASS_SPANS so it neither bypasses sampling as an error nor lands in the crash tables. user_frustration exists because the original user_interaction span is already emitted and ended by the time frustration detection completes, so annotating it in place is not reachable, and re-emitting under the same name double-counted. Also adds the crash.service.name / crash.service.version / crash.environment keys. Resource attributes are frozen at provider construction and there is no per-span override, so a deferred marker cannot restate where it came from except as span attributes. --- src/core/attributes.test.ts | 15 ++++++++++++++- src/core/attributes.ts | 6 ++++++ src/core/spans.ts | 10 ++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/core/attributes.test.ts b/src/core/attributes.test.ts index 59cc3c2..284bc71 100644 --- a/src/core/attributes.test.ts +++ b/src/core/attributes.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { ATTR } from './attributes'; -import { SPAN, BREADCRUMB_TYPE } from './spans'; +import { SPAN, BREADCRUMB_TYPE, ERROR_CLASS_SPANS } from './spans'; import { METRIC } from './metrics'; describe('attribute / span / metric name contract', () => { it('keeps semantic attribute keys stable', () => { @@ -23,6 +23,10 @@ describe('attribute / span / metric name contract', () => { expect(ATTR.APP_STARTUP_DURATION).toBe('app_startup.duration'); expect(ATTR.LONG_TASK_DURATION).toBe('long_task.duration'); expect(ATTR.ANR_DURATION).toBe('anr.duration'); + expect(ATTR.CRASH_SERVICE_NAME).toBe('crash.service.name'); + expect(ATTR.CRASH_SERVICE_VERSION).toBe('crash.service.version'); + expect(ATTR.CRASH_ENVIRONMENT).toBe('crash.environment'); + expect(ATTR.USER_INTERACTION_FRUSTRATION_TYPE).toBe('action.frustration.type'); expect(ATTR.DEVICE_BATTERY_LEVEL).toBe('device.battery.level'); expect(ATTR.DEVICE_BATTERY_STATE).toBe('device.battery.state'); expect(ATTR.NETWORK_CONNECTION_TYPE).toBe('network.connection.type'); @@ -36,12 +40,21 @@ describe('attribute / span / metric name contract', () => { expect(SPAN.APP_PAUSED).toBe('app_paused'); expect(SPAN.APP_RESUMED).toBe('app_resumed'); expect(SPAN.APP_CRASH).toBe('app_crash'); + expect(SPAN.APP_UNCLEAN_EXIT).toBe('app_unclean_exit'); + expect(SPAN.USER_FRUSTRATION).toBe('user_frustration'); expect(SPAN.ERROR).toBe('error'); expect(SPAN.LONG_TASK).toBe('long_task'); expect(SPAN.FROZEN_FRAME).toBe('frozen_frame'); expect(SPAN.ANR).toBe('anr'); expect(SPAN.HTTP_REQUEST).toBe('http.request'); }); + it('keeps app_unclean_exit out of the error-class span set', () => { + // Membership here means "bypasses sampling as an error" and is what the + // backend's crash tables select on. A tab close is neither. + expect(ERROR_CLASS_SPANS.has(SPAN.APP_CRASH)).toBe(true); + expect(ERROR_CLASS_SPANS.has(SPAN.APP_UNCLEAN_EXIT)).toBe(false); + expect(ERROR_CLASS_SPANS.has(SPAN.USER_FRUSTRATION)).toBe(false); + }); it('keeps the breadcrumb type tags parity', () => { expect(BREADCRUMB_TYPE.TAP).toBe('tap'); expect(BREADCRUMB_TYPE.NAVIGATION).toBe('navigation'); diff --git a/src/core/attributes.ts b/src/core/attributes.ts index 6204fd6..28af952 100644 --- a/src/core/attributes.ts +++ b/src/core/attributes.ts @@ -120,6 +120,12 @@ export const ATTR = { CRASH_LAST_SCREEN: 'crash.last_screen', CRASH_TYPE: 'crash.type', CRASH_REASON: 'crash.reason', + /** Where the session actually died. Resource attributes are frozen at + * provider construction, so a deferred marker cannot restate them; these + * carry the originating identity as span attributes instead. */ + CRASH_SERVICE_NAME: 'crash.service.name', + CRASH_SERVICE_VERSION: 'crash.service.version', + CRASH_ENVIRONMENT: 'crash.environment', /** Which detection path produced the record: exit_info, ndk_signal, ... */ CRASH_SOURCE: 'crash.source', CRASH_DRAIN_APP_STATE: 'crash.drain_app_state', diff --git a/src/core/spans.ts b/src/core/spans.ts index 063cae6..34ada7d 100644 --- a/src/core/spans.ts +++ b/src/core/spans.ts @@ -1,5 +1,10 @@ export const SPAN = { USER_INTERACTION: 'user_interaction', + /** A frustration signal (dead / rage / error click) about an interaction. + * A separate name because the original `user_interaction` span is already + * emitted and ended by the time detection completes, and re-emitting under + * that name double-counted every frustrated click in `view.action.count`. */ + USER_FRUSTRATION: 'user_frustration', SCREEN_VIEW: 'screen_view', SCREEN_LOAD: 'screen_load', VIEW_SESSION: 'view_session', @@ -7,6 +12,11 @@ export const SPAN = { APP_PAUSED: 'app_paused', APP_RESUMED: 'app_resumed', APP_CRASH: 'app_crash', + /** A session that ended without a clean shutdown signal. Deliberately NOT + * `app_crash`: `pagehide` does not fire on force-quit, OS shutdown, tab + * discard or task-switcher eviction, so its absence is not evidence of a + * crash and must not depress crash-free rate. */ + APP_UNCLEAN_EXIT: 'app_unclean_exit', NATIVE_CRASH: 'native_crash', ERROR: 'error', LONG_TASK: 'long_task', From 3c3ee9ee1f94d5917590e784dff2f2d4e7ee8057 Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Tue, 8 Sep 2026 12:20:17 +0530 Subject: [PATCH 2/6] fix(anr): ignore background-tab throttling, name the unit, size the span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector is a Web Worker watchdog: the main thread posts a beat every 1000ms and the worker reports how late it arrives. Workers are not throttled but a hidden tab's timers are clamped to roughly once a minute, so the worker faithfully measured a ~59s lateness that was throttling, not a hang. One idle tab produced 11 anr spans in 11 minutes with inter-arrival gaps spread across 16ms — a metronome, which no real workload-dependent stall could be. The main thread now stops beating while hidden and resets the worker's baseline before it resumes. The reset matters on its own: without it the first beat back charges the whole background interval, which is how a "resumed" lifecycle event was followed 16ms later by a 35s anr. Spans also carry anr.visibility_state; the SDK recorded no visibility signal at all, so it could not distinguish a hidden tab from a hung one. The span is no longer a zero-duration marker either. The hang length lived only in a bespoke string attribute, so the trace waterfall, p95 duration panels and anything else generic over spans read 0 — which at one point suggested the ANR data was empty when it was not. Finally, anr.duration / anr.threshold carried SECONDS under names that gave no unit, while the SDK's own option is anrThresholdMs. That mismatch already produced a 1000x display bug downstream. They become anr.duration_ms / anr.threshold_ms. Renaming rather than redefining in place is deliberate: the same key silently changing units by 1000x is exactly the failure that was paid for once already, and distinct keys let a single query serve old and new rows. The test suite is new; it executes the real worker source rather than a copy, so the reset and threshold logic are covered rather than reimplemented. --- src/core/attributes.test.ts | 4 +- src/core/attributes.ts | 8 +- src/native/instrumentations/anr.ts | 11 +- src/web/instrumentations/anr.test.ts | 220 +++++++++++++++++++++++++++ src/web/instrumentations/anr.ts | 70 +++++++-- 5 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 src/web/instrumentations/anr.test.ts diff --git a/src/core/attributes.test.ts b/src/core/attributes.test.ts index 284bc71..d836254 100644 --- a/src/core/attributes.test.ts +++ b/src/core/attributes.test.ts @@ -22,7 +22,9 @@ describe('attribute / span / metric name contract', () => { expect(ATTR.APP_STARTUP_TYPE).toBe('app_startup.type'); expect(ATTR.APP_STARTUP_DURATION).toBe('app_startup.duration'); expect(ATTR.LONG_TASK_DURATION).toBe('long_task.duration'); - expect(ATTR.ANR_DURATION).toBe('anr.duration'); + expect(ATTR.ANR_DURATION_MS).toBe('anr.duration_ms'); + expect(ATTR.ANR_THRESHOLD_MS).toBe('anr.threshold_ms'); + expect(ATTR.ANR_VISIBILITY_STATE).toBe('anr.visibility_state'); expect(ATTR.CRASH_SERVICE_NAME).toBe('crash.service.name'); expect(ATTR.CRASH_SERVICE_VERSION).toBe('crash.service.version'); expect(ATTR.CRASH_ENVIRONMENT).toBe('crash.environment'); diff --git a/src/core/attributes.ts b/src/core/attributes.ts index 28af952..7ca3b35 100644 --- a/src/core/attributes.ts +++ b/src/core/attributes.ts @@ -140,8 +140,12 @@ export const ATTR = { LONG_TASK_STYLE_AND_LAYOUT_START_MS: 'long_task.style_and_layout_start_ms', LONG_TASK_FIRST_UI_EVENT_TIMESTAMP_MS: 'long_task.first_ui_event_timestamp_ms', LONG_TASK_SCRIPTS_JSON: 'long_task.scripts_json', - ANR_DURATION: 'anr.duration', - ANR_THRESHOLD: 'anr.threshold', + /** Milliseconds. Named for its unit so it can never be confused with the + * pre-0.1.17 `anr.duration`, which carried seconds under an unsuffixed key. */ + ANR_DURATION_MS: 'anr.duration_ms', + ANR_THRESHOLD_MS: 'anr.threshold_ms', + /** `visible` on every span the detector emits — hidden tabs are not sampled. */ + ANR_VISIBILITY_STATE: 'anr.visibility_state', ANR_MAIN_THREAD_STACK: 'anr.main_thread_stack', ANR_THREADS_JSON: 'anr.threads_json', ANR_THREAD_COUNT: 'anr.thread_count', diff --git a/src/native/instrumentations/anr.ts b/src/native/instrumentations/anr.ts index af7f0ac..5e6d40b 100644 --- a/src/native/instrumentations/anr.ts +++ b/src/native/instrumentations/anr.ts @@ -44,8 +44,8 @@ export async function installNativeAnrDetector( const screen = getCurrentScreen(); const source = typeof payload?.source === 'string' ? payload.source : 'main'; const attrs: Record = { - [ATTR.ANR_DURATION]: durationMs / 1000, - [ATTR.ANR_THRESHOLD]: Number(payload?.thresholdMs ?? thresholdMs) / 1000, + [ATTR.ANR_DURATION_MS]: durationMs, + [ATTR.ANR_THRESHOLD_MS]: Number(payload?.thresholdMs ?? thresholdMs), 'anr.source_thread': source, ...(screen ? { [ATTR.SCREEN_NAME]: screen } : {}), [ATTR.BREADCRUMBS]: scout.breadcrumbsManager.serialize(), @@ -64,7 +64,12 @@ export async function installNativeAnrDetector( attrs[ATTR.ANR_THREAD_COUNT] = payload.threadCount; } try { - scout.emitSpan(SPAN.ANR, attrs as Record); + // The hang belongs in the span's duration, not only in an attribute. + const endTime = Date.now(); + scout.emitSpan(SPAN.ANR, attrs as Record, { + startTime: endTime - durationMs, + endTime, + }); scout.breadcrumbsManager.add( BREADCRUMB_TYPE.ANR, `App not responding (${source}): ${Math.round(durationMs)}ms`, diff --git a/src/web/instrumentations/anr.test.ts b/src/web/instrumentations/anr.test.ts new file mode 100644 index 0000000..177595b --- /dev/null +++ b/src/web/instrumentations/anr.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { hrTimeToMilliseconds } from '@opentelemetry/core'; +import { installAnrDetector } from './anr'; +import { Scout } from '../../core/scout'; +import { SPAN } from '../../core/spans'; +import { makeRecorder, memoryPlatform, type Recorder } from '../../test/recorder'; + +const THRESHOLD_MS = 5000; + +async function makeScout() { + const s = new Scout( + { + serviceName: 'test-svc', + endpoint: 'http://localhost:4318', + secure: false, + sessionSampleRate: 100, + }, + memoryPlatform(), + ); + await s.bootstrap(); + return s; +} + +/** + * Captures what the detector posts to its worker, and hands back the real + * worker source so the worker's own logic can be exercised (see runWorker). + */ +function stubWorker() { + const posted: Array> = []; + let source = ''; + const instance = { + onmessage: null as ((e: MessageEvent) => void) | null, + postMessage: (m: Record) => void posted.push(m), + terminate: vi.fn(), + }; + vi.stubGlobal( + 'Blob', + class { + constructor(parts: string[]) { + source = parts.join(''); + } + }, + ); + vi.stubGlobal('URL', { createObjectURL: () => 'blob:stub', revokeObjectURL: () => {} }); + vi.stubGlobal( + 'Worker', + class { + constructor() { + return instance as unknown as Worker; + } + }, + ); + return { + posted, + instance, + source: () => source, + /** Delivers an `anr` report from the worker to the main thread. */ + report: (durationMs: number) => + instance.onmessage?.({ data: { type: 'anr', durationMs } } as MessageEvent), + }; +} + +/** Runs the detector's actual worker source against a fake `self`. */ +function runWorker(source: string) { + const out: Array> = []; + const self = { + onmessage: null as ((e: { data: unknown }) => void) | null, + postMessage: (m: Record) => void out.push(m), + }; + new Function('self', source)(self); + return { + out, + send: (data: unknown) => self.onmessage?.({ data }), + }; +} + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }); + document.dispatchEvent(new Event('visibilitychange')); +} + +describe('ANR detector', () => { + let recorder: Recorder; + const disposers: Array<() => void> = []; + beforeEach(() => { + recorder = makeRecorder(); + vi.useFakeTimers(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); + }); + afterEach(() => { + disposers.splice(0).forEach((d) => d()); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('stops beating while the tab is hidden, so throttling cannot look like a hang', async () => { + const w = stubWorker(); + const scout = await makeScout(); + disposers.push(installAnrDetector(scout, THRESHOLD_MS)); + + vi.advanceTimersByTime(3000); + expect(w.posted.filter((m) => m.type === 'beat')).toHaveLength(3); + + setVisibility('hidden'); + w.posted.length = 0; + // A backgrounded tab clamps timers to ~1/min; five minutes of wall clock. + vi.advanceTimersByTime(5 * 60 * 1000); + expect(w.posted).toHaveLength(0); + }); + + it('resets the worker baseline before beats resume, so the hidden gap is not charged', async () => { + const w = stubWorker(); + const scout = await makeScout(); + disposers.push(installAnrDetector(scout, THRESHOLD_MS)); + + setVisibility('hidden'); + vi.advanceTimersByTime(5 * 60 * 1000); + w.posted.length = 0; + setVisibility('visible'); + + expect(w.posted[0]).toEqual({ type: 'reset' }); + vi.advanceTimersByTime(1000); + expect(w.posted[1]).toEqual({ type: 'beat' }); + }); + + it('does not beat when installed into an already-hidden tab', async () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); + const w = stubWorker(); + const scout = await makeScout(); + disposers.push(installAnrDetector(scout, THRESHOLD_MS)); + + vi.advanceTimersByTime(10_000); + expect(w.posted).toHaveLength(0); + }); + + it('emits a span whose duration is the hang, in milliseconds', async () => { + const w = stubWorker(); + const scout = await makeScout(); + disposers.push(installAnrDetector(scout, THRESHOLD_MS)); + + w.report(7000); + + const spans = recorder.spans().filter((s) => s.name === SPAN.ANR); + expect(spans).toHaveLength(1); + const span = spans[0]!; + expect(hrTimeToMilliseconds(span.duration)).toBeCloseTo(7000, 0); + expect(span.attributes['anr.duration_ms']).toBe(7000); + expect(span.attributes['anr.threshold_ms']).toBe(THRESHOLD_MS); + expect(span.attributes['anr.visibility_state']).toBe('visible'); + // The ambiguous seconds keys are gone, not merely supplemented. + expect(span.attributes['anr.duration']).toBeUndefined(); + expect(span.attributes['anr.threshold']).toBeUndefined(); + }); + + it('ignores a non-positive or unparseable duration', async () => { + const w = stubWorker(); + const scout = await makeScout(); + disposers.push(installAnrDetector(scout, THRESHOLD_MS)); + + w.report(0); + w.report(Number.NaN); + + expect(recorder.spans().filter((s) => s.name === SPAN.ANR)).toHaveLength(0); + }); + + it('stops beating and terminates the worker on dispose', async () => { + const w = stubWorker(); + const scout = await makeScout(); + const dispose = installAnrDetector(scout, THRESHOLD_MS); + dispose(); + + w.posted.length = 0; + vi.advanceTimersByTime(10_000); + setVisibility('visible'); + expect(w.posted).toHaveLength(0); + expect(w.instance.terminate).toHaveBeenCalled(); + }); + + describe('the worker itself', () => { + async function sourceOf() { + const w = stubWorker(); + const scout = await makeScout(); + disposers.push(installAnrDetector(scout, THRESHOLD_MS)); + return w.source(); + } + + it('reports lag beyond the threshold', async () => { + const worker = runWorker(await sourceOf()); + vi.setSystemTime(Date.now() + 1000 + THRESHOLD_MS + 1); + worker.send({ type: 'beat' }); + expect(worker.out).toEqual([{ type: 'anr', durationMs: THRESHOLD_MS + 1 }]); + }); + + it('stays quiet for an on-time beat', async () => { + const worker = runWorker(await sourceOf()); + vi.setSystemTime(Date.now() + 1000); + worker.send({ type: 'beat' }); + expect(worker.out).toHaveLength(0); + }); + + it('treats `reset` as a new baseline rather than a hang', async () => { + const worker = runWorker(await sourceOf()); + vi.setSystemTime(Date.now() + 5 * 60 * 1000); + worker.send({ type: 'reset' }); + vi.setSystemTime(Date.now() + 1000); + worker.send({ type: 'beat' }); + expect(worker.out).toHaveLength(0); + }); + }); +}); diff --git a/src/web/instrumentations/anr.ts b/src/web/instrumentations/anr.ts index c3d5d9d..4d4c4a2 100644 --- a/src/web/instrumentations/anr.ts +++ b/src/web/instrumentations/anr.ts @@ -6,10 +6,20 @@ export function installAnrDetector(scout: Scout, thresholdMs: number): () => voi return () => {}; } const PING_INTERVAL_MS = 1000; + // The worker measures how late the main thread's beat arrives. That is a + // faithful measure of main-thread lateness — but a hidden tab's timers are + // clamped to roughly once a minute, and throttling is indistinguishable from + // blocking from in here. The main thread stops beating while hidden and + // sends `reset` before it resumes, so the worker never sees a throttled gap. const workerSrc = ` let lastBeat = Date.now(); self.onmessage = (e) => { - if (e.data && e.data.type === 'beat') { + if (!e.data) return; + if (e.data.type === 'reset') { + lastBeat = Date.now(); + return; + } + if (e.data.type === 'beat') { const now = Date.now(); const lag = now - lastBeat - ${PING_INTERVAL_MS}; lastBeat = now; @@ -29,24 +39,62 @@ export function installAnrDetector(scout: Scout, thresholdMs: number): () => voi } catch { return () => {}; } + const visibilityState = (): string => + typeof document === 'undefined' ? 'unknown' : (document.visibilityState ?? 'unknown'); + const isVisible = (): boolean => visibilityState() !== 'hidden'; worker.onmessage = (e: MessageEvent) => { if (e.data?.type !== 'anr') return; const duration = Number(e.data.durationMs); - if (!Number.isFinite(duration)) return; + if (!Number.isFinite(duration) || duration <= 0) return; try { - scout.emitSpan(SPAN.ANR, { - [ATTR.ANR_DURATION]: duration / 1000, - [ATTR.ANR_THRESHOLD]: thresholdMs / 1000, - ...scout.commonAttributes(), - }); + // A span's duration is the right home for how long the hang lasted. + // Emitting it as a zero-duration marker hid the value from the waterfall + // and from every generic p95-over-Duration consumer. + const endTime = Date.now(); + scout.emitSpan( + SPAN.ANR, + { + [ATTR.ANR_DURATION_MS]: duration, + [ATTR.ANR_THRESHOLD_MS]: thresholdMs, + [ATTR.ANR_VISIBILITY_STATE]: visibilityState(), + ...scout.commonAttributes(), + }, + { startTime: endTime - duration, endTime }, + ); scout.addBreadcrumb(BREADCRUMB_TYPE.ANR, `${Math.round(duration)}ms`); } catch {} }; - beatTimer = setInterval(() => { - worker?.postMessage({ type: 'beat' }); - }, PING_INTERVAL_MS); + const startBeating = () => { + if (beatTimer) return; + beatTimer = setInterval(() => { + worker?.postMessage({ type: 'beat' }); + }, PING_INTERVAL_MS); + }; + const stopBeating = () => { + if (!beatTimer) return; + clearInterval(beatTimer); + beatTimer = null; + }; + const onVisibility = () => { + if (isVisible()) { + // Discard the gap we just spent hidden before the next beat charges it + // as a hang — that is what produced an `anr` 16ms after `resumed`. + worker?.postMessage({ type: 'reset' }); + startBeating(); + } else { + stopBeating(); + } + }; + // A tab can be opened in the background; don't start beating until it shows. + if (isVisible()) startBeating(); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibility); + } return () => { - if (beatTimer) clearInterval(beatTimer); + stopBeating(); + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibility); + } worker?.terminate(); worker = null; }; From d499a29b35c941a28f03a45ff60322d4e34920ac Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Tue, 8 Sep 2026 12:20:27 +0530 Subject: [PATCH 3/6] fix(crash): scope markers per tenant, stop counting tab closes as crashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred crash marker lived under one unscoped localStorage key, and a single origin can serve many tenants under different paths. A tab that died in one tenant was therefore filed against whichever tenant the browser opened next, carrying that tenant's URLs and screen names into another's partition. The key is now scoped by service name and environment, which stops the cross-read at source rather than repairing the attribution afterwards. A legacy marker cannot be attributed to any tenant, so it is discarded rather than guessed at — guessing is what produced the bug. The marker also records the originating service name, version and environment and reports them as crash.* attributes, since the resource cannot restate them. The span's screen.name is now the dead session's rather than the detecting page's, which previously sat next to crash.last_screen describing a different page. These markers are no longer app_crash. Writing them as app_crash meant routine tab closes fed the crash counters and depressed crash-free rate. They are emitted as app_unclean_exit with forceSample, since they report on a previous session and the current session's sample decision has nothing to say about them. --- src/native/instrumentations/crash.ts | 36 ++++---- src/web/instrumentations/crash.test.ts | 115 +++++++++++++++++++++++-- src/web/instrumentations/crash.ts | 66 ++++++++++---- 3 files changed, 178 insertions(+), 39 deletions(-) diff --git a/src/native/instrumentations/crash.ts b/src/native/instrumentations/crash.ts index e0d1f33..4f485a5 100644 --- a/src/native/instrumentations/crash.ts +++ b/src/native/instrumentations/crash.ts @@ -36,21 +36,27 @@ export async function installNativeCrashDetector(scout: Scout): Promise<() => vo const common = scout.commonAttributes(); common[ATTR.SESSION_ID] = prev.sessionId; common[ATTR.SESSION_START_TIME] = prev.startedAt; - scout.emitSpan(SPAN.APP_CRASH, { - [ATTR.CRASH_PREVIOUS_SESSION_ID]: prev.sessionId, - [ATTR.CRASH_STARTED_AT]: prev.startedAt, - // When the app was last known alive, not when we noticed on relaunch. - [ATTR.CRASH_TIMESTAMP]: prev.lastActiveAt ?? prev.startedAt, - [ATTR.CRASH_STATUS]: 'session_marker', - [ATTR.CRASH_LAST_SCREEN]: - prev.lastScreen || - lastScreenFromBreadcrumbs(scout.breadcrumbsManager.orphaned()), - [ATTR.CRASH_TYPE]: 'unclean_termination', - // The dead session's trail — the live one is empty this early, and - // would describe the wrong session anyway. - [ATTR.BREADCRUMBS]: scout.breadcrumbsManager.serializeOrphaned() ?? '[]', - ...common, - }); + scout.emitSpan( + SPAN.APP_UNCLEAN_EXIT, + { + [ATTR.CRASH_PREVIOUS_SESSION_ID]: prev.sessionId, + [ATTR.CRASH_STARTED_AT]: prev.startedAt, + // When the app was last known alive, not when we noticed on relaunch. + [ATTR.CRASH_TIMESTAMP]: prev.lastActiveAt ?? prev.startedAt, + [ATTR.CRASH_STATUS]: 'session_marker', + [ATTR.CRASH_LAST_SCREEN]: + prev.lastScreen || + lastScreenFromBreadcrumbs(scout.breadcrumbsManager.orphaned()), + [ATTR.CRASH_TYPE]: 'unclean_termination', + // The dead session's trail — the live one is empty this early, and + // would describe the wrong session anyway. + [ATTR.BREADCRUMBS]: scout.breadcrumbsManager.serializeOrphaned() ?? '[]', + ...common, + }, + // Reports on a *previous* session, so the current session's sample + // decision must not gate it. Not an error-class span, so no bypass. + { forceSample: true }, + ); } } } catch {} diff --git a/src/web/instrumentations/crash.test.ts b/src/web/instrumentations/crash.test.ts index 6e48d90..67e94f6 100644 --- a/src/web/instrumentations/crash.test.ts +++ b/src/web/instrumentations/crash.test.ts @@ -6,15 +6,17 @@ import { ATTR } from '../../core/attributes'; import { SPAN } from '../../core/spans'; import { makeRecorder, memoryPlatform, type Recorder } from '../../test/recorder'; -const MARKER_KEY = 'scout.session-marker'; +const LEGACY_MARKER_KEY = 'scout.session-marker'; +const MARKER_KEY = `${LEGACY_MARKER_KEY}:test-svc:`; -async function makeScout() { +async function makeScout(identity: { serviceName?: string; environment?: string } = {}) { const s = new Scout( { serviceName: 'test-svc', endpoint: 'http://localhost:4318', secure: false, sessionSampleRate: 100, + ...identity, }, memoryPlatform(), ); @@ -38,7 +40,7 @@ function memoryStorage() { }; } -describe('app_crash from the session marker', () => { +describe('app_unclean_exit from the session marker', () => { let recorder: Recorder; let storage: ReturnType; const disposers: Array<() => void> = []; @@ -53,9 +55,9 @@ describe('app_crash from the session marker', () => { vi.useRealTimers(); }); - function seedCrashedSession(marker: Record) { + function seedCrashedSession(marker: Record, key: string = MARKER_KEY) { storage.setItem( - MARKER_KEY, + key, JSON.stringify({ sessionId: 'dead-session', startedAt: '2026-01-01T00:00:00.000Z', @@ -70,7 +72,7 @@ describe('app_crash from the session marker', () => { seedCrashedSession({ lastActiveAt: '2026-01-01T00:05:00.000Z' }); const s = await makeScout(); disposers.push(installCrashDetector(s)); - const span = recorder.spans().find((sp) => sp.name === SPAN.APP_CRASH); + const span = recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT); expect(span).toBeDefined(); expect(span!.attributes[ATTR.SESSION_ID]).toBe('dead-session'); expect(span!.attributes[ATTR.SESSION_ID]).not.toBe(s.sessionId); @@ -82,7 +84,7 @@ describe('app_crash from the session marker', () => { seedCrashedSession({ lastActiveAt: '2026-01-01T00:05:00.000Z' }); const s = await makeScout(); disposers.push(installCrashDetector(s)); - const span = recorder.spans().find((sp) => sp.name === SPAN.APP_CRASH); + const span = recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT); expect(span!.attributes[ATTR.CRASH_TIMESTAMP]).toBe('2026-01-01T00:05:00.000Z'); }); @@ -92,7 +94,7 @@ describe('app_crash from the session marker', () => { seedCrashedSession({}); const s = await makeScout(); disposers.push(installCrashDetector(s)); - const span = recorder.spans().find((sp) => sp.name === SPAN.APP_CRASH); + const span = recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT); expect(span!.attributes[ATTR.CRASH_TIMESTAMP]).toBe('2026-01-01T00:00:00.000Z'); }); @@ -121,7 +123,7 @@ describe('app_crash from the session marker', () => { await s.bootstrap(); s.addBreadcrumb('tap', 'a crumb from the new session'); disposers.push(installCrashDetector(s)); - const span = recorder.spans().find((sp) => sp.name === SPAN.APP_CRASH); + const span = recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT); const crumbs = JSON.parse(String(span!.attributes[ATTR.BREADCRUMBS])); expect(crumbs).toHaveLength(1); expect(crumbs[0].message).toBe('screen: /checkout'); @@ -131,7 +133,102 @@ describe('app_crash from the session marker', () => { seedCrashedSession({ active: false }); const s = await makeScout(); disposers.push(installCrashDetector(s)); + expect( + recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT), + ).toBeUndefined(); + }); + + it('never emits app_crash, so a routine tab close cannot depress crash-free rate', async () => { + seedCrashedSession({ lastActiveAt: '2026-01-01T00:05:00.000Z' }); + const s = await makeScout(); + disposers.push(installCrashDetector(s)); expect(recorder.spans().find((sp) => sp.name === SPAN.APP_CRASH)).toBeUndefined(); + expect( + recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT), + ).toBeDefined(); + }); + + it('does not read another tenant’s marker from the same origin', async () => { + // One host serves many tenants under different paths; before the key was + // scoped, oteldemo2's dead tab was filed against whichever tenant loaded next. + seedCrashedSession( + { sessionId: 'oteldemo2-session', lastScreen: '/oteldemo2/a/base14-logx-app' }, + `${LEGACY_MARKER_KEY}:other-svc:nbg1-oteldemo2`, + ); + const s = await makeScout({ serviceName: 'test-svc', environment: 'nbg1-axi' }); + disposers.push(installCrashDetector(s)); + expect( + recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT), + ).toBeUndefined(); + }); + + it('reads back only its own tenant’s marker', async () => { + seedCrashedSession( + { sessionId: 'axi-session' }, + `${LEGACY_MARKER_KEY}:test-svc:nbg1-axi`, + ); + const s = await makeScout({ environment: 'nbg1-axi' }); + disposers.push(installCrashDetector(s)); + const span = recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT); + expect(span!.attributes[ATTR.CRASH_PREVIOUS_SESSION_ID]).toBe('axi-session'); + }); + + it('discards an unattributable legacy marker instead of guessing a tenant', async () => { + seedCrashedSession({ sessionId: 'ambiguous' }, LEGACY_MARKER_KEY); + const s = await makeScout(); + disposers.push(installCrashDetector(s)); + expect( + recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT), + ).toBeUndefined(); + expect(storage.getItem(LEGACY_MARKER_KEY)).toBeNull(); + }); + + it('carries the originating identity, which resource attributes cannot restate', async () => { + seedCrashedSession( + { + lastActiveAt: '2026-01-01T00:05:00.000Z', + serviceName: 'test-svc', + serviceVersion: '9.9.9', + environment: 'nbg1-axi', + }, + `${LEGACY_MARKER_KEY}:test-svc:nbg1-axi`, + ); + const s = await makeScout({ environment: 'nbg1-axi' }); + disposers.push(installCrashDetector(s)); + const span = recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT); + expect(span!.attributes[ATTR.CRASH_SERVICE_NAME]).toBe('test-svc'); + expect(span!.attributes[ATTR.CRASH_SERVICE_VERSION]).toBe('9.9.9'); + expect(span!.attributes[ATTR.CRASH_ENVIRONMENT]).toBe('nbg1-axi'); + }); + + it('reports the dead session’s screen, not the page that detected it', async () => { + seedCrashedSession({ lastActiveAt: '2026-01-01T00:05:00.000Z' }); + const s = await makeScout(); + s.setCurrentScreen('/the-new-page'); + disposers.push(installCrashDetector(s)); + const span = recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT); + expect(span!.attributes[ATTR.SCREEN_NAME]).toBe('/checkout'); + expect(span!.attributes[ATTR.CRASH_LAST_SCREEN]).toBe('/checkout'); + }); + + it('survives the current session being sampled out', async () => { + // The marker describes a previous session; the live session's sample + // decision has nothing to say about whether it should be reported. + seedCrashedSession({ lastActiveAt: '2026-01-01T00:05:00.000Z' }); + const s = new Scout( + { + serviceName: 'test-svc', + endpoint: 'http://localhost:4318', + secure: false, + sessionSampleRate: 0, + }, + memoryPlatform(), + ); + await s.bootstrap(); + disposers.push(installCrashDetector(s)); + expect( + recorder.spans().find((sp) => sp.name === SPAN.APP_UNCLEAN_EXIT), + ).toBeDefined(); }); it('records the live session’s own start time in the marker it writes', async () => { diff --git a/src/web/instrumentations/crash.ts b/src/web/instrumentations/crash.ts index a7c6d0b..7cbbcdb 100644 --- a/src/web/instrumentations/crash.ts +++ b/src/web/instrumentations/crash.ts @@ -2,7 +2,14 @@ import { ATTR } from '../../core/attributes'; import { SPAN, BREADCRUMB_TYPE } from '../../core/spans'; import type { Scout } from '../../core/scout'; import { getCurrentScreen } from './route'; -const MARKER_KEY = 'scout.session-marker'; +/** + * Pre-0.1.17 key. One origin can serve many tenants (a Grafana host serves + * every tenant under its own path), so a single unscoped key let one tenant's + * marker be read — and filed — by whichever tenant loaded next. + */ +const LEGACY_MARKER_KEY = 'scout.session-marker'; +const markerKeyFor = (serviceName: string, environment?: string): string => + `${LEGACY_MARKER_KEY}:${serviceName}:${environment ?? ''}`; const TICK_MS = 2000; /** 5 ticks × 2s = refresh `lastActiveAt` at least every 10s. */ const HEARTBEAT_TICKS = 5; @@ -13,9 +20,22 @@ interface Marker { active: boolean; /** Wall-clock of the last time the tab was known to be alive. */ lastActiveAt?: string; + /** Identity of the session that wrote this, restored on flush. */ + serviceName?: string; + serviceVersion?: string; + environment?: string; } export function installCrashDetector(scout: Scout): () => void { if (typeof localStorage === 'undefined') return () => {}; + const { serviceName, serviceVersion, environment } = scout.config; + const MARKER_KEY = markerKeyFor(serviceName, environment); + try { + // A legacy marker cannot be attributed to a tenant, so it is discarded + // rather than guessed at — guessing is what produced the bug. + if (localStorage.getItem(LEGACY_MARKER_KEY) !== null) { + localStorage.removeItem(LEGACY_MARKER_KEY); + } + } catch {} try { const raw = localStorage.getItem(MARKER_KEY); if (raw) { @@ -26,20 +46,33 @@ export function installCrashDetector(scout: Scout): () => void { const common = scout.commonAttributes(); common[ATTR.SESSION_ID] = prev.sessionId; common[ATTR.SESSION_START_TIME] = prev.startedAt; - scout.emitSpan(SPAN.APP_CRASH, { - [ATTR.CRASH_PREVIOUS_SESSION_ID]: prev.sessionId, - [ATTR.CRASH_STARTED_AT]: prev.startedAt, - // When the tab was last known alive, not when we noticed on reload. - [ATTR.CRASH_TIMESTAMP]: prev.lastActiveAt ?? prev.startedAt, - [ATTR.CRASH_STATUS]: 'session_marker', - [ATTR.CRASH_LAST_SCREEN]: prev.lastScreen, - [ATTR.CRASH_TYPE]: 'unclean_termination', - [ATTR.CRASH_REASON]: 'tab_terminated_without_pagehide', - // The dead session's trail — the live one is empty this early, and - // would describe the wrong session anyway. - [ATTR.BREADCRUMBS]: scout.breadcrumbsManager.serializeOrphaned() ?? '[]', - ...common, - }); + // Otherwise the *new* page's screen rides along next to the dead + // session's crash.last_screen, describing two different pages. + common[ATTR.SCREEN_NAME] = prev.lastScreen; + scout.emitSpan( + SPAN.APP_UNCLEAN_EXIT, + { + [ATTR.CRASH_PREVIOUS_SESSION_ID]: prev.sessionId, + [ATTR.CRASH_STARTED_AT]: prev.startedAt, + // When the tab was last known alive, not when we noticed on reload. + [ATTR.CRASH_TIMESTAMP]: prev.lastActiveAt ?? prev.startedAt, + [ATTR.CRASH_STATUS]: 'session_marker', + [ATTR.CRASH_LAST_SCREEN]: prev.lastScreen, + [ATTR.CRASH_TYPE]: 'unclean_termination', + [ATTR.CRASH_REASON]: 'tab_terminated_without_pagehide', + [ATTR.CRASH_SERVICE_NAME]: prev.serviceName ?? serviceName, + [ATTR.CRASH_SERVICE_VERSION]: prev.serviceVersion ?? serviceVersion, + [ATTR.CRASH_ENVIRONMENT]: prev.environment ?? environment ?? '', + // The dead session's trail — the live one is empty this early, and + // would describe the wrong session anyway. + [ATTR.BREADCRUMBS]: scout.breadcrumbsManager.serializeOrphaned() ?? '[]', + ...common, + }, + // This reports on a *previous* session; gating it on the current + // session's sample decision would drop it for unrelated reasons. + // It is not an error-class span, so it has no other bypass. + { forceSample: true }, + ); } } } catch {} @@ -53,6 +86,9 @@ export function installCrashDetector(scout: Scout): () => void { lastScreen: getLastScreen(), active, lastActiveAt: new Date().toISOString(), + serviceName, + serviceVersion, + environment, }; localStorage.setItem(MARKER_KEY, JSON.stringify(m)); } catch {} From e5c9b259b9c0065460a91938a4966aa00655c0e9 Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Tue, 8 Sep 2026 12:20:44 +0530 Subject: [PATCH 4/6] fix(metrics): bound metric dimensions and export histograms as delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects that compounded rather than merely added. web.vital.id (unique per measurement), web.vital.value (already the histogram's sum) and web.vital.target_selector (a ~250-character CSS chain) were metric ATTRIBUTES, so every measurement became its own time series. Meanwhile the histograms were cumulative with a fixed start time, so FCP, LCP and TTFB — which fire once per page load — had their same count=1 point re-exported every 30 seconds for the life of the page: roughly 180 rows for 3 real measurements in a half-hour session. They compound because the metrics table sorts on attributes before time. Repeats alone would compress well; wide attributes alone would be sparse. Together the repeats can never co-locate, because every repeat carries a unique id. The compression that would have absorbed the duplication is what the cardinality destroys. emitHistogram and emitGauge also folded the whole commonAttributes() bag into every point, which put user.id — routinely an email address — into metric dimensions. Those are retained longer, rolled up harder and far more expensive to delete selectively than spans. metricAttributes() replaces it with a bounded set. session.id stays: the screen web-vitals dashboards filter on it. Everything dropped from metrics is unchanged on spans and logs, where correlation belongs. Instruments are cached rather than rebuilt on every record, matching how the view counters already work. The metric side of web vitals had no test at all — recorder.metrics() was never called anywhere in the suite. --- src/core/otlp-exporter.test.ts | 10 ++- src/core/otlp-exporter.ts | 15 ++-- src/core/scout.test.ts | 51 +++++++++++++ src/core/scout.ts | 52 ++++++++++++-- src/web/instrumentations/web-vitals.test.ts | 79 +++++++++++++++++++++ src/web/instrumentations/web-vitals.ts | 11 ++- 6 files changed, 205 insertions(+), 13 deletions(-) diff --git a/src/core/otlp-exporter.test.ts b/src/core/otlp-exporter.test.ts index cf88e16..ca0d520 100644 --- a/src/core/otlp-exporter.test.ts +++ b/src/core/otlp-exporter.test.ts @@ -163,11 +163,10 @@ describe('otlp-exporter — at-most-once delivery', () => { expect(fetchMock.mock.calls[1]![1].headers.authorization).toBe('Bearer refreshed'); }); - it('keeps the stock exporter’s CUMULATIVE temporality', () => { + it('keeps counters CUMULATIVE', () => { const exporter = createOtlpMetricExporter({ url: 'https://c.test/v1/metrics' }); for (const t of [ InstrumentType.COUNTER, - InstrumentType.HISTOGRAM, InstrumentType.OBSERVABLE_GAUGE, InstrumentType.UP_DOWN_COUNTER, ]) { @@ -176,4 +175,11 @@ describe('otlp-exporter — at-most-once delivery', () => { ); } }); + + it('exports histograms as DELTA so a one-shot vital is written once', () => { + const exporter = createOtlpMetricExporter({ url: 'https://c.test/v1/metrics' }); + expect(exporter.selectAggregationTemporality!(InstrumentType.HISTOGRAM)).toBe( + AggregationTemporality.DELTA, + ); + }); }); diff --git a/src/core/otlp-exporter.ts b/src/core/otlp-exporter.ts index 63e31cd..d188ba0 100644 --- a/src/core/otlp-exporter.ts +++ b/src/core/otlp-exporter.ts @@ -8,7 +8,7 @@ import { AggregationTemporality, AggregationType, type AggregationOption, - type InstrumentType, + InstrumentType, type PushMetricExporter, type ResourceMetrics, } from '@opentelemetry/sdk-metrics'; @@ -170,10 +170,15 @@ export function createOtlpMetricExporter(opts: OtlpExporterOptions): PushMetricE ); return { export: (metrics, resultCallback) => doExport(metrics, resultCallback), - // Cumulative is what OTLPMetricExporter defaults to; anything else - // would silently change what the backend stores. - selectAggregationTemporality: (_instrumentType: InstrumentType) => - AggregationTemporality.CUMULATIVE, + // Counters stay cumulative, which is what OTLPMetricExporter defaults to. + // Histograms are delta: the vitals recorded into them fire once per page + // load, and under cumulative temporality the SDK re-exported that same + // count=1 point every export interval for the life of the page — roughly + // 180 rows for 3 real measurements in a 30-minute session. + selectAggregationTemporality: (instrumentType: InstrumentType) => + instrumentType === InstrumentType.HISTOGRAM + ? AggregationTemporality.DELTA + : AggregationTemporality.CUMULATIVE, selectAggregation: (_instrumentType: InstrumentType) => DEFAULT_AGGREGATION, forceFlush: async () => {}, shutdown: async () => { diff --git a/src/core/scout.test.ts b/src/core/scout.test.ts index 4056420..a5f3327 100644 --- a/src/core/scout.test.ts +++ b/src/core/scout.test.ts @@ -358,3 +358,54 @@ describe('sampling gates fail closed before the session is hydrated', () => { expect(recorder.spans()).toHaveLength(0); }); }); + +describe('view counters', () => { + let recorder: Recorder; + beforeEach(() => { + recorder = makeRecorder(); + }); + + async function countersFor(name: string, attrs: Record = {}) { + const s = await makeScout(); + s.setCurrentScreen('/checkout'); + s.emitSpan(name, attrs as never); + const resourceMetrics = await recorder.metrics(); + const out: Record = {}; + for (const rm of resourceMetrics) { + for (const sm of rm.scopeMetrics) { + for (const m of sm.metrics) { + for (const dp of m.dataPoints) { + out[m.descriptor.name] = (out[m.descriptor.name] ?? 0) + Number(dp.value); + } + } + } + } + return out; + } + + it('counts a plain interaction as an action and nothing else', async () => { + const counters = await countersFor(SPAN.USER_INTERACTION); + expect(counters['view.action.count']).toBe(1); + expect(counters['view.frustration.count'] ?? 0).toBe(0); + }); + + it('does not count a frustration as another action', async () => { + // The frustration describes an interaction that was already counted; + // counting it again inflated view.action.count by one per frustrated click. + const counters = await countersFor(SPAN.USER_FRUSTRATION, { + [ATTR.USER_INTERACTION_FRUSTRATION_TYPE]: 'dead_click', + }); + expect(counters['view.action.count'] ?? 0).toBe(0); + expect(counters['view.frustration.count']).toBe(1); + }); + + it('does not count an unclean exit as a crash', async () => { + const counters = await countersFor(SPAN.APP_UNCLEAN_EXIT); + expect(counters['view.crash.count'] ?? 0).toBe(0); + }); + + it('still counts a real crash', async () => { + const counters = await countersFor(SPAN.APP_CRASH); + expect(counters['view.crash.count']).toBe(1); + }); +}); diff --git a/src/core/scout.ts b/src/core/scout.ts index 847cdbb..0e4c574 100644 --- a/src/core/scout.ts +++ b/src/core/scout.ts @@ -5,6 +5,8 @@ import { type Tracer, type Meter, type Span, + type Histogram, + type UpDownCounter, SpanStatusCode, } from '@opentelemetry/api'; import { logs, type Logger as OtelLogger } from '@opentelemetry/api-logs'; @@ -106,6 +108,10 @@ function extractErrorCauses(err: unknown): Array<{ return out; } export class Scout { + // Instruments are cached rather than re-created per record; the meter + // dedupes by name, but only after building and matching a descriptor. + private _histograms = new Map(); + private _gauges = new Map(); private _config: ResolvedConfig; private platform: PlatformAdapter; private tracer: Tracer; @@ -305,6 +311,28 @@ export class Scout { if (anon) attrs[ATTR.USER_ANONYMOUS_ID] = anon; return attrs; } + /** + * The subset of `commonAttributes()` that is safe as a metric dimension. + * + * Metric attributes are not span attributes. `otel_metrics_histogram` is + * ORDER BY (ServiceName, MetricName, Attributes, TimeUnix), so every distinct + * combination is its own time series, kept longer than traces and far harder + * to delete selectively. That rules out `user.id` and the caller-supplied + * `user.*` / runtime / session attribute bags, which are unbounded and, in + * `user.id`'s case, routinely an email address. + * + * `session.id` stays: it is bounded per session and dashboards filter on it. + */ + metricAttributes(): Attributes { + const attrs: Attributes = { + [ATTR.SESSION_TYPE]: 'user', + [ATTR.SESSION_SAMPLE_RATE]: String(this.session.configuredSampleRate), + }; + const sid = this.session.sessionId; + if (sid) attrs[ATTR.SESSION_ID] = sid; + if (this._currentScreen) attrs[ATTR.SCREEN_NAME] = this._currentScreen; + return attrs; + } private _anonymousId: string | null = null; private _webViewBridgeSend?: (payload: Record) => void; /** @@ -676,10 +704,16 @@ export class Scout { switch (spanName) { case SPAN.USER_INTERACTION: this.viewCounters.action?.add(1, dims); - if (attributes['action.frustration.type'] != null) { + if (attributes[ATTR.USER_INTERACTION_FRUSTRATION_TYPE] != null) { this.viewCounters.frustration?.add(1, dims); } break; + // A frustration describes an interaction that was already counted; + // counting it again is what inflated view.action.count by one for + // every frustrated click. + case SPAN.USER_FRUSTRATION: + this.viewCounters.frustration?.add(1, dims); + break; case SPAN.ERROR: this.viewCounters.error?.add(1, dims); break; @@ -727,12 +761,16 @@ export class Scout { if (!this.session.isSampled) return; const filtered = applyBeforeSend(this._config.beforeSend, 'metric', name, { ...attrs, - ...this.commonAttributes(), + ...this.metricAttributes(), value, }); if (!filtered) return; try { - const histogram = this.meter.createHistogram(name); + let histogram = this._histograms.get(name); + if (!histogram) { + histogram = this.meter.createHistogram(name); + this._histograms.set(name, histogram); + } const recordAttrs = { ...filtered.attributes }; delete recordAttrs.value; histogram.record(value, toOtelAttrs(recordAttrs)); @@ -744,12 +782,16 @@ export class Scout { if (!this.session.isSampled) return; const filtered = applyBeforeSend(this._config.beforeSend, 'metric', name, { ...attrs, - ...this.commonAttributes(), + ...this.metricAttributes(), value, }); if (!filtered) return; try { - const gauge = this.meter.createUpDownCounter(name); + let gauge = this._gauges.get(name); + if (!gauge) { + gauge = this.meter.createUpDownCounter(name); + this._gauges.set(name, gauge); + } const recordAttrs = { ...filtered.attributes }; delete recordAttrs.value; gauge.add(value, toOtelAttrs(recordAttrs)); diff --git a/src/web/instrumentations/web-vitals.test.ts b/src/web/instrumentations/web-vitals.test.ts index 33caf58..48cc8e2 100644 --- a/src/web/instrumentations/web-vitals.test.ts +++ b/src/web/instrumentations/web-vitals.test.ts @@ -70,6 +70,14 @@ describe('installWebVitalsTracker', () => { return d; } + /** Same, but hands back the Scout so a test can inspect what it emitted. */ + async function installWithScout(): Promise { + const scout = await newScout(); + scout.setCurrentScreen('/checkout'); + disposers.push(installWebVitalsTracker(scout)); + return scout; + } + it('emits a web_vital span when a metric settles', async () => { await install(); fire('LCP', 2400); @@ -98,6 +106,77 @@ describe('installWebVitalsTracker', () => { expect(Object.keys(span.attributes)).not.toContain('vital.id'); }); + // Metric dimensions are not span attributes: otel_metrics_histogram sorts by + // Attributes before TimeUnix, so a per-measurement dimension gives every + // point its own never-co-located time series. These assert the histogram's + // dimensions stay bounded even though the span above keeps the full detail. + describe('histogram dimensions', () => { + async function vitalPointAttributes(scout: Scout) { + const resourceMetrics = await recorder.metrics(); + const points = resourceMetrics + .flatMap((rm) => rm.scopeMetrics) + .flatMap((sm) => sm.metrics) + .filter((m) => m.descriptor.name.startsWith('web.vital.')) + .flatMap((m) => m.dataPoints); + expect(scout).toBeDefined(); + expect(points.length).toBeGreaterThan(0); + return points.map((p) => p.attributes); + } + + it('carries only bounded dimensions', async () => { + const scout = await installWithScout(); + fire('LCP', 2400); + for (const attrs of await vitalPointAttributes(scout)) { + expect(Object.keys(attrs).sort()).toEqual([ + 'screen.name', + 'session.id', + 'session.sample_rate', + 'session.type', + 'web.vital.name', + 'web.vital.rating', + ]); + } + }); + + it('drops the per-measurement id, the redundant value and the target selector', async () => { + const scout = await installWithScout(); + fire('LCP', 2400); + for (const attrs of await vitalPointAttributes(scout)) { + expect(attrs).not.toHaveProperty('web.vital.id'); + expect(attrs).not.toHaveProperty('web.vital.value'); + expect(attrs).not.toHaveProperty('web.vital.target_selector'); + } + }); + + it('never writes user identity into a metric dimension', async () => { + const scout = await installWithScout(); + scout.setUser('someone@example.com', { plan: 'pro' }); + fire('LCP', 2400); + for (const attrs of await vitalPointAttributes(scout)) { + expect(attrs).not.toHaveProperty('user.id'); + expect(attrs).not.toHaveProperty('user.plan'); + expect(attrs).not.toHaveProperty('user.anonymous_id'); + expect(Object.keys(attrs).some((k) => k.startsWith('user.'))).toBe(false); + } + }); + + it('keeps session.id, which the screen web-vitals dashboards filter on', async () => { + const scout = await installWithScout(); + fire('LCP', 2400); + for (const attrs of await vitalPointAttributes(scout)) { + expect(attrs['session.id']).toBe(scout.sessionId); + } + }); + + it('keeps user identity on the span, where correlation belongs', async () => { + const scout = await installWithScout(); + scout.setUser('someone@example.com'); + fire('LCP', 2400); + const [span] = recorder.spans().filter((s) => s.name === SPAN.WEB_VITAL); + expect(span.attributes['user.id']).toBe('someone@example.com'); + }); + }); + // 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. diff --git a/src/web/instrumentations/web-vitals.ts b/src/web/instrumentations/web-vitals.ts index 09a2074..b3fcc47 100644 --- a/src/web/instrumentations/web-vitals.ts +++ b/src/web/instrumentations/web-vitals.ts @@ -138,7 +138,16 @@ 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); - target.emitHistogram(metricName, m.value, { ...base, ...extras }); + // The metric gets only dimensions worth grouping by. `web.vital.id` is + // unique per measurement, `web.vital.value` is already the histogram's + // sum/min/max, and `web.vital.target_selector` is a ~250-character CSS + // chain — on a histogram each of those makes every measurement its own + // time series. They all survive on the span below, which is where the + // dashboards read them from. + target.emitHistogram(metricName, m.value, { + [ATTR.WEB_VITAL_NAME]: m.name, + [ATTR.WEB_VITAL_RATING]: m.rating, + }); target.emitSpan(SPAN.WEB_VITAL, { ...base, ...extras, From 3863c56c9a9cb3ff945f2c0ab7961dd30fa98ea3 Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Tue, 8 Sep 2026 12:20:44 +0530 Subject: [PATCH 5/6] fix(web): stop double-counting frustrations, sanitize third-party URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dead or rage click emitted a SECOND user_interaction span rather than a distinct event, so view.action.count counted both. In one sampled session view.action.count was 20 against view.frustration.count 3 — roughly 15% of the reported actions were duplicates, skewing engagement metrics by exactly the users having the worst experience. The twin also omitted user_interaction.id, target and target.type, so it could not even be deduplicated. Frustrations now emit user_frustration and carry the originating interaction's id, target and target type, handed over by a one-slot registry the tap tracker writes and the frustration tracker reads. Annotating the original span was not reachable: it is emitted and ended synchronously, and holding every interaction span open for the 600ms dead-click window would delay export for all of them. Two more spurious-frustration bugs found while testing this. The 120ms rage timer and the 600ms dead-click timer were independent, so one gesture could produce three spans; a rage episode now claims the clicks that made it up and reports once. And the "last error seen" sentinel was 0, which performance.now() is also close to just after load — so every click in the first 100ms of a page was classified as an error_click. Separately, third-party request URLs are now sanitized by default. Analytics beacons encode the current page URL in their query string, so a captured collect call carried an entire dashboard URL — template variable values, and with them a tenant's Kubernetes pod name — to the third party and then into stored traces. firstPartyHosts only ever drove traceparent injection and filtered nothing. thirdPartyResources ('sanitized' | 'off' | 'full') now governs how much of a non-first-party URL is recorded. Same-origin is always first party, whatever firstPartyHosts says, so an app that never configured it is unaffected. --- src/core/config.ts | 15 ++ src/core/telemetry.ts | 3 + src/web/instrumentations/frustration.test.ts | 165 ++++++++++++++++++ src/web/instrumentations/frustration.ts | 32 +++- .../instrumentations/interaction-registry.ts | 35 ++++ src/web/instrumentations/network.test.ts | 87 +++++++++ src/web/instrumentations/network.ts | 53 ++++-- src/web/instrumentations/tap.ts | 13 +- 8 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 src/web/instrumentations/frustration.test.ts create mode 100644 src/web/instrumentations/interaction-registry.ts diff --git a/src/core/config.ts b/src/core/config.ts index 925ac58..3fb88c9 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -17,6 +17,7 @@ export const DEFAULT_INTERACTION_EVENTS: InteractionEvent[] = [ 'submit', 'input', ]; +export type ThirdPartyResourceMode = 'sanitized' | 'off' | 'full'; export interface ScoutConfig { serviceName: string; endpoint: string; @@ -60,6 +61,19 @@ export interface ScoutConfig { alwaysCaptureErrors?: boolean; firstPartyHosts?: Array; ignoreUrlPatterns?: RegExp[]; + /** + * How much of a non-first-party request URL to record. + * + * - `sanitized` (default) — keep origin and path, drop the query string and + * fragment. Third-party beacons routinely encode the current page URL in + * their query (an analytics `collect` call carries the full dashboard URL, + * template variable values included), so the query is where the leak is. + * - `off` — do not record third-party requests at all. + * - `full` — record the URL verbatim, as before 0.1.17. + * + * Same-origin requests are always first-party, whatever `firstPartyHosts` says. + */ + thirdPartyResources?: ThirdPartyResourceMode; maxOfflineStorageMb?: number; beforeSend?: BeforeSendCallback; customTargetResolver?: CustomTargetResolver; @@ -202,6 +216,7 @@ export function resolveConfig(config: ScoutConfig): ResolvedConfig { alwaysCaptureErrors: config.alwaysCaptureErrors ?? true, firstPartyHosts: config.firstPartyHosts, ignoreUrlPatterns: config.ignoreUrlPatterns, + thirdPartyResources: config.thirdPartyResources ?? 'sanitized', maxOfflineStorageMb: config.maxOfflineStorageMb ?? 5, beforeSend: config.beforeSend, customTargetResolver: config.customTargetResolver, diff --git a/src/core/telemetry.ts b/src/core/telemetry.ts index d593a66..f68c4df 100644 --- a/src/core/telemetry.ts +++ b/src/core/telemetry.ts @@ -63,6 +63,9 @@ export function emitScoutConfigLog(scout: Scout): void { attrs['scout.config.has_custom_headers'] = !!cfg['headers'] && Object.keys(cfg['headers'] as object).length > 0; attrs['scout.config.track_resources'] = !!cfg['enableNetworkTracking']; + attrs['scout.config.third_party_resources'] = String( + cfg['thirdPartyResources'] ?? 'sanitized', + ); attrs['scout.config.track_long_task'] = !!cfg['enableLongTaskDetection']; attrs['scout.config.track_user_interactions'] = !!cfg['enableAutoTapTracking']; attrs['scout.config.track_frustrations'] = !!cfg['enableAutoTapTracking']; diff --git a/src/web/instrumentations/frustration.test.ts b/src/web/instrumentations/frustration.test.ts new file mode 100644 index 0000000..f69d4e1 --- /dev/null +++ b/src/web/instrumentations/frustration.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { installFrustrationTracker } from './frustration'; +import { installTapTracker } from './tap'; +import { resetInteractionRegistry } from './interaction-registry'; +import { Scout } from '../../core/scout'; +import { ATTR } from '../../core/attributes'; +import { SPAN } from '../../core/spans'; +import { makeRecorder, memoryPlatform, type Recorder } from '../../test/recorder'; + +const RAGE_WINDOW_MS = 120; +const DEAD_CLICK_WINDOW_MS = 600; + +async function makeScout() { + const s = new Scout( + { + serviceName: 'test-svc', + endpoint: 'http://localhost:4318', + secure: false, + sessionSampleRate: 100, + }, + memoryPlatform(), + ); + await s.bootstrap(); + return s; +} + +function clickOn(el: Element) { + el.dispatchEvent( + new MouseEvent('click', { bubbles: true, clientX: 1042, clientY: 66 }), + ); +} + +describe('frustration tracker', () => { + let recorder: Recorder; + let scout: Scout; + let button: HTMLElement; + const disposers: Array<() => void> = []; + + beforeEach(async () => { + recorder = makeRecorder(); + resetInteractionRegistry(); + vi.useFakeTimers(); + document.body.innerHTML = '
topbar
'; + button = document.querySelector('[data-testid="topbar"]')!; + scout = await makeScout(); + }); + afterEach(() => { + disposers.splice(0).forEach((d) => d()); + vi.useRealTimers(); + document.body.innerHTML = ''; + }); + + /** Installs in the same order as the web entry point: tap, then frustration. */ + function install({ withTap = true } = {}) { + if (withTap) disposers.push(installTapTracker(scout)); + disposers.push(installFrustrationTracker(scout)); + } + + const named = (name: string) => recorder.spans().filter((s) => s.name === name); + + it('reports a dead click without emitting a second user_interaction', async () => { + install(); + clickOn(button); + // Nothing mutates the DOM, so the click is dead. + vi.advanceTimersByTime(DEAD_CLICK_WINDOW_MS + 10); + + expect(named(SPAN.USER_INTERACTION)).toHaveLength(1); + const frustrations = named(SPAN.USER_FRUSTRATION); + expect(frustrations).toHaveLength(1); + expect(frustrations[0]!.attributes[ATTR.USER_INTERACTION_FRUSTRATION_TYPE]).toBe( + 'dead_click', + ); + }); + + it('carries the originating interaction id, so the two can be joined', async () => { + install(); + clickOn(button); + vi.advanceTimersByTime(DEAD_CLICK_WINDOW_MS + 10); + + const interaction = named(SPAN.USER_INTERACTION)[0]!; + const frustration = named(SPAN.USER_FRUSTRATION)[0]!; + const id = interaction.attributes[ATTR.USER_INTERACTION_ID]; + expect(id).toBeTruthy(); + expect(frustration.attributes[ATTR.USER_INTERACTION_ID]).toBe(id); + expect(frustration.attributes[ATTR.USER_INTERACTION_TARGET]).toBe( + interaction.attributes[ATTR.USER_INTERACTION_TARGET], + ); + expect(frustration.attributes[ATTR.USER_INTERACTION_TARGET_TYPE]).toBe( + interaction.attributes[ATTR.USER_INTERACTION_TARGET_TYPE], + ); + }); + + it('reports a rage click under the frustration name', async () => { + install(); + clickOn(button); + clickOn(button); + clickOn(button); + vi.advanceTimersByTime(RAGE_WINDOW_MS + 10); + + const frustrations = named(SPAN.USER_FRUSTRATION); + expect(frustrations.length).toBeGreaterThan(0); + expect( + String(frustrations.at(-1)!.attributes[ATTR.USER_INTERACTION_FRUSTRATION_TYPE]), + ).toContain('rage_click'); + // Three real clicks produced three interaction spans, and no more. + expect(named(SPAN.USER_INTERACTION)).toHaveLength(3); + }); + + it('does not also call a rage click dead', async () => { + install(); + clickOn(button); + clickOn(button); + clickOn(button); + // Past both the 120ms rage timer and the 600ms dead-click timer. + vi.advanceTimersByTime(DEAD_CLICK_WINDOW_MS + 10); + + const types = named(SPAN.USER_FRUSTRATION).map((s) => + String(s.attributes[ATTR.USER_INTERACTION_FRUSTRATION_TYPE]), + ); + expect(types.some((t) => t.includes('dead_click'))).toBe(false); + }); + + it('reports one rage episode once, not once per click in the run', async () => { + install(); + clickOn(button); + clickOn(button); + clickOn(button); + vi.advanceTimersByTime(DEAD_CLICK_WINDOW_MS + 10); + + expect(named(SPAN.USER_FRUSTRATION)).toHaveLength(1); + }); + + it('stays quiet when the click mutates the DOM', async () => { + install(); + button.addEventListener('click', () => { + document.body.appendChild(document.createElement('span')); + }); + clickOn(button); + // MutationObserver callbacks are microtasks, not timers. + await Promise.resolve(); + vi.advanceTimersByTime(DEAD_CLICK_WINDOW_MS + 10); + + expect(named(SPAN.USER_FRUSTRATION)).toHaveLength(0); + }); + + it('still reports when the tap tracker is disabled, just without correlation', async () => { + install({ withTap: false }); + clickOn(button); + vi.advanceTimersByTime(DEAD_CLICK_WINDOW_MS + 10); + + const frustrations = named(SPAN.USER_FRUSTRATION); + expect(frustrations).toHaveLength(1); + expect(frustrations[0]!.attributes[ATTR.USER_INTERACTION_ID]).toBeUndefined(); + }); + + it('emits nothing after dispose', async () => { + install(); + disposers.splice(0).forEach((d) => d()); + clickOn(button); + vi.advanceTimersByTime(DEAD_CLICK_WINDOW_MS + 10); + + expect(named(SPAN.USER_FRUSTRATION)).toHaveLength(0); + }); +}); diff --git a/src/web/instrumentations/frustration.ts b/src/web/instrumentations/frustration.ts index 3e263bf..9d63904 100644 --- a/src/web/instrumentations/frustration.ts +++ b/src/web/instrumentations/frustration.ts @@ -2,6 +2,9 @@ import { ATTR } from '../../core/attributes'; import { SPAN } from '../../core/spans'; import type { Scout } from '../../core/scout'; import type { Attributes } from '../../core/types'; +import { lastInteractionFor } from './interaction-registry'; +/** How long after a click its `user_interaction` span is still the same gesture. */ +const CORRELATION_WINDOW_MS = 1000; export function installFrustrationTracker(scout: Scout): () => void { if (typeof document === 'undefined') return () => {}; type ClickRow = { @@ -14,9 +17,12 @@ export function installFrustrationTracker(scout: Scout): () => void { row: ClickRow; attrs: Attributes; mutated: boolean; + reported: boolean; timer: ReturnType; }> = []; - let lastErrorAt = 0; + // Not 0: `performance.now()` is also near 0 for the first moments of a page, + // so a 0 sentinel made every click in the first 100ms an `error_click`. + let lastErrorAt = Number.NEGATIVE_INFINITY; let mutObserver: MutationObserver | null = null; try { mutObserver = new MutationObserver(() => { @@ -64,7 +70,15 @@ export function installFrustrationTracker(scout: Scout): () => void { const sameTarget = recent.filter((r) => r.selector === selector); const isRage = sameTarget.length >= 3; const rect = (target as HTMLElement).getBoundingClientRect?.(); + const origin = lastInteractionFor(target, CORRELATION_WINDOW_MS); const baseAttrs: Attributes = { + ...(origin + ? { + [ATTR.USER_INTERACTION_ID]: origin.id, + [ATTR.USER_INTERACTION_TARGET]: origin.description, + [ATTR.USER_INTERACTION_TARGET_TYPE]: origin.targetType, + } + : {}), [ATTR.USER_INTERACTION_TYPE]: 'click', [ATTR.USER_INTERACTION_TARGET_SELECTOR]: selector, [ATTR.USER_INTERACTION_TARGET_X]: Math.round(e.clientX), @@ -85,7 +99,13 @@ export function installFrustrationTracker(scout: Scout): () => void { Math.abs(lastErrorAt - row.t) < 100; if (erroredNearby) frustrations.push('error_click'); if (frustrations.length > 0) { - scout.emitSpan(SPAN.USER_INTERACTION, { + // A rage episode IS a run of dead clicks on the same target. Claim + // every click that made it up, so one episode reports once instead + // of once per click in the run. + for (const p of pending) { + if (p.row.selector === selector) p.reported = true; + } + scout.emitSpan(SPAN.USER_FRUSTRATION, { ...baseAttrs, [ATTR.USER_INTERACTION_FRUSTRATION_TYPE]: frustrations.join(','), }); @@ -95,11 +115,15 @@ export function installFrustrationTracker(scout: Scout): () => void { row, attrs: baseAttrs, mutated: false, + reported: false, timer: setTimeout(() => { const idx = pending.indexOf(entry); if (idx >= 0) pending.splice(idx, 1); - if (!entry.mutated) { - scout.emitSpan(SPAN.USER_INTERACTION, { + // A click already reported as rage or error is not additionally a + // dead click; the two timers are independent and would otherwise + // both fire for the same gesture. + if (!entry.mutated && !entry.reported) { + scout.emitSpan(SPAN.USER_FRUSTRATION, { ...entry.attrs, [ATTR.USER_INTERACTION_FRUSTRATION_TYPE]: 'dead_click', }); diff --git a/src/web/instrumentations/interaction-registry.ts b/src/web/instrumentations/interaction-registry.ts new file mode 100644 index 0000000..02a9edc --- /dev/null +++ b/src/web/instrumentations/interaction-registry.ts @@ -0,0 +1,35 @@ +/** + * A one-slot handoff from the tap tracker to the frustration tracker. + * + * Both listen for `click` on `document` in the capture phase and the tap + * tracker is installed first, so by the time frustration detection runs the + * `user_interaction` span for that same click has already been emitted. This + * carries its identity across so a frustration span can point back at the + * interaction it describes instead of being an uncorrelatable twin. + * + * A single element reference is retained at a time — the same shape the + * frustration tracker's own `recent` buffer already holds. + */ +export interface RecordedInteraction { + id: string; + target: Element; + description: string; + targetType: string; + /** `performance.now()` at emit. */ + at: number; +} +let last: RecordedInteraction | null = null; +export function recordInteraction(interaction: RecordedInteraction): void { + last = interaction; +} +/** The interaction emitted for `target`, if it is recent enough to be the same gesture. */ +export function lastInteractionFor( + target: Element, + withinMs: number, +): RecordedInteraction | null { + if (!last || last.target !== target) return null; + return performance.now() - last.at <= withinMs ? last : null; +} +export function resetInteractionRegistry(): void { + last = null; +} diff --git a/src/web/instrumentations/network.test.ts b/src/web/instrumentations/network.test.ts index ecd652d..7f665c3 100644 --- a/src/web/instrumentations/network.test.ts +++ b/src/web/instrumentations/network.test.ts @@ -246,3 +246,90 @@ describe('installNetworkTracker — XMLHttpRequest', () => { expect(xhr.headers.traceparent).toBeUndefined(); }); }); + +// The leak this guards: a Google Analytics `collect` beacon encodes the current +// page URL in its `dl` parameter, so a captured span carried an entire logX +// dashboard URL — template variable values, and with them a tenant's +// Kubernetes pod name — to Google and then into our own ClickHouse. +describe('installNetworkTracker — third-party URLs', () => { + let recorder: Recorder; + let originalFetch: typeof fetch; + /** The underlying spy; `globalThis.fetch` is the tracker's wrapper after install. */ + let fetchSpy: ReturnType; + const disposers: Array<() => void> = []; + + const GA_BEACON = + 'https://www.google-analytics.com/g/collect?v=2&tid=G-X' + + '&dl=https%3A%2F%2Fplay.example.io%2Faxi%2Fa%2Fapp%3Fvar-pod%3Dacct-7c5b757fd8-c5tjp'; + + beforeEach(() => { + recorder = makeRecorder(); + originalFetch = globalThis.fetch; + fetchSpy = vi.fn(async () => new Response('ok', { status: 200 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + }); + afterEach(() => { + disposers.splice(0).forEach((d) => d()); + globalThis.fetch = originalFetch; + }); + + async function install(thirdPartyResources?: 'sanitized' | 'off' | 'full') { + const scout = new Scout( + { + serviceName: 't', + endpoint: 'http://collector.example:4318', + secure: false, + sessionSampleRate: 100, + firstPartyHosts: ['api.acme.com'], + ...(thirdPartyResources ? { thirdPartyResources } : {}), + }, + memoryPlatform(), + ); + await scout.bootstrap(); + disposers.push(installNetworkTracker(scout)); + } + + const recordedUrls = () => + recorder + .spans() + .filter((s) => s.name === SPAN.HTTP_REQUEST) + .map((s) => s.attributes[ATTR.HTTP_URL]); + + it('strips the query string from a third-party URL by default', async () => { + await install(); + await fetch(GA_BEACON); + expect(recordedUrls()).toEqual(['https://www.google-analytics.com/g/collect']); + }); + + it('keeps the query string on a declared first-party host', async () => { + await install(); + await fetch('https://api.acme.com/users?token=abc'); + expect(recordedUrls()).toEqual(['https://api.acme.com/users?token=abc']); + }); + + it('treats same-origin as first party even though it is not in firstPartyHosts', async () => { + await install(); + await fetch('/api/dashboards/home?from=now-1h'); + expect(recordedUrls()).toEqual(['/api/dashboards/home?from=now-1h']); + }); + + it('drops the span entirely under "off"', async () => { + await install('off'); + await fetch(GA_BEACON); + await fetch('https://api.acme.com/users?token=abc'); + expect(recordedUrls()).toEqual(['https://api.acme.com/users?token=abc']); + }); + + it('records the URL verbatim under "full"', async () => { + await install('full'); + await fetch(GA_BEACON); + expect(recordedUrls()).toEqual([GA_BEACON]); + }); + + it('still performs the request when the span is dropped', async () => { + await install('off'); + const res = await fetch(GA_BEACON); + expect(res.status).toBe(200); + expect(fetchSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/web/instrumentations/network.ts b/src/web/instrumentations/network.ts index 2396dd5..3d32298 100644 --- a/src/web/instrumentations/network.ts +++ b/src/web/instrumentations/network.ts @@ -8,6 +8,8 @@ import { lookupProvider } from '../../core/provider-lookup'; import { parseGraphQLRequest, parseGraphQLResponse } from '../../core/graphql-parser'; import type { Attributes } from '../../core/types'; import { uuidv4 } from '../../core/uuid'; +const baseHref = (): string => + typeof location !== 'undefined' ? location.href : 'http://localhost'; export function installNetworkTracker(scout: Scout): () => void { const ignore = scout.config.ignoreUrlPatterns ?? []; const firstPartyMatchers = compileFirstPartyMatchers( @@ -24,15 +26,41 @@ export function installNetworkTracker(scout: Scout): () => void { }; const isFirstParty = (url: string): boolean => { try { - const u = new URL( - url, - typeof location !== 'undefined' ? location.href : 'http://localhost', - ); + const u = new URL(url, baseHref()); return firstPartyMatchers.some((m) => m(u.host)); } catch { return false; } }; + const thirdPartyMode = scout.config.thirdPartyResources; + const isThirdParty = (url: string): boolean => { + try { + const u = new URL(url, baseHref()); + // Same-origin is first-party whatever `firstPartyHosts` says — otherwise + // an app that never configured it would have its own URLs sanitized. + if (typeof location !== 'undefined' && u.origin === location.origin) return false; + return !firstPartyMatchers.some((m) => m(u.host)); + } catch { + return false; + } + }; + /** + * The URL to record, or `null` to record nothing. + * + * Third-party beacons encode the current page URL in their query string, so + * a captured analytics `collect` call carried an entire dashboard URL — + * template variable values, and with them tenant pod names — into our traces. + */ + const urlToRecord = (url: string): string | null => { + if (thirdPartyMode === 'full' || !isThirdParty(url)) return url; + if (thirdPartyMode === 'off') return null; + try { + const u = new URL(url, baseHref()); + return u.origin + u.pathname; + } catch { + return null; + } + }; const restore: Array<() => void> = []; if (typeof globalThis.fetch === 'function') { const originalFetch = globalThis.fetch; @@ -47,6 +75,10 @@ export function installNetworkTracker(scout: Scout): () => void { if (shouldSkip(url)) { return originalFetch(input as any, init); } + const recordedUrl = urlToRecord(url); + if (recordedUrl === null) { + return originalFetch(input as any, init); + } const start = performance.now(); const headers = new Headers(init?.headers ?? (input as Request).headers ?? {}); const providerAttrs = providerAttrsFor(url); @@ -54,7 +86,7 @@ export function installNetworkTracker(scout: Scout): () => void { const tracked = scout.startTrackedSpan(SPAN.HTTP_REQUEST, { [ATTR.HTTP_RESOURCE_ID]: uuidv4(), [ATTR.HTTP_METHOD]: method, - [ATTR.HTTP_URL]: url, + [ATTR.HTTP_URL]: recordedUrl, ...providerAttrs, ...graphqlAttrs, ...scout.commonAttributes(), @@ -106,7 +138,7 @@ export function installNetworkTracker(scout: Scout): () => void { } scout.addBreadcrumb( BREADCRUMB_TYPE.HTTP, - `${method} ${url} → ${response.status}`, + `${method} ${recordedUrl} → ${response.status}`, ); return response; } catch (error) { @@ -119,7 +151,7 @@ export function installNetworkTracker(scout: Scout): () => void { httpSpan.setStatus({ code: SpanStatusCode.ERROR }); tracked?.end(); } - scout.addBreadcrumb(BREADCRUMB_TYPE.HTTP, `${method} ${url} → error`); + scout.addBreadcrumb(BREADCRUMB_TYPE.HTTP, `${method} ${recordedUrl} → error`); throw error; } }; @@ -142,7 +174,8 @@ export function installNetworkTracker(scout: Scout): () => void { }; proto.send = function (this: any, body?: Document | XMLHttpRequestBodyInit | null) { const meta = this.__scout; - if (!meta || shouldSkip(meta.url)) { + const recordedUrl = meta ? urlToRecord(meta.url) : null; + if (!meta || shouldSkip(meta.url) || recordedUrl === null) { return origSend.call(this, body as any); } const start = performance.now(); @@ -152,7 +185,7 @@ export function installNetworkTracker(scout: Scout): () => void { const tracked = scout.startTrackedSpan(SPAN.HTTP_REQUEST, { [ATTR.HTTP_RESOURCE_ID]: uuidv4(), [ATTR.HTTP_METHOD]: meta.method, - [ATTR.HTTP_URL]: meta.url, + [ATTR.HTTP_URL]: recordedUrl, ...providerAttrsFor(meta.url), ...scout.commonAttributes(), }); @@ -186,7 +219,7 @@ export function installNetworkTracker(scout: Scout): () => void { ); scout.addBreadcrumb( BREADCRUMB_TYPE.HTTP, - `${meta.method} ${meta.url} → ${errorMsg ?? status}`, + `${meta.method} ${recordedUrl} → ${errorMsg ?? status}`, ); }; this.addEventListener('loadend', () => finalize()); diff --git a/src/web/instrumentations/tap.ts b/src/web/instrumentations/tap.ts index 61efed1..6c50ef9 100644 --- a/src/web/instrumentations/tap.ts +++ b/src/web/instrumentations/tap.ts @@ -3,6 +3,7 @@ import { SPAN, BREADCRUMB_TYPE } from '../../core/spans'; import { DEFAULT_INTERACTION_EVENTS, type InteractionEvent } from '../../core/config'; import type { Scout } from '../../core/scout'; import type { Attributes } from '../../core/types'; +import { recordInteraction } from './interaction-registry'; import { uuidv4 } from '../../core/uuid'; /** * Fields whose *existence* we still report, but which must never contribute a @@ -39,8 +40,9 @@ export function installTapTracker(scout: Scout): () => void { const { description, source } = describeElement(target); const typeName = target.tagName ? target.tagName.toLowerCase() : 'unknown'; const rect = target.getBoundingClientRect?.(); + const interactionId = uuidv4(); scout.emitSpan(SPAN.USER_INTERACTION, { - [ATTR.USER_INTERACTION_ID]: uuidv4(), + [ATTR.USER_INTERACTION_ID]: interactionId, [ATTR.USER_INTERACTION_TYPE]: kind, [ATTR.USER_INTERACTION_TARGET]: description, [ATTR.USER_INTERACTION_TARGET_TYPE]: typeName, @@ -57,6 +59,15 @@ export function installTapTracker(scout: Scout): () => void { ...scout.commonAttributes(), }); scout.addBreadcrumb(BREADCRUMB_TYPE.TAP, `${kind} ${typeName}: ${description}`); + // Let the frustration tracker point back at this span rather than + // emitting an anonymous twin for the same gesture. + recordInteraction({ + id: interactionId, + target, + description, + targetType: typeName, + at: performance.now(), + }); } catch {} }; if (enabled.has('click')) { From a9006cdaa53d6bd11f753a2a343ddf7b8cb41da9 Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Tue, 8 Sep 2026 12:20:44 +0530 Subject: [PATCH 6/6] chore(release): 0.1.17 Also adds anr.ts, crash.ts, frustration.ts, web-vitals.ts and the new interaction-registry.ts to the coverage include list. All of them were outside it, which is part of why these defects shipped. --- CHANGELOG.md | 109 ++++++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/core/scope.ts | 2 +- vitest.config.ts | 5 ++ 6 files changed, 119 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2006f..5f20e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.17] - 2026-09-07 + +Eight data-quality defects found validating browser RUM against live data. Four +of them changed what the SDK writes to the wire; see **Breaking** below. + +### Fixed + +- **ANR detection no longer reports background-tab timer throttling as a hang** + (B14-1858). Browsers clamp timers in hidden tabs to roughly once a minute, and + the detector read its own throttled lateness as a blocked main thread — one + false `anr` span per minute, indefinitely, from every backgrounded tab. The + main thread now stops beating while hidden and resets the worker's baseline + before it resumes, so neither the throttled interval nor the gap on return is + charged as a hang. Spans carry `anr.visibility_state`; the SDK previously + recorded no visibility signal at all. + +- **`anr` spans now carry the hang in the span's own duration** (B14-1862). + They were emitted as zero-duration markers, so the trace waterfall, p95 + duration panels and anything else generic over spans saw `0`; the real value + was reachable only through a bespoke string attribute. + +- **A deferred crash marker can no longer be attributed to a different tenant** + (B14-1860). The marker lived under one unscoped `localStorage` key, and a + single origin can serve many tenants — so a tab that died in one tenant was + filed against whichever tenant the browser opened next, carrying its URLs and + screen names across. The key is now scoped by service name and environment. + The marker also records the originating `service.name`, `service.version` and + `environment`, reported as `crash.service.name`, `crash.service.version` and + `crash.environment`, and the span's `screen.name` is the dead session's rather + than the detecting page's. + +- **An ordinary tab close is no longer counted as a crash** (B14-1861). + `pagehide` does not fire on force-quit, OS shutdown, tab discard or + task-switcher eviction, so its absence is not evidence of a crash. These + markers were written as `app_crash`, which feeds crash counters — routine user + behaviour was depressing crash-free rate. + +- **Web-vital histograms no longer have unbounded attribute cardinality** + (B14-1859). `web.vital.id` (unique per measurement), `web.vital.value` + (already the histogram's sum) and `web.vital.target_selector` (a ~250-character + CSS chain) were metric *dimensions*, so every measurement became its own time + series. They remain on the `web_vital` span, which is where dashboards read + them. + +- **One-shot web vitals are exported once instead of every interval** + (B14-1859). FCP, LCP and TTFB fire once per page load, but histograms were + cumulative with a fixed start time, so the same `count=1` point was re-exported + every 30 seconds for the life of the page — roughly 180 rows for 3 real + measurements in a 30-minute session. Histograms are now delta; counters remain + cumulative. + +- **`user.id` is no longer written into metric dimensions** (B14-1865). It is + frequently an email address, and metric attributes are retained longer, rolled + up harder and far more expensive to delete selectively than spans. It stays on + spans and logs, where session correlation actually needs it. + +- **A frustrated click no longer double-counts as two interactions** + (B14-1863). A dead or rage click emitted a second `user_interaction` span + rather than a distinct event, inflating `view.action.count` by one for every + frustration — skewing engagement metrics by exactly the users having the worst + experience. Frustrations now emit `user_frustration` and carry the originating + `user_interaction.id`, `user_interaction.target` and + `user_interaction.target.type` so the two can be joined. + +- **A rage episode reports once, not once per click in the run** — the 120ms + rage timer and the 600ms dead-click timer were independent, so one gesture + could produce three spans. + +- **Clicks in the first 100ms of a page are no longer classified as + `error_click`.** The "last error seen" sentinel was `0`, which + `performance.now()` is also close to just after load. + +- **Third-party request URLs are sanitized by default** (B14-1864). Analytics + beacons encode the current page URL in their query string, so a captured + `collect` call carried an entire dashboard URL — template variable values, and + with them a tenant's Kubernetes pod name — into stored traces. Query string + and fragment are now dropped for non-first-party hosts. See + `thirdPartyResources`. + +### Added + +- `thirdPartyResources: 'sanitized' | 'off' | 'full'` (default `'sanitized'`) + controls how much of a non-first-party request URL is recorded. Same-origin + requests are always treated as first-party, whatever `firstPartyHosts` says. + +### Breaking + +- `anr.duration` and `anr.threshold` are replaced by **`anr.duration_ms`** and + **`anr.threshold_ms`**. The old keys carried *seconds* under names that gave + no unit, while the SDK's own option is `anrThresholdMs` — an inconsistency + that already produced a 1000x display bug downstream. The keys are renamed + rather than silently redefined so old and new rows stay distinguishable: + read `coalesce(anr.duration_ms, anr.duration * 1000)` while both exist. + Applies to web and React Native. + +- Unclean terminations are emitted as **`app_unclean_exit`**, not `app_crash`. + Consumers that count crashes need no change; consumers that want to see + unclean exits must add the new span name. + +- Frustration signals are emitted as **`user_frustration`**, not + `user_interaction`. Aggregations over `user_interaction` become correct + automatically; anything that specifically wanted frustration spans must add + the new name. + +- Metric attributes are now a bounded set — `session.id`, `session.type`, + `session.sample_rate` and `screen.name`. Attributes set via + `setRuntimeAttribute()` / `setSessionAttributes()` and all `user.*` attributes + no longer appear on metrics. They are unchanged on spans and logs. + ## [0.1.16] - 2026-08-11 ### Added diff --git a/docs/configuration.md b/docs/configuration.md index acf234d..b0342ee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,6 +31,7 @@ await Scout.initialize({ | `headers` | `Record` | `{}` | Extra HTTP headers on every export. Use for auth tokens, tenant IDs, etc. | | `firstPartyHosts` | `Array` | `[]` | Hosts considered "your" backend. Outbound `fetch` and `XMLHttpRequest` calls to these hosts get a `traceparent` header so backend traces correlate. | | `ignoreUrlPatterns` | `RegExp[]` | `[]` | URLs matching any of these are not auto-instrumented (no `http.request` span, no breadcrumb). | +| `thirdPartyResources` | `'sanitized' \| 'off' \| 'full'` | `'sanitized'` | How much of a **non**-first-party request URL to record. `sanitized` keeps origin and path and drops the query string and fragment; `off` records no span at all for third-party hosts; `full` records the URL verbatim. Same-origin requests are always first-party, whatever `firstPartyHosts` says. | ### Rotating an auth token diff --git a/package-lock.json b/package-lock.json index 8547d04..e3be9d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@base-14/scout-react", - "version": "0.1.15", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@base-14/scout-react", - "version": "0.1.15", + "version": "0.1.17", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", diff --git a/package.json b/package.json index d59a65c..ae3b04f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@base-14/scout-react", - "version": "0.1.16", + "version": "0.1.17", "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 f5f6d28..fbf40d3 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.16'; +export const SCOPE_VERSION = '0.1.17'; diff --git a/vitest.config.ts b/vitest.config.ts index b2243b0..2702b3c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,11 @@ export default defineConfig({ 'src/web/instrumentations/route.ts', 'src/web/instrumentations/error.ts', 'src/web/instrumentations/network.ts', + 'src/web/instrumentations/anr.ts', + 'src/web/instrumentations/crash.ts', + 'src/web/instrumentations/frustration.ts', + 'src/web/instrumentations/web-vitals.ts', + 'src/web/instrumentations/interaction-registry.ts', ], exclude: ['**/*.test.ts', '**/types.ts', 'src/test/**'], thresholds: {