@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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Emit } from '../emit';
|
|
2
|
+
|
|
3
|
+
interface ErrorUtilsType {
|
|
4
|
+
getGlobalHandler(): ((error: unknown, isFatal?: boolean) => void) | undefined;
|
|
5
|
+
setGlobalHandler(cb: (error: unknown, isFatal?: boolean) => void): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Captures uncaught JS errors via RN's `global.ErrorUtils` (chaining the prior
|
|
10
|
+
* handler so we never swallow a crash). Emits `error` events. Pure JS.
|
|
11
|
+
*/
|
|
12
|
+
export function installErrors(emit: Emit): () => void {
|
|
13
|
+
const EU = (globalThis as unknown as { ErrorUtils?: ErrorUtilsType }).ErrorUtils;
|
|
14
|
+
if (!EU) return () => {};
|
|
15
|
+
const prev = EU.getGlobalHandler();
|
|
16
|
+
EU.setGlobalHandler((error, isFatal) => {
|
|
17
|
+
try {
|
|
18
|
+
const e = error instanceof Error ? error : new Error(String(error));
|
|
19
|
+
emit({ type: 'error', name: e.name, message: e.message, stack: e.stack, handled: false });
|
|
20
|
+
} catch {
|
|
21
|
+
/* never let instrumentation mask the real crash */
|
|
22
|
+
}
|
|
23
|
+
prev?.(error, isFatal);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
if (prev) EU.setGlobalHandler(prev);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { Emit } from '../emit';
|
|
2
|
+
|
|
3
|
+
export interface NetworkOptions {
|
|
4
|
+
/** The ingest batch URL — never record the SDK's own uploads (infinite loop). */
|
|
5
|
+
skipUrl: string;
|
|
6
|
+
captureBodies: boolean;
|
|
7
|
+
redactHeaders: string[];
|
|
8
|
+
/** relative `t` (ms since session start). */
|
|
9
|
+
now: () => number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const BODY_CAP = 8 * 1024; // chars kept of a captured body preview
|
|
13
|
+
|
|
14
|
+
const num = (h: Headers | undefined, k: string): number | undefined => {
|
|
15
|
+
const v = h?.get(k);
|
|
16
|
+
const n = v ? Number(v) : NaN;
|
|
17
|
+
return Number.isFinite(n) ? n : undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function pickHeaders(h: Headers | undefined, redact: string[]): Record<string, string> | undefined {
|
|
21
|
+
if (!h) return undefined;
|
|
22
|
+
const out: Record<string, string> = {};
|
|
23
|
+
const deny = new Set(redact.map((x) => x.toLowerCase()));
|
|
24
|
+
h.forEach((value, key) => {
|
|
25
|
+
out[key] = deny.has(key.toLowerCase()) ? '[redacted]' : value;
|
|
26
|
+
});
|
|
27
|
+
return Object.keys(out).length ? out : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Coerce fetch/XHR header inputs (Headers | object | array) into a Headers. */
|
|
31
|
+
function toHeaders(input: unknown): Headers | undefined {
|
|
32
|
+
if (input == null) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
return new Headers(input as ConstructorParameters<typeof Headers>[0]);
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Only capture textual bodies (JSON/text/xml/form) — never binary blobs. */
|
|
41
|
+
function isTexty(ct: string | null | undefined): boolean {
|
|
42
|
+
if (!ct) return false;
|
|
43
|
+
return /json|text|xml|javascript|x-www-form-urlencoded|graphql/i.test(ct);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Best-effort stringify of a request body (strings pass through; others skipped). */
|
|
47
|
+
function stringifyBody(body: unknown): string | undefined {
|
|
48
|
+
if (body == null) return undefined;
|
|
49
|
+
if (typeof body === 'string') return body;
|
|
50
|
+
// URLSearchParams / objects with a toString worth keeping; binary/FormData skipped.
|
|
51
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) return body.toString();
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface BodyPreview { contentType?: string; preview?: string; truncated?: boolean; redacted?: boolean }
|
|
56
|
+
function bodyPreview(text: string | undefined, contentType: string | undefined): BodyPreview | undefined {
|
|
57
|
+
if (text == null) return undefined;
|
|
58
|
+
const truncated = text.length > BODY_CAP;
|
|
59
|
+
return { contentType, preview: truncated ? text.slice(0, BODY_CAP) : text, truncated, redacted: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Instruments `fetch` and `XMLHttpRequest` to emit `network` events. Mirrors the web
|
|
64
|
+
* tracker's network capture: method/url/status/timing/sizes, request + response header
|
|
65
|
+
* allowlist with redaction, and — when `captureBodies` is on — size-capped textual
|
|
66
|
+
* request/response bodies. Self-skips the ingest endpoint so the SDK never records (and
|
|
67
|
+
* thus re-uploads) its own traffic. Pure JS — runs on the JS thread, off the native
|
|
68
|
+
* capture path entirely.
|
|
69
|
+
*/
|
|
70
|
+
export function installNetwork(emit: Emit, opts: NetworkOptions): () => void {
|
|
71
|
+
const g = globalThis as unknown as {
|
|
72
|
+
fetch: typeof fetch;
|
|
73
|
+
XMLHttpRequest: typeof XMLHttpRequest;
|
|
74
|
+
};
|
|
75
|
+
const origFetch = g.fetch;
|
|
76
|
+
const OrigXHR = g.XMLHttpRequest;
|
|
77
|
+
const skip = (url: string) => url.indexOf(opts.skipUrl) !== -1;
|
|
78
|
+
|
|
79
|
+
// ── fetch ──
|
|
80
|
+
g.fetch = function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
81
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
|
|
82
|
+
const method = (init?.method || (input instanceof Request ? input.method : 'GET') || 'GET').toUpperCase();
|
|
83
|
+
if (skip(url)) return origFetch(input as RequestInfo, init);
|
|
84
|
+
|
|
85
|
+
const reqHeaders = pickHeaders(
|
|
86
|
+
toHeaders(init?.headers ?? (input instanceof Request ? input.headers : undefined)),
|
|
87
|
+
opts.redactHeaders,
|
|
88
|
+
);
|
|
89
|
+
const requestBody = opts.captureBodies ? bodyPreview(stringifyBody(init?.body), undefined) : undefined;
|
|
90
|
+
const startT = opts.now();
|
|
91
|
+
|
|
92
|
+
return origFetch(input as RequestInfo, init).then(
|
|
93
|
+
(res) => {
|
|
94
|
+
const endT = opts.now();
|
|
95
|
+
const ct = res.headers.get('content-type');
|
|
96
|
+
// Read a clone of the body WITHOUT delaying the response returned to the app:
|
|
97
|
+
// emit fires after the (detached) body read resolves.
|
|
98
|
+
const finalize = (responseBody?: BodyPreview) =>
|
|
99
|
+
emit({
|
|
100
|
+
type: 'network',
|
|
101
|
+
initiator: 'fetch',
|
|
102
|
+
method,
|
|
103
|
+
url,
|
|
104
|
+
status: res.status,
|
|
105
|
+
ok: res.ok,
|
|
106
|
+
responseSize: num(res.headers, 'content-length'),
|
|
107
|
+
timing: { startT, endT, duration: endT - startT },
|
|
108
|
+
requestHeaders: reqHeaders,
|
|
109
|
+
responseHeaders: pickHeaders(res.headers, opts.redactHeaders),
|
|
110
|
+
requestBody,
|
|
111
|
+
responseBody,
|
|
112
|
+
});
|
|
113
|
+
if (opts.captureBodies && isTexty(ct)) {
|
|
114
|
+
res.clone().text().then((t) => finalize(bodyPreview(t, ct || undefined))).catch(() => finalize(undefined));
|
|
115
|
+
} else {
|
|
116
|
+
finalize(undefined);
|
|
117
|
+
}
|
|
118
|
+
return res;
|
|
119
|
+
},
|
|
120
|
+
(err: unknown) => {
|
|
121
|
+
const endT = opts.now();
|
|
122
|
+
emit({
|
|
123
|
+
type: 'network',
|
|
124
|
+
initiator: 'fetch',
|
|
125
|
+
method,
|
|
126
|
+
url,
|
|
127
|
+
status: 0,
|
|
128
|
+
ok: false,
|
|
129
|
+
timing: { startT, endT, duration: endT - startT },
|
|
130
|
+
requestHeaders: reqHeaders,
|
|
131
|
+
requestBody,
|
|
132
|
+
error: err instanceof Error ? err.message : String(err),
|
|
133
|
+
});
|
|
134
|
+
throw err;
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
} as typeof fetch;
|
|
138
|
+
|
|
139
|
+
// ── XMLHttpRequest ──
|
|
140
|
+
type Tracked = XMLHttpRequest & {
|
|
141
|
+
__lum?: { method: string; url: string; startT: number; reqHeaders: Record<string, string>; body?: string };
|
|
142
|
+
};
|
|
143
|
+
const origOpen = OrigXHR.prototype.open;
|
|
144
|
+
const origSend = OrigXHR.prototype.send;
|
|
145
|
+
const origSetHeader = OrigXHR.prototype.setRequestHeader;
|
|
146
|
+
const deny = new Set(opts.redactHeaders.map((x) => x.toLowerCase()));
|
|
147
|
+
|
|
148
|
+
OrigXHR.prototype.open = function open(
|
|
149
|
+
this: Tracked,
|
|
150
|
+
method: string,
|
|
151
|
+
url: string | URL,
|
|
152
|
+
...rest: unknown[]
|
|
153
|
+
) {
|
|
154
|
+
this.__lum = { method: (method || 'GET').toUpperCase(), url: String(url), startT: 0, reqHeaders: {} };
|
|
155
|
+
// @ts-expect-error variadic passthrough to the native impl
|
|
156
|
+
return origOpen.call(this, method, url, ...rest);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
OrigXHR.prototype.setRequestHeader = function setRequestHeader(this: Tracked, name: string, value: string) {
|
|
160
|
+
const meta = this.__lum;
|
|
161
|
+
if (meta) meta.reqHeaders[name] = deny.has(name.toLowerCase()) ? '[redacted]' : value;
|
|
162
|
+
return origSetHeader.call(this, name, value);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// `body` is whatever the app passes to xhr.send(); we forward it untouched.
|
|
166
|
+
OrigXHR.prototype.send = function send(this: Tracked, body?: unknown) {
|
|
167
|
+
const meta = this.__lum;
|
|
168
|
+
if (meta && !skip(meta.url)) {
|
|
169
|
+
meta.startT = opts.now();
|
|
170
|
+
if (opts.captureBodies) meta.body = stringifyBody(body);
|
|
171
|
+
this.addEventListener('loadend', () => {
|
|
172
|
+
const endT = opts.now();
|
|
173
|
+
const ct = this.getResponseHeader('content-type');
|
|
174
|
+
let responseBody: BodyPreview | undefined;
|
|
175
|
+
if (opts.captureBodies && isTexty(ct)) {
|
|
176
|
+
try {
|
|
177
|
+
if (this.responseType === '' || this.responseType === 'text') {
|
|
178
|
+
responseBody = bodyPreview(this.responseText, ct || undefined);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
/* opaque / cross-origin — skip */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
emit({
|
|
185
|
+
type: 'network',
|
|
186
|
+
initiator: 'xhr',
|
|
187
|
+
method: meta.method,
|
|
188
|
+
url: meta.url,
|
|
189
|
+
status: this.status,
|
|
190
|
+
ok: this.status >= 200 && this.status < 400,
|
|
191
|
+
timing: { startT: meta.startT, endT, duration: endT - meta.startT },
|
|
192
|
+
requestHeaders: Object.keys(meta.reqHeaders).length ? meta.reqHeaders : undefined,
|
|
193
|
+
responseHeaders: pickHeaders(toHeaders(parseRawHeaders(this.getAllResponseHeaders())), opts.redactHeaders),
|
|
194
|
+
requestBody: opts.captureBodies ? bodyPreview(meta.body, undefined) : undefined,
|
|
195
|
+
responseBody,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return origSend.call(this, body);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
g.fetch = origFetch;
|
|
204
|
+
OrigXHR.prototype.open = origOpen;
|
|
205
|
+
OrigXHR.prototype.send = origSend;
|
|
206
|
+
OrigXHR.prototype.setRequestHeader = origSetHeader;
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Parse the raw `getAllResponseHeaders()` string into a header object. */
|
|
211
|
+
function parseRawHeaders(raw: string | null): Record<string, string> {
|
|
212
|
+
const out: Record<string, string> = {};
|
|
213
|
+
if (!raw) return out;
|
|
214
|
+
for (const line of raw.trim().split(/[\r\n]+/)) {
|
|
215
|
+
const i = line.indexOf(':');
|
|
216
|
+
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import { uuid } from './util';
|
|
3
|
+
|
|
4
|
+
const SESSION_KEY = 'lumera.session';
|
|
5
|
+
const VISITOR_KEY = 'lumera.visitor';
|
|
6
|
+
|
|
7
|
+
interface StoredSession {
|
|
8
|
+
id: string;
|
|
9
|
+
startedAt: number; // epoch ms
|
|
10
|
+
lastActivity: number; // epoch ms
|
|
11
|
+
seq: number; // next batch seq for the structured-event stream
|
|
12
|
+
userId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface StoredVisitor {
|
|
16
|
+
id: string;
|
|
17
|
+
firstSeenAt: number;
|
|
18
|
+
visitCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ResolvedSession {
|
|
22
|
+
id: string;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
/** Next structured-event-stream seq (continued sessions resume their seq). */
|
|
25
|
+
seq: number;
|
|
26
|
+
resumed: boolean;
|
|
27
|
+
visitorId: string;
|
|
28
|
+
visitCount: number;
|
|
29
|
+
firstSeenAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeParse<T>(raw: string | null): T | null {
|
|
33
|
+
if (!raw) return null;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(raw) as T;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the session + visitor identity from AsyncStorage. Mirrors the web tracker's
|
|
43
|
+
* model: a persistent anonymous visitor (stable across launches → returning-visitor
|
|
44
|
+
* detection) and an optional same-app continuation window. A returning launch within
|
|
45
|
+
* `timeoutMs` resumes the prior session (and its seq) — unless a different known user
|
|
46
|
+
* is supplied (shared-device split), which forces a fresh session.
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveSession(opts: {
|
|
49
|
+
continueSession: boolean;
|
|
50
|
+
timeoutMs: number;
|
|
51
|
+
userId?: string;
|
|
52
|
+
now?: number;
|
|
53
|
+
}): Promise<ResolvedSession> {
|
|
54
|
+
const now = opts.now ?? Date.now();
|
|
55
|
+
|
|
56
|
+
// ── Visitor (persistent, anonymous) ──
|
|
57
|
+
let visitor = safeParse<StoredVisitor>(await AsyncStorage.getItem(VISITOR_KEY));
|
|
58
|
+
if (!visitor) {
|
|
59
|
+
visitor = { id: uuid(), firstSeenAt: now, visitCount: 0 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Session (optional continuation) ──
|
|
63
|
+
let session: StoredSession | null = null;
|
|
64
|
+
if (opts.continueSession) {
|
|
65
|
+
const prior = safeParse<StoredSession>(await AsyncStorage.getItem(SESSION_KEY));
|
|
66
|
+
const fresh = prior && now - prior.lastActivity < opts.timeoutMs;
|
|
67
|
+
const sameUser = !opts.userId || !prior?.userId || prior.userId === opts.userId;
|
|
68
|
+
if (prior && fresh && sameUser) session = prior;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resumed = session != null;
|
|
72
|
+
if (!session) {
|
|
73
|
+
session = { id: uuid(), startedAt: now, lastActivity: now, seq: 0, userId: opts.userId };
|
|
74
|
+
visitor.visitCount += 1; // a brand-new session = a new visit
|
|
75
|
+
}
|
|
76
|
+
session.lastActivity = now;
|
|
77
|
+
if (opts.userId) session.userId = opts.userId;
|
|
78
|
+
|
|
79
|
+
await AsyncStorage.multiSet([
|
|
80
|
+
[SESSION_KEY, JSON.stringify(session)],
|
|
81
|
+
[VISITOR_KEY, JSON.stringify(visitor)],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
id: session.id,
|
|
86
|
+
startedAt: session.startedAt,
|
|
87
|
+
seq: session.seq,
|
|
88
|
+
resumed,
|
|
89
|
+
visitorId: visitor.id,
|
|
90
|
+
visitCount: visitor.visitCount,
|
|
91
|
+
firstSeenAt: visitor.firstSeenAt,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Persist the latest seq + activity so a continued launch resumes correctly. */
|
|
96
|
+
export async function persistProgress(id: string, seq: number, userId?: string): Promise<void> {
|
|
97
|
+
const prior = safeParse<StoredSession>(await AsyncStorage.getItem(SESSION_KEY));
|
|
98
|
+
if (!prior || prior.id !== id) return; // a reset() already moved on — don't clobber
|
|
99
|
+
await AsyncStorage.setItem(
|
|
100
|
+
SESSION_KEY,
|
|
101
|
+
JSON.stringify({ ...prior, seq, lastActivity: Date.now(), userId: userId ?? prior.userId }),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** reset() / logout: drop the continuation record so the next session can't resume this user. */
|
|
106
|
+
export async function clearSession(): Promise<void> {
|
|
107
|
+
await AsyncStorage.removeItem(SESSION_KEY);
|
|
108
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { TurboModule } from 'react-native';
|
|
2
|
+
import { TurboModuleRegistry } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The native screen-capture module (iOS Swift / Android Kotlin), exposed to JS as a
|
|
6
|
+
* New-Architecture TurboModule.
|
|
7
|
+
*
|
|
8
|
+
* DESIGN INVARIANT: the JS surface is start / stop / configure / mask only. The
|
|
9
|
+
* capture loop — UI-thread raster, off-main masking + JPEG encode + gzip + UPLOAD —
|
|
10
|
+
* lives entirely in native code and NEVER crosses this boundary. No pixel or frame
|
|
11
|
+
* buffer is ever marshalled through the bridge/JSI (that is the #1 source of jank in
|
|
12
|
+
* naive implementations). The native side uploads `screen` frames straight to the
|
|
13
|
+
* ingest endpoint using the same siteKey + sessionId the JS layer hands it at start().
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface StartOptions {
|
|
17
|
+
/** Ingest base URL, e.g. https://in.lumera.app — native POSTs frames to `${endpoint}/v1/batch`. */
|
|
18
|
+
endpoint: string;
|
|
19
|
+
/** Ingest key (pk_…). */
|
|
20
|
+
siteKey: string;
|
|
21
|
+
/** Session id shared with the JS structured-event stream so both land on one session. */
|
|
22
|
+
sessionId: string;
|
|
23
|
+
/** Session start epoch ms — native derives each frame's relative `t` from this. */
|
|
24
|
+
startedAtMs: number;
|
|
25
|
+
/** Capture cadence cap (frames/sec). Change-driven underneath; this only bounds the rate. */
|
|
26
|
+
fps: number;
|
|
27
|
+
/** JPEG quality 0..1 on the hot encode path. */
|
|
28
|
+
jpegQuality: number;
|
|
29
|
+
/** Cap the longer edge of each captured frame to this many pixels (downscaled on
|
|
30
|
+
* device before encode). The dominant storage/bandwidth lever — full native res is
|
|
31
|
+
* ~4× larger for no replay benefit. 0 = no cap (full native resolution). */
|
|
32
|
+
maxCaptureDim: number;
|
|
33
|
+
/** Privacy-by-default: mask all text/labels/inputs unless explicitly unmasked. */
|
|
34
|
+
maskAllText: boolean;
|
|
35
|
+
/** Mask all images/media. */
|
|
36
|
+
maskAllImages: boolean;
|
|
37
|
+
/** Capture taps + swipes (native non-blocking gesture observer) as touch events. */
|
|
38
|
+
captureTouches: boolean;
|
|
39
|
+
/** How often native flushes its frame buffer to the ingest endpoint. */
|
|
40
|
+
flushIntervalMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ConfigureOptions {
|
|
44
|
+
fps?: number;
|
|
45
|
+
jpegQuality?: number;
|
|
46
|
+
maskAllText?: boolean;
|
|
47
|
+
maskAllImages?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NativeDiagnostics {
|
|
51
|
+
framesCaptured: number;
|
|
52
|
+
framesSent: number;
|
|
53
|
+
framesDropped: number;
|
|
54
|
+
bytesSent: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Spec extends TurboModule {
|
|
58
|
+
/** Begin the native capture loop. All params are primitives — nothing heavy crosses. */
|
|
59
|
+
start(options: StartOptions): void;
|
|
60
|
+
/** Stop capture and flush any buffered frames. */
|
|
61
|
+
stop(): void;
|
|
62
|
+
/** Live-tune cadence/quality/masking without restarting. */
|
|
63
|
+
configure(options: ConfigureOptions): void;
|
|
64
|
+
/** Explicitly mask/unmask a native view by its RN reactTag (powers <LumeraMask>). */
|
|
65
|
+
setViewMasked(reactTag: number, masked: boolean): void;
|
|
66
|
+
/** Native-side counters for diagnostics (cheap; async so it never blocks). */
|
|
67
|
+
getDiagnostics(): Promise<NativeDiagnostics>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default TurboModuleRegistry.getEnforcing<Spec>('LumeraReplay');
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IngestBatch } from '@obsrviq/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ships the structured-event stream (network / error / console / custom) to the same
|
|
5
|
+
* `POST /v1/batch` endpoint the web tracker uses, with the identical `IngestBatch`
|
|
6
|
+
* shape. We send uncompressed JSON here — the stream is small (no DOM/replay) and the
|
|
7
|
+
* ingest endpoint sniffs gzip magic bytes, so plain JSON is accepted. The heavy
|
|
8
|
+
* screenshot frames are gzipped + uploaded by the native module on its own thread.
|
|
9
|
+
*/
|
|
10
|
+
export class Transport {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly endpoint: string,
|
|
13
|
+
private readonly siteKey: string,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
/** The ingest URL — also used by the network instrument to skip self-recording. */
|
|
17
|
+
get batchUrl(): string {
|
|
18
|
+
return `${this.endpoint.replace(/\/$/, '')}/v1/batch`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async send(batch: IngestBatch): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(this.batchUrl, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
// MUST be octet-stream (not application/json): the ingest reads the raw
|
|
27
|
+
// request buffer via a catch-all content-type parser and sniffs gzip itself.
|
|
28
|
+
// `application/json` would hit Fastify's built-in JSON parser, leaving the
|
|
29
|
+
// route's Buffer.isBuffer() check false → 400 "empty_body". (Matches the web tracker.)
|
|
30
|
+
'content-type': 'application/octet-stream',
|
|
31
|
+
'x-lumera-key': this.siteKey,
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify(batch),
|
|
34
|
+
});
|
|
35
|
+
return res.ok;
|
|
36
|
+
} catch {
|
|
37
|
+
return false; // transient — the caller re-queues
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** RFC4122 v4 UUID — Hermes/JSC-safe (no dependency on crypto.randomUUID). */
|
|
2
|
+
export function uuid(): string {
|
|
3
|
+
let s = '';
|
|
4
|
+
for (let i = 0; i < 36; i++) {
|
|
5
|
+
if (i === 8 || i === 13 || i === 18 || i === 23) s += '-';
|
|
6
|
+
else if (i === 14) s += '4';
|
|
7
|
+
else if (i === 19) s += ((Math.random() * 4) | 8).toString(16);
|
|
8
|
+
else s += ((Math.random() * 16) | 0).toString(16);
|
|
9
|
+
}
|
|
10
|
+
return s;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Relative (`t`, ms since session start) + wall (`ts`, epoch ms) clock. RN/Hermes has
|
|
15
|
+
* no reliable high-res perf clock, so we anchor `t` to Date.now() at start and guard
|
|
16
|
+
* monotonicity (a backward wall-clock adjustment can never make `t` go backwards) —
|
|
17
|
+
* good enough for event ordering at our cadence.
|
|
18
|
+
*/
|
|
19
|
+
export class Clock {
|
|
20
|
+
private readonly startWall: number;
|
|
21
|
+
private lastT = 0;
|
|
22
|
+
constructor(startWall: number = Date.now()) {
|
|
23
|
+
this.startWall = startWall;
|
|
24
|
+
}
|
|
25
|
+
/** ms since session start (monotonic non-decreasing). */
|
|
26
|
+
now(): number {
|
|
27
|
+
const t = Math.max(this.lastT, Date.now() - this.startWall);
|
|
28
|
+
this.lastT = t;
|
|
29
|
+
return t;
|
|
30
|
+
}
|
|
31
|
+
/** epoch ms (for display + server bucketing). */
|
|
32
|
+
wall(): number {
|
|
33
|
+
return Date.now();
|
|
34
|
+
}
|
|
35
|
+
get startedAt(): number {
|
|
36
|
+
return this.startWall;
|
|
37
|
+
}
|
|
38
|
+
}
|