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
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.13] - 2026-08-05

Web interaction coverage and a distributed-tracing correctness fix. No default
turns anything off; `input` tracking is new and on by default — narrow it with
`interactionEvents` if your UI is text-heavy.

### Added

- **Interaction coverage beyond `click`.** Auto-tap tracking now also emits
`user_interaction` spans for `change` (select / checkbox / radio / file /
date / time / range), `submit` (form submission *and* Enter in a text entry,
which React handlers routinely swallow), and `input` (debounced 500 ms after
typing stops, one span per settled edit). `user_interaction.type` carries
which one it was; existing `click` spans are unchanged.
- `interactionEvents` config option — the subset of
`['click','change','submit','input']` to listen to. Defaults to all four;
`[]` disables interaction tracking without touching `enableAutoTapTracking`.
- `user_interaction.trigger` attribute (`pointer` | `keyboard` | `unknown`),
which distinguishes Enter-to-search from clicking a search button.
- `user_interaction.value` attribute, set only for controls with a closed value
space (selected option label, `checked`/`unchecked`). Free text is never
captured, and `password`/`email`/`tel`/`hidden` fields emit no `input` span at
all and report their description as `redacted` rather than falling through to
neighbouring text content.
- `Scout.startTrackedSpan()` — starts a span the caller ends later, applying the
same `beforeSend`, sampling and view-counter bookkeeping as `emitSpan`. Needed
wherever a span's ids must be known before the work it measures completes.

### Fixed

- **`XMLHttpRequest` sent a fabricated `traceparent`.** The header was built
from fresh random ids rather than the emitted `http.request` span's, so XHR
calls never correlated with their own span or with the backend, and the
backend saw a parent span id it would never receive. Both `fetch` and XHR now
derive the header from the span they actually export.
- A failed `XMLHttpRequest` emitted **two** `http.request` spans, because both
the `error` and `loadend` listeners ran the finalizer.
- `fetch` spans were bypassing `beforeSend` and were not counted in
`view.resource.count`; only the XHR path was. Both now behave identically.
- XHR `http.request` spans had a near-zero duration, since the span was created
after the request finished. They now span the request.

### Changed

- XHR `http.request` spans now carry `http.provider.*` classification, which
previously only `fetch` spans had.
- Documented that `headers` is read per export, so an expiring bearer token can
be rotated by mutating the object passed to `initialize` — no re-init, no
dropped batches. Locked by tests in `otlp-exporter.test.ts` / `config.test.ts`.

## [0.1.12] - 2026-08-03

