@obsrviq/react-native
v0.8.0
Published
Obsrviq mobile capture SDK for React Native — privacy-first screenshot session replay + network/error/custom-event recording, engineered for zero added latency on device.
Readme
@obsrviq/react-native
Mobile session replay + analytics capture for React Native (iOS + Android), feeding the same Obsrviq backend as the web tracker. Engineered around one non-negotiable: zero added latency on the device.
Status: shipped. The full pipeline is live — the pure-JS layer (public API, session/identity, network/error/console capture, transport), the native screenshot-capture TurboModule (iOS Swift / Android Kotlin), native touch capture, the backend frame-storage path, and the mobile player renderer (screenshot timeline + touch ripples + network waterfall). Native code is compiled inside the consuming app's iOS/Android build — see Building.
Why this design (the research)
We surveyed the 2026 mobile-replay landscape (Sentry, PostHog, Datadog, FullStory, Smartlook, UXCam — Sentry/PostHog/Datadog are open source and were read at the source level). Findings:
- Nobody records video on-device for analytics replay — battery/heat/privacy kill it.
- The two viable strategies are wireframe (serialize the view tree → reconstruct) and periodic screenshots (1 fps, masked, encoded). Both Sentry & PostHog force screenshot mode for React Native because RN's cross-platform rendering can't be reliably rebuilt from the native tree.
- We chose screenshot-first (pixel-perfect replay) and apply the open-source playbook that makes it jank-free.
The measured reason naive screenshot replay janks: rasterizing a screen on the UI thread costs 25–155 ms/frame (Sentry measured 9–10 dropped frames/sec on their first iOS renderer). The fix is architectural, not incremental.
The zero-latency architecture
┌───────────────────────── DEVICE ─────────────────────────┐
│ │
│ JS thread Native UI thread Background thread │
│ ───────── ─────────────── ───────────────── │
│ init / identify ① one fast raster ──▶ ② mask composite │
│ track / tag (~1 fps, change- ③ JPEG encode │
│ network/error/ driven, drop ④ gzip │
│ console capture overlapping) ⑤ UPLOAD ───────┼──▶ POST /v1/batch
│ │ │ (type:'screen')
│ └── structured events ──▶ POST /v1/batch (type:custom/network/…)
│ │
└───────────────────────────────────────────────────────────┘Invariants (every one is a measured jank source if violated):
- The UI thread does only the single screenshot grab. Everything after — masking, JPEG encode, gzip, upload — runs on a background thread.
- The JS thread is never in the capture loop. It calls
start/stop/configureonce; the native timer reads native views directly (RN renders to realUIView/View, so no JS hook is needed for pixels). - No pixel/frame buffer ever crosses the bridge/JSI. The native module uploads frames itself, using the
siteKey+sessionIdthe JS layer hands it atstart(). - Capture is change-driven, throttled to ~1 fps, and overlapping captures are dropped (an in-flight flag) so a slow frame can't snowball.
- Idle screens cost zero — unchanged view tree ⇒ no capture.
Platform specifics (from the source-level research):
- iOS: raster via a raw
CGContext+view.drawHierarchy(in:afterScreenUpdates:false)at native scale — notUIGraphicsImageRenderer(≈6× slower), notafterScreenUpdates:true(forces a sync layout+commit), notCALayer.render(in:)(drops content). - Android:
PixelCopy.request(window, bitmap, listener, backgroundHandler)(API 26+, async, captures GPU/SurfaceViewcontent) — notView.draw(Canvas)(misses hardware content → black video/maps). Reuse oneRGB_565bitmap. - Masking: privacy-by-default. Redaction rects are collected during the (cheap) traversal and composited onto the already-captured bitmap on the background thread — never a second screenshot.
Install
npm install @obsrviq/react-native @react-native-async-storage/async-storage
cd ios && pod install # iOSRequires React Native ≥ 0.76 (New Architecture). Works under the old architecture via RN's interop shim.
Quickstart
import { ObsrviqReplay } from '@obsrviq/react-native';
ObsrviqReplay.init({
siteKey: 'pk_live_…',
// privacy-by-default: all text + images masked. Opt out per-view with <ObsrviqMask unmask>.
});
// later — identical API to the web SDK:
ObsrviqReplay.identify('user_123', { plan: 'pro' });
ObsrviqReplay.track('checkout_started', { cart: 3 });
ObsrviqReplay.conversion('purchase', { value: 49.0 });
ObsrviqReplay.tag('vip', 'beta');Naming: the SDK is
ObsrviqReplay/ObsrviqConfig/<ObsrviqMask>. The oldLumeraReplay/LumeraConfig/<LumeraMask>names are still exported as deprecated aliases, so pre-rebrand integrations keep working unchanged.
API
Mirrors @obsrviq/tracker 1:1 — init, identify, setMetadata, track, conversion, tag, startTask/endTask, setConsent, reset, stop, flush, getDiagnostics, sessionId. screen(name) records a route view (the mobile analog of a web pageview — call it from your navigation listener).
Config
Passed to init({ siteKey, … }). See src/config.ts for the authoritative list.
| Option | Default | What it does |
|---|---|---|
| siteKey | — | Required. Your ingest key (pk_…). |
| enableReplay | true | Record the screen (native screenshot stream). |
| fps | 1 | Capture cadence cap, frames/sec (change-driven underneath). |
| jpegQuality | 0.4 | JPEG quality 0..1 on the encode path. |
| maxCaptureDim | 1200 | Cap the longer edge of each frame (px) — the storage/bandwidth lever. 0 = full native res. |
| maskAllText | true | Mask all text before any pixel is persisted. |
| maskAllImages | true | Mask all images/media. |
| captureTouches | true | Capture taps + swipes as a touch overlay (native, non-blocking gesture observer — never interferes with the app's own gestures). |
| captureNetworkBodies | false | Capture request + response bodies. Bodies are masked on-device before they're queued (PII patterns + sensitive-key redaction — see Network body masking) and capped at 8KB. Off by default — enable deliberately. |
| maskKeys | [] | Extra field names to redact by value (case-insensitive), on top of the always-on built-in list. e.g. ['accountNumber','dob']. Merged with the per-site keys you configure in the console. |
| redactHeaders | ['authorization','cookie','set-cookie'] | Header allowlist redaction for captured network events (request + response). |
| captureConsole | true | Mirror console.* into the session. |
| sampleRate | 1 | Fraction of sessions recorded (0..1). |
| continueSession | false | Resume the same session across foreground/background within the window. |
| requireConsent | false | Gate all recording until setConsent(true). |
Privacy / masking
Three independent streams, each masked at capture — nothing sensitive leaves the device unmasked:
- Screen pixels — all text and images are masked before any pixel is persisted (masking runs on the native capture thread, pre-encode). Per-view control with
<ObsrviqMask>/<ObsrviqMask unmask>wrappers (callsetViewMasked(reactTag, …)natively). - Network — header allowlist redaction (
authorization/cookie/set-cookieby default; extend withredactHeaders). Request/response bodies are off by default; when enabled they are masked on-device — see Network body masking below. - WebSocket — connection lifecycle (open/close/error) and per-frame direction/size are always captured; the URL's query string is stripped (tokens), and text-frame previews follow
captureNetworkBodiesand go through the same on-device masking as HTTP bodies. Binary frames record size only, never bytes. - Touch — records only coordinates + phase (start/move/end), never the content under the finger.
- Console + errors —
console.*args and uncaught-errormessage/stackare PII-masked on-device (email/card/SSN/long-digit patterns), and console objects also get sensitive-key redaction — same pipeline as network bodies, so aconsole.log({ password })or an error string with a card number can't leak.
Network body masking
Body capture is opt-in (captureNetworkBodies: true). Once enabled, every captured request and response body — fetch and XMLHttpRequest alike — passes through one masking chokepoint on the JS thread before it is ever queued for upload. This is the same masking pipeline the web tracker uses (src/mask.ts is a kept-in-sync port), so mobile and web redact identically.
Two layers run over each body, in order:
1. PII pattern masking (always on) — heuristics catch the obvious personal data anywhere in the text, regardless of field name:
| Pattern | Replaced with | Example |
|---|---|---|
| Email addresses | «email» | [email protected] → «email» |
| Card numbers (13–19 digits, Luhn-length) | «card» | 4111 1111 1111 1111 → «card» |
| US SSN (###-##-####) | «ssn» | 123-45-6789 → «ssn» |
| Long digit runs (≥9, phone/account-ish) | «number» | 5551234567 → «number» |
2. Sensitive-key redaction (always on + configurable) — any field whose name is sensitive has its whole value replaced with «redacted». This works on structured JSON and on non-JSON payloads (form-urlencoded, query strings) and on truncated bodies (a body cut at the 8KB cap still gets a raw-text key scan, so a secret split across the boundary can't leak):
- Built-in keys (no config needed):
password,passwd,passphrase,secret,token,authorization,apiKey/api_key/api-key,creditCard,cardNumber,cardholder,cookie,cvv,cvc, plus the standalone segmentspass,pwd,auth,card,pin,otp,ssn. - The matcher is boundary-aware (splits
camelCaseandsnake_case/kebab-case), soaccessToken,x-api-key,authToken,cardNumberare caught — but innocent lookalikes likeauthor,discard,shipping,usernameare not. - Custom keys — add your own field names two ways, and they combine:
maskKeys: ['accountNumber','dob']atinit()(effective immediately, before the first request is captured).- Per-site keys configured in the Obsrviq console (fetched once at startup via
GET /v1/config). No app release needed — change them in the console and every session picks them up.
ObsrviqReplay.init({
siteKey: 'pk_live_…',
captureNetworkBodies: true, // opt in to body capture
maskKeys: ['accountNumber', 'dob'], // redact these values too (on top of the built-ins)
redactHeaders: ['authorization', 'cookie', 'set-cookie', 'x-session'],
});Timing guarantees. Built-in and init()-supplied maskKeys apply from the very first captured body. The per-site console keys arrive from GET /v1/config a moment after startup; until that request settles (bounded to ≤2s), the SDK holds the structured-event stream so a body captured in that brief window can't be sent before its site-configured keys are known. As a final backstop, a send-time pass re-applies sensitive-key redaction to every batch (bodies, console objects, and custom-event props) right before upload — so a custom key that arrived mid-session still scrubs anything captured earlier.
The masking is not reversible — masked values are gone before the data leaves the device; there is no un-mask on the server. Test your
maskKeysagainst a real request in a debug build if you're unsure a field is covered.
Device & version analytics
Every session automatically carries a coarse (no-PII) device descriptor, and on native the SDK enriches it so the console's Audience page can break traffic down by:
| Field | Example | Source |
|---|---|---|
| OS | iOS / Android | Platform.OS |
| OS version | 17.2 / 14 | native (UIDevice.systemVersion / Build.VERSION.RELEASE) |
| Device model | iPhone15,3 / Google Pixel 8 | native (uname / Build.MANUFACTURER+MODEL) |
| App version | 1.1.6 / 2.1 | native (CFBundleShortVersionString / versionName) |
| App build | 14 | native (CFBundleVersion / versionCode) |
| SDK version | 0.5.0 | this package |
The native fields come from a one-shot getAppInfo() read at startup; if the
native module isn't linked (e.g. Expo Go), they're simply omitted and the session
still records with OS + SDK version. No configuration needed — the breakdowns
populate on the Audience page automatically.
Screens & journeys
screen(name) is the only source of path/journey data on native (there's no
web-style automatic navigation capture) — wire it to your navigation listener:
navigationRef.addListener('state', () => ObsrviqReplay.screen(currentRouteName()));Each call records the previous screen as the pageview's referrer, so the
console's Journeys Sankey chains the from → to flow. Consecutive duplicate
screens are de-duped, and logout (reset()) starts a fresh journey.
Data flow
Two independent uploaders, one session:
| Stream | Owner | Endpoint | Event type | Storage |
|---|---|---|---|---|
| Structured (network/websocket/error/console/custom) | JS | POST /v1/batch | network/ws/error/console/custom | Postgres events |
| Screenshot frames | Native | POST /v1/batch | screen | blob + mobile_frames index |
| Touch (taps/swipes) | Native | POST /v1/batch | touch | Postgres events (synced to the timeline by t) |
All three share siteKey + sessionId. No seq collision: structured events never write replay chunks; screen frames get their own frame index; touch events are small and land in the events table. The worker routes screen to the blob store + mobile_frames, keeps everything else (including touch) in events. The player overlays touch events as ripples and renders network events as a per-request waterfall with an expandable headers/body inspector.
Roadmap
- [x] JS core — public API, session/identity, network/error/console capture, transport (
src/) - [x] Wire format —
MobileScreenEvent(type:'screen') +MobileTouchEvent(type:'touch') in@obsrviq/types - [x] Native iOS capture (Swift TurboModule) — screenshots + touch observer —
ios/ - [x] Native Android capture (Kotlin TurboModule) — screenshots + touch observer —
android/ - [x] Backend — route
type:'screen'to blob +mobile_frames,touchtoevents(migration 017) - [x] Player — mobile screenshot renderer + device-based routing + touch ripples + network waterfall/inspector
- [x]
<ObsrviqMask>/<ObsrviqMask unmask>per-view privacy component (iOS + Android) - [x] Network request/response body capture (opt-in,
captureNetworkBodies) - [x] On-device body masking — PII patterns + sensitive-key redaction (built-in +
maskKeys+ per-site/v1/config), parity with the web tracker (src/mask.ts); also coversconsole/errorcapture - [x] WebSocket capture — lifecycle + frame direction/size + opt-in masked text-frame previews (
instrument/websocket.ts), parity with the web tracker
Building (native)
Native code can't be compiled in the SDK repo — build it inside a real RN app and test on devices (jank only shows on hardware, especially low-end). Codegen runs automatically during the app's iOS/Android build from src/spec/NativeObsrviqReplay.ts.
