diff --git a/CHANGELOG.md b/CHANGELOG.md index e78ac7c..27ab4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.13] - 2026-08-05 + +Web interaction coverage and a distributed-tracing correctness fix. No default +turns anything off; `input` tracking is new and on by default — narrow it with +`interactionEvents` if your UI is text-heavy. + +### Added + +- **Interaction coverage beyond `click`.** Auto-tap tracking now also emits + `user_interaction` spans for `change` (select / checkbox / radio / file / + date / time / range), `submit` (form submission *and* Enter in a text entry, + which React handlers routinely swallow), and `input` (debounced 500 ms after + typing stops, one span per settled edit). `user_interaction.type` carries + which one it was; existing `click` spans are unchanged. +- `interactionEvents` config option — the subset of + `['click','change','submit','input']` to listen to. Defaults to all four; + `[]` disables interaction tracking without touching `enableAutoTapTracking`. +- `user_interaction.trigger` attribute (`pointer` | `keyboard` | `unknown`), + which distinguishes Enter-to-search from clicking a search button. +- `user_interaction.value` attribute, set only for controls with a closed value + space (selected option label, `checked`/`unchecked`). Free text is never + captured, and `password`/`email`/`tel`/`hidden` fields emit no `input` span at + all and report their description as `redacted` rather than falling through to + neighbouring text content. +- `Scout.startTrackedSpan()` — starts a span the caller ends later, applying the + same `beforeSend`, sampling and view-counter bookkeeping as `emitSpan`. Needed + wherever a span's ids must be known before the work it measures completes. + +### Fixed + +- **`XMLHttpRequest` sent a fabricated `traceparent`.** The header was built + from fresh random ids rather than the emitted `http.request` span's, so XHR + calls never correlated with their own span or with the backend, and the + backend saw a parent span id it would never receive. Both `fetch` and XHR now + derive the header from the span they actually export. +- A failed `XMLHttpRequest` emitted **two** `http.request` spans, because both + the `error` and `loadend` listeners ran the finalizer. +- `fetch` spans were bypassing `beforeSend` and were not counted in + `view.resource.count`; only the XHR path was. Both now behave identically. +- XHR `http.request` spans had a near-zero duration, since the span was created + after the request finished. They now span the request. + +### Changed + +- XHR `http.request` spans now carry `http.provider.*` classification, which + previously only `fetch` spans had. +- Documented that `headers` is read per export, so an expiring bearer token can + be rotated by mutating the object passed to `initialize` — no re-init, no + dropped batches. Locked by tests in `otlp-exporter.test.ts` / `config.test.ts`. + ## [0.1.12] - 2026-08-03 Brings scout-react to parity with scout-flutter 0.1.23's production-hardening diff --git a/README.md b/README.md index 07228b8..6631f00 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ On Android USB devices, the OTLP endpoint runs on your dev machine — point it | Signal | Span / metric | Notes | |---|---|---| | Clicks / taps | `user_interaction` | `user_interaction.target`, `target.type` | +| Value changes, submits, text edits | `user_interaction` | Web only. `user_interaction.type` is `click` \| `change` \| `submit` \| `input`; see `interactionEvents` | | Navigation | `screen_view`, `view_session` | screen_view becomes the root span — all spans on that screen share its trace id | | Screen load time | `screen_load` | `screen.load_time` in seconds | | App startup | `app_startup` | cold + warm | diff --git a/docs/configuration.md b/docs/configuration.md index e7fb462..acf234d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,9 +29,25 @@ await Scout.initialize({ | Field | Type | Default | Description | |---|---|---|---| | `headers` | `Record` | `{}` | Extra HTTP headers on every export. Use for auth tokens, tenant IDs, etc. | -| `firstPartyHosts` | `Array` | `[]` | Hosts considered "your" backend. Outbound `fetch` calls to these hosts get a `traceparent` header so backend traces correlate. | +| `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). | +### Rotating an auth token + +`headers` is read on every export rather than snapshotted at init, so an +expiring bearer token is refreshed by mutating the object you passed in: + +```ts +const headers = { Authorization: `Bearer ${token}` }; +await Scout.initialize({ serviceName: 'app', endpoint, headers }); + +// later, before the token expires — no re-initialize, no dropped batches +headers.Authorization = `Bearer ${await mintToken()}`; +``` + +Replacing the object (`headers = {...}`) does **not** work; the exporters hold +the original reference. + ## Export pacing How telemetry is batched and flushed. @@ -150,7 +166,8 @@ Every auto-instrumentation can be turned off independently. All default to `true | Field | Default | What it captures | |---|---|---| -| `enableAutoTapTracking` | `true` | Web: `click` on every element. RN: `onPress` on Pressable/Touchable* (via babel plugin). Emits `user_interaction` spans. | +| `enableAutoTapTracking` | `true` | Web: the DOM events listed under `interactionEvents`. RN: `onPress` on Pressable/Touchable* (via babel plugin). Emits `user_interaction` spans. | +| `interactionEvents` | `['click','change','submit','input']` | Web only. Which DOM events auto-tap tracking listens to; the value lands on the span as `user_interaction.type`. See below. | | `enableErrorTracking` | `true` | `window.onerror`, `unhandledrejection`, native crashes via KSCrash + NDK signal handler + MetricKit + ApplicationExitInfo. Emits `error`, `app_crash`, `native_crash` spans. | | `enableLifecycleTracking` | `true` | App `foreground`/`background`/`paused`/`resumed`. Emits `app_paused` / `app_resumed` spans + `view.in_foreground_periods_json` on screen_view. | | `enableStartupTracking` | `true` | Cold/warm/hot start timing. Emits `app_startup` span. | @@ -167,6 +184,22 @@ Every auto-instrumentation can be turned off independently. All default to `true | `enableLogging` | `true` | Allows `Scout.log*()` calls to emit OTLP logs. | | `captureConsole` / `capturePrintStatements` | `false` | Mirrors `console.log/info/warn/error/debug` to OTLP logs. Original `console` output preserved. | +### `interactionEvents` (web) + +| Value | Fires on | Notes | +|---|---|---| +| `click` | any element | Carries `target.x` / `target.y` and `user_interaction.trigger: pointer`. | +| `change` | `', () => { + const sel = document.createElement('select'); + sel.setAttribute('aria-label', 'environment'); + for (const v of ['prod', 'staging']) { + const opt = document.createElement('option'); + opt.value = v; + opt.text = v; + sel.appendChild(opt); + } + document.body.appendChild(sel); + sel.value = 'staging'; + sel.dispatchEvent(new Event('change', { bubbles: true })); + const span = recorder.spans()[0]; + expect(span?.attributes[ATTR.USER_INTERACTION_TYPE]).toBe('change'); + expect(span?.attributes[ATTR.USER_INTERACTION_TARGET]).toBe('environment'); + expect(span?.attributes[ATTR.USER_INTERACTION_VALUE]).toBe('staging'); + }); + it('emits type=change with checked state for a checkbox', () => { + const box = document.createElement('input'); + box.type = 'checkbox'; + box.id = 'only-errors'; + document.body.appendChild(box); + box.checked = true; + box.dispatchEvent(new Event('change', { bubbles: true })); + const span = recorder.spans()[0]; + expect(span?.attributes[ATTR.USER_INTERACTION_TYPE]).toBe('change'); + expect(span?.attributes[ATTR.USER_INTERACTION_VALUE]).toBe('checked'); + }); + it('ignores change on a free-text input (fires on blur, reports nothing useful)', () => { + const text = document.createElement('input'); + text.type = 'text'; + document.body.appendChild(text); + text.dispatchEvent(new Event('change', { bubbles: true })); + expect(recorder.spans()).toHaveLength(0); + }); + it('emits type=submit on form submission', () => { + const form = document.createElement('form'); + form.setAttribute('aria-label', 'log-search'); + document.body.appendChild(form); + form.dispatchEvent(new Event('submit', { bubbles: true })); + const span = recorder.spans()[0]; + expect(span?.attributes[ATTR.USER_INTERACTION_TYPE]).toBe('submit'); + expect(span?.attributes[ATTR.USER_INTERACTION_TARGET]).toBe('log-search'); + expect(span?.attributes[ATTR.USER_INTERACTION_TRIGGER]).toBe('unknown'); + }); + it('treats Enter in a search box as a keyboard-triggered submit', () => { + const input = document.createElement('input'); + input.type = 'search'; + input.setAttribute('aria-label', 'query'); + document.body.appendChild(input); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + const span = recorder.spans()[0]; + expect(span?.attributes[ATTR.USER_INTERACTION_TYPE]).toBe('submit'); + expect(span?.attributes[ATTR.USER_INTERACTION_TRIGGER]).toBe('keyboard'); + }); + it('ignores non-Enter keys and Enter outside a text entry', () => { + const input = document.createElement('input'); + input.type = 'search'; + document.body.appendChild(input); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); + const div = document.createElement('div'); + document.body.appendChild(div); + div.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(recorder.spans()).toHaveLength(0); + }); + it('never derives a description from a password field’s text content', () => { + const pw = document.createElement('input'); + pw.type = 'password'; + document.body.appendChild(pw); + pw.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + const span = recorder.spans()[0]; + expect(span?.attributes[ATTR.USER_INTERACTION_TARGET]).toBe('redacted'); + expect(span?.attributes[ATTR.USER_INTERACTION_VALUE]).toBeUndefined(); + }); it('falls back to id then className then tag name', () => { const a = document.createElement('button'); a.id = 'pay-now'; @@ -101,3 +176,129 @@ describe('installTapTracker', () => { ); }); }); + +describe('installTapTracker — debounced text edits', () => { + let recorder: Recorder; + let scout: Scout; + let dispose: () => void; + beforeEach(async () => { + vi.useFakeTimers(); + document.body.innerHTML = ''; + recorder = makeRecorder(); + scout = new Scout( + { + serviceName: 't', + endpoint: 'http://localhost', + secure: false, + sessionSampleRate: 100, + }, + memoryPlatform(), + ); + await scout.bootstrap(); + dispose = installTapTracker(scout); + }); + afterEach(() => { + dispose(); + vi.useRealTimers(); + }); + function textInput(label: string, type = 'text'): HTMLInputElement { + const el = document.createElement('input'); + el.type = type; + el.setAttribute('aria-label', label); + document.body.appendChild(el); + return el; + } + function type(el: HTMLElement) { + el.dispatchEvent(new Event('input', { bubbles: true })); + } + it('collapses a burst of keystrokes into one span once typing settles', () => { + const el = textInput('query'); + type(el); + type(el); + type(el); + expect(recorder.spans()).toHaveLength(0); + vi.advanceTimersByTime(499); + expect(recorder.spans()).toHaveLength(0); + vi.advanceTimersByTime(1); + const spans = recorder.spans(); + expect(spans).toHaveLength(1); + expect(spans[0]?.attributes[ATTR.USER_INTERACTION_TYPE]).toBe('input'); + expect(spans[0]?.attributes[ATTR.USER_INTERACTION_TARGET]).toBe('query'); + }); + it('keeps deferring while the user is still typing', () => { + const el = textInput('query'); + type(el); + vi.advanceTimersByTime(400); + type(el); + vi.advanceTimersByTime(400); + expect(recorder.spans()).toHaveLength(0); + vi.advanceTimersByTime(100); + expect(recorder.spans()).toHaveLength(1); + }); + it('flushes the previous field in edit order when focus moves on', () => { + const first = textInput('service'); + const second = textInput('query'); + type(first); + vi.advanceTimersByTime(100); + type(second); + // The first edit lands immediately, not when its own timer would have run. + const afterSwitch = recorder.spans(); + expect(afterSwitch).toHaveLength(1); + expect(afterSwitch[0]?.attributes[ATTR.USER_INTERACTION_TARGET]).toBe('service'); + vi.advanceTimersByTime(500); + const all = recorder.spans(); + expect(all).toHaveLength(2); + expect(all[1]?.attributes[ATTR.USER_INTERACTION_TARGET]).toBe('query'); + }); + it('never reports typing in a sensitive field', () => { + const pw = textInput('password', 'password'); + type(pw); + vi.advanceTimersByTime(1000); + expect(recorder.spans()).toHaveLength(0); + }); + it('drops a pending edit on dispose rather than firing after teardown', () => { + const el = textInput('query'); + type(el); + dispose(); + vi.advanceTimersByTime(1000); + expect(recorder.spans()).toHaveLength(0); + }); +}); + +describe('installTapTracker — interactionEvents narrowing', () => { + let recorder: Recorder; + let dispose: () => void; + afterEach(() => dispose()); + async function install(interactionEvents: InteractionEvent[]) { + document.body.innerHTML = ''; + recorder = makeRecorder(); + const scout = new Scout( + { + serviceName: 't', + endpoint: 'http://localhost', + secure: false, + sessionSampleRate: 100, + interactionEvents, + }, + memoryPlatform(), + ); + await scout.bootstrap(); + dispose = installTapTracker(scout); + } + it('listens to nothing outside the configured set', async () => { + await install(['click']); + const form = document.createElement('form'); + document.body.appendChild(form); + form.dispatchEvent(new Event('submit', { bubbles: true })); + expect(recorder.spans()).toHaveLength(0); + form.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(recorder.spans()).toHaveLength(1); + }); + it('an empty set disables interaction tracking entirely', async () => { + await install([]); + const btn = document.createElement('button'); + document.body.appendChild(btn); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(recorder.spans()).toHaveLength(0); + }); +}); diff --git a/src/web/instrumentations/tap.ts b/src/web/instrumentations/tap.ts index 5524309..61efed1 100644 --- a/src/web/instrumentations/tap.ts +++ b/src/web/instrumentations/tap.ts @@ -1,39 +1,218 @@ import { ATTR } from '../../core/attributes'; 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 { uuidv4 } from '../../core/uuid'; +/** + * Fields whose *existence* we still report, but which must never contribute a + * description or a value — a placeholder or aria-label on a password box is + * frequently the only thing distinguishing it, and text inputs hold whatever + * the user typed. + */ +const SENSITIVE_INPUT_TYPES = new Set(['password', 'email', 'tel', 'hidden']); export function installTapTracker(scout: Scout): () => void { if (typeof document === 'undefined') return () => {}; - const handler = (e: MouseEvent) => { + const enabled = new Set( + scout.config.interactionEvents ?? DEFAULT_INTERACTION_EVENTS, + ); + const cleanups: Array<() => void> = []; + const listen = ( + type: K, + handler: (e: DocumentEventMap[K]) => void, + ) => { + document.addEventListener(type, handler as EventListener, { + capture: true, + passive: true, + }); + cleanups.push(() => + document.removeEventListener(type, handler as EventListener, { capture: true }), + ); + }; + const emit = ( + kind: InteractionEvent, + target: HTMLElement, + e: Event, + extra: Attributes = {}, + ) => { try { - const target = e.target as HTMLElement | null; - if (!target) return; const { description, source } = describeElement(target); const typeName = target.tagName ? target.tagName.toLowerCase() : 'unknown'; const rect = target.getBoundingClientRect?.(); scout.emitSpan(SPAN.USER_INTERACTION, { [ATTR.USER_INTERACTION_ID]: uuidv4(), - [ATTR.USER_INTERACTION_TYPE]: 'click', + [ATTR.USER_INTERACTION_TYPE]: kind, [ATTR.USER_INTERACTION_TARGET]: description, [ATTR.USER_INTERACTION_TARGET_TYPE]: typeName, [ATTR.USER_INTERACTION_TARGET_NAME_SOURCE]: source, [ATTR.USER_INTERACTION_TARGET_SELECTOR]: cssSelectorOf(target), [ATTR.USER_INTERACTION_TARGET_COMPOSED_PATH_SELECTOR]: composedPathSelector(e), - [ATTR.USER_INTERACTION_TARGET_X]: Math.round(e.clientX), - [ATTR.USER_INTERACTION_TARGET_Y]: Math.round(e.clientY), ...(rect ? { [ATTR.USER_INTERACTION_TARGET_WIDTH]: Math.round(rect.width), [ATTR.USER_INTERACTION_TARGET_HEIGHT]: Math.round(rect.height), } : {}), + ...extra, ...scout.commonAttributes(), }); - scout.addBreadcrumb(BREADCRUMB_TYPE.TAP, `${typeName}: ${description}`); + scout.addBreadcrumb(BREADCRUMB_TYPE.TAP, `${kind} ${typeName}: ${description}`); } catch {} }; - document.addEventListener('click', handler, { capture: true, passive: true }); - return () => document.removeEventListener('click', handler, { capture: true }); + if (enabled.has('click')) { + listen('click', (e) => { + const target = e.target as HTMLElement | null; + if (!target) return; + emit('click', target, e, { + [ATTR.USER_INTERACTION_TRIGGER]: 'pointer', + [ATTR.USER_INTERACTION_TARGET_X]: Math.round(e.clientX), + [ATTR.USER_INTERACTION_TARGET_Y]: Math.round(e.clientY), + }); + }); + } + if (enabled.has('change')) { + // `change` is the only signal for