Skip to main content

React Native + React Web

@base-14/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 '@base-14/scout-react/native'Yes — Expo module (auto-linked)
React (web)import Scout from '@base-14/scout-react'No
React-on-web hooksimport { ScoutErrorBoundary } from '@base-14/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)
ANRs (with thread dumps)anr span with duration, threshold, source_thread (main/js), main_thread_stack, threads_json, thread_count, breadcrumbsNative ScoutAnrWatchdog on a dedicated background thread tracks both main-thread (Looper / DispatchQueue.main) and JS-thread heartbeats. On Android captures Thread.getAllStackTraces() (32 KB / 64 frames-per-thread cap); on iOS captures the main thread via Mach thread_suspend + frame-pointer walking + dladdr() symbolication
iOS UI hangui_hang span with duration, threshold, main_thread_stack, breadcrumbsAppHangWatchdog CFRunLoop heartbeat at the configured iosHangThresholdMs (default 250 ms — sub-ANR)
screen_load spanscreen.name, screen.load_time (seconds), view.loading_time_msEmitted on every React Navigation transition; backs per-screen Avg/P95 Load Time dashboard panels
Screen attribution on every spanscreen.name stamped via Scout.commonAttributes() on every span/metricScout.setCurrentScreen(name) called by the built-in route trackers; reads via getCurrentScreen()
Session attributessetSessionAttributes({ … }) stamps arbitrary key-value pairs on every subsequent span/metric/log until clearedIn-memory map merged into Scout.commonAttributes()
CPU usage gaugereact_native.cpu.usage (percent)Periodic 10 s sampling — Android reads /proc/<pid>/stat ticks via wall-clock delta; iOS sums cpu_usage across task threads via Mach thread_basic_info
Device orientationdevice.orientation runtime attribute (portrait/landscape)Auto-updated on Dimensions.change
Jailbreak / root detectiondevice.is_jail_broken resource attribute ("true"/"false")Path probes — /Applications/Cydia.app etc. on iOS, Build.TAGS=test-keys + su-binary + Magisk packages on Android
Battery discharge rate (Android)device.battery.discharge_rate runtime attribute (µA, sampled every 60 s)BatteryManager.BATTERY_PROPERTY_CURRENT_NOW
NDK build-id (Android)ndk.build_id resource attribute (40-char SHA1)Parses .note.gnu.build-id ELF section from libscout_signal_handler.so (works whether the .so is extracted to nativeLibraryDir or loaded directly from inside the APK)
app_crash + native_crash carry the crashed sessioncrash.previous_session_id, crash.session_started_at, crash.last_screenPersisted across the crash boundary via the marker file (app_crash), NDK signal-handler globals (Android native_crash), and KSCrash.userInfo (iOS native_crash)
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, user.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

npm install @base-14/scout-react

For React Native apps with the bare workflow:

cd ios && pod install && cd ..

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

Upgrading

npm install @base-14/scout-react@latest

Or pin to a specific version:

npm install @base-14/scout-react@0.1.11

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: ['@base-14/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 '@base-14/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 '@base-14/scout-react';
import { ScoutErrorBoundary } from '@base-14/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>,
);

SSR-aware initialization

For any setup where the same React tree renders both server-side (SSR / SSG) and in the browser — Next.js, Remix, Astro, Gatsby, Docusaurus — Scout.initialize() must run only in the browser. The SDK depends on window, document, and localStorage, all of which are absent during SSR build time. Two common patterns:

useEffect-gated (Next.js client component, Remix client-only, etc.):

'use client';
import { useEffect } from 'react';
import Scout from '@base-14/scout-react';

let initialized = false;

