@lucida-analytics/react-native 0.1.0 → 0.1.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/dist/index.d.ts +58 -4
- package/dist/index.js +178 -39
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ interface AsyncStorageLike {
|
|
|
37
37
|
multiGet(keys: readonly string[]): Promise<ReadonlyArray<readonly [string, string | null]>>;
|
|
38
38
|
setItem(key: string, value: string): Promise<void>;
|
|
39
39
|
removeItem(key: string): Promise<void>;
|
|
40
|
+
/** The real module has it; used to find spilled replay chunks (`lc_replay.<id>`) to hydrate. Without it they are not reloaded after a restart. */
|
|
41
|
+
getAllKeys?(): Promise<readonly string[]>;
|
|
40
42
|
}
|
|
41
43
|
/** Keys the SDK persists; hydrated up front because the core's Storage port is synchronous. */
|
|
42
44
|
declare const PERSISTED_KEYS: readonly ["lc_identity", "lc_session", "lc_props", "lc_groups", "lc_consent", "lc_flags", "lc_offline", "lc_app_version", "lc_guide_seen", "lc_person_identified"];
|
|
@@ -72,8 +74,8 @@ interface QueueCipher {
|
|
|
72
74
|
/** null when the value cannot be decrypted (key rotated, corrupt): the queue then starts empty. */
|
|
73
75
|
decrypt(cipher: string): string | null;
|
|
74
76
|
}
|
|
75
|
-
/** Decorator: values under `keys` are stored encrypted, everything else passes through. */
|
|
76
|
-
declare function encryptedStorage(inner: Storage, cipher: QueueCipher, keys: readonly string[]): Storage;
|
|
77
|
+
/** Decorator: values under `keys` (or a key starting with one of `prefixes`) are stored encrypted, everything else passes through. */
|
|
78
|
+
declare function encryptedStorage(inner: Storage, cipher: QueueCipher, keys: readonly string[], prefixes?: readonly string[]): Storage;
|
|
77
79
|
|
|
78
80
|
interface RageTapConfig {
|
|
79
81
|
enabled?: boolean;
|
|
@@ -315,6 +317,17 @@ interface ReplayConfig {
|
|
|
315
317
|
throttleMs?: number;
|
|
316
318
|
/** Hold chunks until the session has been recorded this long; a session that ends sooner (or `stop()`) is never sent. Default 0 (off). */
|
|
317
319
|
minDurationMs?: number;
|
|
320
|
+
/**
|
|
321
|
+
* Disk budget for chunks that wait (min duration, offline, upload backoff); over it the oldest whole session is dropped
|
|
322
|
+
* and `onSpoolDrop` fires. Default 10 MiB. Needs `asyncStorage` to survive a restart.
|
|
323
|
+
*/
|
|
324
|
+
spoolBudgetBytes?: number;
|
|
325
|
+
/** A whole session was dropped because the spool exceeded its budget (also logged in debug mode). */
|
|
326
|
+
onSpoolDrop?: (info: {
|
|
327
|
+
sessionId: string;
|
|
328
|
+
chunks: number;
|
|
329
|
+
bytes: number;
|
|
330
|
+
}) => void;
|
|
318
331
|
/** Fetch `GET /v1/config` and honour the project kill switch, sample rate and minimum duration (tightening only). Default true. */
|
|
319
332
|
useRemoteConfig?: boolean;
|
|
320
333
|
/** Start recording only while this flag is on. */
|
|
@@ -352,6 +365,8 @@ interface ReplayService {
|
|
|
352
365
|
setScreen(name: string): void;
|
|
353
366
|
/** A tap at screen point (x, y); ignored without consent, before the first snapshot, while paused or on a gated screen. */
|
|
354
367
|
tap(x: number, y: number): void;
|
|
368
|
+
/** Erase every spilled chunk (opt-out, reset). */
|
|
369
|
+
clearSpool(): void;
|
|
355
370
|
/** One capture pass (the interval calls this); exposed for tests. */
|
|
356
371
|
tick(): Promise<void>;
|
|
357
372
|
/** P-M46: a swipe passed the touch slop; its start structure is captured now. */
|
|
@@ -363,11 +378,14 @@ interface ReplayService {
|
|
|
363
378
|
}
|
|
364
379
|
interface ReplayDeps {
|
|
365
380
|
core: Core;
|
|
381
|
+
/** Where waiting chunks are spilled (the client's storage, encrypted with the queue cipher when one is set). */
|
|
382
|
+
storage: Storage;
|
|
366
383
|
fetch: typeof fetch;
|
|
367
384
|
apiHost: string;
|
|
368
385
|
apiKey: string;
|
|
369
386
|
config: ReplayConfig;
|
|
370
387
|
now?: () => number;
|
|
388
|
+
log?: (...a: unknown[]) => void;
|
|
371
389
|
/** Reads `config.linkedFlag`. */
|
|
372
390
|
isFlagOn?: (key: string) => boolean;
|
|
373
391
|
}
|
|
@@ -577,7 +595,7 @@ interface NativeConfig {
|
|
|
577
595
|
}
|
|
578
596
|
|
|
579
597
|
declare const LIB = "lucida-react-native";
|
|
580
|
-
declare const LIB_VERSION = "0.
|
|
598
|
+
declare const LIB_VERSION = "0.1.1";
|
|
581
599
|
/**
|
|
582
600
|
* Hydrates storage, then builds the client. Nothing is persisted before consent in opt_in mode;
|
|
583
601
|
* `$app_opened` fires on creation and on every foreground, `$app_backgrounded` flushes the queue.
|
|
@@ -640,6 +658,42 @@ declare function withBaggage(existing: string | null | undefined, sessionId: str
|
|
|
640
658
|
/** A fetch that adds the tracing headers when the target host matches; everything else passes through untouched. */
|
|
641
659
|
declare function tracedFetch(fetchImpl: typeof fetch, headersFor: (url: string, baggage?: string | null) => Record<string, string>): typeof fetch;
|
|
642
660
|
|
|
661
|
+
interface SpoolEntry {
|
|
662
|
+
id: number;
|
|
663
|
+
sid: string;
|
|
664
|
+
seq: number;
|
|
665
|
+
/** Waiting for the minimum session duration (dropped if the session ends first); false = waiting for upload. */
|
|
666
|
+
held: boolean;
|
|
667
|
+
/** Body size in bytes. */
|
|
668
|
+
n: number;
|
|
669
|
+
/** Number of parts the body is split into (`lc_replay.<id>.<i>`). */
|
|
670
|
+
p: number;
|
|
671
|
+
/** Body length in UTF-16 units: a torn or truncated body no longer adds up and is discarded. */
|
|
672
|
+
len: number;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Durable, ordered spill for replay chunks that cannot be sent yet (min duration, offline, upload backoff).
|
|
676
|
+
* `lc_replay` is the index (JSON list of entries), `lc_replay.<id>.<i>` holds part i of one chunk body (no api key), split
|
|
677
|
+
* into parts under 1 MB. Write order: `lc_replay.pending` names the entry in flight, then its parts, then the index, then
|
|
678
|
+
* pending is cleared; a crash in between leaves parts the index never lists, which load deletes. Pass a storage
|
|
679
|
+
* wrapped with `encryptedStorage(..., [SPOOL_KEY], [`${SPOOL_KEY}.`])` to encrypt at rest. Over `budget` bytes the oldest
|
|
680
|
+
* whole session is dropped (never single chunks, so a kept session has no hole) and `onDrop` reports it.
|
|
681
|
+
*/
|
|
682
|
+
declare function createReplaySpool(storage: Storage, budget?: number, onDrop?: (sid: string, chunks: number, bytes: number) => void): {
|
|
683
|
+
entries: () => readonly SpoolEntry[];
|
|
684
|
+
totalBytes: () => number;
|
|
685
|
+
add(sid: string, seq: number, held: boolean, body: string): void;
|
|
686
|
+
/** The whole body, or null when a part is missing or the length does not add up (torn write): the caller discards it. */
|
|
687
|
+
read(e: SpoolEntry): string | null;
|
|
688
|
+
remove(e: SpoolEntry): void;
|
|
689
|
+
/** Held chunks of every session except `keep`; returned so the caller can give their seq numbers back. */
|
|
690
|
+
removeHeld(keep?: string): SpoolEntry[];
|
|
691
|
+
/** The session reached its minimum duration: its held chunks become plain pending uploads. */
|
|
692
|
+
release(sid: string): void;
|
|
693
|
+
nextSeq: (sid: string) => number;
|
|
694
|
+
clear(): void;
|
|
695
|
+
};
|
|
696
|
+
|
|
643
697
|
/**
|
|
644
698
|
* P-M17 masked screens and P-M46 text patterns. The native encoders apply both (they receive `maskScreen` and
|
|
645
699
|
* `maskTextPatterns` with every capture, screenshots included); [maskSnapshotText] re-applies the patterns to the
|
|
@@ -781,4 +835,4 @@ interface FeatureGateProps {
|
|
|
781
835
|
}
|
|
782
836
|
declare function FeatureGate({ flag, variant, fallback, children }: FeatureGateProps): react.JSX.Element;
|
|
783
837
|
|
|
784
|
-
export { type AppStateLike, type AppStateStatus, type AsyncStorageLike, type Autocapture, type AutocaptureConfig, type Bootstrap, type CaptureOptions, DEAD_SWIPE_MIN_DISTANCE, type DeadSwipeInput, type ExposureKey, FeatureGate, type FeatureGateProps, type FiberLike, type FlagOptions, type Flags, GuideAnchor, type GuideAnchorSpec, type GuideButton, type GuideStep, type GuideTokens, type GuideView, type GuidesDeps, type GuidesService, type InstallInfo, LIB, LIB_VERSION, LucidaGuides, LucidaMask, LucidaProvider, type LucidaProviderProps, LucidaTouchCapture, LucidaUnmask, type Measurable, type MobileGuide, type NativeCipherModule, type NativeClient, type NativeConfig, type NativeInstallModule, type NativeReplayModule, type NativeSensitiveModule, type NavigationRefLike, PERSISTED_KEYS, PersonMode, type QueueCipher, REPLAY_MARKERS, type RageTapConfig, type ReplayConfig, type ReplayControl, type ReplayService, SENSITIVE_MAX_PLAIN_BYTES, type SensitiveCapture, type SwipeDirection, type TapElement, type TouchEventLike, appLifecycle, appStoreInstallProps, asyncStorageAdapter, bubbleOrigin, chainFromFiber, compilePatterns, createAutocapture, createClient, createGuides, createReplay, createSensitiveCapture, deadSwipeDecision, effectiveMinDurationMs, effectiveSampleRate, encryptSensitive, encryptedStorage, exposureKeyOf, fetchTransport, guideAnchor, guideButtons, guideTokens, installReferrerProps, maskPatterns, maskSnapshotText, matches as matchGuideScreen, measureAnchor, memoryStorage, nativeQueueCipher, registerAnchor, replayMarker, resolveInstallProps, runHooks, sampleHook, screenAllowed, screenExcluded, screenMasked, screenTracker, splitBatches, swipeDirection, touchHandler, tracedFetch, tracingHeaders, truncateUtf8, useActiveFeatureFlags, useCapture, useFeatureFlag, useFeatureFlagPayload, useFeatureFlagVariant, useLucida, useReplay, useScreen, withBaggage, withTimeout };
|
|
838
|
+
export { type AppStateLike, type AppStateStatus, type AsyncStorageLike, type Autocapture, type AutocaptureConfig, type Bootstrap, type CaptureOptions, DEAD_SWIPE_MIN_DISTANCE, type DeadSwipeInput, type ExposureKey, FeatureGate, type FeatureGateProps, type FiberLike, type FlagOptions, type Flags, GuideAnchor, type GuideAnchorSpec, type GuideButton, type GuideStep, type GuideTokens, type GuideView, type GuidesDeps, type GuidesService, type InstallInfo, LIB, LIB_VERSION, LucidaGuides, LucidaMask, LucidaProvider, type LucidaProviderProps, LucidaTouchCapture, LucidaUnmask, type Measurable, type MobileGuide, type NativeCipherModule, type NativeClient, type NativeConfig, type NativeInstallModule, type NativeReplayModule, type NativeSensitiveModule, type NavigationRefLike, PERSISTED_KEYS, PersonMode, type QueueCipher, REPLAY_MARKERS, type RageTapConfig, type ReplayConfig, type ReplayControl, type ReplayService, SENSITIVE_MAX_PLAIN_BYTES, type SensitiveCapture, type SpoolEntry, type SwipeDirection, type TapElement, type TouchEventLike, appLifecycle, appStoreInstallProps, asyncStorageAdapter, bubbleOrigin, chainFromFiber, compilePatterns, createAutocapture, createClient, createGuides, createReplay, createReplaySpool, createSensitiveCapture, deadSwipeDecision, effectiveMinDurationMs, effectiveSampleRate, encryptSensitive, encryptedStorage, exposureKeyOf, fetchTransport, guideAnchor, guideButtons, guideTokens, installReferrerProps, maskPatterns, maskSnapshotText, matches as matchGuideScreen, measureAnchor, memoryStorage, nativeQueueCipher, registerAnchor, replayMarker, resolveInstallProps, runHooks, sampleHook, screenAllowed, screenExcluded, screenMasked, screenTracker, splitBatches, swipeDirection, touchHandler, tracedFetch, tracingHeaders, truncateUtf8, useActiveFeatureFlags, useCapture, useFeatureFlag, useFeatureFlagPayload, useFeatureFlagVariant, useLucida, useReplay, useScreen, withBaggage, withTimeout };
|
package/dist/index.js
CHANGED
|
@@ -16,13 +16,13 @@ var PERSISTED_KEYS = [
|
|
|
16
16
|
];
|
|
17
17
|
function asyncStorageAdapter(as) {
|
|
18
18
|
const mem = /* @__PURE__ */ new Map();
|
|
19
|
-
const
|
|
20
|
-
(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
()
|
|
24
|
-
|
|
25
|
-
);
|
|
19
|
+
const load = async (keys) => {
|
|
20
|
+
for (const [k, v] of await as.multiGet(keys)) if (v !== null) mem.set(k, v);
|
|
21
|
+
};
|
|
22
|
+
const ready = load(PERSISTED_KEYS).then(async () => {
|
|
23
|
+
const all = await as.getAllKeys?.() ?? [];
|
|
24
|
+
await load(all.filter((k) => k === "lc_replay" || k.startsWith("lc_replay.")));
|
|
25
|
+
}).catch(() => void 0);
|
|
26
26
|
const swallow = (p) => void p.catch(() => void 0);
|
|
27
27
|
return {
|
|
28
28
|
ready,
|
|
@@ -104,14 +104,15 @@ function withTimeout(inner, ms) {
|
|
|
104
104
|
})
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
function encryptedStorage(inner, cipher, keys) {
|
|
107
|
+
function encryptedStorage(inner, cipher, keys, prefixes = []) {
|
|
108
108
|
const enc = new Set(keys);
|
|
109
|
+
const covers = (k) => enc.has(k) || prefixes.some((p) => k.startsWith(p));
|
|
109
110
|
return {
|
|
110
111
|
get: (k) => {
|
|
111
112
|
const v = inner.get(k);
|
|
112
|
-
return v !== null &&
|
|
113
|
+
return v !== null && covers(k) ? cipher.decrypt(v) : v;
|
|
113
114
|
},
|
|
114
|
-
set: (k, v) => inner.set(k,
|
|
115
|
+
set: (k, v) => inner.set(k, covers(k) ? cipher.encrypt(v) : v),
|
|
115
116
|
remove: (k) => inner.remove(k)
|
|
116
117
|
};
|
|
117
118
|
}
|
|
@@ -235,6 +236,7 @@ function appStoreInstallProps(json) {
|
|
|
235
236
|
}
|
|
236
237
|
return out;
|
|
237
238
|
}
|
|
239
|
+
var retryableStatus = (s) => s === 404 || s === 408 || s === 429 || s >= 500;
|
|
238
240
|
async function resolveInstallProps(native, d) {
|
|
239
241
|
let fallback = {};
|
|
240
242
|
try {
|
|
@@ -252,7 +254,8 @@ async function resolveInstallProps(native, d) {
|
|
|
252
254
|
try {
|
|
253
255
|
const r = await d.fetch(ASA_URL, { method: "POST", headers: { "Content-Type": "text/plain" }, body: info.token, signal: ctl?.signal ?? null });
|
|
254
256
|
if (r.ok) return appStoreInstallProps(await r.json());
|
|
255
|
-
if (r.status
|
|
257
|
+
if (!retryableStatus(r.status)) break;
|
|
258
|
+
} catch {
|
|
256
259
|
} finally {
|
|
257
260
|
if (timer) clearTimeout(timer);
|
|
258
261
|
}
|
|
@@ -823,6 +826,114 @@ function maskSnapshotText(node, patterns) {
|
|
|
823
826
|
return out;
|
|
824
827
|
}
|
|
825
828
|
|
|
829
|
+
// src/replay-spool.ts
|
|
830
|
+
var SPOOL_KEY = "lc_replay";
|
|
831
|
+
var DEFAULT_SPOOL_BUDGET_BYTES = 10 * 1024 * 1024;
|
|
832
|
+
var SPOOL_PART_CHARS = 3e5;
|
|
833
|
+
var PENDING_KEY = "lc_replay.pending";
|
|
834
|
+
var byteLength = (s) => {
|
|
835
|
+
let n = 0;
|
|
836
|
+
for (let i = 0; i < s.length; i++) {
|
|
837
|
+
const c = s.charCodeAt(i);
|
|
838
|
+
n += c < 128 ? 1 : c < 2048 ? 2 : c >= 55296 && c < 56320 ? (i++, 4) : 3;
|
|
839
|
+
}
|
|
840
|
+
return n;
|
|
841
|
+
};
|
|
842
|
+
function createReplaySpool(storage, budget = DEFAULT_SPOOL_BUDGET_BYTES, onDrop) {
|
|
843
|
+
let items = [];
|
|
844
|
+
let nextId = 0;
|
|
845
|
+
const partKey = (id, i) => `${SPOOL_KEY}.${id}.${i}`;
|
|
846
|
+
const removeParts = (id, count) => {
|
|
847
|
+
for (let i = 0; i < count; i++) storage.remove(partKey(id, i));
|
|
848
|
+
};
|
|
849
|
+
const complete = (e) => {
|
|
850
|
+
for (let i = 0; i < e.p; i++) if (storage.get(partKey(e.id, i)) === null) return false;
|
|
851
|
+
return true;
|
|
852
|
+
};
|
|
853
|
+
let listed = [];
|
|
854
|
+
try {
|
|
855
|
+
const raw = storage.get(SPOOL_KEY);
|
|
856
|
+
const parsed = raw === null ? [] : JSON.parse(raw);
|
|
857
|
+
if (Array.isArray(parsed)) listed = parsed;
|
|
858
|
+
} catch {
|
|
859
|
+
listed = [];
|
|
860
|
+
}
|
|
861
|
+
items = listed.filter((e) => typeof e.p === "number" && complete(e));
|
|
862
|
+
for (const e of listed) if (!items.includes(e)) removeParts(e.id, typeof e.p === "number" ? e.p : 0);
|
|
863
|
+
const pending = storage.get(PENDING_KEY);
|
|
864
|
+
if (pending !== null) {
|
|
865
|
+
const [pid, pcount] = pending.split(":").map(Number);
|
|
866
|
+
if (pid !== void 0 && pcount !== void 0 && !items.some((e) => e.id === pid)) removeParts(pid, pcount);
|
|
867
|
+
storage.remove(PENDING_KEY);
|
|
868
|
+
}
|
|
869
|
+
nextId = Math.max(listed.reduce((m, e) => Math.max(m, e.id + 1), 0), items.reduce((m, e) => Math.max(m, e.id + 1), 0));
|
|
870
|
+
if (items.length !== listed.length) storage.set(SPOOL_KEY, JSON.stringify(items));
|
|
871
|
+
const save = () => items.length === 0 ? storage.remove(SPOOL_KEY) : storage.set(SPOOL_KEY, JSON.stringify(items));
|
|
872
|
+
const drop = (gone) => {
|
|
873
|
+
for (const e of gone) removeParts(e.id, e.p);
|
|
874
|
+
items = items.filter((e) => !gone.includes(e));
|
|
875
|
+
};
|
|
876
|
+
return {
|
|
877
|
+
entries: () => items,
|
|
878
|
+
totalBytes: () => items.reduce((a, e) => a + e.n, 0),
|
|
879
|
+
add(sid, seq, held, body) {
|
|
880
|
+
const parts = [];
|
|
881
|
+
for (let at = 0; at < body.length || parts.length === 0; ) {
|
|
882
|
+
let end = Math.min(body.length, at + SPOOL_PART_CHARS);
|
|
883
|
+
const last = body.charCodeAt(end - 1);
|
|
884
|
+
if (end < body.length && last >= 55296 && last < 56320) end -= 1;
|
|
885
|
+
parts.push(body.slice(at, end));
|
|
886
|
+
at = end;
|
|
887
|
+
}
|
|
888
|
+
const e = { id: nextId++, sid, seq, held, n: byteLength(body), p: parts.length, len: body.length };
|
|
889
|
+
storage.set(PENDING_KEY, `${e.id}:${e.p}`);
|
|
890
|
+
parts.forEach((part, i) => storage.set(partKey(e.id, i), part));
|
|
891
|
+
items.push(e);
|
|
892
|
+
const dropped = [];
|
|
893
|
+
while (items.length > 0 && items.reduce((a, x) => a + x.n, 0) > budget) {
|
|
894
|
+
const first = items[0];
|
|
895
|
+
const gone = items.filter((x) => x.sid === first.sid);
|
|
896
|
+
drop(gone);
|
|
897
|
+
dropped.push([first.sid, gone.length, gone.reduce((a, x) => a + x.n, 0)]);
|
|
898
|
+
}
|
|
899
|
+
save();
|
|
900
|
+
storage.remove(PENDING_KEY);
|
|
901
|
+
for (const [s, c, b] of dropped) onDrop?.(s, c, b);
|
|
902
|
+
},
|
|
903
|
+
/** The whole body, or null when a part is missing or the length does not add up (torn write): the caller discards it. */
|
|
904
|
+
read(e) {
|
|
905
|
+
let out = "";
|
|
906
|
+
for (let i = 0; i < e.p; i++) {
|
|
907
|
+
const part = storage.get(partKey(e.id, i));
|
|
908
|
+
if (part === null) return null;
|
|
909
|
+
out += part;
|
|
910
|
+
}
|
|
911
|
+
return out.length === e.len ? out : null;
|
|
912
|
+
},
|
|
913
|
+
remove(e) {
|
|
914
|
+
drop([e]);
|
|
915
|
+
save();
|
|
916
|
+
},
|
|
917
|
+
/** Held chunks of every session except `keep`; returned so the caller can give their seq numbers back. */
|
|
918
|
+
removeHeld(keep) {
|
|
919
|
+
const gone = items.filter((e) => e.held && e.sid !== keep);
|
|
920
|
+
drop(gone);
|
|
921
|
+
save();
|
|
922
|
+
return gone;
|
|
923
|
+
},
|
|
924
|
+
/** The session reached its minimum duration: its held chunks become plain pending uploads. */
|
|
925
|
+
release(sid) {
|
|
926
|
+
items = items.map((e) => e.held && e.sid === sid ? { ...e, held: false } : e);
|
|
927
|
+
save();
|
|
928
|
+
},
|
|
929
|
+
nextSeq: (sid) => items.filter((e) => e.sid === sid).reduce((m, e) => Math.max(m, e.seq), -1) + 1,
|
|
930
|
+
clear() {
|
|
931
|
+
drop([...items]);
|
|
932
|
+
save();
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
826
937
|
// src/sensitive.ts
|
|
827
938
|
import { sha256Hex } from "@lucida-analytics/sdk-core";
|
|
828
939
|
var SENSITIVE_MAX_PLAIN_BYTES = 190;
|
|
@@ -913,8 +1024,10 @@ function createReplay(d) {
|
|
|
913
1024
|
let remote = null;
|
|
914
1025
|
let remoteLoaded = false;
|
|
915
1026
|
let sessionStart = 0;
|
|
916
|
-
|
|
917
|
-
|
|
1027
|
+
const spool = createReplaySpool(d.storage, config.spoolBudgetBytes, (sessionId, chunks, bytes) => {
|
|
1028
|
+
d.log?.(`replay spool over budget: dropped session ${sessionId} (${chunks} chunks, ${bytes} bytes)`);
|
|
1029
|
+
config.onSpoolDrop?.({ sessionId, chunks, bytes });
|
|
1030
|
+
});
|
|
918
1031
|
let touches = [];
|
|
919
1032
|
let tapTarget = 0;
|
|
920
1033
|
let tapTree;
|
|
@@ -991,15 +1104,38 @@ function createReplay(d) {
|
|
|
991
1104
|
core.capture("$sensitive_capture", p);
|
|
992
1105
|
return true;
|
|
993
1106
|
}
|
|
994
|
-
|
|
995
|
-
const body = JSON.stringify({
|
|
1107
|
+
function store(events, sessionId, windowId, held) {
|
|
1108
|
+
const body = JSON.stringify({ session_id: sessionId, window_id: windowId, seq, events });
|
|
996
1109
|
if (body.length > (config.maxEventBytes ?? 5e6)) return;
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1110
|
+
spool.add(sessionId, seq, held, body);
|
|
1111
|
+
seq += 1;
|
|
1112
|
+
}
|
|
1113
|
+
async function drain() {
|
|
1114
|
+
for (const e of [...spool.entries()]) {
|
|
1115
|
+
if (e.held) continue;
|
|
1116
|
+
const stored = spool.read(e);
|
|
1117
|
+
if (stored === null) {
|
|
1118
|
+
spool.remove(e);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
let status = 0;
|
|
1122
|
+
try {
|
|
1123
|
+
const r = await d.fetch(`${d.apiHost}/v1/replay`, {
|
|
1124
|
+
method: "POST",
|
|
1125
|
+
headers: { "Content-Type": "application/json" },
|
|
1126
|
+
body: JSON.stringify({ api_key: d.apiKey, ...JSON.parse(stored) })
|
|
1127
|
+
});
|
|
1128
|
+
status = r.ok ? 200 : r.status;
|
|
1129
|
+
} catch {
|
|
1130
|
+
}
|
|
1131
|
+
if (status === 200) spool.remove(e);
|
|
1132
|
+
else if (status === 0 || status === 408 || status === 429 || status >= 500) {
|
|
1133
|
+
lastFp = null;
|
|
1134
|
+
return;
|
|
1135
|
+
} else {
|
|
1136
|
+
spool.remove(e);
|
|
1137
|
+
d.log?.(`replay chunk rejected with ${status}; dropped`);
|
|
1138
|
+
}
|
|
1003
1139
|
}
|
|
1004
1140
|
}
|
|
1005
1141
|
async function tick() {
|
|
@@ -1007,7 +1143,7 @@ function createReplay(d) {
|
|
|
1007
1143
|
lastFp = null;
|
|
1008
1144
|
hasSnapshot = false;
|
|
1009
1145
|
touches = [];
|
|
1010
|
-
|
|
1146
|
+
spool.clear();
|
|
1011
1147
|
return;
|
|
1012
1148
|
}
|
|
1013
1149
|
await loadRemote();
|
|
@@ -1046,10 +1182,10 @@ function createReplay(d) {
|
|
|
1046
1182
|
const { sessionId, windowId } = core.session.touch();
|
|
1047
1183
|
const rotated = sessionId !== lastSession;
|
|
1048
1184
|
if (rotated) {
|
|
1049
|
-
seq = 0;
|
|
1050
1185
|
lastSession = sessionId;
|
|
1051
1186
|
sessionStart = now();
|
|
1052
|
-
|
|
1187
|
+
spool.removeHeld(sessionId);
|
|
1188
|
+
seq = spool.nextSeq(sessionId);
|
|
1053
1189
|
}
|
|
1054
1190
|
const fp = fingerprint(events);
|
|
1055
1191
|
const ts = now();
|
|
@@ -1066,16 +1202,12 @@ function createReplay(d) {
|
|
|
1066
1202
|
tapTree = events.find((e) => e.type === 2)?.data.node;
|
|
1067
1203
|
hasSnapshot = true;
|
|
1068
1204
|
}
|
|
1069
|
-
if (out.length === 0) return;
|
|
1070
1205
|
const minMs = effectiveMinDurationMs(config.minDurationMs ?? 0, remote);
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
const backlog = held;
|
|
1077
|
-
held = [];
|
|
1078
|
-
for (const chunk of [...backlog, out]) await send(chunk, sessionId, windowId);
|
|
1206
|
+
const holding = minMs > 0 && now() - sessionStart < minMs;
|
|
1207
|
+
if (out.length > 0) store(out, sessionId, windowId, holding);
|
|
1208
|
+
if (holding) return;
|
|
1209
|
+
spool.release(sessionId);
|
|
1210
|
+
await drain();
|
|
1079
1211
|
}
|
|
1080
1212
|
return {
|
|
1081
1213
|
start() {
|
|
@@ -1086,7 +1218,8 @@ function createReplay(d) {
|
|
|
1086
1218
|
if (timer === void 0) return;
|
|
1087
1219
|
(config.clearInterval ?? ((h) => clearInterval(h)))(timer);
|
|
1088
1220
|
timer = void 0;
|
|
1089
|
-
|
|
1221
|
+
const back = spool.removeHeld().filter((e) => e.sid === lastSession).map((e) => e.seq);
|
|
1222
|
+
if (back.length > 0) seq = Math.min(...back);
|
|
1090
1223
|
},
|
|
1091
1224
|
pause() {
|
|
1092
1225
|
paused = true;
|
|
@@ -1106,6 +1239,7 @@ function createReplay(d) {
|
|
|
1106
1239
|
data: { source: 2, type: 2, id: hitTest(tapTree, x, y, tapTarget), x, y }
|
|
1107
1240
|
});
|
|
1108
1241
|
},
|
|
1242
|
+
clearSpool: () => spool.clear(),
|
|
1109
1243
|
tick,
|
|
1110
1244
|
swipeBegan() {
|
|
1111
1245
|
if (config.captureDeadSwipes === false || !touchesRecording()) return;
|
|
@@ -1119,8 +1253,8 @@ function createReplay(d) {
|
|
|
1119
1253
|
|
|
1120
1254
|
// src/client.ts
|
|
1121
1255
|
var LIB = "lucida-react-native";
|
|
1122
|
-
var LIB_VERSION = "0.
|
|
1123
|
-
var DEFAULT_HOST = "https://
|
|
1256
|
+
var LIB_VERSION = "0.1.1";
|
|
1257
|
+
var DEFAULT_HOST = "https://capture.lucidace.com";
|
|
1124
1258
|
var VERSION_KEY = "lc_app_version";
|
|
1125
1259
|
var noop = { send: async () => ({ status: 200 }) };
|
|
1126
1260
|
async function createClient(apiKey, config = {}) {
|
|
@@ -1137,7 +1271,7 @@ async function createClient(apiKey, config = {}) {
|
|
|
1137
1271
|
await a.ready;
|
|
1138
1272
|
storage = a.storage;
|
|
1139
1273
|
}
|
|
1140
|
-
if (config.queueCipher) storage = encryptedStorage(storage, config.queueCipher, ["lc_offline"]);
|
|
1274
|
+
if (config.queueCipher) storage = encryptedStorage(storage, config.queueCipher, ["lc_offline", "lc_replay"], ["lc_replay."]);
|
|
1141
1275
|
let transport = config.transport ?? fetchTransport(fetchImpl, config.requestTimeoutMs);
|
|
1142
1276
|
if (config.maxBatchSize) transport = splitBatches(transport, config.maxBatchSize);
|
|
1143
1277
|
if (config.transport && config.requestTimeoutMs) transport = withTimeout(transport, config.requestTimeoutMs);
|
|
@@ -1237,7 +1371,7 @@ async function createClient(apiKey, config = {}) {
|
|
|
1237
1371
|
if (c === "identify" || c === "reset" || c === "group") load();
|
|
1238
1372
|
});
|
|
1239
1373
|
}
|
|
1240
|
-
const replay = config.replay?.enabled && !config.disabled ? createReplay({ core, fetch: fetchImpl, apiHost, apiKey, config: config.replay, isFlagOn: (k) => {
|
|
1374
|
+
const replay = config.replay?.enabled && !config.disabled ? createReplay({ core, storage, log, fetch: fetchImpl, apiHost, apiKey, config: config.replay, isFlagOn: (k) => {
|
|
1241
1375
|
const v = flags.all()[k];
|
|
1242
1376
|
return v !== void 0 && v !== false;
|
|
1243
1377
|
} }) : void 0;
|
|
@@ -1280,6 +1414,7 @@ async function createClient(apiKey, config = {}) {
|
|
|
1280
1414
|
},
|
|
1281
1415
|
reset() {
|
|
1282
1416
|
person.reset();
|
|
1417
|
+
replay?.clearSpool();
|
|
1283
1418
|
core.reset({ reuseAnonymousId: !!config.reuseAnonymousId });
|
|
1284
1419
|
},
|
|
1285
1420
|
register: (p) => core.register(p),
|
|
@@ -1287,7 +1422,10 @@ async function createClient(apiKey, config = {}) {
|
|
|
1287
1422
|
unregister: (k) => core.unregister(k),
|
|
1288
1423
|
group: (t, k, p) => core.group(t, k, p),
|
|
1289
1424
|
optIn: () => core.optIn(),
|
|
1290
|
-
optOut: () =>
|
|
1425
|
+
optOut: () => {
|
|
1426
|
+
replay?.clearSpool();
|
|
1427
|
+
core.optOut();
|
|
1428
|
+
},
|
|
1291
1429
|
getFlag: (k) => flags.get(k),
|
|
1292
1430
|
getFlagPayload: (k) => flags.payload(k),
|
|
1293
1431
|
getFlagResult: (k, o) => flags.result(k, o),
|
|
@@ -1719,6 +1857,7 @@ export {
|
|
|
1719
1857
|
createClient,
|
|
1720
1858
|
createGuides,
|
|
1721
1859
|
createReplay,
|
|
1860
|
+
createReplaySpool,
|
|
1722
1861
|
createSensitiveCapture,
|
|
1723
1862
|
deadSwipeDecision,
|
|
1724
1863
|
effectiveMinDurationMs,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lucida-analytics/react-native",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"react": ">=18"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@lucida-analytics/sdk-core": "0.1.
|
|
31
|
+
"@lucida-analytics/sdk-core": "0.1.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^22.0.0",
|