React Native RUM SDK
Install and configure the Last9 React Native RUM SDK. TypeScript API, CDN-hosted npm tarball, TurboModule over native Android and iOS SDKs. Auto-instruments fetch/XHR, React Navigation, errors, ANRs. New Architecture (bridgeless) supported.
Real User Monitoring for React Native apps. Automatic instrumentation for sessions, views, network requests (fetch/XHR), errors, and resource metrics via OpenTelemetry. ANR detection is available on Android only.
The React Native SDK wraps the Android and iOS native SDKs. Each platform’s CDN repo must be configured so the native dependencies resolve.
Prerequisites
- React Native >= 0.74 (New Architecture / bridgeless is the default from 0.76+; supported from SDK
1.1.6) - iOS 15.1+
- Android minSdk 21 (Android 5.0+)
- Native dependencies:
- Android:
io.last9:rum-android:1.6.0(resolved from CDN Maven — the consumer app must declare the repo; see Installation) - iOS:
Last9RUM 1.6.0(resolved from CDN podspec)
Create Client Monitoring Tokens
Create one Client token per platform — do not combine Android and iOS origins on a single token. Each build uses the native SDK for that platform, so separate tokens keep origin scoping, rotation, and access control aligned with Android and iOS setup.
- Open Last9 → Settings → Ingestion Tokens
- Click Create Token → choose type Client → create an Android token with allowed origin
android://com.yourcompany.yourapp(your app’s exact package name). Copy the token. - Create a second Client token for iOS with allowed origin
ios://com.yourcompany.yourapp(your app’s exact bundle ID). Copy the token. - Copy the OTLP endpoint URL (the same URL is used for both platforms)
CDN artifacts
| Artifact | Stable URL | Versioned URL |
|---|---|---|
| Tarball | https://cdn.last9.io/rum-sdk/react-native/builds/stable/v1/last9-rum-react-native.tgz | https://cdn.last9.io/rum-sdk/react-native/builds/1.6.0/last9-rum-react-native-1.6.0.tgz |
| Checksum | https://cdn.last9.io/rum-sdk/react-native/builds/stable/v1/last9-rum-react-native.tgz.sha256 | https://cdn.last9.io/rum-sdk/react-native/builds/1.6.0/last9-rum-react-native-1.6.0.tgz.sha256 |
The major-pinned stable/v1 channel currently serves React Native RUM SDK
1.6.0. The latest versioned release is 1.6.0; staging builds use the
-alpha.<run_number> suffix and explicit versioned URLs.
Installing or upgrading to 1.6.0
The stable/v1 tarball currently serves 1.6.0. To pin an explicit version in
your lockfile, use the versioned 1.6.0 URL. npm and yarn pin URL dependencies in the
lockfile with an integrity hash from the first download, so using an explicit
version also avoids stable-channel cache and EINTEGRITY surprises:
# npmnpm uninstall @last9/rum-react-nativenpm cache clean --forcenpm install https://cdn.last9.io/rum-sdk/react-native/builds/1.6.0/last9-rum-react-native-1.6.0.tgz
# yarn: remove the @last9/rum-react-native entry from yarn.lock, thenyarn cache cleanyarn add https://cdn.last9.io/rum-sdk/react-native/builds/1.6.0/last9-rum-react-native-1.6.0.tgzTo check which version the stable channel currently serves:
curl -sL https://cdn.last9.io/rum-sdk/react-native/builds/stable/v1/last9-rum-react-native.tgz | tar -xzO package/package.json | grep '"version"'Installation
-
Install the package
npm install https://cdn.last9.io/rum-sdk/react-native/builds/1.6.0/last9-rum-react-native-1.6.0.tgz -
Android — add the CDN Maven repository
Gradle repositories are not transitive — the React Native package cannot inject this repo into your app. Your consumer app must declare it so
io.last9:rum-androidresolves on the app’s classpath.In
android/settings.gradle(orandroid/build.gradle):dependencyResolutionManagement {repositories {google()mavenCentral()maven { url uri("https://cdn.last9.io/rum-sdk/android/maven/") }}} -
iOS — add the Last9RUM podspec
In
ios/Podfile:pod 'Last9RUM', :podspec => 'https://cdn.last9.io/rum-sdk/ios/builds/1.6.0/Last9RUM.podspec'Then install pods:
cd ios && pod install -
Initialize the SDK
At app entry (before any screens render):
import { Platform } from "react-native";import { L9Rum } from "@last9/rum-react-native";const isIos = Platform.OS === "ios";try {const { sessionId } = await L9Rum.initialize({baseUrl: "https://otlp-ext-aps1.last9.io/v1/otlp/organizations/<org>",// Use the platform-matching token and origin pair — see// "Create Client Monitoring Tokens" above.origin: isIos? "ios://com.yourcompany.yourapp": "android://com.yourcompany.yourapp",clientToken: isIos? "your-ios-client-token": "your-android-client-token",serviceName: "my-rn-app",serviceVersion: "1.0.0",deploymentEnvironment: "production",});// Prefer awaiting initialize (or L9Rum.isActive()) over treating a null// getSessionId() after a fire-and-forget call as the only failure signal.console.log("RUM ready", sessionId);} catch (error) {console.error("RUM failed to initialize", error);}
There is no postinstall script or codegen stub to run after npm install — the package ships a real React Native Codegen spec (RNL9RumSpec) and Codegen runs as part of the normal native build.
Expo (Continuous Native Generation)
From SDK 1.2.0, an Expo config plugin re-applies the native install steps on every expo prebuild. Add the plugin to app.json (or app.config.js):
{ "expo": { "plugins": ["@last9/rum-react-native"] }}Then run a prebuild or dev build:
npx expo prebuild --clean# or: npx expo run:ios / npx expo run:androidThe plugin injects the Last9 CDN Maven repository on Android and the Last9RUM podspec source on iOS into the generated native projects. It is idempotent across repeated prebuilds and requires a development build (EAS Build or expo run:*) — it does not run in Expo Go, which cannot load native modules. Bare React Native apps can continue using the manual install steps above.
New Architecture (bridgeless)
From SDK 1.1.6, the native module is a real TurboModule (RNL9RumSpec). JS↔native calls dispatch over JSI directly, so promises settle reliably on the New Architecture (bridgeless mode, the default in React Native 0.76+).
| Architecture | Support |
|---|---|
| New Architecture (bridgeless) | Full support from 1.1.6. Use await L9Rum.initialize(...) as shown above. |
| Old Architecture (Paper) | Still supported. TurboModuleRegistry.getEnforcing falls back to the legacy native module path when bridgeless is off. |
The public JS API (L9Rum, config types) is unchanged — no app-code changes are required beyond upgrading the package and rebuilding native projects (pod install, Gradle sync).
Configuration
L9Rum.initialize({ // --- Required ---------------------------------------------------------
// OTLP collector endpoint baseUrl: "https://otlp-ext-aps1.last9.io/v1/otlp/organizations/<org>",
// Authentication token from Last9 — use the Android or iOS Client token // for the platform this build is running on. clientToken: Platform.OS === "ios" ? "your-ios-client-token" : "your-android-client-token",
// Application identifier (maps to service.name) serviceName: "my-rn-app",
// App version string (maps to service.version) serviceVersion: "1.0.0",
// Environment name deploymentEnvironment: "production",
// --- Optional ---------------------------------------------------------
// Origin sent as X-LAST9-ORIGIN header. Required for client_monitoring // tokens. Must match the origin on the platform's Client token — // android://<package> on Android, ios://<bundle-id> on iOS. Pass the // matching clientToken for the same platform. Needs `import { Platform } // from "react-native"` as shown in the Installation section above. origin: Platform.OS === "ios" ? "ios://com.yourcompany.yourapp" : "android://com.yourcompany.yourapp",
// Specific build identifier (maps to app.build_id) appBuildId: "1.0.0-build-42",
// Optional override for the app.installation.id resource attribute. // The Client-ID header always uses the native SDK-generated per-install UUID. appInstallationId: undefined,
// Session sampling rate: 0-100 (percentage). 100 = sample everything. sampleRate: 100,
// Print debug logs to console debugLogs: false,
// Automatically instrument network requests through native hooks networkInstrumentation: true,
// Automatically capture unhandled JS errors errorInstrumentation: true,
// Max spans per export batch maxExportBatchSize: 100,
// How long native batch processors wait before flushing queued spans/logs (ms). // Omit: 5000 in production, 1000 when debugLogs is true (`1.4.0+`). scheduleDelayMs: undefined,
// When false, native Activity/ViewController auto-tracking no longer opens // or closes view spans — the app owns view names (`1.5.0+`). autoViewTrackingEnabled: true,
// Export timeout in milliseconds exportTimeoutMs: 30000,
// ANR detection (Android only) anrDetectionEnabled: true, anrThresholdMs: 5000,
// Periodically sample memory and CPU resourceMonitoringEnabled: true, resourceSamplingIntervalMs: 30000,
// Setting this to true will hide network requests (and their // DNS/TCP/TLS/TTFB phase child spans) from the Last9 dashboard's // Sessions → APIs tab. Each request would get its own traceId // instead of sharing the current view's traceId, and that tab // only fetches spans that share the View's traceId. Keep this // false unless you specifically need per-request trace isolation. isolateTracePerRequest: false,
// Fine-grained network ignore rules. Strings use substring matching; // RegExp uses regex search semantics. Matched URLs are dropped before // span creation. ignorePatterns: { fullUrl: ["https://cdn.example.com", /^https:\/\/.*\.example\.com/i], pathname: [".pdf", ".jpg", /^\/internal\/metrics/], hostname: ["cdn.example.com", /(^|\.)assets\.example\.com$/i], },
// 'preserve' (default): keep traceparent on ignored requests. // 'strip': remove traceparent from ignored requests. propagationMode: "preserve",
// Custom resource attributes added to every span resourceAttributes: { "app.platform": "react-native", },
// W3C Baggage propagation on outgoing requests baggage: { enabled: false, allowedKeys: ["session.id", "user.id"], maxTotalBytes: 8192, warnAtPercentage: 80, },});Stable release notes
React Native 1.6.0 auto-names WebView-hosted views from the current URL (via native instrumentWebView): app.screen.name becomes the folded pathname and view.url carries the full URL. An explicit startView / setViewName still wins. 1.5.2 attaches addEvent during screen teardown to that view (1000 ms grace), not the next one. 1.5.1 is a version-parity release that pins native Android 1.5.1 and iOS Last9RUM 1.5.1. 1.5.0 adds autoViewTrackingEnabled (default true) and carries native fixes so startView never leaves a duplicate native view and setViewName renames the active view in place. 1.4.1 keeps the session alive across Home / background. 1.4.0 adds scheduleDelayMs. 1.3.0 folds dynamic segments in network span names. React Native 1.2.1 hardens instrumentWebView so it accepts a WebView load event (event.nativeEvent.target), a numeric reactTag, a ref, or a component instance without throwing — recommended: onLoadStart={(e) => L9Rum.instrumentWebView(e.nativeEvent.target)}. Native pins bump to 1.2.1, carrying an iOS fix for a main-thread hang when instrumenting a WKWebView. 1.2.0 adds an Expo config plugin for CNG apps. 1.1.10 fixes a New Architecture Android clean-build failure at :app:configureCMakeDebug. 1.1.9 bumps native Android and iOS SDK references and fixes await L9Rum.initialize(config) resolving with a null or empty sessionId on iOS — native session start is now synchronous and never surfaces an internal placeholder id. getSessionId() is fixed by the same change on both platforms and never returns an empty string. Earlier: 1.1.7 picked up the Android AGP consumer compatibility fix (no forced androidx.core 1.17.0), and from 1.1.6 the bridge is a real TurboModule so initialize() resolves on the New Architecture (bridgeless). Rebuild native projects after upgrading.
appInstallationId only overrides the app.installation.id resource attribute. It does not override the Client-ID header, so the header and the resource attribute can differ when you set appInstallationId manually.
The SDK also normalizes non-Error JavaScript throws and promise rejections, including strings, primitives, plain objects, and bridged native fallback errors, so exception.type stays populated.
When nativeNetworkInterception is enabled, RN Android apps use the native OkHttp interceptor. As of native 1.1.1, telemetry attribute collection is isolated from the request path and telephony reads are permission-gated and guarded, so a missing (or OEM-restricted) READ_PHONE_STATE permission can no longer turn a successful HTTP response into a failed request. iOS is not affected.
Network ignore patterns
Skip noisy URLs before span creation by matching against full URL, pathname, or hostname. Strings use substring matching; RegExp uses regex search semantics.
L9Rum.initialize({ ignorePatterns: { fullUrl: ["https://cdn.example.com", /^https:\/\/.*\.example\.com/i], pathname: [".pdf", ".jpg", /^\/internal\/metrics/], hostname: ["cdn.example.com", /(^|\.)assets\.example\.com$/i], }, // 'preserve' (default): keep traceparent on ignored requests. // 'strip': remove traceparent from ignored requests. propagationMode: "strip",});Network phase child spans
Network instrumentation is native by default so the SDK emits child spans for individual HTTP phases:
| Child span | What it measures |
|---|---|
dns | DNS lookup duration |
tcp_connect | TCP connection establishment |
tls_handshake | TLS negotiation |
ttfb | Time from request sent to first response byte |
No SDK config change is required. Android wires the OkHttp client factory with the Last9 interceptor and EventListener.Factory. iOS uses native URLProtocol/URLSession instrumentation and reads URLSession task metrics.
Reused connections skip DNS, TCP, and TLS work. For those requests the SDK emits zero-duration child spans with l9rum.network.phase.skipped=true so the waterfall shape stays consistent.
From 1.3.0, REST network span names fold fully-numeric and UUID path segments to ? (for example GET /workspaces/1 becomes GET /workspaces/?). GraphQL span names are unaffected. The raw URL is retained on the native span.
GraphQL network observability
GraphQL enrichment is on by default (Layer 1). GraphQL requests get a descriptive span name and operation metadata, and server-side errors returned with an HTTP 200 are flagged:
| Span attribute | Example | Notes |
|---|---|---|
| Span name | GraphQL: "GetUserPreferences" query | Renamed to a descriptive name |
graphql.operation.name | GetUserPreferences | query, mutation, or subscription name |
graphql.operation.type | query | Operation type |
error.type | GraphQLError | Set when the response contains a non-empty errors[] |
graphql.error.count | number | Number of entries in the errors[] array |
With native interception enabled, enrichment comes from the Android/iOS interceptors. When nativeNetworkInterception: false, the JS fetch/XHR interceptor captures request/response bodies (capped) and forwards them through the bridge so the same enrichment applies.
Layer 2 — Apollo Link (opt-in)
For richer GraphQL telemetry with Apollo Client, use the l9GqlLink Apollo Link. It owns the GraphQL span and suppresses the SDK’s JS transport interceptor for that request, so pair it with nativeNetworkInterception: false. It requires the optional @apollo/client peer dependency.
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";import { l9GqlLink } from "@last9/rum-react-native";
const client = new ApolloClient({ cache: new InMemoryCache(), link: from([ l9GqlLink({ captureVariables: true, // allow-listed + redacted captureErrorMessages: true, // truncated }), new HttpLink({ uri: "https://api.example.com/graphql" }), ]),});captureVariables— opt in to capture operation variables. Values are allow-listed and redacted.captureErrorMessages— opt in to capture GraphQL error messages. Values are truncated.
API reference
React Navigation integration
For automatic view tracking with React Navigation:
import { L9ReactNavigationInstrumentation } from '@last9/rum-react-native';import { NavigationContainer } from '@react-navigation/native';
function App() { return ( <NavigationContainer onStateChange={L9ReactNavigationInstrumentation.onStateChange} > {/* screens */} </NavigationContainer> );}Identify a user
L9Rum.identify({ id: "user-123", name: "Jane", email: "jane@example.com", fullName: "Jane Doe", roles: ["admin"],});Clear user on sign-out
L9Rum.clearUser();Capture errors
try { // risky operation} catch (error) { L9Rum.captureError(error, { screen: "checkout" });}Track views manually
L9Rum.startView("ProductDetailsScreen");L9Rum.setViewName("Product #42");From 1.5.0, startView never leaves a duplicate native view alongside Activity/ViewController auto-tracking, and setViewName renames the active view in place (including auto-tracked child view controllers). Set autoViewTrackingEnabled: false when the app owns all view names via navigation instrumentation or manual startView.
Custom events
L9Rum.addEvent("purchase_completed", { product_id: "12345", amount: 29.99,});Each call dual-emits (behavior inherited from the native Android and iOS SDKs):
- A span event on the active view span, so the event shows up on the view’s timeline in RUM.
- An OTLP log record carrying
event.type=custom,event.name, your attributes, andsession.id/user attributes. When a view is active the log is correlated to it viatrace.id/span.id/view.id, so you can pivot between the log and the RUM session.
Log emission is unconditional. If no view is active when you call addEvent, the span event is dropped (no view is fabricated to attach it to) but the log is still sent as an orphan, omitting trace.id/span.id/view.id. A custom event never silently vanishes because it fired outside a view.
From 1.5.2, an addEvent fired while a screen is being torn down attaches to that screen (a 1000 ms grace window after the view ends), not the next one. After the window, and whenever no view is current, the OTLP log still correlates to the last view for the rest of the session. A manual startView(...) ends the previous view immediately. Prefer firing screen summaries before teardown (for example on blur / a screen’s unmount effect).
Global span attributes
L9Rum.spanAttributes({ experiment: "checkout_v2", feature_flag: "new_cart",});
// ClearL9Rum.spanAttributes(null);Session ID / init status
const { sessionId: readySessionId } = await L9Rum.initialize(config);const sessionId = await L9Rum.getSessionId(); // same id once activeconst running = await L9Rum.isActive(); // true between successful initialize and shutdowninitialize rejects when native init fails (missing config or the SDK does not become active). Use that rejection — or isActive() — rather than assuming a null getSessionId() after a void/fire-and-forget call.
From 1.1.9, when initialize() resolves it always returns a real sessionId — never null or an empty string. Earlier iOS builds could resolve successfully but hand back an empty or missing session id because the native session started asynchronously; native session start is now synchronous and the internal placeholder id is never surfaced. getSessionId() never returns an empty string on either platform.
From 1.4.1, backgrounding the app (Home button or a system file picker) no longer ends the RUM session. Returning within 30 minutes resumes the same session.id. A force-quit or crash backfills Session End on the next cold start (session.end_reason=process_death) and starts a fresh session.
Flush pending data
L9Rum.flush();Embedded per-flow lifecycle
For embedded integrations scoped to a single flow, use shutdown() and isActive() (both bridged to the native SDKs) to control the SDK lifecycle:
// Scope RUM to a single flow, then tear it down so the next flow starts clean.await L9Rum.initialize(config);
// Attributes known only after the flow starts apply to every later span.L9Rum.spanAttributes({ "tenant.id": "acme", "feature.flag": "beta" });
if (await L9Rum.isActive()) { await L9Rum.shutdown(); // flush + full teardown; a later initialize() re-arms RUM}shutdown() returns a Promise<void> that resolves once native teardown completes, so a per-flow caller can await L9Rum.shutdown() before re-initializing the next flow instead of racing teardown. A later initialize() starts a fresh flow.
WebView correlation
Instrument a react-native-webview WebView to share the native session ID with Browser RUM spans running inside it:
import { WebView } from 'react-native-webview';import { L9Rum } from '@last9/rum-react-native';
<WebView source={{ uri: 'https://app.example.com' }} onLoadStart={(e) => L9Rum.instrumentWebView(e.nativeEvent.target)}/>Pass the load event’s nativeEvent.target (the native reactTag). This form works across all react-native-webview versions and both RN architectures. instrumentWebView also accepts a numeric tag, a ref object, or a component instance, but react-native-webview >= 13 exposes a methods-only imperative handle on .current that cannot be resolved — so prefer the event.
The SDK resolves the underlying native WKWebView (iOS) or android.webkit.WebView (Android) and re-injects session context on every navigation automatically.
From 1.6.0, the host native view is auto-named from the current main-frame URL: app.screen.name becomes the folded pathname, and view.url carries the full URL. Android updates the name on full loads and in-WebView history changes; iOS updates on navigation commit (SPA route changes are not observable). An explicit L9Rum.startView / setViewName still wins.
Name the native host screen with setViewName() — do not call native startView() on an auto-tracked Activity/ViewController. In-WebView SPA routes stay on Browser RUM startView(). See the WebView Session Correlation guide for the full pattern, auto-load options, and verification steps.
Next steps
Once data is flowing, explore it in Discover > Applications — performance, errors, and sessions.
Troubleshooting
await L9Rum.initialize() never resolves (hangs)
Symptom: The app starts but RUM never becomes active — initialize() never settles and isActive() stays false.
Cause: On the New Architecture (bridgeless), SDK versions before 1.1.6 routed through React Native’s legacy interop layer, where native resolve() did not deliver to the JS promise.
Fix: Upgrade to @last9/rum-react-native 1.1.6 or later (latest versioned: 1.6.0), rebuild native projects (cd ios && pod install, then a clean Android build), and confirm with await L9Rum.initialize(...).
Android :app:configureCMakeDebug fails on clean build (New Architecture)
Symptom: Clean or parallel Android builds (or Android Studio Gradle sync) fail with add_subdirectory given source ".../codegen/jni/" which is not an existing directory and react_codegen_RNL9RumSpec which is not built by this project.
Cause: Introduced in React Native SDK 1.1.6 (TurboModule/Codegen migration). The app’s CMake configure can run before the library’s Gradle codegen task produces the JNI directory.
Fix: Upgrade to @last9/rum-react-native 1.2.1 or later (stable/v1 currently serves 1.6.0; latest versioned is 1.6.0), reinstall from stable/v1 or the versioned tarball, then run a clean Android build.
Android build cannot resolve io.last9:rum-android
Gradle repositories are not transitive. Add the Last9 CDN Maven repo to your app’s settings.gradle / build.gradle as shown in Installation — not only in a library module.
iOS pod install fails to find Last9RUM
Add the CDN podspec URL to your Podfile as shown in Installation. The pod is not published to the public CocoaPods trunk.