@obsrviq/react-native 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/android/build.gradle +31 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/app/lumera/replay/LumeraMaskViewManager.kt +39 -0
- package/android/src/main/java/app/lumera/replay/LumeraReplayModule.kt +530 -0
- package/android/src/main/java/app/lumera/replay/LumeraReplayPackage.kt +50 -0
- package/ios/LumeraMaskView.h +19 -0
- package/ios/LumeraMaskView.m +79 -0
- package/ios/LumeraMaskViewManager.m +29 -0
- package/ios/LumeraReplay.swift +703 -0
- package/ios/LumeraReplayModule.h +20 -0
- package/ios/LumeraReplayModule.mm +93 -0
- package/lumera-react-native.podspec +19 -0
- package/package.json +46 -0
- package/src/LumeraMask.tsx +52 -0
- package/src/config.ts +96 -0
- package/src/emit.ts +5 -0
- package/src/index.ts +292 -0
- package/src/instrument/console.ts +46 -0
- package/src/instrument/errors.ts +28 -0
- package/src/instrument/network.ts +219 -0
- package/src/session.ts +108 -0
- package/src/spec/NativeLumeraReplay.ts +70 -0
- package/src/transport.ts +40 -0
- package/src/util.ts +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @obsrviq/react-native
|
|
2
|
+
|
|
3
|
+
Mobile session replay + analytics capture for **React Native (iOS + Android)**, feeding the same Lumera backend as the web tracker. Engineered around one non-negotiable: **zero added latency on the device.**
|
|
4
|
+
|
|
5
|
+
> **Status: in development.** The pure-JS layer (public API, session/identity, network/error/console capture, transport) is implemented. The native screenshot-capture TurboModule (iOS Swift / Android Kotlin), the backend frame-storage path, and the mobile player renderer are in progress — see [Roadmap](#roadmap).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why this design (the research)
|
|
10
|
+
|
|
11
|
+
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:
|
|
12
|
+
|
|
13
|
+
- Nobody records video on-device for analytics replay — battery/heat/privacy kill it.
|
|
14
|
+
- 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.
|
|
15
|
+
- We chose **screenshot-first** (pixel-perfect replay) and apply the open-source playbook that makes it jank-free.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
## The zero-latency architecture
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
┌───────────────────────── DEVICE ─────────────────────────┐
|
|
23
|
+
│ │
|
|
24
|
+
│ JS thread Native UI thread Background thread │
|
|
25
|
+
│ ───────── ─────────────── ───────────────── │
|
|
26
|
+
│ init / identify ① one fast raster ──▶ ② mask composite │
|
|
27
|
+
│ track / tag (~1 fps, change- ③ JPEG encode │
|
|
28
|
+
│ network/error/ driven, drop ④ gzip │
|
|
29
|
+
│ console capture overlapping) ⑤ UPLOAD ───────┼──▶ POST /v1/batch
|
|
30
|
+
│ │ │ (type:'screen')
|
|
31
|
+
│ └── structured events ──▶ POST /v1/batch (type:custom/network/…)
|
|
32
|
+
│ │
|
|
33
|
+
└───────────────────────────────────────────────────────────┘
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Invariants (every one is a measured jank source if violated):**
|
|
37
|
+
|
|
38
|
+
1. The **UI thread** does *only* the single screenshot grab. Everything after — masking, JPEG encode, gzip, upload — runs on a background thread.
|
|
39
|
+
2. The **JS thread** is never in the capture loop. It calls `start/stop/configure` once; the native timer reads native views directly (RN renders to real `UIView`/`View`, so no JS hook is needed for pixels).
|
|
40
|
+
3. **No pixel/frame buffer ever crosses the bridge/JSI.** The native module uploads frames itself, using the `siteKey` + `sessionId` the JS layer hands it at `start()`.
|
|
41
|
+
4. Capture is **change-driven, throttled to ~1 fps**, and **overlapping captures are dropped** (an in-flight flag) so a slow frame can't snowball.
|
|
42
|
+
5. **Idle screens cost zero** — unchanged view tree ⇒ no capture.
|
|
43
|
+
|
|
44
|
+
**Platform specifics** (from the source-level research):
|
|
45
|
+
|
|
46
|
+
- **iOS:** raster via a raw `CGContext` + `view.drawHierarchy(in:afterScreenUpdates:false)` at native scale — *not* `UIGraphicsImageRenderer` (≈6× slower), *not* `afterScreenUpdates:true` (forces a sync layout+commit), *not* `CALayer.render(in:)` (drops content).
|
|
47
|
+
- **Android:** `PixelCopy.request(window, bitmap, listener, backgroundHandler)` (API 26+, async, captures GPU/`SurfaceView` content) — *not* `View.draw(Canvas)` (misses hardware content → black video/maps). Reuse one `RGB_565` bitmap.
|
|
48
|
+
- **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.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npm install @obsrviq/react-native @react-native-async-storage/async-storage
|
|
54
|
+
cd ios && pod install # iOS
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires React Native ≥ 0.76 (New Architecture). Works under the old architecture via RN's interop shim.
|
|
58
|
+
|
|
59
|
+
## Quickstart
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { LumeraReplay } from '@obsrviq/react-native';
|
|
63
|
+
|
|
64
|
+
LumeraReplay.init({
|
|
65
|
+
siteKey: 'pk_live_…',
|
|
66
|
+
// privacy-by-default: all text + images masked. Opt out per-view with <LumeraMask unmask>.
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// later — identical API to the web SDK:
|
|
70
|
+
LumeraReplay.identify('user_123', { plan: 'pro' });
|
|
71
|
+
LumeraReplay.track('checkout_started', { cart: 3 });
|
|
72
|
+
LumeraReplay.conversion('purchase', { value: 49.0 });
|
|
73
|
+
LumeraReplay.tag('vip', 'beta');
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API
|
|
77
|
+
|
|
78
|
+
Mirrors `@obsrviq/tracker` 1:1 — `init`, `identify`, `setMetadata`, `track`, `conversion`, `tag`, `startTask`/`endTask`, `setConsent`, `reset`, `stop`, `flush`, `getDiagnostics`, `sessionId`. See [`src/config.ts`](src/config.ts) for the full config.
|
|
79
|
+
|
|
80
|
+
## Privacy / masking
|
|
81
|
+
|
|
82
|
+
- **Default:** all text and images are masked before any pixel is persisted (masking runs on the capture thread, pre-encode).
|
|
83
|
+
- **Per-view control:** `<LumeraMask>` / `<LumeraMask unmask>` wrappers (call `setViewMasked(reactTag, …)` natively). *(component pending — task #95/#96)*
|
|
84
|
+
- Network header redaction (`authorization`/`cookie`/`set-cookie` by default), bodies off by default.
|
|
85
|
+
|
|
86
|
+
## Data flow
|
|
87
|
+
|
|
88
|
+
Two independent uploaders, one session:
|
|
89
|
+
|
|
90
|
+
| Stream | Owner | Endpoint | Event type | Storage |
|
|
91
|
+
|---|---|---|---|---|
|
|
92
|
+
| Structured (network/error/console/custom) | JS | `POST /v1/batch` | `network`/`error`/`console`/`custom` | Postgres `events` |
|
|
93
|
+
| Screenshot frames | **Native** | `POST /v1/batch` | `screen` | blob + `mobile_frames` index |
|
|
94
|
+
|
|
95
|
+
Both share `siteKey` + `sessionId`. No `seq` collision: structured events never write replay chunks; `screen` frames get their own frame index. The ingest endpoint already accepts new event types (permissive validation), so **no ingest change is needed** — only a worker routing rule for `type:'screen'`.
|
|
96
|
+
|
|
97
|
+
## Roadmap
|
|
98
|
+
|
|
99
|
+
- [x] JS core — public API, session/identity, network/error/console capture, transport (`src/`)
|
|
100
|
+
- [x] Wire format — `MobileScreenEvent` (`type:'screen'`) + `MobileTouchEvent` in `@obsrviq/types`
|
|
101
|
+
- [ ] Native iOS capture (Swift TurboModule) — `ios/` *(task #95)*
|
|
102
|
+
- [ ] Native Android capture (Kotlin TurboModule) — `android/` *(task #96)*
|
|
103
|
+
- [ ] Backend — route `type:'screen'` to blob + `mobile_frames`, migration *(task #97)*
|
|
104
|
+
- [ ] Player — mobile screenshot renderer + device-based routing *(task #98)*
|
|
105
|
+
|
|
106
|
+
## Building (native)
|
|
107
|
+
|
|
108
|
+
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/NativeLumeraReplay.ts`](src/spec/NativeLumeraReplay.ts).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
repositories { google(); mavenCentral() }
|
|
3
|
+
dependencies { classpath "com.android.tools.build:gradle:8.2.1" }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
apply plugin: "com.android.library"
|
|
7
|
+
apply plugin: "org.jetbrains.kotlin.android" // REQUIRED: sources are Kotlin — without this the .kt files are silently skipped
|
|
8
|
+
apply plugin: "com.facebook.react" // enables New-Arch codegen for the TurboModule
|
|
9
|
+
|
|
10
|
+
android {
|
|
11
|
+
namespace "app.lumera.replay"
|
|
12
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 35)
|
|
13
|
+
|
|
14
|
+
defaultConfig {
|
|
15
|
+
minSdkVersion safeExtGet("minSdkVersion", 24) // PixelCopy(Surface) = 24; window overload = 26
|
|
16
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 35)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
buildFeatures { buildConfig true }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
repositories { google(); mavenCentral() }
|
|
23
|
+
|
|
24
|
+
dependencies {
|
|
25
|
+
implementation "com.facebook.react:react-android"
|
|
26
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def safeExtGet(prop, fallback) {
|
|
30
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package app.lumera.replay
|
|
2
|
+
|
|
3
|
+
import android.view.View
|
|
4
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
5
|
+
import com.facebook.react.uimanager.SimpleViewManager
|
|
6
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Backs the optional `<LumeraMask>` JS component. It is a plain pass-through container
|
|
10
|
+
* (children render normally); its sole job is to tell the capture engine "redact my
|
|
11
|
+
* subtree". On attach it registers its own View.id in [LumeraReplayModule.maskedTags];
|
|
12
|
+
* on detach it removes it. This is the geometry [LumeraReplayModule.collectMaskRects]
|
|
13
|
+
* walks: an explicitly-masked container yields one redaction block over the whole subtree.
|
|
14
|
+
*
|
|
15
|
+
* Kept deliberately minimal — no custom props, no shadow node. JS can also drive masking
|
|
16
|
+
* imperatively via `NativeLumeraReplay.setViewMasked(reactTag, masked)` without this view.
|
|
17
|
+
*/
|
|
18
|
+
@ReactModule(name = LumeraMaskViewManager.REACT_CLASS)
|
|
19
|
+
class LumeraMaskViewManager : SimpleViewManager<View>() {
|
|
20
|
+
|
|
21
|
+
override fun getName() = REACT_CLASS
|
|
22
|
+
|
|
23
|
+
override fun createViewInstance(reactContext: ThemedReactContext): View =
|
|
24
|
+
object : View(reactContext) {
|
|
25
|
+
override fun onAttachedToWindow() {
|
|
26
|
+
super.onAttachedToWindow()
|
|
27
|
+
LumeraReplayModule.maskTag(id)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override fun onDetachedFromWindow() {
|
|
31
|
+
LumeraReplayModule.unmaskTag(id)
|
|
32
|
+
super.onDetachedFromWindow()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
companion object {
|
|
37
|
+
const val REACT_CLASS = "LumeraMask"
|
|
38
|
+
}
|
|
39
|
+
}
|