From dc768fad16bfb20632ed4b8c0aa301f203238122 Mon Sep 17 00:00:00 2001 From: Nitin Misra Date: Wed, 5 Aug 2026 14:24:30 +0530 Subject: [PATCH] feat: web interaction coverage beyond click + fix fabricated XHR traceparent (v0.1.13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-tap tracking listened only to `click`, which made search-and-filter UIs largely invisible: a keyboard-submitted search, a `, checkbox, radio, file, date, time, range | Free-text inputs are excluded — their `change` fires on blur, which reports an edit somewhere the user does not associate with it. Adds `user_interaction.value` for closed value spaces only (selected option label, `checked`/`unchecked`). | +| `submit` | form submission, **and** Enter in a text entry | `user_interaction.trigger` distinguishes `unknown` (form) from `keyboard` (Enter). The Enter case exists because React handlers routinely swallow the real `submit` event. | +| `input` | text entries | Debounced 500 ms after typing stops, so one edit is one span. Moving to another field flushes the previous edit immediately, preserving edit order. Never fires for `password`/`email`/`tel`/`hidden`. | + +Narrow the list on chatty UIs — `input` is usually the first to drop. `[]` +disables interaction tracking without touching `enableAutoTapTracking`. + +Free text never leaves the page: `user_interaction.value` is only set for +controls with a closed value space, and a sensitive field's description is +reported as `redacted` rather than falling through to nearby text content. + ## Thresholds | Field | Type | Default | Description | diff --git a/package.json b/package.json index 4fa12a2..9b96bfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@base-14/scout-react", - "version": "0.1.12", + "version": "0.1.13", "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/attributes.ts b/src/core/attributes.ts index bd745c8..51cb4eb 100644 --- a/src/core/attributes.ts +++ b/src/core/attributes.ts @@ -37,6 +37,13 @@ export const ATTR = { OPERATION_FAILURE_REASON: 'operation.failure_reason', USER_INTERACTION_ID: 'user_interaction.id', USER_INTERACTION_TYPE: 'user_interaction.type', + /** How the interaction was produced: `pointer` | `keyboard` | `unknown`. */ + USER_INTERACTION_TRIGGER: 'user_interaction.trigger', + /** + * The committed value, but only for controls with a closed value space + * (checkbox state, selected option label). Never free text. + */ + USER_INTERACTION_VALUE: 'user_interaction.value', USER_INTERACTION_TARGET: 'user_interaction.target', USER_INTERACTION_TARGET_TYPE: 'user_interaction.target.type', USER_INTERACTION_TARGET_NAME_SOURCE: 'user_interaction.target.name_source', diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 0968db2..ad36ab9 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; import { resolveConfig, resolveEndpoint } from './config'; describe('resolveConfig', () => { + // The other half of the in-place token-refresh contract locked in + // otlp-exporter.test.ts: the exporters can only observe a mutation if the + // header map survives config resolution by reference. + it('passes the headers object through by reference, not by copy', () => { + const headers = { authorization: 'Bearer old' }; + const r = resolveConfig({ serviceName: 'svc', endpoint: 'https://o.test', headers }); + expect(r.headers).toBe(headers); + }); it('applies sensible defaults', () => { const r = resolveConfig({ serviceName: 'svc', diff --git a/src/core/config.ts b/src/core/config.ts index ff39de7..925ac58 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -5,6 +5,18 @@ export interface CustomTargetInfo { searchForText?: boolean; } export type CustomTargetResolver = (node: unknown) => CustomTargetInfo | null; +/** + * DOM events auto-tap tracking can emit `user_interaction` spans for. The value + * lands on the span as `user_interaction.type`, so it is also the vocabulary + * dashboards filter on. + */ +export type InteractionEvent = 'click' | 'change' | 'submit' | 'input'; +export const DEFAULT_INTERACTION_EVENTS: InteractionEvent[] = [ + 'click', + 'change', + 'submit', + 'input', +]; export interface ScoutConfig { serviceName: string; endpoint: string; @@ -16,6 +28,12 @@ export interface ScoutConfig { headers?: Record; resourceAttributes?: Attributes; enableAutoTapTracking?: boolean; + /** + * Which DOM events auto-tap tracking listens to. Web only; ignored when + * `enableAutoTapTracking` is false. Narrow this on chatty UIs — `input` is + * the usual first thing to drop. + */ + interactionEvents?: InteractionEvent[]; enableErrorTracking?: boolean; enableLifecycleTracking?: boolean; enableStartupTracking?: boolean; @@ -155,6 +173,7 @@ export function resolveConfig(config: ScoutConfig): ResolvedConfig { headers: config.headers, resourceAttributes: config.resourceAttributes, enableAutoTapTracking: config.enableAutoTapTracking ?? true, + interactionEvents: config.interactionEvents ?? DEFAULT_INTERACTION_EVENTS, enableErrorTracking: config.enableErrorTracking ?? true, enableLifecycleTracking: config.enableLifecycleTracking ?? true, enableStartupTracking: config.enableStartupTracking ?? true, diff --git a/src/core/otlp-exporter.test.ts b/src/core/otlp-exporter.test.ts index 35c038a..cf88e16 100644 --- a/src/core/otlp-exporter.test.ts +++ b/src/core/otlp-exporter.test.ts @@ -148,6 +148,21 @@ describe('otlp-exporter — at-most-once delivery', () => { }); }); + // Integrators whose ingest auth is a short-lived bearer token refresh it by + // mutating the very object they passed to `Scout.initialize`. That only works + // because the header map is read per export rather than snapshotted at + // construction — a defensive copy anywhere along that path would strand every + // exporter on the token the page was loaded with. + it('reads the headers object per export, so in-place token refresh takes effect', async () => { + const headers: Record = { authorization: 'Bearer old' }; + const exporter = createOtlpTraceExporter({ ...OPTS, headers }); + await exportOnce(exporter); + expect(fetchMock.mock.calls[0]![1].headers.authorization).toBe('Bearer old'); + headers.authorization = 'Bearer refreshed'; + await exportOnce(exporter); + expect(fetchMock.mock.calls[1]![1].headers.authorization).toBe('Bearer refreshed'); + }); + it('keeps the stock exporter’s CUMULATIVE temporality', () => { const exporter = createOtlpMetricExporter({ url: 'https://c.test/v1/metrics' }); for (const t of [ diff --git a/src/core/scope.ts b/src/core/scope.ts index 0c0a0ac..c65dcb3 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.12'; +export const SCOPE_VERSION = '0.1.13'; diff --git a/src/core/scout.ts b/src/core/scout.ts index 3a614e3..216774d 100644 --- a/src/core/scout.ts +++ b/src/core/scout.ts @@ -21,6 +21,18 @@ import type { PlatformAdapter } from './platform'; import type { Attributes, AttributeValue, SeverityText } from './types'; import { SCOPE_NAME, SCOPE_VERSION } from './scope'; import { uuidv4 } from './uuid'; +/** + * Handle for a span whose lifetime spans an in-flight operation. See + * {@link Scout.startTrackedSpan}. + */ +export interface TrackedSpan { + /** The live span. Prefer `end()` over calling `span.end()` directly. */ + span: Span; + /** W3C `traceparent` value for this span, sampled-flag set. */ + traceparent(): string; + /** Merges `extra` attributes, applies status, ends the span and records it. */ + end(extra?: Attributes, opts?: { status?: SpanStatusCode }): void; +} const MAX_STACK_LEN = 8000; function errorFingerprint(type: string, message: string, stack: string): string { const firstFrame = @@ -551,6 +563,71 @@ export class Scout { } return span; } + /** + * Starts a span the caller ends later, with the same `beforeSend` filtering, + * sampling and view-counter bookkeeping `emitSpan` applies. + * + * `emitSpan` creates and ends a span in one call, so it cannot serve callers + * that need the span's ids *before* the work finishes. W3C trace context is + * the motivating case: `traceparent` must go out with the request, while the + * status and duration are only known once it comes back. + * + * Returns null when the span is sampled out or dropped by `beforeSend` — and + * that null is meaningful: callers must not inject a `traceparent` for a span + * that will never be exported, or the backend sees a dangling parent. + */ + startTrackedSpan(name: string, attributes: Attributes = {}): TrackedSpan | null { + if (!this.session.isSampled) return null; + const filtered = applyBeforeSend(this._config.beforeSend, 'span', name, attributes); + if (!filtered) return null; + let span: Span; + try { + const parentCtx = + this._rootSpan && this._rootSpan !== this._rootSpanSentinel + ? trace.setSpan(context.active(), this._rootSpan) + : context.active(); + span = this.tracer.startSpan( + name, + { attributes: toOtelAttrs(filtered.attributes) }, + parentCtx, + ); + } catch (e) { + this.debug('startTrackedSpan failed', e); + return null; + } + const recorded: Attributes = { ...filtered.attributes }; + return { + span, + traceparent: () => { + const ctx = span.spanContext(); + return `00-${ctx.traceId}-${ctx.spanId}-01`; + }, + end: (extra, opts) => { + try { + if (extra) { + Object.assign(recorded, extra); + span.setAttributes(toOtelAttrs(extra)); + } + if (opts?.status === SpanStatusCode.ERROR) { + span.setStatus({ code: SpanStatusCode.ERROR }); + } + span.end(); + this.debug('emit', name, recorded[ATTR.SCREEN_NAME] ?? '(no-screen)'); + this.session.touch(); + this.bumpViewCounter(name, recorded); + if (this._webViewBridgeSend) { + this._webViewBridgeSend({ + type: name, + attributes: recorded, + timestamp_ms: Date.now(), + }); + } + } catch (e) { + this.debug('startTrackedSpan end failed', e); + } + }, + }; + } private bumpViewCounter(spanName: string, attributes: Attributes): void { const screen = attributes[ATTR.SCREEN_NAME] ?? this._runtimeAttrs[ATTR.SCREEN_NAME]; const dims: Record = {}; diff --git a/src/web/instrumentations/network.test.ts b/src/web/instrumentations/network.test.ts index 4ecb690..ecd652d 100644 --- a/src/web/instrumentations/network.test.ts +++ b/src/web/instrumentations/network.test.ts @@ -123,3 +123,126 @@ describe('installNetworkTracker — fetch', () => { expect(span?.status.code).toBe(2); }); }); + +/** + * Stands in for jsdom's XMLHttpRequest, which would attempt a real request. + * Only the surface `installNetworkTracker` patches is modelled; `dispatch` + * lets a test drive the loadend/error/timeout/abort lifecycle by hand. + */ +class FakeXHR { + status = 0; + sentBody: unknown = undefined; + readonly headers: Record = {}; + private readonly listeners: Record void>> = {}; + open(_method: string, _url: string | URL): void {} + setRequestHeader(name: string, value: string): void { + this.headers[name] = value; + } + send(body?: unknown): void { + this.sentBody = body; + } + addEventListener(type: string, fn: () => void): void { + (this.listeners[type] ??= []).push(fn); + } + dispatch(type: string): void { + for (const fn of this.listeners[type] ?? []) fn(); + } +} + +describe('installNetworkTracker — XMLHttpRequest', () => { + let recorder: Recorder; + let scout: Scout; + let dispose: () => void; + let originalXHR: typeof XMLHttpRequest; + beforeEach(async () => { + recorder = makeRecorder(); + scout = new Scout( + { + serviceName: 't', + endpoint: 'http://collector.example:4318', + secure: false, + sessionSampleRate: 100, + firstPartyHosts: ['api.acme.com'], + }, + memoryPlatform(), + ); + await scout.bootstrap(); + originalXHR = globalThis.XMLHttpRequest; + globalThis.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest; + dispose = installNetworkTracker(scout); + }); + afterEach(() => { + dispose(); + globalThis.XMLHttpRequest = originalXHR; + }); + function request(url: string, method = 'GET'): FakeXHR { + const xhr = new globalThis.XMLHttpRequest() as unknown as FakeXHR; + xhr.open(method, url); + xhr.send(); + return xhr; + } + it('emits an http.request span carrying method, url and status', () => { + const xhr = request('https://api.acme.com/users', 'POST'); + xhr.status = 201; + xhr.dispatch('loadend'); + const span = recorder.spans().find((s) => s.name === SPAN.HTTP_REQUEST); + expect(span?.attributes[ATTR.HTTP_METHOD]).toBe('POST'); + expect(span?.attributes[ATTR.HTTP_URL]).toBe('https://api.acme.com/users'); + expect(span?.attributes[ATTR.HTTP_STATUS_CODE]).toBe(201); + expect(typeof span?.attributes[ATTR.HTTP_DURATION_MS]).toBe('number'); + }); + it('injects a traceparent carrying the http.request span ids, not random ones', () => { + const xhr = request('https://api.acme.com/x'); + xhr.status = 200; + xhr.dispatch('loadend'); + const span = recorder.spans().find((s) => s.name === SPAN.HTTP_REQUEST); + const [version, traceId, spanId, flags] = (xhr.headers.traceparent ?? '').split('-'); + expect(version).toBe('00'); + expect(flags).toBe('01'); + expect(traceId).toBe(span?.spanContext().traceId); + expect(spanId).toBe(span?.spanContext().spanId); + }); + it('does NOT inject traceparent on third-party hosts', () => { + const xhr = request('https://fonts.googleapis.com/x'); + xhr.status = 200; + xhr.dispatch('loadend'); + expect(xhr.headers.traceparent).toBeUndefined(); + expect(recorder.spans().some((s) => s.name === SPAN.HTTP_REQUEST)).toBe(true); + }); + it('skips its own collector endpoint to avoid recursion', () => { + const xhr = request('http://collector.example:4318/v1/traces', 'POST'); + xhr.status = 200; + xhr.dispatch('loadend'); + expect(recorder.spans().some((s) => s.name === SPAN.HTTP_REQUEST)).toBe(false); + expect(xhr.headers.traceparent).toBeUndefined(); + }); + it('records http.error and ERROR status on a transport failure', () => { + const xhr = request('https://api.acme.com/oops'); + xhr.dispatch('error'); + const span = recorder.spans().find((s) => s.name === SPAN.HTTP_REQUEST); + expect(span?.attributes[ATTR.HTTP_ERROR]).toBe('network error'); + expect(span?.attributes[ATTR.HTTP_STATUS_CODE]).toBe(0); + expect(span?.status.code).toBe(2); + }); + it('honours beforeSend dropping an http.request span', async () => { + dispose(); + const filtered = new Scout( + { + serviceName: 't', + endpoint: 'http://collector.example:4318', + secure: false, + sessionSampleRate: 100, + firstPartyHosts: ['api.acme.com'], + beforeSend: (event) => (event.name === SPAN.HTTP_REQUEST ? null : event), + }, + memoryPlatform(), + ); + await filtered.bootstrap(); + dispose = installNetworkTracker(filtered); + const xhr = request('https://api.acme.com/x'); + xhr.status = 200; + xhr.dispatch('loadend'); + expect(recorder.spans().some((s) => s.name === SPAN.HTTP_REQUEST)).toBe(false); + expect(xhr.headers.traceparent).toBeUndefined(); + }); +}); diff --git a/src/web/instrumentations/network.ts b/src/web/instrumentations/network.ts index 3ff181a..2396dd5 100644 --- a/src/web/instrumentations/network.ts +++ b/src/web/instrumentations/network.ts @@ -51,7 +51,7 @@ export function installNetworkTracker(scout: Scout): () => void { const headers = new Headers(init?.headers ?? (input as Request).headers ?? {}); const providerAttrs = providerAttrsFor(url); const graphqlAttrs = graphqlReqAttrsFor(init?.body); - const httpSpan = scout.startChildSpan(SPAN.HTTP_REQUEST, { + const tracked = scout.startTrackedSpan(SPAN.HTTP_REQUEST, { [ATTR.HTTP_RESOURCE_ID]: uuidv4(), [ATTR.HTTP_METHOD]: method, [ATTR.HTTP_URL]: url, @@ -59,9 +59,9 @@ export function installNetworkTracker(scout: Scout): () => void { ...graphqlAttrs, ...scout.commonAttributes(), }); - if (httpSpan && isFirstParty(url)) { - const ctx = httpSpan.spanContext(); - headers.set('traceparent', `00-${ctx.traceId}-${ctx.spanId}-01`); + const httpSpan = tracked?.span; + if (tracked && isFirstParty(url)) { + headers.set('traceparent', tracked.traceparent()); } try { const response = await originalFetch(input as any, { ...init, headers }); @@ -102,7 +102,7 @@ export function installNetworkTracker(scout: Scout): () => void { }); } catch {} } - httpSpan.end(); + tracked?.end(); } scout.addBreadcrumb( BREADCRUMB_TYPE.HTTP, @@ -117,7 +117,7 @@ export function installNetworkTracker(scout: Scout): () => void { [ATTR.HTTP_ERROR]: error instanceof Error ? error.message : String(error), }); httpSpan.setStatus({ code: SpanStatusCode.ERROR }); - httpSpan.end(); + tracked?.end(); } scout.addBreadcrumb(BREADCRUMB_TYPE.HTTP, `${method} ${url} → error`); throw error; @@ -146,12 +146,25 @@ export function installNetworkTracker(scout: Scout): () => void { return origSend.call(this, body as any); } const start = performance.now(); - if (isFirstParty(meta.url)) { + // Started before send() so the span's own ids are what goes out on the + // wire — a header minted from fresh random ids would name a parent the + // backend never receives. + const tracked = scout.startTrackedSpan(SPAN.HTTP_REQUEST, { + [ATTR.HTTP_RESOURCE_ID]: uuidv4(), + [ATTR.HTTP_METHOD]: meta.method, + [ATTR.HTTP_URL]: meta.url, + ...providerAttrsFor(meta.url), + ...scout.commonAttributes(), + }); + if (tracked && isFirstParty(meta.url)) { try { - origSetHeader.call(this, 'traceparent', makeTraceparent()); + origSetHeader.call(this, 'traceparent', tracked.traceparent()); } catch {} } + let finalized = false; const finalize = (statusOverride?: number, errorMsg?: string) => { + if (finalized) return; + finalized = true; const duration = performance.now() - start; const status = statusOverride ?? this.status; const phaseAttrs = (() => { @@ -162,17 +175,12 @@ export function installNetworkTracker(scout: Scout): () => void { return {}; } })(); - scout.emitSpan( - SPAN.HTTP_REQUEST, + tracked?.end( { - [ATTR.HTTP_RESOURCE_ID]: uuidv4(), - [ATTR.HTTP_METHOD]: meta.method, - [ATTR.HTTP_URL]: meta.url, [ATTR.HTTP_STATUS_CODE]: status, [ATTR.HTTP_DURATION_MS]: duration, ...(errorMsg ? { [ATTR.HTTP_ERROR]: errorMsg } : {}), ...phaseAttrs, - ...scout.commonAttributes(), }, { status: errorMsg || status >= 400 ? SpanStatusCode.ERROR : undefined }, ); @@ -226,9 +234,6 @@ function normalizeHost(h: string): string { function stripScheme(url: string): string { return url.replace(/^https?:\/\//, ''); } -function makeTraceparent(): string { - return `00-${randHex(32)}-${randHex(16)}-01`; -} function providerAttrsFor(url: string): Attributes { const p = lookupProvider(url); if (!p) return {}; @@ -252,10 +257,3 @@ function graphqlReqAttrsFor(body: unknown): Attributes { } return out; } -function randHex(len: number): string { - const bytes = new Uint8Array(len / 2); - const g: any = globalThis; - if (g.crypto?.getRandomValues) g.crypto.getRandomValues(bytes); - else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); -} diff --git a/src/web/instrumentations/tap.test.ts b/src/web/instrumentations/tap.test.ts index eca9ab3..be47101 100644 --- a/src/web/instrumentations/tap.test.ts +++ b/src/web/instrumentations/tap.test.ts @@ -1,8 +1,9 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Scout } from '../../core/scout'; import { ATTR } from '../../core/attributes'; import { SPAN, BREADCRUMB_TYPE } from '../../core/spans'; +import type { InteractionEvent } from '../../core/config'; import { makeRecorder, memoryPlatform, type Recorder } from '../../test/recorder'; import { installTapTracker } from './tap'; describe('installTapTracker', () => { @@ -83,6 +84,80 @@ describe('installTapTracker', () => { expect(crumbs[0]?.type).toBe(BREADCRUMB_TYPE.TAP); expect(crumbs[0]?.message).toContain('foo'); }); + it('emits type=change with the selected option label for a and for checkbox/radio reached by + // keyboard; a click listener alone reports the widget was touched but never + // that a value was actually committed. + listen('change', (e) => { + const target = e.target as HTMLElement | null; + if (!target || !isValueControl(target)) return; + emit('change', target, e, { + [ATTR.USER_INTERACTION_TRIGGER]: 'unknown', + ...selectionAttributes(target), + }); + }); + } + if (enabled.has('submit')) { + listen('submit', (e) => { + const target = e.target as HTMLElement | null; + if (!target) return; + emit('submit', target, e, { [ATTR.USER_INTERACTION_TRIGGER]: 'unknown' }); + }); + // Enter inside a search box is a submit the user perceives but the DOM often + // never fires a `submit` for — React handlers routinely swallow it. + listen('keydown', (e) => { + if (e.key !== 'Enter' || e.isComposing) return; + const target = e.target as HTMLElement | null; + if (!target || !isTextEntry(target)) return; + emit('submit', target, e, { [ATTR.USER_INTERACTION_TRIGGER]: 'keyboard' }); + }); + } + if (enabled.has('input')) { + // A settled edit — the user stopped typing — rather than one span per + // keystroke. `pending` holds the timer for the field currently being edited. + let pending: ReturnType | undefined; + let pendingTarget: HTMLElement | null = null; + const flush = () => { + if (!pendingTarget) return; + const target = pendingTarget; + pendingTarget = null; + emit('input', target, new Event('input'), { + [ATTR.USER_INTERACTION_TRIGGER]: 'keyboard', + }); + }; + listen('input', (e) => { + const target = e.target as HTMLElement | null; + if (!target || !isTextEntry(target)) return; + handleTextEdit(target, { + pendingTarget, + setPendingTarget: (el) => { + pendingTarget = el; + }, + restartTimer: (ms) => { + if (pending) clearTimeout(pending); + pending = setTimeout(flush, ms); + }, + flushNow: () => { + if (pending) clearTimeout(pending); + flush(); + }, + }); + }); + cleanups.push(() => { + if (pending) clearTimeout(pending); + pendingTarget = null; + }); + } + return () => { + for (const c of cleanups) c(); + }; +} +/** Quiet period that marks the end of one edit. */ +const TEXT_EDIT_SETTLE_MS = 500; +/** + * Decides when a stream of `input` events becomes one reportable edit. + * + * Sensitive fields are dropped outright: the span could only ever say "someone + * typed in a password box", which is not worth the row it costs. + * + * Moving to a different field flushes the previous edit immediately rather than + * letting its timer expire. Two pending timers would emit in timer order, not + * edit order, so a form filled top-to-bottom could report bottom-to-top. + */ +function handleTextEdit( + target: HTMLElement, + ctl: { + pendingTarget: HTMLElement | null; + setPendingTarget: (el: HTMLElement | null) => void; + restartTimer: (ms: number) => void; + flushNow: () => void; + }, +): void { + if (isSensitive(target)) return; + if (ctl.pendingTarget && ctl.pendingTarget !== target) ctl.flushNow(); + ctl.setPendingTarget(target); + ctl.restartTimer(TEXT_EDIT_SETTLE_MS); +} +/** + * True for controls whose `change` event means "a value was committed". + * Free-text inputs are excluded — they fire `change` on blur, which reports an + * edit at a time and place the user does not associate with anything. + */ +function isValueControl(el: HTMLElement): boolean { + const tag = el.tagName?.toLowerCase(); + if (tag === 'select') return true; + if (tag !== 'input') return false; + const type = (el as HTMLInputElement).type?.toLowerCase() ?? 'text'; + return [ + 'checkbox', + 'radio', + 'file', + 'date', + 'datetime-local', + 'time', + 'range', + ].includes(type); +} +function isTextEntry(el: HTMLElement): boolean { + const tag = el.tagName?.toLowerCase(); + if (tag === 'textarea') return true; + if (el.isContentEditable) return true; + if (el.getAttribute?.('role') === 'searchbox') return true; + if (tag !== 'input') return false; + const type = (el as HTMLInputElement).type?.toLowerCase() ?? 'text'; + return ['text', 'search', 'url', 'number', 'email', 'tel', 'password'].includes(type); +} +/** + * Describes *what* was chosen without leaking free text. Only controls whose + * value space is closed (checkbox state, select option label) contribute a + * value; everything else reports the fact of a change and nothing more. + */ +function selectionAttributes(el: HTMLElement): Attributes { + const tag = el.tagName?.toLowerCase(); + try { + if (tag === 'select') { + const sel = el as HTMLSelectElement; + const label = sel.selectedOptions?.[0]?.text ?? ''; + return label ? { [ATTR.USER_INTERACTION_VALUE]: label.trim().slice(0, 60) } : {}; + } + const input = el as HTMLInputElement; + const type = input.type?.toLowerCase(); + if (type === 'checkbox' || type === 'radio') { + return { [ATTR.USER_INTERACTION_VALUE]: input.checked ? 'checked' : 'unchecked' }; + } + } catch {} + return {}; } function describeElement(el: HTMLElement): { description: string; @@ -50,6 +229,7 @@ function describeElement(el: HTMLElement): { const dataTest = el.getAttribute?.('data-testid') ?? el.getAttribute?.('data-test'); if (dataTest) return { description: `[data-testid=${dataTest}]`, source: 'standard_attribute' }; + if (isSensitive(el)) return { description: 'redacted', source: 'redacted' }; const text = (el.textContent ?? '').trim().slice(0, 60); if (text) return { description: text, source: 'text_content' }; const cls = @@ -57,6 +237,11 @@ function describeElement(el: HTMLElement): { if (cls) return { description: `.${cls}`, source: 'standard_attribute' }; return { description: el.tagName?.toLowerCase() ?? 'unknown', source: 'blank' }; } +function isSensitive(el: HTMLElement): boolean { + if (el.tagName?.toLowerCase() !== 'input') return false; + const type = (el as HTMLInputElement).type?.toLowerCase() ?? 'text'; + return SENSITIVE_INPUT_TYPES.has(type); +} function cssSelectorOf(el: Element): string { const parts: string[] = []; let cur: Element | null = el;