export function ScoutBootstrap() {
useEffect(() => {
if (initialized) return;
initialized = true;
void Scout.initialize({
serviceName: 'my-web-app',
endpoint: 'https://rum.example.com/<tenant>/otlp',
secure: true,
headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_SCOUT_TOKEN}` },
captureConsole: true,
});
}, []);
return null;
}

Render <ScoutBootstrap /> once at the root of your app.

typeof window !== 'undefined' guard (any framework, top-level module):

src/scout-client.ts
import Scout from '@base-14/scout-react';

let initialized = false;
if (typeof window !== 'undefined' && !initialized) {
initialized = true;
void Scout.initialize({
serviceName: 'my-web-app',
endpoint: 'https://rum.example.com/<tenant>/otlp',
secure: true,
headers: { Authorization: `Bearer ${YOUR_TOKEN}` },
captureConsole: true,
});
}

Import this module once from your client entry (Vite main.tsx, CRA index.tsx, Next.js app/layout.tsx, Docusaurus clientModules, etc.).

The !initialized flag is a belt-and-braces idempotency check — bundlers and HMR sometimes re-evaluate top-level modules, and you don't want a second Scout instance attached.

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)1Per-session binary sampling rate. Default is 1 (1% of sessions) to bound telemetry volume for production. Error / crash / ANR / UI-hang spans bypass this gate (controlled by alwaysCaptureErrors) so failures are always captured regardless of sampling. 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 / JS-thread block duration that fires an anr span. Below 1000 is clamped up. Watchdog polls every threshold/10 ms (min 200 ms).
iosHangThresholdMsnumber25050iOS-only sub-ANR threshold that fires a ui_hang span. Set to 0 to disable. Catches micro-stutters (tap → 300 ms freeze → recover) that the 5 s ANR threshold misses.
maxTombstoneBytesnumber1310724096Android-only cap on crash.tombstone payload size for ApplicationExitInfo crashes. Some tombstones are multi-MB; this prevents span-payload bloat.

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.
enableCpuMetricstruereact_native.cpu.usage gauge. Android reads /proc/<pid>/stat and computes percentage via wall-clock delta; iOS uses Mach thread_info summed across all task threads. Sampled every 10 s.
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.

Identifying the user and setting custom attributes

Once Scout.initialize(...) has resolved you can attach identity, account, feature-flag, and free-form attributes that ride on every subsequent span, metric, and log until you change or clear them. Five APIs cover the common cases:

Scout.setUser(id, attributes?) — end-user identity

Scout.setUser('user-123', {
email: 'jane@example.com',
name: 'Jane Doe',
plan: 'pro',
signupDate: '2025-08-14',
});

Maps to OpenTelemetry semantic-convention attributes — user.id is the primary key; everything else in the attributes map is prefixed user.<key> so it lands as user.email, user.plan, etc. Errors and crashes captured after this call carry these attributes automatically — your dashboard can filter "errors for users on plan=pro."

Scout.setAccount(id, name?) — B2B tenant

Scout.setAccount('acme-corp', 'Acme Corp');

For multi-tenant apps. Emits account.id and (optionally) account.name. Useful for grouping sessions by tenant in dashboards.

Scout.setFeatureFlag(name, value) — flag values at error time

Scout.setFeatureFlag('new-checkout', true);
Scout.setFeatureFlag('checkout-variant', 'B');

Each flag becomes a feature_flag.<name> attribute. The killer use case: when an error span is emitted, the flag values active at error time are attached to it, so you can correlate "this crash only happens when new-checkout=true."

Scout.setSessionAttributes(attrs) — session-scoped attribute bag

Scout.setSessionAttributes({
'tenant.id': 'acme',
'tenant.plan': 'enterprise',
'build.flavor': 'play',
});

A simple key-value map merged into commonAttributes() on every span, metric, and log. Survives session rotations until you call Scout.clearSessionAttributes(). Use for stable integrator-supplied context that isn't a user identity (tenant id, deployment region, build flavor, A/B test cohort). Mirrors scout-flutter's ScoutFlutter.setSessionAttributes.

Scout.setRuntimeAttribute(key, value) — free-form session attribute

This is the general-purpose hook for any custom attribute you want on every signal in this session — A/B experiments, app theme, route prefix, current locale, anything that doesn't fit the named APIs above.

Scout.setRuntimeAttribute('experiment.cohort', 'B');
Scout.setRuntimeAttribute('app.theme', 'dark');
Scout.setRuntimeAttribute('subscription.tier', 'pro');

The key is used verbatim as the attribute name — no namespacing — so you control the schema. Supported value types: string, number, boolean, or arrays of those.

Not strictly an attribute, but related: every breadcrumb you record lands in a ring buffer that gets serialized onto every subsequent error / app_crash / native_crash span. Useful for "what did the user do in the 20 actions before this crash?"

Scout.addBreadcrumb('checkout', 'added item to cart');
Scout.addBreadcrumb('navigation', 'screen: /payment');

Removing attributes

To removeCall
The user identity (and all user.* attributes)Scout.clearUser()
The B2B account identityScout.clearAccount()
A single feature flagScout.setFeatureFlag(name, null)
All feature flags at onceScout.clearFeatureFlags()
All session attributesScout.clearSessionAttributes()
A single runtime attributeScout.setRuntimeAttribute(key, null) (null or undefined deletes the key)
All breadcrumbs (rarely needed)They roll out of the ring buffer naturally; no explicit clear

A typical sign-out flow:

async function signOut() {
await api.signOut();
Scout.clearUser();
Scout.clearAccount();
Scout.clearFeatureFlags();
Scout.setRuntimeAttribute('experiment.cohort', null);
}

Lifetime and persistence

These attributes live in memory for the SDK instance — i.e., for the lifetime of the session. They are NOT persisted across app restarts. If you want a user identity to be reattached on every launch, call Scout.setUser(...) again in your initialization code (typically inside a useEffect that re-reads from your auth store).

The OpenTelemetry session lifecycle (the session.id resource attribute) rotates after sessionTimeoutMinutes of inactivity (default 30 min) — but user / account / runtime attributes you set survive that rotation as long as the JS context is alive.

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 | signal | nsexception | cppexception
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.bundle_id: io.base14.example
crash.app_id: io.base14.example (alias of crash.bundle_id)
crash.app_version: 1.4.2 (build 412)
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.pc / .lr / .fp / .sp (NDK arm64 register snapshot)
crash.exception_register: x16: 0x… x17: 0x… (NDK arm64; PAC/BTI diagnosis)
crash.registers / .memory_map (NDK path only)

Native ANR detection with thread dumps

The SDK runs a native watchdog on a dedicated background thread that tracks two heartbeats independently — the native main thread (Android Looper.getMainLooper() / iOS DispatchQueue.main) and the JS thread (via a periodic notifyJsAlive() call from JS at anrThresholdMs / 5 intervals). If either is silent past anrThresholdMs, the watchdog fires immediately while the thread is still blocked — captures the dump, ships it via a native event (ScoutAnr), and JS attaches it to an anr span when it processes the event.

This is critical for React Native: a pure-JS busy loop blocks the JS thread but the Android UI thread keeps responding, so the OS-level ANR never fires. The watchdog's JS-thread heartbeat fills that gap.

Attributes on the anr span:

anr.duration: 5.391 (seconds)
anr.threshold: 5.0
anr.source_thread: js (or "main")
anr.thread_count: 44
anr.main_thread_stack: "android.os.MessageQueue.nativePollOnce ..."
anr.threads_json: [{"name":"main", "state":"RUNNABLE", "frames":[…]}, …] (~25 KB)
breadcrumbs: [<full trail>]
screen.name: "Profile"

Implementation per platform

  • AndroidScoutAnrWatchdog.kt on a HandlerThread; posts heartbeat runnables to the main Looper; ScoutThreadDumpCollector serializes every thread via Thread.getAllStackTraces() capped at 32 KB / 64 frames per thread.
  • iOSAppHangWatchdog.swift runs two tiers — ui_hang at iosHangThresholdMs (default 250 ms), anr at anrThresholdMs (default 5000 ms). ScoutThreadBacktrace.swift captures the main thread via Mach thread_suspend + thread_get_state + FP/LR frame walking + dladdr() symbolication, arm64 + x86_64.

Testing it from the example diagnostics panel

The Expo example (examples/platform-design-mobile) ships six test buttons that exercise the SDK's failure-mode paths:

ButtonTriggersExpected span
anr (JS thread, 6s freeze)while (Date.now() < end) {} on JS threadanr with source_thread=js
anr (UI thread, 6s freeze)ScoutCrash.__debugBlockMainThread(6000) blocks the Android UI thread / iOS main thread for 6sanr with source_thread=main
anr (JS thread, 12s long freeze)Same as JS freeze, longer durationanr with duration ≈ 12s
ui_hang (UI thread, 500ms)iOS-only — 500 ms main-thread block triggers AppHangWatchdog at default 250 ms thresholdui_hang (iOS only)
manual breadcrumbScout.addBreadcrumb('manual', '<msg>')breadcrumb in the next captured span's trail
log info / warn / errorScout.logInfo(…) / logWarning(…) / logError(…)three OTel log records

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 depends on the published SDK ("@base-14/scout-react": "^0.1.11"):

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 inside node_modules/@base-14/scout-reactCorrupt install, partial downloadrm -rf node_modules package-lock.json && npm install
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['user.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?