Brings scout-react to parity with scout-flutter 0.1.23's production-hardening
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ On Android USB devices, the OTLP endpoint runs on your dev machine — point it
| Signal | Span / metric | Notes |
|---|---|---|
| Clicks / taps | `user_interaction` | `user_interaction.target`, `target.type` |
| Value changes, submits, text edits | `user_interaction` | Web only. `user_interaction.type` is `click` \| `change` \| `submit` \| `input`; see `interactionEvents` |
| Navigation | `screen_view`, `view_session` | screen_view becomes the root span — all spans on that screen share its trace id |
| Screen load time | `screen_load` | `screen.load_time` in seconds |
| App startup | `app_startup` | cold + warm |
Expand Down
37 changes: 35 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,25 @@ await Scout.initialize({
| Field | Type | Default | Description |
|---|---|---|---|
| `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` calls to these hosts get a `traceparent` header so backend traces correlate. |
| `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). |

### Rotating an auth token

`headers` is read on every export rather than snapshotted at init, so an
expiring bearer token is refreshed by mutating the object you passed in:

```ts
const headers = { Authorization: `Bearer ${token}` };
await Scout.initialize({ serviceName: 'app', endpoint, headers });

// later, before the token expires — no re-initialize, no dropped batches
headers.Authorization = `Bearer ${await mintToken()}`;
```

Replacing the object (`headers = {...}`) does **not** work; the exporters hold
the original reference.

## Export pacing

How telemetry is batched and flushed.
Expand Down Expand Up @@ -150,7 +166,8 @@ Every auto-instrumentation can be turned off independently. All default to `true

| Field | Default | What it captures |
|---|---|---|
| `enableAutoTapTracking` | `true` | Web: `click` on every element. RN: `onPress` on Pressable/Touchable* (via babel plugin). Emits `user_interaction` spans. |
| `enableAutoTapTracking` | `true` | Web: the DOM events listed under `interactionEvents`. RN: `onPress` on Pressable/Touchable* (via babel plugin). Emits `user_interaction` spans. |
| `interactionEvents` | `['click','change','submit','input']` | Web only. Which DOM events auto-tap tracking listens to; the value lands on the span as `user_interaction.type`. See below. |
| `enableErrorTracking` | `true` | `window.onerror`, `unhandledrejection`, native crashes via KSCrash + NDK signal handler + MetricKit + ApplicationExitInfo. Emits `error`, `app_crash`, `native_crash` spans. |
| `enableLifecycleTracking` | `true` | App `foreground`/`background`/`paused`/`resumed`. Emits `app_paused` / `app_resumed` spans + `view.in_foreground_periods_json` on screen_view. |
| `enableStartupTracking` | `true` | Cold/warm/hot start timing. Emits `app_startup` span. |
Expand All @@ -167,6 +184,22 @@ Every auto-instrumentation can be turned off independently. All default to `true
| `enableLogging` | `true` | Allows `Scout.log*()` calls to emit OTLP logs. |
| `captureConsole` / `capturePrintStatements` | `false` | Mirrors `console.log/info/warn/error/debug` to OTLP logs. Original `console` output preserved. |

### `interactionEvents` (web)

| Value | Fires on | Notes |
|---|---|---|
| `click` | any element | Carries `target.x` / `target.y` and `user_interaction.trigger: pointer`. |
| `change` | `<select>`, checkbox, radio, file, date, time, range | Free-text inputs are excluded — their `change` fires on blur, which reports an edit somewhere the user does not associate with it. Adds `user_interaction.value` for closed value spaces only (selected option label, `checked`/`unchecked`). |
| `submit` | form submission, **and** Enter in a text entry | `user_interaction.trigger` distinguishes `unknown` (form) from `keyboard` (Enter). The Enter case exists because React handlers routinely swallow the real `submit` event. |
| `input` | text entries | Debounced 500 ms after typing stops, so one edit is one span. Moving to another field flushes the previous edit immediately, preserving edit order. Never fires for `password`/`email`/`tel`/`hidden`. |

Narrow the list on chatty UIs — `input` is usually the first to drop. `[]`
disables interaction tracking without touching `enableAutoTapTracking`.

Free text never leaves the page: `user_interaction.value` is only set for
controls with a closed value space, and a sensitive field's description is
reported as `redacted` rather than falling through to nearby text content.

## Thresholds

| Field | Type | Default | Description |
Expand Down
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.12",
"version": "0.1.13",
"description": "Zero-config OpenTelemetry RUM for React and React Native. Auto-captures clicks, navigation, errors, lifecycle, network, performance, and web vitals.",
"license": "MIT",
"author": "base-14",
Expand Down
7 changes: 7 additions & 0 deletions src/core/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export const ATTR = {
OPERATION_FAILURE_REASON: 'operation.failure_reason',
USER_INTERACTION_ID: 'user_interaction.id',
USER_INTERACTION_TYPE: 'user_interaction.type',
/** How the interaction was produced: `pointer` | `keyboard` | `unknown`. */
USER_INTERACTION_TRIGGER: 'user_interaction.trigger',
/**
* The committed value, but only for controls with a closed value space
* (checkbox state, selected option label). Never free text.
*/
USER_INTERACTION_VALUE: 'user_interaction.value',
USER_INTERACTION_TARGET: 'user_interaction.target',
USER_INTERACTION_TARGET_TYPE: 'user_interaction.target.type',
USER_INTERACTION_TARGET_NAME_SOURCE: 'user_interaction.target.name_source',
Expand Down
8 changes: 8 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest';
import { resolveConfig, resolveEndpoint } from './config';
describe('resolveConfig', () => {
// The other half of the in-place token-refresh contract locked in
// otlp-exporter.test.ts: the exporters can only observe a mutation if the
// header map survives config resolution by reference.
it('passes the headers object through by reference, not by copy', () => {
const headers = { authorization: 'Bearer old' };
const r = resolveConfig({ serviceName: 'svc', endpoint: 'https://o.test', headers });
expect(r.headers).toBe(headers);
});
it('applies sensible defaults', () => {
const r = resolveConfig({
serviceName: 'svc',
Expand Down
19 changes: 19 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ export interface CustomTargetInfo {
searchForText?: boolean;
}
export type CustomTargetResolver = (node: unknown) => CustomTargetInfo | null;
/**
* DOM events auto-tap tracking can emit `user_interaction` spans for. The value
* lands on the span as `user_interaction.type`, so it is also the vocabulary
* dashboards filter on.
*/
export type InteractionEvent = 'click' | 'change' | 'submit' | 'input';
export const DEFAULT_INTERACTION_EVENTS: InteractionEvent[] = [
'click',
'change',
'submit',
'input',
];
export interface ScoutConfig {
serviceName: string;
endpoint: string;
Expand All @@ -16,6 +28,12 @@ export interface ScoutConfig {
headers?: Record<string, string>;
resourceAttributes?: Attributes;
enableAutoTapTracking?: boolean;
/**
* Which DOM events auto-tap tracking listens to. Web only; ignored when
* `enableAutoTapTracking` is false. Narrow this on chatty UIs — `input` is
* the usual first thing to drop.
*/
interactionEvents?: InteractionEvent[];
enableErrorTracking?: boolean;
enableLifecycleTracking?: boolean;
enableStartupTracking?: boolean;
Expand Down Expand Up @@ -155,6 +173,7 @@ export function resolveConfig(config: ScoutConfig): ResolvedConfig {
headers: config.headers,
resourceAttributes: config.resourceAttributes,
enableAutoTapTracking: config.enableAutoTapTracking ?? true,
interactionEvents: config.interactionEvents ?? DEFAULT_INTERACTION_EVENTS,
enableErrorTracking: config.enableErrorTracking ?? true,
enableLifecycleTracking: config.enableLifecycleTracking ?? true,
enableStartupTracking: config.enableStartupTracking ?? true,
Expand Down
15 changes: 15 additions & 0 deletions src/core/otlp-exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,21 @@ describe('otlp-exporter — at-most-once delivery', () => {
});
});

// Integrators whose ingest auth is a short-lived bearer token refresh it by
// mutating the very object they passed to `Scout.initialize`. That only works
// because the header map is read per export rather than snapshotted at
// construction — a defensive copy anywhere along that path would strand every
// exporter on the token the page was loaded with.
it('reads the headers object per export, so in-place token refresh takes effect', async () => {
const headers: Record<string, string> = { authorization: 'Bearer old' };
const exporter = createOtlpTraceExporter({ ...OPTS, headers });
await exportOnce(exporter);
expect(fetchMock.mock.calls[0]![1].headers.authorization).toBe('Bearer old');
headers.authorization = 'Bearer refreshed';
await exportOnce(exporter);
expect(fetchMock.mock.calls[1]![1].headers.authorization).toBe('Bearer refreshed');
});

it('keeps the stock exporter’s CUMULATIVE temporality', () => {
const exporter = createOtlpMetricExporter({ url: 'https://c.test/v1/metrics' });
for (const t of [
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.12';
export const SCOPE_VERSION = '0.1.13';
77 changes: 77 additions & 0 deletions src/core/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ import type { PlatformAdapter } from './platform';
import type { Attributes, AttributeValue, SeverityText } from './types';
import { SCOPE_NAME, SCOPE_VERSION } from './scope';
import { uuidv4 } from './uuid';
/**
* Handle for a span whose lifetime spans an in-flight operation. See
* {@link Scout.startTrackedSpan}.
*/
export interface TrackedSpan {
/** The live span. Prefer `end()` over calling `span.end()` directly. */
span: Span;
/** W3C `traceparent` value for this span, sampled-flag set. */
traceparent(): string;
/** Merges `extra` attributes, applies status, ends the span and records it. */
end(extra?: Attributes, opts?: { status?: SpanStatusCode }): void;
}
const MAX_STACK_LEN = 8000;
function errorFingerprint(type: string, message: string, stack: string): string {
const firstFrame =
Expand Down Expand Up @@ -551,6 +563,71 @@ export class Scout {
}
return span;
}
/**
* Starts a span the caller ends later, with the same `beforeSend` filtering,
* sampling and view-counter bookkeeping `emitSpan` applies.
*
* `emitSpan` creates and ends a span in one call, so it cannot serve callers
* that need the span's ids *before* the work finishes. W3C trace context is
* the motivating case: `traceparent` must go out with the request, while the
* status and duration are only known once it comes back.
*
* Returns null when the span is sampled out or dropped by `beforeSend` — and
* that null is meaningful: callers must not inject a `traceparent` for a span
* that will never be exported, or the backend sees a dangling parent.
*/
startTrackedSpan(name: string, attributes: Attributes = {}): TrackedSpan | null {
if (!this.session.isSampled) return null;
const filtered = applyBeforeSend(this._config.beforeSend, 'span', name, attributes);
if (!filtered) return null;
let span: Span;
try {
const parentCtx =
this._rootSpan && this._rootSpan !== this._rootSpanSentinel
? trace.setSpan(context.active(), this._rootSpan)
: context.active();
span = this.tracer.startSpan(
name,
{ attributes: toOtelAttrs(filtered.attributes) },
parentCtx,
);
} catch (e) {
this.debug('startTrackedSpan failed', e);
return null;
}
const recorded: Attributes = { ...filtered.attributes };
return {
span,
traceparent: () => {
const ctx = span.spanContext();
return `00-${ctx.traceId}-${ctx.spanId}-01`;
},
end: (extra, opts) => {
try {
if (extra) {
Object.assign(recorded, extra);
span.setAttributes(toOtelAttrs(extra));
}
if (opts?.status === SpanStatusCode.ERROR) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
span.end();
this.debug('emit', name, recorded[ATTR.SCREEN_NAME] ?? '(no-screen)');
this.session.touch();
this.bumpViewCounter(name, recorded);
if (this._webViewBridgeSend) {
this._webViewBridgeSend({
type: name,
attributes: recorded,
timestamp_ms: Date.now(),
});
}
} catch (e) {
this.debug('startTrackedSpan end failed', e);
}
},
};
}
private bumpViewCounter(spanName: string, attributes: Attributes): void {
const screen = attributes[ATTR.SCREEN_NAME] ?? this._runtimeAttrs[ATTR.SCREEN_NAME];
const dims: Record<string, string> = {};
Expand Down
Loading
Loading