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/attributes.test.ts b/src/core/attributes.test.ts index 59cc3c2..d836254 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', () => { @@ -22,7 +22,13 @@ 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'); + 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 +42,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..7ca3b35 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', @@ -134,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/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/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/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/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/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', 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/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/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/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; }; 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 {} 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')) { 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, 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: {