Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ await Scout.initialize({
| `headers` | `Record<string, string>` | `{}` | Extra HTTP headers on every export. Use for auth tokens, tenant IDs, etc. |
| `firstPartyHosts` | `Array<string \| RegExp>` | `[]` | 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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 17 additions & 2 deletions src/core/attributes.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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');
Expand All @@ -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');
Expand Down
14 changes: 12 additions & 2 deletions src/core/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,6 +61,19 @@ export interface ScoutConfig {
alwaysCaptureErrors?: boolean;
firstPartyHosts?: Array<string | RegExp>;
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;
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions src/core/otlp-exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]) {
Expand All @@ -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,
);
});
});
15 changes: 10 additions & 5 deletions src/core/otlp-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
AggregationTemporality,
AggregationType,
type AggregationOption,
type InstrumentType,
InstrumentType,
type PushMetricExporter,
type ResourceMetrics,
} from '@opentelemetry/sdk-metrics';
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/core/scope.ts
Original file line number Diff line number Diff line change
@@ -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';
51 changes: 51 additions & 0 deletions src/core/scout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) {
const s = await makeScout();
s.setCurrentScreen('/checkout');
s.emitSpan(name, attrs as never);
const resourceMetrics = await recorder.metrics();
const out: Record<string, number> = {};
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);
});
});
Loading
Loading