Skip to main content

React Native + React Web

@base14/scout-react is a single npm package that ships zero-config OpenTelemetry RUM for three runtimes:

RuntimeEntry importNative bridge required?
React Native (iOS + Android)import Scout from '@base14/scout-react/native'Yes — Expo module (auto-linked)
React (web)import Scout from '@base14/scout-react'No
React-on-web hooksimport { ScoutErrorBoundary } from '@base14/scout-react/react'No

The SDK auto-captures the full Real User Monitoring (RUM) event set (except Session Replay and Profiling) and exports it as OTLP traces, metrics, and logs to a Scout collector. No manual Scout.track(...) calls anywhere in your app — every tap, navigation, HTTP request, error, crash, scroll, and frame metric is gathered automatically.

What You Get

CapabilitySignal ShapeMechanism
Tap / press trackinguser_interaction span (type=tap, target, name_source, permanent_id, x/y)Babel plugin wraps every onPress at build time
Web click trackinguser_interaction span (type=click, target.selector, composed_path_selector, width/height)document.addEventListener('click', …, capture)
Frustration signalsuser_interaction.action.frustration.type (rage_click, dead_click, error_click)DOM mutation observer + error correlation (web only)
Screen / page navigationscreen_view ROOT span with view.id, view.loading_type, view.referrer, view.is_active, per-view counters@react-navigation integration + history listener (web)
HTTP requestshttp.request span with method / url / status / duration / size / provider classification / GraphQL parseWraps fetch + XMLHttpRequest globally
Errorserror span with error.id, fingerprint, handling, source, causes_json, time_since_app_start_ms, breadcrumbsErrorUtils.setGlobalHandler (RN) + window.onerror + unhandledrejection
Native crashes (iOS)native_crash span with FAR/ESR registers, mach_exception, signal, NSException, callstack tree, binary imagesKSCrash 2.5+ + MetricKit subscriber
Native crashes (Android)native_crash span with NDK signal info, tombstone, ApplicationExitInfo subreason, PSS/RSSCustom NDK signal handler (scout_signal_handler.c) + ApplicationExitInfo (API 30+) + JVM uncaught handler
Frame metrics (RN)react_native.frame.refresh_rate, slow_frames_rate, freeze_rate, frozen_frame spansrAF-based polling loop + view.slow_frames_json
Long taskslong_task span with id, duration, thresholdPerformanceObserver('longtask') (web) + main-thread polling (RN)
ANRsanr span with duration, thresholdTimer drift detector + iOS MetricKit.didReceive hang payloads
Scroll depthdisplay.scroll.max_depth, max_depth_scroll_top, max_scroll_height, max_scroll_height_time_ms on screen_viewRN.ScrollView lazy-getter wrap (RN) + window.scroll listener (web)
Web vitalsweb_vital span with name, value, rating (LCP, INP, CLS, FCP, TTFB)web-vitals library on web
CSP violationserror span with error.csp.violated_directive, blocked_uri, dispositionsecuritypolicyviolation event listener (web)
Page lifecycleview.page_states_json, view.in_foreground_periods_jsonvisibilitychange + freeze/resume events (web), AppState (RN)
Session managementsession.id UUID, session.type: user, enduser.anonymous_id persisted across sessionsAsyncStorage (RN) / localStorage (web)
Resource attributesservice.*, device.*, os.*, network.*, a11y.* (~20 a11y flags), screen.*, viewport.*, application.current_localeCollected at init
Configurable batchingtraceExportIntervalMs, traceMaxQueueSize, traceMaxExportBatchSize, logExportScheduledDelayMs, metricExportIntervalMs, exportTimeoutMsOTel BatchSpanProcessor config
Retry with backoffExponential backoff + full jitter on network errors / 408 / 429 / 5xx; default 3 retries, 1s initial, 30s capCustom wrapWithRetry exporter wrapper
On-disk offline bufferPersists retry-exhausted batches to AsyncStorage / localStorage; replays on init + on resume / online / visibilitychange=visiblePer-signal item caps (offlineBuffer.maxItems.{traces,metrics,logs})
Background flushForce-flush all in-flight batches on AppState=background / visibilitychange=hidden / pagehideLifecycle hook calls Scout.flush()

Prerequisites

  • React Native 0.74+ (Hermes recommended) for RN apps, or React 18+ for web
  • Node 20 or 22 (for SDK build / Metro)
  • Xcode 15+ + CocoaPods for iOS, Android Studio + NDK r25+ for Android
  • Scout Collector reachable from your app — see Docker Compose Setup for local dev

Compatibility Matrix

