@groundcover/react-native
v0.0.1-rc.0
Published
groundcover Real User Monitoring (RUM) SDK for React Native — sessions, network + distributed tracing, screen navigation, tap and typing interactions, logs, custom events and JS errors. On a dev build it also captures native crashes, ANR/app-hangs, app st
Readme
groundcover's Real User Monitoring (RUM) SDK for React Native. It emits the same
/json/rum wire format as @groundcover/browser,
so mobile sessions land in the same backend and dashboards as your web RUM.
The SDK captures sessions, network + distributed tracing, screen navigation, tap, typing and gesture interactions, console logs, custom events and JS errors/exceptions — with privacy masking on by default. On a dev build it also captures native crashes, ANR / app-hangs, app start + TTI and frame vitals.
Expo Go vs dev build. Everything in the JS layer works in Expo Go with zero native setup. The four native features need a dev build (
expo run:ios/expo run:android, or a bare RN app) — in Expo Go they simply never arm and the rest of the SDK is unaffected. See Native features.
Release candidate. Session replay is not included.
See the groundcover RUM documentation for the full product guide.
Table of contents
- Install
- Quick start
- Configuration reference
- Privacy & data masking
- What is captured
- Native features
- Architecture
- Coverage & limitations
- Session persistence (AsyncStorage)
- Session lifecycle
- Performance considerations
- API reference
Install
# npm
npm install @groundcover/react-native@rc
# yarn
yarn add @groundcover/react-native@rc
# pnpm
pnpm add @groundcover/react-native@rc
# Expo
npx expo install @groundcover/react-native@rcWhy
@rc. This is a release candidate, published under thercdist-tag. npm does not resolve a prerelease from a bare package name, so the@rcsuffix is required until the first stable release.
Peer requirements
| Peer | Version | Required | Purpose |
|---|---|---|---|
| react | >= 18.0.0 | ✅ | — |
| react-native | >= 0.74.0 | ✅ | Hermes ships a global TextEncoder from 0.74, and the transport needs one that encodes emoji correctly. See the encoding note. |
| @react-native-async-storage/async-storage | >= 1.19.0 | optional | Session continuity across app restarts. See Session persistence. |
| @react-navigation/native | >= 6.0.0 | optional | Automatic screen tracking via startNavigationTracking. |
# optional — session continuity across app restarts:
npm install @react-native-async-storage/async-storage
# optional — automatic React Navigation screen tracking:
npm install @react-navigation/nativeSupported vs tested
These are two different claims and the gap between them is real, so both are stated. Supported is what the code requires — every API the native engines use sits at or below these floors, and anything above them is version-guarded per feature. Tested is what CI actually compiles and runs on a device every build.
| | Supported | Tested every build |
|---|---|---|
| React Native | >= 0.74 | 0.86 (Expo 57, React 19.2) |
| iOS | 13.4+ | Simulator under Xcode 26 |
| Android | API 23+ | Emulator, API 34 |
The untested span is not known-broken — it is unproven. Widening CI to a compatibility matrix across the declared range is on the roadmap. If you run the SDK below the tested row and hit something, it is worth reporting.
Quick start
Call init() once (module top is fine), wrap your tree in <GroundcoverProvider>,
and wire navigation tracking to your NavigationContainer ref:
import AsyncStorage from "@react-native-async-storage/async-storage";
import groundcover, { GroundcoverProvider } from "@groundcover/react-native";
import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
groundcover.init({
apiKey: "your-rum-api-key",
dsn: "your-rum-ingest-host.groundcover.com", // a bare host is upgraded to https://
cluster: "your-cluster",
appId: "my-app",
environment: "production",
releaseId: "1.0.0",
// Persist session state across app restarts. Passing AsyncStorage explicitly is
// recommended (and required in pnpm/monorepo setups where the bundler can't
// resolve the SDK's own optional require). Omit it and the SDK auto-detects the
// peer; without either, session state is memory-only (a fresh session each launch).
storage: AsyncStorage,
options: {
// Send distributed-tracing headers to your own API hosts:
tracing: { propagationUrls: ["https://api.myapp.com/*"] },
},
});
export default function App() {
const navigationRef = useNavigationContainerRef();
return (
<GroundcoverProvider>
<NavigationContainer
ref={navigationRef}
onReady={() => groundcover.startNavigationTracking(navigationRef)}
>
{/* your app */}
</NavigationContainer>
</GroundcoverProvider>
);
}init()is fire-and-forget and safe to call at module top. It returns immediately, hydrates storage asynchronously, and buffers any API calls made before it's ready (bounded FIFO, drop-oldest) — nothing is lost in the startup window.<GroundcoverProvider>wraps the app to capture taps. It observes touches without claiming the responder, so it never interferes with your gestures.startNavigationTracking(ref)turns each React Navigation screen change into anavigationevent and stamps the current screen on every event. Call it fromonReady(the ref is populated by then). expo-router users can pass itsuseNavigationContainerRef().
From there, enrich the data:
groundcover.identifyUser({ id: "u_123", email: "[email protected]", organization: "acme" });
groundcover.captureException(new Error("Checkout failed"), { feature: "checkout" });
groundcover.logger.warn("Payment retry", { provider: "stripe", attempt: 2 });
groundcover.sendCustomEvent({ event: "plan_upgraded", attributes: { plan: "pro" } });Configuration reference
init() takes connection/identity fields at the top level (plus the RN-only
storage) and all behavioral knobs under options, grouped by concern.
groundcover.init({
// ── Connection & identity ──
apiKey, dsn, cluster, appId, environment,
namespace, releaseId, user, sessionId,
storage, // RN-only: AsyncStorage instance
options: {
debug,
sessionSampleRate, eventSampleRate,
sessionMaxDuration,
enabledEvents, excludedUrls,
appVersion, appBuild,
beforeSend, enrichEvent,
privacy: { /* … */ },
tracing: { /* … */ },
transport: { /* … */ },
native: { /* … */ }, // RN-only: native-feature tuning
},
});Top-level (connection & identity)
| Field | Type | Required | Description |
|---|---|---|---|
| apiKey | string | ✅ | Your groundcover RUM API key. |
| dsn | string | ✅ | RUM intake host. A bare host is upgraded to https://. |
| cluster | string | ✅ | Target groundcover cluster. |
| appId | string | ✅ | Application identifier; reported as the service/app name. |
| environment | string | ✅ | Deployment environment (e.g. production, staging). |
| namespace | string | — | Logical grouping/namespace for the app. |
| releaseId | string | — | Release/version identifier (e.g. a semver or commit hash). |
| user | Partial<UserIdentifiers> | — | Identify the user at init (same shape as identifyUser). |
| sessionId | string | — | Bind a specific session id at init (e.g. to share one across surfaces). |
| storage | InjectedAsyncStorage | — | AsyncStorage instance for cross-restart persistence. See Session persistence. |
| options | Partial<SDKOptions> | — | Behavioral configuration, below. |
Required fields are enforced by the
SDKConfigInputtype. At runtime, missing string fields fall back to""— the type is the contract you should code to.
options — top-level knobs
| Option | Type | Default | Description |
|---|---|---|---|
| debug | boolean | false | Verbose SDK console logging. |
| sessionSampleRate | number | 1 | Fraction of sessions captured (0–1). |
| eventSampleRate | number | 1 | Fraction of events captured (0–1). |
| sessionMaxDuration | number | 14400000 (4 h) | Target max session length in ms (1 min – 8 h). See session lifecycle. |
| enabledEvents | RNEventName[] | [] | Listeners to enable. Empty = all. Members: "interaction", "network", "logs", "navigation", "exceptions", and the dev-build-only "crashes", "hangs", "appStart", "vitals". |
| native | NativeOptions | — | Tuning for the native features: hangThresholdMs, vitalsMaxSegmentMs, tti. Enabling/disabling lives in enabledEvents. |
| excludedUrls | Array<string \| RegExp> | — | Network requests matching these are not captured. Strings support * / ? globs; otherwise matched exactly. |
| appVersion | string | — | App version stamped on session attributes. See App version stamping. |
| appBuild | string | — | App build number stamped on session attributes. See App version stamping. |
| beforeSend | (event) => boolean | — | Drop gate — return false to discard an event. Receives dot-flattened attributes. See below. |
| enrichEvent | (event) => event | — | Mutate/replace an event before batching. Receives dot-flattened attributes. See below. |
beforeSend / enrichEvent
Both hooks receive the event with its attributes dot-flattened — the exact
shape the SDK puts on the wire. The SDK flattens every nested attribute object
before these hooks run, so a build-shape field like interaction_target: { label }
becomes the flat key "interaction_target.label", and url: { full } becomes
"url.full". Read flattened fields with bracket keys — nested access
(event.attributes.url.full) is undefined at runtime and would throw. The one
exception is the RN-stamped screen block, which stays nested and typed.
event.type still discriminates the six kinds (interaction, network, log,
navigation, custom, exception), and the exported CallbackEvent /
FlattenedEventAttributes types annotate your own handlers.
beforeSend runs first (return false to drop), then enrichEvent:
import type { CallbackEvent } from "@groundcover/react-native";
options: {
beforeSend: (event) => {
// Attributes are FLAT — use bracket keys, not nested property access.
if (
event.type === "network" &&
String(event.attributes["url.full"] ?? "").includes("/health")
) {
return false; // drop noisy health-check network events
}
return true;
},
enrichEvent: (event) => {
event.attributes.screen.name; // `screen` is the one block that stays nested + typed
return event;
},
}Both hooks receive the already-redacted, enriched event.
App version stamping
appVersion / appBuild are stamped onto session attributes (app.version /
app.build; app.id comes from appId). Explicit values take precedence. When
either is omitted, the SDK reads it from expo-constants if that module is
installed (expoConfig.version, ios.buildNumber, android.versionCode). On bare
React Native (no expo-constants), pass appVersion / appBuild explicitly to
stamp them:
options: { appVersion: "1.4.0", appBuild: "142" }options.privacy
Data-masking config, on by default. See Privacy & data masking for the full reference.
options.tracing
Distributed-tracing header propagation for outgoing requests.
| Option | Type | Default | Description |
|---|---|---|---|
| propagationUrls | string[] | [] | Request URLs (*-glob or literal match, full URL or path) that receive injected tracing headers. |
| propagationHeaders | string[] | [] | Header names to inject; falls back to traceparent when empty. |
| traceIdHeaderName | string | "" | Additional header carrying the (decimal) trace id. |
| spanIdHeaderName | string | "" | Additional header carrying the (decimal) span id. |
| origin | { name: string; value: string } | { name: "", value: "" } | Origin tag stamped on injected trace headers. |
options: {
tracing: {
propagationUrls: ["https://api.myapp.com/*"],
// traceparent (W3C) is injected by default on matched URLs.
},
}Only
*is a wildcard inpropagationUrls; every other character is matched literally. This is deliberate — an unescaped.would let trace headers (and yourorigintag) leak to look-alike hosts.
options.transport
Batching and delivery of outgoing event batches.
| Option | Type | Default | Description |
|---|---|---|---|
| batchSize | number | 10 | Max events buffered before a batch flushes. |
| batchTimeout | number | 10000 | Max ms a batch waits before flushing regardless of size. |
| compression | boolean | true | Gzip-compress outgoing batches. |
RN gzip caveat: some RN networking stacks (OkHttp) strip the request
Content-Encodingheader, so a gzipped batch can reach the intake unlabeled and fail to decode. If you see decode failures, settransport: { compression: false }to send plain JSON (the playground app does this).
Updating config at runtime
updateConfig mirrors the init shape with three exceptions, each enforced by
the SDKConfigUpdate type rather than left to documentation:
sessionIdis dropped — session identity is not a config value; usesetSessionId.storageis dropped — the backend is bound once at init and never rebound.options.enabledEventsis dropped — which signals are captured is fixed atinit. Listeners are installed once and read their enablement then, and the native engines return their existing state from a second initialization rather than tearing anything down. Allowing the key would type-check a call that changes nothing, leaving you believing a signal was switched off while it keeps reporting. Choose the set atinit.
Nested groups merge, so a partial update preserves the other keys in the group:
groundcover.updateConfig({
options: {
transport: { batchSize: 20 }, // batchTimeout/compression preserved
privacy: { level: "mask-all" },
},
});For user, updateConfig additionally accepts null to clear the current
identity (e.g. on logout):
groundcover.updateConfig({ user: null });Privacy & data masking
Masking is on by default (privacy.level: "mask-sensitive"). A single level
is the master switch; finer toggles and a custom hook refine it.
| level | Network / logs / errors | Tap & input labels |
|---|---|---|
| mask-sensitive (default) | sensitive fields redacted (bodies, query params, headers, log & error fields) | captured (structural id + label) |
| mask-all | redacted | tap/input labels also masked |
| allow | off (but auth headers are still stripped — see below) | captured |
Auth headers are always stripped, even under
allow.authorization,cookie,set-cookie, and any header name matchingtoken/key/secret/passwd/password/auth/bearer/credentialare redacted at every privacy level.Typed text is never captured at any level — input interactions record only that a field was edited and its structural identity, never the value entered.
Options
| Option | Type | Default | Description |
|---|---|---|---|
| level | "mask-sensitive" \| "mask-all" \| "allow" | "mask-sensitive" | Master masking switch. |
| maskNetworkBodies | boolean | true¹ | Redact network request/response bodies. |
| maskNetworkQueryParams | boolean | true¹ | Redact URL query params. |
| maskLogs | boolean | true¹ | Redact console/log messages and attributes. |
| maskErrors | boolean | true¹ | Redact error messages, metadata and stack-frame URLs. |
| sensitiveKeys | string[] | — | Extra case-insensitive key substrings treated as sensitive, merged with the built-ins. |
| redact | Redactor | — | Per-leaf custom redactor for non-replay events (see below). |
| maskInteractionLabels | boolean | on only when level: "mask-all" | Mask tap/input labels + hints (accessibilityLabel / hint / value text). role / component / component path / state stay (structural). |
¹ Each mask* toggle defaults on unless level is "allow".
options: {
privacy: {
level: "mask-sensitive",
sensitiveKeys: ["account_no"],
maskInteractionLabels: true, // mask labels without going full mask-all
},
}Built-in sensitive patterns
These are always treated as sensitive (case-insensitive); your sensitiveKeys are merged in on top.
- Key substrings — matched as a substring of a body or query key:
token,secret,passwd,password,api_key,access_key,write_key,auth,bearer,credential,cvv,ssn,credit_card,card_number. Forapi_key,access_key,write_key,credit_cardandcard_numberthe separator is flexible — absent,_or-all match, soapikey,api_keyandapi-keyare equivalent. The rest match literally. Kept deliberately specific so a barecardorsessiondoesn't swallowcardinalityorsession_id. - Request/response headers — stripped at every
level, including"allow":authorization,cookie,set-cookie, plus any header name containingtoken,key,secret,passwd,password,auth,bearerorcredential. Header names match a broader pattern than body keys, so a barekeysubstring still catchesx-api-keyand friends. - Query / form param names — matched as a whole key and never inside JSON bodies, covering
the OAuth authorization-code callback:
code,state,session_state,id_token,access_token,refresh_token,token. Whole-key only becausecodeandstatelegitimately mean an HTTP status and UI state inside a body.
To opt out entirely: privacy: { level: "allow" } — note this does not restore sensitive
headers, which are stripped at every level.
A custom
redacthook overrides all of the above. Undermask-sensitiveandmask-allit runs on every leaf — sensitive headers included — and any non-undefinedreturn value replaces the built-in[REDACTED]. That is the point of the hook: each field arrives carrying asensitiveflag so you can decide. But it means a catch-all likeredact: (field) => field.valuesilently un-redactsauthorizationandcookie. Returnundefinedfor anything you don't handle and the built-in masking applies untouched. (Underlevel: "allow"the hook is not consulted for headers at all, so the built-in list still wins.)
Per-element masking with dataPrivate
To mask a single element's interaction label/hint/value even under the default
level, add a truthy dataPrivate prop. The SDK's fiber walk detects it on the
tapped node or any ancestor, so tagging one wrapper masks its whole subtree.
Structural fields (role, component, component path, state) are unaffected.
// Mask this control's label…
<Pressable dataPrivate accessibilityLabel="SSN 123-45-6789" onPress={…} />
// …or a whole region by tagging a wrapper (recommended — avoids passing an
// unknown prop to a host component):
<PrivateSection dataPrivate>
<ProfileCard /> {/* every tap inside is masked */}
</PrivateSection>The redact hook
redact is called on each leaf value during the redaction walk (never on
objects/arrays — the SDK recurses those itself). Return a primitive replacement,
or undefined to defer to the SDK default (sensitive ? "[REDACTED]" : value),
so a hook that only cares about one field can't accidentally disable masking
elsewhere:
import type { RedactField } from "@groundcover/react-native";
options: {
privacy: {
redact: (field: RedactField) => {
// field.key, field.value, field.path, field.kind, field.sensitive
if (field.key === "email") return "[email]";
return undefined; // fall back to SDK default
},
},
}field.kind is one of "request-body" | "response-body" | "query" | "header" |
"log" | "error".
What is captured
- Sessions — id, rotation on a 30-minute inactivity gap and the duration cap,
driven by app foreground (
AppState) and taps. Backgrounding flushes buffered events. Continuity across restarts requires AsyncStorage — see Session persistence. - Network —
fetch,XMLHttpRequestandaxios, withtraceparent(and configurable) trace-header injection. BothXMLHttpRequestand the globalfetchare wrapped, so classic RN and Expo's native-backedfetchare covered alike, with per-call gating so nothing is captured twice. Method, URL, headers, status, and request/response bodies (redacted, then capped at ~5 KB) are recorded. Textual response Blobs (RN'sfetchpolyfill) are read viaFileReaderwith a 3s timeout; binary/form-data bodies become a placeholder ([binary data],[image data],[form data], …). - Errors — uncaught JS errors and errors reported via
captureException()emit anexceptionevent (type, message, parsed stack trace, fingerprint, handled flag, redacted metadata). Automatic capture hooks RN's globalErrorUtilshandler and chains the previous handler, so the app's own crash/redbox behavior is untouched. Unhandled promise rejections are also captured asexceptionevents withhandled: false— see Coverage & limitations. - Navigation — React Navigation screen changes (auto, via
startNavigationTracking) or the manualstartNavigation/endNavigationAPI. Route params are not captured by default (they often hold ids/tokens). - Taps — emitted as
interactionevents (interaction_type: "tap") with a component path and a target describing the element:component,label(fromaccessibilityLabel/aria-label/ text),role,hint(accessibilityHint), and — when present —state(accessibilityState) andvalue(accessibilityValue). Target quality depends on your accessibility hygiene. - Typing — every
<TextInput>is instrumented automatically (the SDK patches the component at init — no wrapper needed). Finishing an edit (blur / submit) emits ONEinteractionevent withinteraction_type: "input", labeled like a tap. The typed value is never captured — only that the field was edited and its structural identity. - Gestures — swipes, scrolls, long presses and pinches are auto-captured as
interactionevents alongside taps, with the same target and component path. Each carriesinteraction_gesture: direction, distance, velocity, duration and pointer count. On a dev build the native tracker measures the movement (it sees a scroll through to its end, where the JS touch stream is cut short) and JavaScript supplies the labels;interaction_gesture.capture_sourcesays which side measured it. Without the native module JavaScript handles everything, and a scroll Android cancelled mid-gesture is markedpartial— its distance and velocity are lower bounds. - Logs — captured
console.*calls and the explicitlogger.*API. - Custom events and user identification.
Device metadata (source: "mobile-rum", OS name/version, device model/brand,
screen dimensions, and app version/build) is stamped on session attributes.
source distinguishes mobile RUM from web; os.name ("ios" / "android", or
"other" on a non-mobile host such as web/macOS) distinguishes the platform.
Auto-captured vs manual
| Signal | Auto-captured | Manual API |
|---|---|---|
| Network requests | ✅ (XHR/fetch/axios) | — |
| JS errors (uncaught) | ✅ (ErrorUtils) | captureException(error, metadata?) |
| Unhandled promise rejections | ✅ (rejection tracker) | captureException(...) |
| Taps | ✅ (GroundcoverProvider) | — |
| Gestures (swipe/scroll/long-press/pinch) | ✅ (GroundcoverProvider) | — |
| Typing (input finished) | ✅ (patched <TextInput>) | — |
| Native crashes | ✅ (dev build) | — |
| ANR / app-hangs | ✅ (dev build) | — |
| App start | ✅ (dev build) | — |
| Time to interactive | ✅ (dev build) | markAppInteractive() |
| Frame vitals | ✅ (dev build) | — |
| Screen navigation | ✅ (startNavigationTracking(ref)) | startNavigation / endNavigation |
| Console logs | ✅ (console.*) | logger.{log,info,warn,error,debug,trace} |
| Custom events | — | sendCustomEvent({ event, attributes }) |
| User identity | — | identifyUser(user) |
Native features
Native crashes, ANR / app-hangs, app start + TTI and frame vitals are captured by a native module that ships inside this package. It is autolinked — there is no config plugin to add and no manual registration — but it has to be compiled into the app, so it needs a dev build:
npx expo run:ios # or: npx expo run:androidIn Expo Go there is no native module at all. The four features never arm, nothing throws, and every JS feature above keeps working.
Each is a separate enabledEvents name, so you can keep crash reporting while
dropping frame vitals:
groundcover.init({
// …
options: {
// Omit a name to turn that feature off. An empty array (the default) = all.
enabledEvents: ["interaction", "network", "logs", "navigation", "exceptions", "crashes"],
native: {
// Main-thread block before a hang is reported. Defaults to 2s on iOS and
// 5s on Android — below Android's own 5s ANR bar there is nothing worth
// reporting.
//
// A NUMBER APPLIES TO BOTH PLATFORMS. Prefer the per-platform object when
// tuning, or a value chosen for iOS also replaces Android's 5s default
// with a watchdog that fires below the platform's own bar:
// hangThresholdMs: { ios: 1_500 } // Android stays 5s
// hangThresholdMs: { ios: 1_500, android: 8_000 }
hangThresholdMs: 2_000,
// Longest a frame-vitals segment may run before it is force-closed.
// Safety valve only — segments normally end on a navigation or on
// backgrounding.
vitalsMaxSegmentMs: 60_000,
// How "interactive" is decided for TTI. See below.
tti: "auto",
},
},
});Crash delivery
A crash kills the JS runtime, so nothing can be sent as it happens. Native persists the report along with a snapshot of the session, and the SDK sends it on the next launch, under the session it happened in — not the fresh one.
Delivery is at-least-once: the record is kept until the upload is confirmed,
so a crash on a plane is reported when the app next runs with a network. A retry
can therefore deliver the same crash twice; error_metadata.crash_report_id is
stable across attempts so the backend can collapse duplicates.
On Android, ANRs and native (NDK) crashes are recovered from the system's own exit records on API 30+. Below that the process is killed without a trace and they are invisible.
When another crash reporter is present
On iOS, native crash capture uses KSCrash, which is a process-wide singleton — and Sentry and Crashlytics use it too. If one of them installs first, this SDK does not take it over: reading their store would delete their reports before they had been sent. Native crash capture is then unavailable for the session, and the SDK logs a warning saying so:
[groundcover] native crash capture is unavailable: <reason>. Native crashes will
not be reported for this session.Every other signal — JS errors, hangs, app start, vitals, gestures, network — is unaffected. On Android the SDK chains onto whatever handler it finds, so a second reporter is the normal case and needs nothing.
Querying hangs: error_type differs per platform
A main-thread stall is one product concept with two platform names, and the SDK reports each platform's own:
| Platform | error_type | error_message |
|---|---|---|
| Android | ANR | Main thread blocked for 3003ms |
| iOS | AppHang | Main thread blocked for 3101ms |
This is deliberate. ANR is Android's own term and what its docs, Play Console
and tooling use; AppHang is Apple's. Either name is what an engineer on that
platform would actually type into a search box, and collapsing them to one would
make hangs harder to find in the case that matters most — someone debugging a
specific platform.
So a query for one of them finds half your hangs. Match both:
error_type IN ("ANR", "AppHang")Both Android paths use ANR — the live watchdog and the ANRs recovered from
ApplicationExitInfo — so the pair above covers every hang from both platforms.
Tell the two apart with error_metadata.recovered: true for a stall the app came
back from, false for one the process never returned from.
What the live watchdogs can and cannot see
Both platforms detect a stall by asking the main thread whether it is still
answering, on a fixed cadence — so the threshold is a floor, not an exact
boundary. A stall is reported reliably once it exceeds the threshold plus one
polling interval: 0.5s on Android, 0.1s on iOS. Between those two points
detection depends on where the stall falls relative to a poll, so a stall of
almost exactly hangThresholdMs may or may not arrive.
error_metadata.hang_duration_ms is measured from the last instant the main
thread was known to be responsive, which is up to one polling interval before the
stall truly began. Durations therefore err slightly long, by at most that
interval, and never short.
Neither number needs tuning for the defaults to work: Android's 5s default reliably reports anything past 5.5s, well below the ~10s at which users abandon an app, and iOS's 2s default reports anything past 2.1s.
Native crash types diverge for a stronger reason and are not unified either: a
java.lang.IllegalStateException and an EXC_BAD_ACCESS are genuinely different
things, not two names for one.
Frame vitals
Frame vitals are captured per screen, not on a timer. Native counts every
frame the display produced and holds one open segment; the segment closes — and
becomes one performance event — when one of three things happens:
| performance_segment_reason | when |
|---|---|
| navigation | the user left the screen |
| background | the app left the foreground |
| safety | the segment ran longer than vitalsMaxSegmentMs (60s) |
A segment closed by a navigation is attributed to the screen being left, not
the one arrived at — the frames were drawn there. Consecutive safety segments
followed by a navigation/background one can be summed to recover a whole
screen's totals.
Vitals stop while the session is idle. A closed segment is reported only while
the session is still active. Thirty minutes with no presence signal — a tap,
gesture or keystroke, or the app returning to the foreground — and segments are
dropped rather than sent; the next presence signal resumes them. This is
deliberate: the safety close fires on elapsed time alone, so an app left
foregrounded with nobody using it would otherwise report frame timings for an
untouched screen indefinitely, and session duration is derived from the last
event. Expect a gap in performance events across any idle stretch — it means
nobody was there, not that vitals stopped working.
Each closed segment emits ONE event, frames_delay_total:
| metric | unit | meaning |
|---|---|---|
| frames_delay_total | milliseconds | how much time the user spent waiting — every slow frame's duration beyond the frame budget, summed. Absolute, not a rate. The _total is load-bearing: the value is the whole segment's lost time, not one frame's, so quantiling it is not "how long a frame took". |
It carries:
| attribute | meaning |
|---|---|
| performance_refresh_rate_hz | the display's nominal rate — what the panel can do. Not the bar slow was judged against; see the next row. |
| performance_frame_budget_ms | the budget each frame was actually measured against. On an adaptive (ProMotion) panel iOS judges against the cadence the display is really running at, so an idle 120Hz screen has a ~16.7ms budget while the nominal rate implies 8.33ms. Ships so you can reproduce performance_frames_slow and frames_delay_total from the wire. |
| performance_segment_reason | one of the three reasons above |
| performance_frames_slow / _frozen | the raw counts — absolute, and comparable across platforms |
| start_timestamp / end_timestamp | segment bounds, epoch nanoseconds |
| screen | the screen the frames were drawn on |
frames_delay_total is the one to sort screens by: it says how much of the
user's time went missing, which is what you want when deciding what to fix.
Normalize it by the segment bounds — milliseconds lost per second of screen time
— and the number is comparable across platforms.
There is deliberately no refresh-rate metric. refresh_rate_min shipped
briefly and was withdrawn: its value came from a single frame, the worst
non-frozen one in the segment, so one stutter anywhere in a long otherwise-smooth
screen dominated it and identical jank produced very different numbers depending
on how long the segment happened to be.
Datadog does ship refresh_rate_min and refresh_rate_average under those
names, derived the same way — a min over per-frame instantaneous FPS. Theirs
differs in two respects ours did not have: every sample is scaled onto a 0–60
range, so it reads as a fraction of the frame budget rather than as a claim about
the panel, and on Android any frame under 1 FPS is dropped so a freeze cannot
reach the minimum. frames_delay_total carries the time lost and the counts
carry the frequency, and both aggregate over segments without a weight.
Why there is no slow-frames rate
Earlier releases shipped slow_frames_rate and frozen_frames_rate as
slow / total. They are gone, because the denominator was not the same
quantity on the two platforms:
- Android counts only frames it actually drew (
FrameMetricsfires on a real draw). An idle screen draws nothing. - iOS counts every display refresh (
CADisplayLinkticks whether or not the app drew).
Measured on a device: iOS counted 60.00 frames per second — the refresh rate handed straight back — against Android's 4.24. The same app therefore reported 0.03% on iOS and 88% on Android, and seconds spent reading a still screen diluted iOS's number away entirely. Neither Sentry nor Datadog ships a slow-frames rate, for this reason.
performance_frames_total is gone too, for the same reason. It carried a note
saying it was not a denominator; a dashboard divided by it anyway. Across 200 iOS
segments of real data the observed frames-per-second was 59.8-60.0 — the refresh
rate handed straight back, carrying nothing the segment bounds do not already
have — against Android's 0.9-2.7, which is genuine render activity. One field
name, two different quantities.
Normalize the surviving counts by TIME. start_timestamp and
end_timestamp ship on every segment, and a second is a second on both
platforms:
slow frames per minute = performance_frames_slow / ((end_timestamp - start_timestamp) / 6e10)On that same corpus this reads 139 and 45 on Android against 3.1 and 0.1 on iOS — a ~45x difference that is real (a software-rasterising emulator against a hardware-accelerated simulator), where the ratio had invented an 8,600x one.
There is deliberately no refresh_rate_average either. On iOS it is the
refresh rate by construction; on Android the same arithmetic reads ~4 fps, which
would suggest the app runs at 4 fps when the screen simply had nothing new to
draw.
A frame is slow past 1000 / (refreshRate - 1) ms — one frame interval, with
a vsync's worth of tolerance, which is the same bar Sentry and Datadog use.
Relative to the display rather than a fixed 16.7ms, so a stuttering 120Hz device
is not reported as healthy. Frozen is a frame over 700ms, and is a subset of
slow.
A screen that renders nothing produces no frames on Android, so its segment can be empty — an empty segment is not reported at all, which keeps "0 frames measured" distinguishable from "0% slow".
A background segment is closed when the app leaves the foreground but is
usually delivered on the way back in: the SDK flushes its queue from the
AppState handler, and the native signal crosses the bridge just after that, so
the pair lands in a pool that has already been emptied. Nothing is lost — it
ships with the next batch.
Time to interactive
TTI measures from the launch anchor to the moment the app became usable, and only JavaScript knows when that is:
tti: "auto"(default) — the first screen landing. Honest as a floor: it fires before that screen's own data has loaded.tti: "manual"— you decide. Callgroundcover.markAppInteractive()once, when the first screen is genuinely ready:
<NavigationContainer onReady={() => {
groundcover.startNavigationTracking(navigationRef);
}}>// …then, after your first screen's data has actually arrived:
groundcover.markAppInteractive();The timestamp is taken at the call, even before init() has finished, so it is
never distorted by SDK startup.
Architecture
Every captured event flows through one pipeline from capture to intake. The two public hooks —
beforeSend (drop gate) and enrichEvent (transform) — run in the events pool, after
privacy redaction and internal enrichment, so they see the final, already-redacted event.
Except for
sendCustomEvent. Custom-event payloads are deliberately not auto-redacted — they are values you supplied on purpose.enrichEventis the documented place to scrub them.
Unlike the browser SDK, there are two capture sources: the JavaScript listeners, and — on a dev
build — the native engines, whose signals arrive over a single emitter channel that
NativeSignalsRouter fans out by kind. Both converge on the same redaction and enrichment path.
Native crashes are not on this diagram. A crash is not a live signal — there is no runtime left to emit one. It is persisted by native and replayed on the next launch down a separate path that never touches the router or the pool. See Crash reporting takes a different path.
flowchart TD
JS["JS listeners<br/>network · navigation · interaction<br/>logs · errors"]
NAT["Native engines (dev build only)<br/>hang · app start/TTI · frame vitals · gestures"]
RT["NativeSignalsRouter<br/>one emitter subscription, fanned out by kind"]
R["Privacy redaction<br/>(getPrivacy) — bodies, headers, query params,<br/>logs, errors, tap labels"]
EI["EventEnricher<br/>id · span/trace ids · timestamps · screen"]
BS{"beforeSend(event)<br/>returns false?"}
DROP["✗ dropped"]
EN["enrichEvent(event)<br/>mutate / replace"]
OBS["session reconciliation<br/>(rotation derives the new session's start)"]
P["EventsPool<br/>batch by transport.batchSize / transport.batchTimeout"]
T["transporter<br/>inline gzip (transport.compression)"]
I[("groundcover intake")]
JS --> R
NAT --> RT --> R
R --> EI --> BS
BS -- "false" --> DROP
BS -- "keep" --> EN --> OBS --> P --> T --> IEvent lifecycle (in order):
- Capture — a JS listener builds the raw event, or a native engine emits a signal the router hands to its listener.
- Redact — sensitive payload is masked per the resolved
privacyconfig. - Enrich (internal) — the SDK stamps id / span & trace ids / timestamps /
screen. beforeSend(event)— your hook; returnfalseto drop the event. Receives the fully-redacted, enriched event with dot-flattened attributes.enrichEvent(event)— your hook; mutate or replace the event before it is buffered. A return value that isn't a usable event drops it rather than falling back to the original — the original is exactly what a failed scrubber was supposed to remove. (On the crash path this one differs: it falls back instead of dropping — see below.)- Reconcile — the final event drives session reconciliation. This runs after
enrichEventon purpose: a rotation derives the new session's start time from the event, and a hook that moved a timestamp earlier than the pre-enrichment value would ship an event predating the very session it opened. - Batch — buffered in the events pool and flushed on
transport.batchTimeout, on backgrounding, and on reachingtransport.batchSize— except fornavigation,interactionandperformanceevents, which never trigger the size flush on their own. A buffer holding only those (taps and screen changes, the common mobile case) ships on the timeout instead. - Send — gzipped (
transport.compression) and delivered to intake.
Supporting pieces:
instrumentation-managerinstalls listeners perenabledEvents.config-managerholds the resolved config and a memoizedgetPrivacy()read by every listener; runtimeupdateConfiginvalidates the privacy memo.session-managerowns the session lifecycle — see below.native-bridgeloads the native module. In Expo Go it isn't there, the router is never subscribed, and the native branch of the diagram simply doesn't exist.
No Web Worker. React Native has none, so gzip runs inline on the JS thread rather than being offloaded as it is in the browser SDK. See Performance considerations.
Crash reporting takes a different path
A native crash cannot be reported in the session it happened in — the process is gone. Native writes the crash record and a snapshot of the session envelope to disk; on the next launch the drain reads them back and rebuilds the event with the crash-time timestamp and the crash-time screen.
That path bypasses the events pool, so it applies the two public hooks itself — otherwise a
host's beforeSend / enrichEvent would silently never run for crashes:
flowchart LR
D[("pending crash records<br/>+ session snapshot on disk")]
R["redact (getPrivacy)"]
B["rebuild event<br/>crash-time timestamp + screen"]
BS{"beforeSend"}
EN["enrichEvent"]
S["send under the STORED<br/>session envelope"]
I[("groundcover intake")]
D --> R --> B --> BS -- "keep" --> EN --> S --> I
BS -- "false / throws" --> ACK["✗ dropped, record acked"]Four things differ from the live path, deliberately:
- It sends under the stored session envelope, not the current one, so the crash is attributed to the session it happened in.
- It is exempt from
eventSampleRate— a fatal is the rarest and most valuable event the SDK sends. Session sampling still applies implicitly: an unsampled session never had a snapshot pushed in the first place. - Both hooks are contained, with different answers on failure. A throwing
beforeSendacks the record and drops it, matching the live path where the pool drops an event whosebeforeSendthrows — not acking would retry a permanently broken callback on every launch forever, and the pending directory is capped. A throwingenrichEventcosts only its added attributes; the crash report itself still ships. - An unusable
enrichEventreturn falls back rather than drops. The pool drops such an event; this path cannot, because a fatal is the most valuable event the SDK sends — and it does not need to, because the crash event was already redacted when it was built, so there is nothing unscrubbed to protect against. The return is still validated: a hook that returns a truthy non-event ({}from an untyped caller, or an arrow with an implicit return) is ignored and the built crash report ships unchanged.
Delivery is at-least-once with a stable crash_report_id for backend dedupe.
Coverage & limitations
Session replay is not included.
- Network capture wraps both transports. Classic RN routes
fetchthroughXMLHttpRequest, but Expo replaces the globalfetchwith a native-backed implementation that never touches XHR — so the SDK wraps bothXMLHttpRequestandglobalThis.fetch, and gates each call so a request travelling through both is captured once, not twice.- A direct
import { fetch } from "expo/fetch"is not captured. The wrapper is installed on the globalfetch, which under Expo already is the native-backed implementation. Importing the binding straight from the module bypasses the global and escapes capture — use the globalfetchfor requests you want traced.
- A direct
- Unhandled promise rejections are captured as
exceptionevents (handled: false) via the engine's rejection tracker — Hermes'enablePromiseRejectionTracker, or RN's bundledpromisepolyfill on JavaScriptCore. The engine exposes a single tracker slot.- Caveat — Hermes + Metro dev builds: React Native installs its own
rejection tracker at startup, but only in
__DEV__(see RN'sLibraries/Core/polyfillPromise.js). In a dev build RN owns the single slot, so rejections surface through its default "Possible Unhandled Promise Rejection" warning rather than a groundcoverexception. In release builds RN does not install it, so the SDK's tracker is active and rejections are captured. The JavaScriptCore path always uses the SDK's tracker. - Gated by the
"exceptions"signal. Rejection capture is installed only when exceptions are enabled — i.e.enabledEventsis empty (all signals) or includes"exceptions". To opt out entirely (and leave the engine slot for another library), setenabledEventsto the signals you want without"exceptions", e.g.["interaction", "network", "logs", "navigation"]. - Compatibility — the tracker slot is engine-wide and shared. Because the
engine exposes a single slot, enabling exceptions makes the SDK the app's
rejection tracker. If another error/RUM SDK or a library also installs one,
only the last to initialize wins; don't run two. If you already rely on your
own rejection tracker, keep it and opt out via
enabledEventsas above. Integrators upgrading from the RN default dev warning should expect it to be replaced by groundcoverexceptionevents in release builds; verify withenabledEventsboth including and excluding"exceptions"in dev and release. - The
ErrorUtilsglobal handler (uncaught errors) is separate and is always chained, never replaced.
- Caveat — Hermes + Metro dev builds: React Native installs its own
rejection tracker at startup, but only in
- No session replay. Mobile replay is native view-hierarchy capture, a separate engine from the web SDK's; it is out of scope for this release.
- Native features need a dev build. In Expo Go the native module is absent, so crashes, hangs, app start / TTI and frame vitals never arm — every JS feature is unaffected. See Native features.
- Native crash stacks are not symbolicated yet. iOS frames are symbolicated on-device where possible; app frames in a stripped release build arrive as addresses. The binary's UUID and load address are recorded so they can be resolved once server-side symbolication exists. Android JVM stacks are readable as-is.
- Android sees ANRs and NDK crashes only on API 30+, where the system keeps exit records. Below that the process is killed without leaving a trace.
- Android frame vitals need API 24+. They ride
FrameMetrics, which does not exist below that; on API 23 the tracker simply does not arm, since a wrong number would be worse than none. Every other native feature honours the API 23 floor. - Expo Go runs every JS feature with zero native setup.
- RN 0.74+ is a text-encoding floor, and it is why emoji survive the wire. The
transport encodes each batch through the global
TextEncoder, which Hermes ships from RN 0.74. Below that — or on JSC at any version — there is no global, and the compression library falls back to its own encoder, which gets astral-plane characters wrong:🎉goes out asF0 9D AE 89instead ofF0 9F 8E 89. Every emoji in a screen name, log line or captured body would arrive corrupted, and nothing would fail loudly to say so. BMP text (accented Latin, Hebrew, CJK) is encoded correctly either way. Network capture is unaffected regardless — body sizing uses a separate hand-rolled byte counter that is correct on any engine.
Session persistence (AsyncStorage)
Session state (the current session id, activity clock, and sampling verdict) is
persisted through AsyncStorage so a session survives app restarts. AsyncStorage is
an optional peer (>= 1.19.0); there are two ways to wire it, and one degrade
path:
Inject explicitly (recommended) — pass the AsyncStorage default export to
init({ storage }):import AsyncStorage from "@react-native-async-storage/async-storage"; groundcover.init({ /* … */, storage: AsyncStorage });This is the reliable path. In a pnpm / monorepo / workspace setup the bundler often can't resolve the SDK's own optional
requireof the peer, but the app always can — so explicit injection is required there.Auto-detect — omit
storageand the SDK attempts torequirethe peer itself. Convenient for a plain single-package app; fragile in monorepos (see above).Memory-only degrade — if the peer is absent, hydration fails, or it exceeds the 3s hydration budget, the SDK logs a warning and runs memory-only: telemetry works normally, but each launch starts a fresh session (no cross-restart continuity). The SDK never throws over storage.
init({ storage }) accepts either the AsyncStorage instance or its module
namespace ({ default: AsyncStorage }), so both import AsyncStorage from … and
import * as AsyncStorage from … work.
Session lifecycle
sessionMaxDuration sets a target maximum wall-clock session length (default
4 hours / 14400000 ms; must be between 1 minute and 8 hours — out-of-range or
invalid values fall back to the default with a console.warn).
Rotation is activity-gated, not timer-driven — so the cap is not a hard upper bound:
- Once the cap has elapsed, the next qualifying event flushes pending events under the current session id, mints a fresh id, and resumes tracking. An idle or backgrounded app that produces no events keeps its id past the cap until activity resumes.
- Sessions are also bounded by a 30-minute inactivity gap, enforced the same lazy way: when activity resumes after 30+ minutes of silence, the session rotates first. The same gap check runs at init, so a cold launch after a long break also starts a fresh session.
- The flush is best-effort; rotation always proceeds regardless of delivery success.
getSessionId() returns the current id ("" before the SDK is ready).
setSessionId(id?) flushes buffered events and rebinds the session id (passing no
argument mints a fresh one); it returns a Promise that resolves once the rebind
settles.
When a rotated session starts. A new session's session_start_time is taken
from the earliest event that belongs to it — normally the event that triggered the
rotation — rather than from the instant the rotation ran. Using the later value
would report a session as starting after an event it carries, which reads
downstream as a negative duration. Candidates are only accepted if they are no
older than one inactivity window and not in the future, so a beforeSend /
enrichEvent hook returning a wildly skewed timestamp cannot move a session's
start (that value also drives the cap and inactivity clocks).
This assumes a fully configured SDK, which means cluster supplied at init — it
is required, and every other guarantee here is stated for that case.
Performance considerations
- Batching & compression — events are buffered (
transport.batchSize/transport.batchTimeout) and gzipped (transport.compression), so the SDK makes few requests with small payloads. The pool also flushes when the app is backgrounded, rather than holding a batch until the next launch. - Gzip runs inline — React Native has no Web Worker, so compression happens on the JS thread
instead of being offloaded as it is in the browser SDK. This is the one place the SDK does
measurable work on your thread; the default batch sizes are set so it stays small, and raising
transport.batchSizesubstantially trades request count against a longer single compression. - Sampling — cap volume with
sessionSampleRate/eventSampleRate.eventSampleRateapplies to network, navigation, interaction, log, hang, app-start and frame-vitals events. It is deliberately not applied to JS exceptions (captureExceptionand uncaught errors),sendCustomEvent,logger.*or native crashes — the rare, high-value events you almost never want thinned.sessionSampleRatestill governs all of them. - Scope what you capture — drop noisy endpoints with
excludedUrls, and disable what you don't need viaenabledEvents. Each native signal is independently switchable ("crashes","hangs","appStart","vitals"), so you can keep crash reporting while turning frame vitals off. - Native watchdogs are cheap but not free — hang detection is a watchdog thread comparing
heartbeats (default 2s on iOS, 5s on Android), not main-thread work. Frame vitals ride the
platform's existing callback (
CADisplayLink/FrameMetrics) and aggregate natively, emitting one event per screen segment rather than on a fixed cadence —native.vitalsMaxSegmentMs(default 60s) is a safety valve, and lowering it increases event volume while fragmenting a screen's stats. - Lazy session rotation — no background timers; rotation work happens only on real activity.
- Bundle size — ships ESM + CJS with built-in types. The published bundle is deliberately unminified and shipped with sourcemaps, because Metro does your app's minification and RN release stacks are symbolicated against those maps — so the installed size is larger than what ends up in your app binary.
API reference
All methods are on the default export.
| Method | Description |
|---|---|
| init(config) | Initialize the SDK and install instrumentation (async, fire-and-forget). |
| identifyUser(user) | Attach user identity to the session and subsequent events. |
| updateConfig(partial) | Update options / user at runtime (merges nested groups). Cannot change sessionId, storage or enabledEvents. |
| sendCustomEvent({ event, attributes }) | Emit a custom business event. |
| captureException(error, metadata?) | Report a handled error as an exception event. |
| logger.{log,info,warn,error,debug,trace}(message, attributes?) | Structured logging. |
| getSessionId() | Read the current session id ("" before ready). |
| setSessionId(id?) | Flush and rebind the session id (returns a Promise). |
| startNavigation(meta?) / endNavigation(meta?) | Manual screen tracking (when auto is disabled). |
| startNavigationTracking(ref) / stopNavigationTracking() | Automatic React Navigation tracking. |
| markAppInteractive() | Mark the app usable, for TTI in tti: "manual" mode. |
The package also exports the GroundcoverProvider component and the types
SDKConfigInput, SDKConfigUpdate, SDKOptions, NativeOptions,
PrivacyOptions, PrivacyLevel, RedactField, RedactKind, Redactor,
EventTypes, RNEventName, RNSessionAttributes,
InteractionEventAttributes, AppStartMetricName, ScreenAttributes,
CallbackEvent, FlattenedEventAttributes, GroundcoverProviderProps, and
NavigationContainerRefLike.
identifyUser
groundcover.identifyUser({
id: "u_123",
email: "[email protected]",
organization: "acme",
});UserIdentifiers fields (all optional): id, email, name, role,
organization, properties.
captureException
groundcover.captureException(new Error("Payment failed"), {
userId: "u_123",
feature: "checkout",
});Emits an exception event with handled: true. Metadata is redacted per your
privacy config.
logger.*
One method per level; the second arg is a structured attributes object:
groundcover.logger.warn("Checkout failed", { orderId: "ord_42", attempt: 2 });
groundcover.logger.error("Payment error", { provider: "stripe" });sendCustomEvent
groundcover.sendCustomEvent({
event: "plan_upgraded",
attributes: { plan: "pro", seats: 5 },
});Navigation
Automatic — wire the React Navigation ref once (see Quick start):
groundcover.startNavigationTracking(navigationRef);
// later, to stop:
groundcover.stopNavigationTracking();Manual — when you don't use React Navigation, or auto-tracking is disabled:
groundcover.startNavigation({ screen: "Checkout" });
// …
groundcover.endNavigation({ screen: "Checkout" });getSessionId / setSessionId
const id = groundcover.getSessionId(); // "" before ready
await groundcover.setSessionId("my-id"); // flush + rebind
await groundcover.setSessionId(); // flush + mint a fresh id