Zero-config OpenTelemetry RUM (Real User Monitoring) for React (browser) and React Native. Install the package, call Scout.initialize() — that's it.
Auto-captures clicks, navigation, errors, lifecycle, network, performance, web vitals, and device context. Exports over OTLP/HTTP.
import Scout from '@base-14/scout-react';
await Scout.initialize({
serviceName: 'my-app',
endpoint: 'https://otlp.example.com:4318',
});That's all the code you write.
npm install @base-14/scout-reactPeer deps are installed by the host app on demand (none of them are required for the web entry):
| Use case | Peer deps |
|---|---|
| Web (browser) | react (only if you use /react helpers) |
| React Native | react-native, @react-native-async-storage/async-storage |
| RN + navigation tracking | @react-navigation/native |
| RN + connectivity tracking | @react-native-community/netinfo |
| RN + device model/manufacturer | react-native-device-info |
| RN + battery level | expo-battery (preferred) or react-native-device-info |
The native layer (ScoutCrash: ANR / UI-hang detection, native crash capture, accurate cold-start timing) is built on the Expo Modules API. That's the only part of Expo it uses — no managed workflow, Expo CLI, or Expo Go. Bare apps need Expo's module infrastructure installed once:
npx install-expo-modules@latest
npx pod-installThis adds the expo package and wires up autolinking in MainApplication / AppDelegate. Installing expo-modules-core on its own is not enough — the module won't be linked.
Native capture behaves identically in bare and Expo apps. If the module isn't linked, the SDK doesn't fail: it falls back to JS-only capture, which means no ANR, hang, or native-crash spans, and cold start measured from JS bundle load instead of process start. To confirm it's linked, check in a release build on both platforms:
import { requireOptionalNativeModule } from 'expo-modules-core';
const mod = requireOptionalNativeModule('ScoutCrash');
console.log('ScoutCrash linked:', !!mod && (await mod.isInstalled()));import Scout from '@base-14/scout-react';
await Scout.initialize({
serviceName: 'my-app',
endpoint: 'https://otlp.example.com:4318',
});That's it. Clicks, route changes, errors, fetch + XHR, lifecycle, web vitals, long tasks, memory, and crashes are all auto-captured. No per-button instrumentation.
Optional React helpers from the /react subpath:
import { ScoutProvider, useScout, ScoutErrorBoundary, ScoutAction } from '@base-14/scout-react/react';
<ScoutProvider config={{ serviceName: 'my-app', endpoint: '...' }}>
<ScoutErrorBoundary fallback={(e) => <ErrorPage error={e} />}>
<App />
</ScoutErrorBoundary>
</ScoutProvider>;
// Override the tap label for a widget the SDK can't auto-name:
<ScoutAction description="Add to cart">
<MyFancyButton />
</ScoutAction>Two lines in your entry file. Nothing in your component tree.
// index.ts
import Scout from '@base-14/scout-react/native';
import App from './App';
Scout.registerRootComponent(App);registerRootComponent installs a root-level error + touch boundary via RN's AppRegistry.setWrapperComponentProvider. Every tap becomes a user_interaction span, every render error becomes an error span (with error.component_stack), automatically.
Running the native entry under plain Node (Jest without a React Native preset, SSR, scripts) — use the named export,
import { Scout } from '@base-14/scout-react/native'.dist/nativeis CommonJS so Metro can see literalrequire()s; Node's ESM interop does not honour its__esModulemarker, so the default import resolves to the module namespace rather than the class. Metro and Babel handle the default import correctly, so app code is unaffected.
Then in your App.tsx:
import Scout from '@base-14/scout-react/native';
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
export default function App() {
const navRef = useNavigationContainerRef();
useEffect(() => {
Scout.initialize({
serviceName: 'my-app',
endpoint: 'https://otlp.example.com:4318',
});
}, []);
return (
<NavigationContainer
ref={navRef}
onReady={() => Scout.attachNavigationContainer(navRef)}>
{/* ... */}
</NavigationContainer>
);
}On Android USB devices, the OTLP endpoint runs on your dev machine — point it at http://localhost:<port> and run adb reverse tcp:<port> tcp:<port> so the phone forwards through USB. iOS simulator shares the host network so localhost works directly.
| 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 |
| Lifecycle | app_paused, app_resumed |
Web: visibilitychange; RN: AppState |
| Errors (uncaught) | error span + error.count metric |
error.handled=false |
| Errors (manual / boundary) | error span |
error.handled=true, error.component_stack if from a React boundary |
| Unhandled rejections | error span |
Web: window event; RN: HermesInternal |
| Long tasks | long_task |
Default threshold 100ms |
| Frozen frames | frozen_frame |
Long task ≥ 700ms; background / suspended time excluded, capped at frozenFrameMaxMs |
| ANR | anr |
Web: worker watchdog. RN: timer-drift watchdog. |
| HTTP (fetch + XHR) | http.request |
Method, URL, status, duration, content-length |
| Unclean exit (OOM / force-kill / tab discard) | app_unclean_exit on next launch |
Persistent session marker (localStorage on web, AsyncStorage on RN). Not a crash — pagehide also fails to fire on ordinary closes, so it never touches crash-free rate. Off by default inside embedded WebViews (enableUncleanExitDetection) |
| Native crash (RN) | native_crash on next launch |
iOS: KSCrash 2.5+ (mach exceptions, POSIX signals, C++, NSException, main-thread deadlock) + MetricKit (MXCrashDiagnostic, MXHangDiagnostic) on iOS 14+. Android: uncaught Java/Kotlin (Thread.setDefaultUncaughtExceptionHandler) + NDK signal handler for native crashes + ApplicationExitInfo (API 30+) for OS-recorded process deaths including ANR (low-memory kills are emitted as app_exit, not as crashes). Reports persisted to disk and emitted on next launch with full register / stack / binary-image dumps, prior breadcrumbs, and crash.type / crash.reason / crash.stack_trace |
| Logs | OTLP logs | Scout.logDebug/Info/Warning/Error and (opt-in) console.* capture |
| Signal | Notes |
|---|---|
| Web vitals | LCP, INP, CLS, TTFB, FCP via web-vitals (with sub-parts — INP input_delay_ms/processing_duration_ms/presentation_delay_ms, LCP load_delay_ms/load_time_ms/render_delay_ms, CLS layout-shift rects) |
| Memory | web.memory.usage gauge (Chromium only) |
| Frame metrics | web.frame.build_time histogram via Long Animation Frame API |
| Battery | device.battery.level, device.battery.state via navigator.getBattery (Chromium) |
| Console capture | Opt-in via captureConsole: true |
| Signal | Notes |
|---|---|
| Battery | device.battery.level, device.battery.state via expo-battery or react-native-device-info |
| Device | model, manufacturer, brand, is_physical, screen dimensions via react-native-device-info + RN.Dimensions |
| Network connectivity type | via @react-native-community/netinfo |
| OS name / version | via Platform.OS, Platform.Version |
| Memory | react_native.memory.usage gauge — polls react-native-device-info.getUsedMemory() every 10s |
| Frame metrics | react_native.frame.build_time histogram + react_native.frame.dropped gauge sampled via requestAnimationFrame |
| Long tasks / frozen frames | long_task and frozen_frame spans derived from the same rAF loop (≥ longTaskThresholdMs, ≥ 700ms respectively) |
| Console capture | Opt-in via captureConsole: true |
Attached to every ResourceSpans / ResourceMetrics / ResourceLogs batch:
service.name,service.version,environmentos.name,os.versiondevice.model.name,device.manufacturer,device.brand,device.is_physicalscreen.width,screen.height,screen.pixel_ratiodevice.battery.level,device.battery.state- Anything you pass via
config.resourceAttributes(e.g.deployment.region,team)
Every span, metric, and log emitted by this SDK carries a single OpenTelemetry InstrumentationScope:
name: base14.scout.react
version: <package.json version>
This is enforced by a CI guard test — backends can filter on scope.name
to identify all Scout-originated telemetry regardless of which signal or
which auto-instrumentation produced it.
Hosts listed in firstPartyHosts receive a W3C traceparent header on outgoing fetch / XHR. The header uses the trace + span ID of the SDK's http.request span, so your backend's spans parent under the browser's request and the entire flow appears in one trace.
firstPartyHosts: [
'api.acme.com', // exact host
'*.acme.com', // matches all subdomains AND the apex
/^api\d+\.acme\.com$/, // regex tested against the URL host
],Third-party hosts (Stripe, Google Fonts, Segment, etc.) are still captured as http.request spans locally — they just don't get the header.
Scout.setUser('user-123', { email: 'jane@example.com', plan: 'pro' });
Scout.clearUser();
// With user-based sampling in play, switching to a different user (or
// clearUser) starts a new session. See "User-based sampling" below.
Scout.addBreadcrumb('checkout', 'added item to cart');
Scout.addBreadcrumb('checkout', 'entered payment details');
// The last 20 breadcrumbs are JSON-serialized onto every `error` and
// `app_crash` span so you can see what the user did before the crash.
Scout.reportError(new Error('payment failed'));
Scout.logEvent('purchase_completed', { sku: 'SKU-1', amount: '49.99' });Runs on every span / metric / log before export. Return null to drop, return the event to send modified.
beforeSend: (event) => {
// Drop health checks
if (String(event['http.url'] ?? '').includes('/health')) return null;
// Scrub PII
delete event['enduser.email'];
return event;
},Scout defaults to 1% session sampling (sessionSampleRate: 1). Out of every ~100 user sessions, only one keeps the full instrumentation trail; the other 99 emit nothing. This keeps telemetry volume (and collector cost) bounded as your app scales.
Errors are never sampled out by default. When alwaysCaptureErrors: true (the default), these signals bypass the session-sample gate and are always exported, regardless of whether the session was sampled in:
| Signal | Span / log | Why it bypasses |
|---|---|---|
Caught error reported via Scout.reportError(...) |
error span |
You always want failures visible. |
| Uncaught JS exception, unhandled rejection, React error-boundary catch | error span |
Same — drop on the floor isn't acceptable. |
Scout.logError(...) / ERROR-severity logs |
log record | High-severity logs treated like errors. |
Native crash marker (RN app_crash) |
app_crash on next launch |
Crashes that lose the prior session must reach you. (app_unclean_exit is not crash-class: it is reported only when the session it describes was sampled.) |
| Native crash (iOS Mach exception / Android NDK signal / JVM uncaught) | native_crash on next launch |
Same — fatal-class signal. |
| ANR (app not responding) | anr span |
Fatal-class UX failure. |
If you set alwaysCaptureErrors: false, errors get the same 1% treatment as everything else. Most teams should leave it at the default.
Everything else — user_interaction, screen_view, http.request, long_task, frozen_frame, web vitals, performance metrics — respects sessionSampleRate. Crank it to 100 for dev, leave it at 1 for production high-traffic apps, or set it to something in between (e.g. 10) for a less aggressive sample.
Scout.initialize({
serviceName: 'my-app',
endpoint: 'https://otel.example.com:4318',
sessionSampleRate: 1, // default — 1% of sessions
alwaysCaptureErrors: true, // default — errors always export
// For local dev / staging:
// sessionSampleRate: 100, // capture every session
});The sampling decision is made once per session, then frozen for that session's lifetime. A session is either fully sampled in (every signal flows) or sampled out (only error-class signals flow). This keeps individual sessions coherent — you never get half-a-trace.
Per-session sampling means one user ends up with some sessions kept and others dropped. To decide per user instead, for example by cohort, plan or geography, pass a value from your own remote config or feature-flag service. Scout makes no network call for this; the targeting is entirely yours.
// At startup: the last-fetched remote-config value, read synchronously from its cache.
Scout.initialize({ serviceName: 'my-app', endpoint, userSampling: remoteConfig.cached('scout_user_sampling') });
// When the value arrives or changes (fetch completes, user logs in):
Scout.setUserSampling(true); // keep this user's sessions
Scout.setUserSampling(false); // drop this user's spans; logs and metrics still flow
Scout.setUserSampling(null); // no opinion: fall back to sessionSampleRate| Value at session start | Spans | Logs and metrics |
|---|---|---|
true |
all sent | sent |
false |
none, except error-class spans while alwaysCaptureErrors is on |
still sent |
null / never set |
sessionSampleRate roll, as above |
as above |
- Applies at the next session start, never mid-session. A new session starts after idle timeout, after max duration, on a reload after the session expired, or on a user switch. A reload within the session keeps its decision.
- Persisted. The latest value is stored, so a load that passes nothing still honours it. A non-null
userSamplingat init wins over the stored value. AsetUserSamplingcall made beforeinitialize()resolves is held and decides the first session. - User switches. While user sampling is in play,
clearUser()(which also resets the value), andsetUser()with an id different from the previous one, start a new session. On an account switch, callsetUserSampling(newValue)beforesetUser(newId). Going from anonymous to the first login keeps the current session. - Verify in Scout. Every span, log and metric carries
session.sampling_source=user|fallback. - Not a kill switch. The SDK keeps running for a "no" user; only their spans stop.
await Scout.initialize({
// Required
serviceName: 'my-app',
endpoint: 'https://otlp.example.com:4318',
// Identity / pipeline
serviceVersion: '1.0.0',
environment: 'production',
secure: true, // https when scheme omitted
headers: { Authorization: 'Bearer ...' }, // sent on every OTLP export
// Auto-instrumentation toggles (all default true)
enableAutoTapTracking: true,
enableErrorTracking: true,
enableLifecycleTracking: true,
enableStartupTracking: true,
enableConnectivityTracking: true,
enablePerformanceMetrics: true,
enableLongTaskDetection: true,
enableAnrDetection: true,
enableFrameMetrics: true, // web + RN
enableMemoryMetrics: true, // web + RN
enableWebVitals: true, // web
enableBatteryTracking: true,
enableNetworkTracking: true,
enableLogging: true,
captureConsole: false,
capturePrintStatements: false, // alias for captureConsole
// Thresholds
longTaskThresholdMs: 100,
anrThresholdMs: 5000,
// Sessions
sessionSampleRate: 1, // 0..100, default 1 (1% of sessions)
alwaysCaptureErrors: true, // errors bypass sessionSampleRate (default true)
sessionTimeoutMinutes: 30,
// Network
firstPartyHosts: ['api.example.com', '*.example.com'],
ignoreUrlPatterns: [/\/health$/],
// Storage
maxOfflineStorageMb: 5,
// Filtering
beforeSend: (event) => event,
// Custom resource attributes
resourceAttributes: {
'deployment.region': 'us-east-1',
team: 'mobile',
},
// Export pacing (defaults match production needs)
metricExportIntervalMs: 30000,
logExportScheduledDelayMs: 5000,
// Retry on retryable failures (network errors, 408, 429, 5xx)
exportRetry: {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
},
// On-disk offline buffer — replayed on next init / foreground / online
offlineBuffer: {
enabled: true,
maxItems: { traces: 5000, metrics: 2000, logs: 5000 },
},
// Debug
debug: false,
});On RN, the SDK calls Scout.flush() when AppState transitions from active
to background / inactive. On web, it flushes on visibilitychange = hidden and pagehide. This drains the BatchSpanProcessor, metric reader,
and log processor before the OS suspends / kills the process — without it,
events emitted in the last few seconds (the ones leading up to a crash)
would die with the in-memory batch queue.
If your in-memory exporter still doesn't deliver in time (e.g. the OS kills
us mid-POST), the offline buffer persists the batch to disk and
replays it on next Scout.initialize(). See Configuration → Offline
buffer.
When this page runs inside a native app that also reports RUM (a Flutter app
using scout_flutter, say), the two
SDKs would otherwise open two unrelated sessions for one user flow. The host
fixes that by handing the page its identity:
Scout.setWebViewBridge({
sessionId: 'native-session-id',
anonymousId: 'native-anonymous-id',
});Safe to call before initialize() — the call is parked and applied once the
SDK is up, which is what lets a host inject it as the page starts loading.
Three modes, selected by which fields you pass:
| Mode | Fields | Behaviour |
|---|---|---|
| Session adoption (recommended) | sessionId, anonymousId |
Page keeps exporting to the collector, tagged with the host's session. One copy of every signal, full fidelity. |
| Relay | + send, relay: true |
Page stops POSTing spans; the host delivers them. Use when the WebView can't reach the collector. Spans only — logs and metrics still go over HTTP. |
| Mirror | + send |
Page exports and hands the host a copy. Both reach the backend, so expect duplicate spans. Only useful if the host needs to observe web events locally. |
Scout.isExportingSpans reports whether the page still owns delivery — worth
asserting in a smoke test, since a mis-wired relay is silently lossy.
Prefer session adoption unless the WebView genuinely can't reach the collector; it needs no host-side relay code and loses nothing.
Web builds emit ES2020 syntax and are tested down to Chrome / Android System
WebView 87 (Android 8.1) and Safari 14. The SDK calls no prototype method
newer than that floor — make check-compat fails the build if dist/ does —
and gates dependency features behind runtime checks: web-vitals' CLS and INP
tracking needs Array.prototype.at (Chrome 92) and is skipped below it, while
LCP, FCP and TTFB still report. No polyfills are shipped. An error thrown from
inside the SDK bundle is recorded with error.origin: sdk /
error.category: sdk_internal, once per distinct failure per page, and does not
count towards error.count.
| Signal | Why not |
|---|---|
| Session Replay (DOM / view-tree recording) | Heavyweight payload + privacy implications. Not on the roadmap. |
| Profiling (continuous CPU sampling) | No reliable cross-platform sampler. Not on the roadmap. |
| Frame raster time (web) | Browsers expose only whole-frame durations via Long Animation Frame. We emit web.frame.build_time covering the full frame. |
| CPU usage (web) | No browser API. |
| Sample | Stack | How to run |
|---|---|---|
examples/platform-design-web |
Vite + React | cd examples/platform-design-web && npm run dev |
examples/platform-design-mobile |
Expo + React Native | cd examples/platform-design-mobile && npx expo run:ios (or run:android). The mobile example bundles the ScoutCrash Expo module — npx expo prebuild is run automatically the first time. |
Both default to the local OTel collector on :34318. The repo includes a sample collector config in dev/ (gitignored) — point your collector at it or use your own.
make install # npm ci
make build # tsup for web entries, tsc for native (preserves literal require())
make typecheck
make lint
make fmt # prettier --write
make test # 90 unit + jsdom integration tests
make test-coverage # core/** ≥ 75% lines, ≥ 70% functions
make audit # npm audit prod + all
make ci # fmt-check → lint → typecheck → test → buildSee CHANGELOG.md for release history.
MIT