ComponentMinimumRecommended
React18.018.3+
React Native0.740.76+
Expo SDK (if using)5153+
Node (build)2022
iOS deployment target13.016.0+
Android minSdkVersion24 (Android 7.0)31+ (Android 12+) for ApplicationExitInfo
@react-navigation/native (optional, for screen tracking)6.06.1+

Installation

The package is distributed from GitHub (not published to npm). Pin to a released tag in your package.json:

package.json
{
"dependencies": {
"@base14/scout-react": "github:base-14/scout-react#v0.1.5"
}
}

Then install:

npm install

The repo's prepare script auto-runs tsup + tsc after npm install, so the dist/ bundle is built locally from source — you don't need to download a pre-built artifact. The first install takes ~30 s because of the build; subsequent installs from the same tag are cached by npm.

Always pin to a tagged version (#v0.1.5), never to #main. Tags are the only stable, build-verified entry points; main may be mid-refactor.

For React Native apps with bare workflow:

cd ios && pod install && cd ..

For Expo workflow no extra step — the Expo module auto-links on prebuild.

Upgrading

Bump the version pin and re-install:

"@base14/scout-react": "github:base-14/scout-react#v0.1.5"
rm -rf node_modules/@base14/scout-react package-lock.json
npm install

The rm -rf for the SDK directory is needed because npm caches git installs aggressively; without it you can end up with stale dist/ from the previous tag.

Babel plugin (React Native only)

Tap tracking on React Native uses a Babel plugin that wraps every onPress prop at compile time. Add it to babel.config.js:

babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['@base14/scout-react/babel-plugin'],
};
};

The plugin transforms:

<Pressable onPress={handleTap} accessibilityLabel="Buy now" />

into:

<Pressable onPress={(...$scoutArgs) => {
if (typeof globalThis.__scoutTap === 'function') {
globalThis.__scoutTap({
componentName: 'Pressable',
accessibilityLabel: 'Buy now',
testID: undefined,
children: undefined,
}, $scoutArgs);
}
return handleTap && handleTap.apply(this, $scoutArgs);
}} accessibilityLabel="Buy now" />

This runs before any other JSX transform, so it catches every Pressable, TouchableOpacity, TouchableHighlight, TouchableWithoutFeedback, TouchableNativeFeedback, and Button regardless of how they're imported.

Initialization

React Native

index.js
import Scout from '@base14/scout-react/native';
import App from './App';

await Scout.initialize({
serviceName: 'my-app',
endpoint: 'http://localhost:34318',
serviceVersion: '1.0.0',
});

Scout.registerRootComponent(App);

registerRootComponent is a drop-in replacement for Expo's registerRootComponent (or RN's AppRegistry.registerComponent). It wraps your root tree with ScoutRootBoundary so render errors become error spans automatically.

Attach @react-navigation's ref in onReady:

App.tsx
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';

export default function App() {
const navRef = useNavigationContainerRef();
return (
<NavigationContainer
ref={navRef}
onReady={() => Scout.attachNavigationContainer(navRef)}
>
{/* … */}
</NavigationContainer>
);
}

The SDK buffers the navigationRef if attachNavigationContainer is called before Scout.initialize resolves, and installs the tracker once init completes — safe to call from onReady regardless of init timing.

Web

