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.
# from npm (after publish)
npm install @base-14/scout-react
# from git (pin to a tag)
npm install github:base-14/scout-react#v0.1.4Peer 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 |
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.
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 |
| 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 (web only) |
| ANR | anr |
Web: worker watchdog. RN: timer-drift watchdog. |
| HTTP (fetch + XHR) | http.request |
Method, URL, status, duration, content-length |
| Crash (OOM / force-kill) | app_crash on next launch |
Persistent session marker (localStorage on web, AsyncStorage on RN) — survives unclean termination |
| 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 OOM and ANR. 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();
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;
},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: 100, // 0..100
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.
| 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