@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 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
+ }