main.tsx
import Scout from '@base14/scout-react';
import { ScoutErrorBoundary } from '@base14/scout-react/react';
import { BrowserRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import App from './App';

await Scout.initialize({
serviceName: 'my-app',
endpoint: 'https://otel.example.com',
});

createRoot(document.getElementById('root')!).render(
<ScoutErrorBoundary>
<BrowserRouter>
<App />
</BrowserRouter>
</ScoutErrorBoundary>,
);

Configuration

Every option you can pass to Scout.initialize():

Identity

FieldTypeDefaultDescription
serviceNamestringrequiredservice.name resource attribute
endpointstringrequiredOTLP-HTTP collector URL (suffixes /v1/{traces,metrics,logs} appended automatically)
serviceVersionstring'1.0.0'service.version
applicationIdstring?Maps to application.id
buildIdstring?Build hash; maps to app.build_id
securebooleantruePrefix https:// when scheme is missing

Transport

FieldTypeDefaultDescription
headersRecord<string,string>{}Extra HTTP headers (auth tokens, tenant IDs)
firstPartyHostsArray<string | RegExp>[]Hosts that get a traceparent injected for distributed tracing
ignoreUrlPatternsRegExp[][]URLs matching these are not auto-instrumented

Batching

FieldTypeDefaultDescription
traceExportIntervalMsnumber5000Trace flush interval
traceMaxQueueSizenumber2048Max spans buffered before drop
traceMaxExportBatchSizenumber512Max spans per HTTP POST
metricExportIntervalMsnumber30000Metric reader interval
logExportScheduledDelayMsnumber5000Log flush interval
logMaxQueueSizenumber2048
logMaxExportBatchSizenumber512
exportTimeoutMsnumber30000Per-export HTTP timeout

Retry + Offline

FieldTypeDefaultDescription
exportRetry.maxRetriesnumber3Retries per batch on retryable failures (5xx / 408 / 429 / network). 0 disables.
exportRetry.initialDelayMsnumber1000First retry backoff
exportRetry.maxDelayMsnumber30000Cap on exponential backoff
offlineBuffer.enabledbooleantruePersist retry-exhausted batches to disk
offlineBuffer.maxItems.tracesnumber5000FIFO item cap
offlineBuffer.maxItems.metricsnumber2000
offlineBuffer.maxItems.logsnumber5000
maxOfflineStorageMbnumber5Coarse total-disk cap that runs alongside the per-signal item caps. Lower priority than offlineBuffer.maxItems.*.

Sessions

FieldTypeDefaultDescription
sessionTimeoutMinutesnumber30Inactivity before new session
sessionSampleRatenumber (0-100)100Per-session binary sampling rate. Below 100, full sessions are dropped (never partial) so traces stay coherent.

Thresholds

FieldTypeDefaultMinDescription
longTaskThresholdMsnumber10020JS task duration that qualifies as a long_task span. Below 20 is clamped up.
anrThresholdMsnumber50001000Main-thread block duration that fires an anr span. Below 1000 is clamped up.

Resource attributes

FieldTypeDescription
resourceAttributesRecord<string, string | number | boolean>Extra attrs merged into every signal's Resource block (e.g. deployment.region, team). Static — set once at init, never re-evaluated.

Auto-instrumentation toggles

Every auto-instrumentation can be turned off independently. All default to true except captureConsole / capturePrintStatements.

ToggleDefaultWhat you lose when set to false
enableAutoTapTrackingtrueAll user_interaction spans (taps on RN, clicks on web). Babel-plugin compile-time wrap still runs but the runtime hook is inert.
enableErrorTrackingtrueerror spans from FlutterError.onError / window.onerror / unhandledrejection / ErrorUtils.setGlobalHandler. Manual Scout.reportError(…) still works.
enableLifecycleTrackingtrueapp_paused/app_resumed spans, background flush, screen_view ROOT span end on background. Heavy loss — recommend leaving on.
enableStartupTrackingtrueapp_startup span (cold + warm start measurement).
enableConnectivityTrackingtruenetwork.connection.type, network.cellular.carrier_name resource attrs and changes on network transitions.
enablePerformanceMetricstruereact_native.memory.usage metric, generic perf samples.
enableLongTaskDetectiontruelong_task spans (use longTaskThresholdMs to tune sensitivity instead of disabling).
enableAnrDetectiontrueanr spans, iOS hang watchdog, Android ANR detector.
enableFrameMetricstruereact_native.frame.refresh_rate / slow_frames_rate / freeze_rate metrics + frozen_frame spans + view.slow_frames_json attribute.
enableMemoryMetricstrueRN-only process memory polling.
enableWebVitalstrueWeb-only LCP / INP / CLS / FCP / TTFB spans.
enableBatteryTrackingtruedevice.battery.level / device.battery.state on every span.
enableNetworkTrackingtruehttp.request spans, fetch/XHR wrap, GraphQL parse, provider classification, traceparent injection.
enableLoggingtrueScout.log*() calls become no-ops (or the OTel log pipeline never gets created).
captureConsolefalse(Off by default) When true, mirrors console.log/info/warn/error/debug to OTLP logs. Original console output is preserved.
capturePrintStatementsfalseAlias of captureConsole for Flutter-flavored naming consistency.

Filtering

FieldTypeDescription
beforeSend(event) => event | nullRuns on every span / metric / log before export. Return null to drop. Mutate the passed object to redact PII. Sees per-span attributes only; resource attributes set on the OTel Resource (e.g. service.name, os.name, device.*) are not in the event payload.

Native crash setup

iOS (KSCrash + MetricKit)

The Expo module auto-installs KSCrash 2.5+ with all five monitors:

  • Mach exceptions
  • POSIX signals
  • C++ exceptions
  • NSExceptions
  • Main-thread deadlocks

Plus a MetricKit subscriber that collects delayed crash + hang diagnostic payloads the OS delivers asynchronously, up to 24 h after the event.

On the next launch after a crash, both pipelines drain into the same native_crash span with full attribute coverage:

crash.type: mach
crash.reason: EXC_BREAKPOINT
crash.mach_exception: EXC_BREAKPOINT
crash.mach_code: KERN_INVALID_ADDRESS
crash.signal: SIGTRAP
crash.signal_code: 0
crash.cpu_arch: arm64
crash.os_name: iOS
crash.os_version: 17.5
crash.kernel_version: Darwin Kernel Version 24.5.0...
crash.device_model: iPhone17,2
crash.machine: arm64e
crash.build_type: debug
crash.report_id: 04446A8C-65BC-486C-A7CD-F7A65DAB797B
crash.stack_trace: libswiftCore.dylib 0x… $ss17_assertionFailure…
crash.registers_json: { "basic": { "pc": …, "lr": …, "sp": …, "fp": …,
"x0": …, …, "x29": … },
"exception": { "far": …, "esr": …, "exception": 0 } }
crash.binary_images_json: [ { "name": …, "uuid": …, "image_addr": …, … }, … ]
crash.callstack_tree_json: [ { "thread_id": …, "crashed": true, "backtrace": … }, … ]

The FAR (Fault Address Register) and ESR (Exception Syndrome Register) values are the gold standard for ARM64 fault diagnosis — they tell the backend exactly what memory access caused the fault.

Android (NDK signal handler + ApplicationExitInfo)

The plugin ships:

  • A custom NDK signal handler in android/src/main/cpp/scout_signal_handler.c that catches SIGSEGV / SIGABRT / SIGBUS / SIGFPE / SIGILL / SIGTRAP and writes a JSON report to disk before re-raising.
  • A JVM uncaught exception handler for Kotlin / Java crashes.
  • An ApplicationExitInfo collector (Android 11 / API 30+) that drains every historical process death reason — including ANRs, OOM kills, low-memory kills, user force-stops — with tombstone payload and (on API 31+, via reflection) the subReason int.

Resulting attributes:

crash.type: native_crash | jvm_exception | anr | low_memory | …
crash.reason: signal name or exception message
crash.signal: SIGSEGV (signal source only)
crash.signal_code: SEGV_MAPERR
crash.signal_address: 0x0
crash.tombstone: (truncated to 32 KB) full Android tombstone text
crash.subreason: 12 (e.g. SUBREASON_TOO_MANY_EMPTY)
crash.exit_status: 139
crash.importance: 300
crash.pss_kb: 125440
crash.rss_kb: 145200
crash.death_timestamp_ms: 1747469392458
crash.process_name: com.example.myapp
crash.pid / .tid / .uid
crash.abi: arm64-v8a
crash.build_fingerprint: google/sdk_gphone64_arm64/...
crash.kernel: Linux version 5.15.…
crash.process_uptime_secs: 847
crash.last_screen: OrderDetailScreen
crash.registers / .memory_map (NDK path only)

React Native lifecycle integration

The SDK installs an AppState listener that:

  1. On background / inactive — ends the active screen_view ROOT span (so its decorated display.scroll.*, view.slow_frames_json, view.page_states_json attrs flush), emits an app_paused span, and force-flushes every batch processor so taps emitted in the last few seconds don't die with the OS suspending the process.
  2. On active — rotates session if inactivity timeout elapsed, restarts the screen_view ROOT for the current route (so spans after resume are properly parented), emits app_resumed, drains the offline buffer.

The fire-and-forget initialization means Scout.initialize() never blocks the host UI even if the collector is unreachable or init internally throws — the host app renders normally and telemetry just no-ops.

What happens when export fails

Three layers of resilience, in order:

  1. Retry with jitter: wrapWithRetry wraps every OTLP exporter. On a retryable failure (network error / 408 / 429 / 5xx), the batch is re-sent after exponential backoff with full jitter (configurable via exportRetry). Permanent 4xx failures (400 / 401 / 403) drop immediately so we don't waste retries.

  2. On-disk offline buffer: after maxRetries exhausts, the batch is serialized to OTLP-compliant JSON via @opentelemetry/otlp-transformer and persisted to AsyncStorage (RN) / localStorage (web) under per-signal keys. Per-signal FIFO caps (offlineBuffer.maxItems) bound storage.

  3. Replay on next opportunity: persisted batches are drained when:

    • Scout.initialize() resolves
    • On RN, AppState transitions to active
    • On web, visibilitychange → visible or online fires

    The replay POSTs each batch directly via fetch (using your configured headers so auth still applies) and stops on the first failure, leaving the remaining batches on disk for the next attempt.

What's still lost:

  • Process killed before a batch is even queued (very rare).
  • Disk write fails (QuotaExceededError on web, sandbox issues on RN) — the batch is silently dropped.
  • Storage cap is hit during a long outage — oldest items evict first; your most-recent telemetry survives.

Running the example app

The repo ships a runnable Expo example at examples/platform-design-mobile. Its package.json already pins the SDK by GitHub tag ("@base14/scout-react": "github:base-14/scout-react#v0.1.5"):

git clone https://github.com/base-14/scout-react.git
cd scout-react/examples/platform-design-mobile
npm install
# iOS sim
npx expo run:ios
# Android emulator / device
npx expo run:android

Tap around — every interaction generates spans. The example points at http://localhost:34318 by default; edit App.tsx if your collector lives elsewhere. For Android, also run adb reverse tcp:34318 tcp:34318 so the emulator can reach the collector on the host.

Troubleshooting

SymptomLikely causeFix
Bundle JS error: globalThis.__scoutTap?.call is not a functionBabel plugin not picked up by Metro cacheRestart Metro with --clear
Taps captured as target: "pressable" / target.type: "Component" with same permanent_id every timeThe responder system is intercepting at the wrong layerMake sure the babel plugin is in babel.config.js (not just the deprecated runtime patch)
scout_anonymous_id not presentFirst launch; AsyncStorage write failed silentlyVerify file system permissions; check device storage isn't full
No screen_view spansattachNavigationContainer(navRef) never calledAdd it to NavigationContainer.onReady
No display.scroll.* attrsApp was on the same screen when backgrounded (root span never ended)The bg-flush hook ends it on background; otherwise navigate to flush
iOS sim: Failed to load script red boxadb reverse-style port forwarding missingiOS sim shares host network — no extra step needed; check Metro on port 8081
Android: localhost unreachable from appAndroid emulator doesn't share host network like iOS simadb reverse tcp:8081 tcp:8081 and adb reverse tcp:34318 tcp:34318
dist/ not found error after npm install from git tagBuild step skippedAdd "prepare": "npm run build" to package.json (already present in v0.1.2+)
Web: Scout.flush() doesn't drain anythingPage already navigated; service worker may be interceptingUse pagehide listener (already wired internally)

Performance considerations

  • Tap spans: ~0.5 ms per tap (synchronous fiber walk for descriptor extraction, async OTLP queue).
  • Span size: ~5 KB per scout-react span average — ~3-5× the typical backend span because of rich RUM context (battery, network, a11y, device, session, enduser, screen).
  • Default trace flush: 5 s — at 100 spans/s a busy app generates ~500 KB/flush. Tune traceExportIntervalMs + traceMaxExportBatchSize for your traffic shape.
  • Offline buffer: default 5000 trace items ≈ 25 MB worst case on disk. Drop to traces: 2000 (10 MB) for low-end Android.
  • Babel plugin overhead: zero runtime cost — the wrapping happens at compile time.

Security considerations

  • PII redaction: use the beforeSend callback to scrub fields before export:

    beforeSend: (event) => {
    delete event['enduser.email'];
    delete event['http.url']; // if it contains tokens in query string
    return event;
    }
  • No silent SDK failure logging in production: Scout.initialize() rejections are silently caught by the example app's fire-and-forget pattern. Don't propagate them to the user-facing error UI.

  • Headers contain credentials: anything you pass in headers (e.g. Authorization Bearer tokens) is replayed on offline-buffer drain too. Use short-lived tokens or rotate frequently.

  • Anonymous user ID is persistent: stored in ${ApplicationDocuments}/scout_anonymous_id (RN) or localStorage (web). Clear it on logout if your use case requires it.

FAQ

Does scroll tracking work on FlatList?

Yes. The SDK patches RN.ScrollView's lazy getter at module load — since FlatList → VirtualizedList → ScrollView, every list's onScroll flows through the same observer. Custom ScrollView subclasses you don't pull from react-native won't be tracked.

Will the babel plugin break my existing onPress handlers?

No. The plugin's wrapper preserves this binding, forwards all arguments, returns the original handler's return value, and uses a typeof === 'function' guard so the call short-circuits cleanly when the SDK isn't loaded.

What if I'm on React Native 0.71 (old architecture)?

Mostly fine. The babel plugin works on any React/Babel version >= 7. The ScrollView lazy-getter patch relies on RN's react-native/index.js using lazy get-based exports — this has been the case since RN 0.60. KSCrash 2.5+ requires iOS 13.0 minimum.

Can I use this with React Navigation v6 AND v7?

Yes. The integration depends only on NavigationContainerRef's addListener('state', fn) API which is stable across both major versions.

What's next

References

Was this page helpful?