@repliqo/sdk-react-native 0.3.7 → 0.4.0
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/INTEGRATION_GUIDE.md +1384 -1312
- package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +242 -230
- package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -170
- package/dist/components/RepliqoFlatList.d.ts +5 -0
- package/dist/components/RepliqoFlatList.js +39 -0
- package/dist/components/RepliqoScrollView.d.ts +3 -0
- package/dist/components/RepliqoScrollView.js +16 -265
- package/dist/components/useRepliqoScrollTracking.d.ts +33 -0
- package/dist/components/useRepliqoScrollTracking.js +304 -0
- package/dist/core/client.d.ts +52 -5
- package/dist/core/client.js +310 -48
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.js +5 -1
- package/dist/core/storage.d.ts +29 -0
- package/dist/core/storage.js +33 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/snapshot/NativeScreenCapture.d.ts +6 -44
- package/dist/snapshot/NativeScreenCapture.js +6 -64
- package/dist/snapshot/capture.d.ts +7 -0
- package/dist/snapshot/capture.js +17 -5
- package/dist/trackers/error.tracker.d.ts +25 -0
- package/dist/trackers/error.tracker.js +114 -37
- package/dist/trackers/navigation.tracker.js +11 -0
- package/dist/transport/api.client.d.ts +34 -1
- package/dist/transport/api.client.js +102 -19
- package/dist/transport/batch-queue.d.ts +53 -1
- package/dist/transport/batch-queue.js +126 -19
- package/dist/types/events.d.ts +12 -0
- package/package.json +68 -64
- package/src/components/RepliqoFlatList.tsx +64 -0
- package/src/components/RepliqoScrollView.tsx +53 -302
- package/src/components/useRepliqoScrollTracking.ts +366 -0
- package/src/core/client.ts +953 -672
- package/src/core/config.ts +38 -30
- package/src/core/storage.ts +51 -0
- package/src/index.ts +54 -52
- package/src/snapshot/NativeScreenCapture.ts +62 -157
- package/src/snapshot/capture.ts +154 -142
- package/src/trackers/error.tracker.ts +211 -136
- package/src/trackers/navigation.tracker.ts +107 -98
- package/src/transport/api.client.ts +430 -327
- package/src/transport/batch-queue.ts +212 -95
- package/src/types/events.ts +72 -60
- package/android/src/main/java/com/repliqo/screencapture/FullContentCapture.java +0 -273
- package/android/src/main/java/com/repliqo/screencapture/PixelCopyScan.java +0 -279
- package/android/src/main/java/com/repliqo/screencapture/ScrollScanCapture.java +0 -248
- package/src/snapshot/ScreenCaptureManager.ts +0 -156
package/dist/core/client.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.AppAnalytics = void 0;
|
|
|
4
4
|
const react_native_1 = require("react-native");
|
|
5
5
|
const capture_1 = require("../snapshot/capture");
|
|
6
6
|
const config_1 = require("./config");
|
|
7
|
+
const storage_1 = require("./storage");
|
|
7
8
|
const logger_1 = require("./logger");
|
|
8
9
|
const api_client_1 = require("../transport/api.client");
|
|
9
10
|
const batch_queue_1 = require("../transport/batch-queue");
|
|
@@ -18,6 +19,18 @@ class AppAnalytics {
|
|
|
18
19
|
this.currentScreenEnteredAt = null;
|
|
19
20
|
this.scrollOffsetY = 0;
|
|
20
21
|
this.appStateSubscription = null;
|
|
22
|
+
/**
|
|
23
|
+
* User identity set via identify(). Kept for the process lifetime and
|
|
24
|
+
* automatically re-attached to every new session, so the host app only
|
|
25
|
+
* needs to call identify() once (e.g. after login).
|
|
26
|
+
*/
|
|
27
|
+
this.identity = null;
|
|
28
|
+
/**
|
|
29
|
+
* Offline persistence backend (AsyncStorage-compatible). Null when the
|
|
30
|
+
* host app neither passed one nor has AsyncStorage installed — the SDK
|
|
31
|
+
* then behaves exactly as before (in-memory buffering only).
|
|
32
|
+
*/
|
|
33
|
+
this.storage = null;
|
|
21
34
|
/**
|
|
22
35
|
* Registry of active ScrollViews on the current screen.
|
|
23
36
|
* Each RepliqoScrollView registers its window-relative bounds and current
|
|
@@ -30,15 +43,63 @@ class AppAnalytics {
|
|
|
30
43
|
* viewport position, while still mapping scrollable content correctly.
|
|
31
44
|
*/
|
|
32
45
|
this.scrollViewRegistry = new Map();
|
|
46
|
+
/**
|
|
47
|
+
* Serialization chain for pending-crash read-modify-write operations.
|
|
48
|
+
* Two near-simultaneous crashes (e.g. an error cascade) would otherwise
|
|
49
|
+
* interleave getItem/setItem and lose one of the entries.
|
|
50
|
+
*/
|
|
51
|
+
this.crashStoreLock = Promise.resolve();
|
|
52
|
+
/** Monotonic suffix so identical crashes get distinct keys. */
|
|
53
|
+
this.crashSeq = 0;
|
|
33
54
|
this.config = (0, config_1.resolveConfig)(config);
|
|
34
55
|
this.logger = new logger_1.Logger(this.config.debug);
|
|
35
56
|
this.apiClient = new api_client_1.ApiClient(config_1.REPLIQO_API_URL, this.config.apiKey, this.logger);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
// Events are stamped with their session at ENQUEUE time (__sessionId)
|
|
58
|
+
// and sent per session at flush time. Reading this.sessionId at flush
|
|
59
|
+
// time instead would attribute events buffered at the end of session A
|
|
60
|
+
// to session B if a new session starts before the flush.
|
|
61
|
+
//
|
|
62
|
+
// Delivery is per CONTIGUOUS RUN of same-session events (not a merged
|
|
63
|
+
// map) so the queue's prefix accounting stays exact even if sessions
|
|
64
|
+
// interleave in the buffer (e.g. restored old-session events landing
|
|
65
|
+
// between new-session events). On a transient failure we report the
|
|
66
|
+
// delivered prefix via PartialFlushError so the queue retries ONLY the
|
|
67
|
+
// remainder — never re-sending delivered events (duplicates) and never
|
|
68
|
+
// dropping unattempted ones alongside a poison run.
|
|
69
|
+
this.eventQueue = new batch_queue_1.BatchQueue(this.config.batchSize, this.config.flushInterval, async (queued) => {
|
|
70
|
+
// Split into contiguous same-session runs, preserving order.
|
|
71
|
+
const runs = [];
|
|
72
|
+
for (const { __sessionId, ...event } of queued) {
|
|
73
|
+
const last = runs[runs.length - 1];
|
|
74
|
+
if (last && last.sessionId === __sessionId) {
|
|
75
|
+
last.events.push(event);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
runs.push({ sessionId: __sessionId, events: [event] });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let consumed = 0;
|
|
82
|
+
for (const run of runs) {
|
|
83
|
+
try {
|
|
84
|
+
await this.apiClient.sendEventsBatch(run.sessionId, run.events);
|
|
85
|
+
consumed += run.events.length;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof batch_queue_1.NonRetryableFlushError) {
|
|
89
|
+
// Poison run: permanently rejected. Count it as consumed
|
|
90
|
+
// (dropped) and keep delivering the remaining runs.
|
|
91
|
+
this.logger.warn(`Dropping ${run.events.length} events permanently rejected for session ${run.sessionId}`);
|
|
92
|
+
consumed += run.events.length;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// Transient failure: stop here. Anything already delivered or
|
|
96
|
+
// dropped must not be retried.
|
|
97
|
+
if (consumed > 0) {
|
|
98
|
+
throw new batch_queue_1.PartialFlushError(consumed, true);
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
40
102
|
}
|
|
41
|
-
await this.apiClient.sendEventsBatch(this.sessionId, events);
|
|
42
103
|
});
|
|
43
104
|
this.screenVisitQueue = new batch_queue_1.BatchQueue(this.config.batchSize, this.config.flushInterval, async (visits) => {
|
|
44
105
|
await this.apiClient.sendScreenVisitsBatch(visits);
|
|
@@ -61,14 +122,28 @@ class AppAnalytics {
|
|
|
61
122
|
}, undefined, // default captureScreenshot provider
|
|
62
123
|
(...args) => this.logger.log(...args), (...args) => this.logger.warn(...args));
|
|
63
124
|
}
|
|
125
|
+
// Offline persistence: explicit storage from config, or auto-loaded
|
|
126
|
+
// AsyncStorage if the host app has it installed.
|
|
127
|
+
this.storage = this.config.storage ?? (0, storage_1.tryLoadAsyncStorage)();
|
|
64
128
|
if (this.config.enableCrashTracking) {
|
|
65
129
|
this.errorTracker = new error_tracker_1.ErrorTracker((crash) => {
|
|
66
|
-
//
|
|
67
|
-
|
|
130
|
+
// Persist FIRST — on a fatal crash the app dies before the network
|
|
131
|
+
// request completes; the persisted copy is re-sent on next launch.
|
|
132
|
+
// Then send immediately and clear the persisted copy on success.
|
|
133
|
+
(async () => {
|
|
134
|
+
const key = await this.persistCrash(crash);
|
|
135
|
+
const sent = await this.apiClient.sendCrashReport(crash);
|
|
136
|
+
if (sent && key) {
|
|
137
|
+
await this.removePendingCrash(key);
|
|
138
|
+
}
|
|
139
|
+
})().catch(() => { });
|
|
68
140
|
});
|
|
69
141
|
this.errorTracker.start();
|
|
70
142
|
}
|
|
71
143
|
this.setupAppStateListener();
|
|
144
|
+
// Re-send anything persisted by a previous run (events buffered at
|
|
145
|
+
// backgrounding, crashes whose send never completed).
|
|
146
|
+
this.restorePersistedData().catch(() => { });
|
|
72
147
|
this.logger.log('SDK initialized with config:', {
|
|
73
148
|
apiUrl: config_1.REPLIQO_API_URL,
|
|
74
149
|
appId: this.config.appId,
|
|
@@ -112,17 +187,37 @@ class AppAnalytics {
|
|
|
112
187
|
instance.appStateSubscription.remove();
|
|
113
188
|
instance.appStateSubscription = null;
|
|
114
189
|
}
|
|
115
|
-
//
|
|
116
|
-
|
|
190
|
+
// Close the session on the server (also flushes remaining data).
|
|
191
|
+
// Fire-and-forget: destroy() is sync and must not block.
|
|
192
|
+
if (instance.sessionId) {
|
|
193
|
+
instance.endSession().catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
instance.flush().catch(() => { });
|
|
197
|
+
}
|
|
117
198
|
AppAnalytics.instance = null;
|
|
118
199
|
}
|
|
119
200
|
}
|
|
120
201
|
async startSession(deviceId, deviceInfo) {
|
|
202
|
+
// Calling startSession with a session already active would leak the
|
|
203
|
+
// previous session (left open forever on the backend) and strand its
|
|
204
|
+
// buffered events. Close it first.
|
|
205
|
+
if (this.sessionId) {
|
|
206
|
+
this.logger.warn('startSession called with an active session; ending it first');
|
|
207
|
+
await this.endSession();
|
|
208
|
+
}
|
|
121
209
|
try {
|
|
122
210
|
const response = await this.apiClient.startSession(deviceId, deviceInfo);
|
|
123
211
|
this.sessionId = response.id;
|
|
124
212
|
this.logger.log('Session started with ID:', this.sessionId);
|
|
125
213
|
this.errorTracker?.setSessionId(this.sessionId);
|
|
214
|
+
// Re-attach a previously-set identity to the new session so the
|
|
215
|
+
// host app only needs to call identify() once per login.
|
|
216
|
+
if (this.identity) {
|
|
217
|
+
this.apiClient
|
|
218
|
+
.identifySession(this.sessionId, this.identity.userId, this.identity.traits)
|
|
219
|
+
.catch(() => { });
|
|
220
|
+
}
|
|
126
221
|
if (this.snapshotCapture) {
|
|
127
222
|
this.snapshotCapture.setSessionId(this.sessionId);
|
|
128
223
|
this.snapshotCapture.reset();
|
|
@@ -223,12 +318,20 @@ class AppAnalytics {
|
|
|
223
318
|
activeScrollOffset = 0;
|
|
224
319
|
isFixed = true;
|
|
225
320
|
}
|
|
321
|
+
else if (this.scrollOffsetY > 0) {
|
|
322
|
+
// No ScrollViews registered, but the host app is reporting scroll
|
|
323
|
+
// manually via setScrollOffset() (legacy integration without
|
|
324
|
+
// RepliqoScrollView). Honor it — otherwise those touches would be
|
|
325
|
+
// misclassified as fixed and land at the wrong heatmap position.
|
|
326
|
+
// Stale values are not a risk: onScreenEnter resets it to 0.
|
|
327
|
+
activeScrollOffset = this.scrollOffsetY;
|
|
328
|
+
isFixed = false;
|
|
329
|
+
}
|
|
226
330
|
else {
|
|
227
|
-
// No ScrollViews registered
|
|
331
|
+
// No ScrollViews registered and no manual offset. Two cases:
|
|
228
332
|
// (a) The screen has no scrollable content → touch is fixed
|
|
229
333
|
// (b) RepliqoScrollView hasn't measured yet (first ~1 frame)
|
|
230
|
-
// Either way, marking as fixed is safer than guessing
|
|
231
|
-
// position with stale scrollOffsetY from a previous screen.
|
|
334
|
+
// Either way, marking as fixed is safer than guessing.
|
|
232
335
|
activeScrollOffset = 0;
|
|
233
336
|
isFixed = true;
|
|
234
337
|
}
|
|
@@ -245,7 +348,8 @@ class AppAnalytics {
|
|
|
245
348
|
},
|
|
246
349
|
timestamp: new Date().toISOString(),
|
|
247
350
|
};
|
|
248
|
-
|
|
351
|
+
// Stamp the ACTIVE session at enqueue time (see QueuedEvent).
|
|
352
|
+
this.eventQueue.add({ ...event, __sessionId: this.sessionId });
|
|
249
353
|
this.logger.log('Touch tracked:', event);
|
|
250
354
|
}
|
|
251
355
|
trackNavigation(fromScreen, toScreen) {
|
|
@@ -262,7 +366,8 @@ class AppAnalytics {
|
|
|
262
366
|
data: { fromScreen, toScreen },
|
|
263
367
|
timestamp: new Date().toISOString(),
|
|
264
368
|
};
|
|
265
|
-
|
|
369
|
+
// Stamp the ACTIVE session at enqueue time (see QueuedEvent).
|
|
370
|
+
this.eventQueue.add({ ...event, __sessionId: this.sessionId });
|
|
266
371
|
this.logger.log('Navigation tracked:', fromScreen, '->', toScreen);
|
|
267
372
|
}
|
|
268
373
|
trackCustomEvent(eventName, data) {
|
|
@@ -276,9 +381,40 @@ class AppAnalytics {
|
|
|
276
381
|
data: { eventName, ...data },
|
|
277
382
|
timestamp: new Date().toISOString(),
|
|
278
383
|
};
|
|
279
|
-
|
|
384
|
+
// Stamp the ACTIVE session at enqueue time (see QueuedEvent).
|
|
385
|
+
this.eventQueue.add({ ...event, __sessionId: this.sessionId });
|
|
280
386
|
this.logger.log('Custom event tracked:', eventName);
|
|
281
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Identify the current user (call once after login). The identity is
|
|
390
|
+
* attached to the active session immediately and re-attached to every
|
|
391
|
+
* future session automatically until resetIdentity() is called.
|
|
392
|
+
*
|
|
393
|
+
* @param userId The host app's own user identifier.
|
|
394
|
+
* @param traits Optional free-form traits (plan, role, locale...).
|
|
395
|
+
* Avoid PII you don't want stored (emails in plain text).
|
|
396
|
+
*/
|
|
397
|
+
identify(userId, traits) {
|
|
398
|
+
if (!userId || typeof userId !== 'string') {
|
|
399
|
+
this.logger.warn('identify() requires a non-empty string userId');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.identity = { userId, traits };
|
|
403
|
+
this.logger.log('Identity set:', userId);
|
|
404
|
+
if (this.sessionId) {
|
|
405
|
+
this.apiClient
|
|
406
|
+
.identifySession(this.sessionId, userId, traits)
|
|
407
|
+
.catch(() => { });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Clear the stored identity (call on logout). Sessions started after
|
|
412
|
+
* this are anonymous again; already-identified sessions keep their user.
|
|
413
|
+
*/
|
|
414
|
+
resetIdentity() {
|
|
415
|
+
this.identity = null;
|
|
416
|
+
this.logger.log('Identity cleared');
|
|
417
|
+
}
|
|
282
418
|
reportError(error, metadata) {
|
|
283
419
|
if (!this.errorTracker) {
|
|
284
420
|
this.logger.warn('Cannot report error: crash tracking is not enabled');
|
|
@@ -364,31 +500,15 @@ class AppAnalytics {
|
|
|
364
500
|
}
|
|
365
501
|
})();
|
|
366
502
|
}
|
|
367
|
-
/**
|
|
368
|
-
* Programmatically scroll-scan the current ScrollView and upload all
|
|
369
|
-
* tiles to the backend. Invisible to the user (~300-500ms).
|
|
370
|
-
*/
|
|
371
|
-
autoScanScreen(screenName) {
|
|
372
|
-
(async () => {
|
|
373
|
-
try {
|
|
374
|
-
const tiles = await (0, NativeScreenCapture_1.scanFullContent)();
|
|
375
|
-
if (!tiles || tiles.length === 0) {
|
|
376
|
-
this.logger.log(`Auto-scan: no ScrollView found for "${screenName}"`);
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
this.logger.log(`Auto-scan: "${screenName}" captured ${tiles.length} tiles ` +
|
|
380
|
-
`(content=${tiles[0].contentHeight}px)`);
|
|
381
|
-
// Upload all tiles in parallel
|
|
382
|
-
const sessionId = this.sessionId;
|
|
383
|
-
await Promise.all(tiles.map((tile) => this.apiClient.uploadScreenTile(sessionId, screenName, tile.image, tile.width, tile.height, tile.scrollOffsetY, tile.viewportHeight, tile.contentHeight).catch(() => { })));
|
|
384
|
-
this.logger.log(`Auto-scan: "${screenName}" tiles uploaded`);
|
|
385
|
-
}
|
|
386
|
-
catch (err) {
|
|
387
|
-
this.logger.warn('Auto-scan failed:', err);
|
|
388
|
-
}
|
|
389
|
-
})();
|
|
390
|
-
}
|
|
391
503
|
onScreenEnter(screenName) {
|
|
504
|
+
// Re-entering the SAME screen (e.g. onReady + onStateChange both firing
|
|
505
|
+
// for the initial route) must not wipe the ScrollView registry that the
|
|
506
|
+
// screen's RepliqoScrollView already populated — touches in the gap
|
|
507
|
+
// until re-measure would be misclassified as fixed UI.
|
|
508
|
+
if (this.currentScreen === screenName) {
|
|
509
|
+
this.logger.log('Screen re-entered (no-op):', screenName);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
392
512
|
this.currentScreen = screenName;
|
|
393
513
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
394
514
|
this.scrollOffsetY = 0;
|
|
@@ -396,14 +516,12 @@ class AppAnalytics {
|
|
|
396
516
|
this.scrollViewRegistry.clear();
|
|
397
517
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
398
518
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
399
|
-
// NOTE:
|
|
400
|
-
// React Native does NOT render off-screen content to
|
|
401
|
-
// tree, so programmatic
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
// Full-content capture relies entirely on RepliqoScrollView capturing
|
|
406
|
-
// viewport tiles as the user naturally scrolls through the content.
|
|
519
|
+
// NOTE: full-content capture is NOT done via programmatic scroll +
|
|
520
|
+
// native capture. React Native does NOT render off-screen content to
|
|
521
|
+
// the GPU or view tree, so any programmatic-scroll approach produces
|
|
522
|
+
// blank tiles. Full-content heatmap backgrounds are built entirely
|
|
523
|
+
// from viewport tiles captured by RepliqoScrollView as the user
|
|
524
|
+
// naturally scrolls through the content.
|
|
407
525
|
this.logger.log('Screen entered:', screenName);
|
|
408
526
|
}
|
|
409
527
|
onScreenExit(screenName) {
|
|
@@ -473,6 +591,129 @@ class AppAnalytics {
|
|
|
473
591
|
isInitialized() {
|
|
474
592
|
return true;
|
|
475
593
|
}
|
|
594
|
+
// ─── Offline persistence ────────────────────────────────────────────
|
|
595
|
+
/**
|
|
596
|
+
* Persist the un-flushed event buffer (called when backgrounding).
|
|
597
|
+
* Events carry their __sessionId stamp, so on restore they are still
|
|
598
|
+
* attributed to the session they belonged to.
|
|
599
|
+
*/
|
|
600
|
+
async persistPendingEvents() {
|
|
601
|
+
if (!this.storage)
|
|
602
|
+
return;
|
|
603
|
+
try {
|
|
604
|
+
const pending = this.eventQueue.peekAll().slice(-storage_1.MAX_PERSISTED_EVENTS);
|
|
605
|
+
if (pending.length === 0) {
|
|
606
|
+
await this.storage.removeItem(storage_1.STORAGE_KEYS.pendingEvents);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
await this.storage.setItem(storage_1.STORAGE_KEYS.pendingEvents, JSON.stringify(pending));
|
|
610
|
+
this.logger.log('Persisted', pending.length, 'pending events');
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
this.logger.warn('Failed to persist pending events:', err);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
withCrashStoreLock(op) {
|
|
617
|
+
const run = this.crashStoreLock.then(op, op);
|
|
618
|
+
// Keep the chain alive regardless of op outcome.
|
|
619
|
+
this.crashStoreLock = run.catch(() => { });
|
|
620
|
+
return run;
|
|
621
|
+
}
|
|
622
|
+
/** Append a crash to the persisted pending list; returns its key. */
|
|
623
|
+
async persistCrash(crash) {
|
|
624
|
+
if (!this.storage)
|
|
625
|
+
return null;
|
|
626
|
+
const storage = this.storage;
|
|
627
|
+
try {
|
|
628
|
+
return await this.withCrashStoreLock(async () => {
|
|
629
|
+
const raw = await storage.getItem(storage_1.STORAGE_KEYS.pendingCrashes);
|
|
630
|
+
const list = raw
|
|
631
|
+
? JSON.parse(raw)
|
|
632
|
+
: [];
|
|
633
|
+
// Unique per crash: identical timestamp+message pairs must not
|
|
634
|
+
// share a key or removePendingCrash would delete both.
|
|
635
|
+
const key = `${crash.timestamp}|${++this.crashSeq}|${crash.message}`.slice(0, 300);
|
|
636
|
+
list.push({ key, crash });
|
|
637
|
+
await storage.setItem(storage_1.STORAGE_KEYS.pendingCrashes, JSON.stringify(list.slice(-storage_1.MAX_PERSISTED_CRASHES)));
|
|
638
|
+
return key;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
this.logger.warn('Failed to persist crash:', err);
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** Remove a crash from the persisted pending list (after send success). */
|
|
647
|
+
async removePendingCrash(key) {
|
|
648
|
+
if (!this.storage)
|
|
649
|
+
return;
|
|
650
|
+
const storage = this.storage;
|
|
651
|
+
try {
|
|
652
|
+
await this.withCrashStoreLock(async () => {
|
|
653
|
+
const raw = await storage.getItem(storage_1.STORAGE_KEYS.pendingCrashes);
|
|
654
|
+
if (!raw)
|
|
655
|
+
return;
|
|
656
|
+
const list = JSON.parse(raw);
|
|
657
|
+
const remaining = list.filter((entry) => entry.key !== key);
|
|
658
|
+
if (remaining.length === 0) {
|
|
659
|
+
await storage.removeItem(storage_1.STORAGE_KEYS.pendingCrashes);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
await storage.setItem(storage_1.STORAGE_KEYS.pendingCrashes, JSON.stringify(remaining));
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
// Best effort — a stale entry only means a duplicate crash report.
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Restore data persisted by a previous run: re-enqueue events (still
|
|
672
|
+
* stamped with their original session) and re-send pending crashes.
|
|
673
|
+
* Called once from the constructor.
|
|
674
|
+
*/
|
|
675
|
+
async restorePersistedData() {
|
|
676
|
+
if (!this.storage)
|
|
677
|
+
return;
|
|
678
|
+
// Events: re-enqueue and clear. They flush with the normal cycle.
|
|
679
|
+
try {
|
|
680
|
+
const raw = await this.storage.getItem(storage_1.STORAGE_KEYS.pendingEvents);
|
|
681
|
+
if (raw) {
|
|
682
|
+
await this.storage.removeItem(storage_1.STORAGE_KEYS.pendingEvents);
|
|
683
|
+
const events = JSON.parse(raw);
|
|
684
|
+
if (Array.isArray(events) && events.length > 0) {
|
|
685
|
+
for (const event of events) {
|
|
686
|
+
if (event && typeof event.__sessionId === 'string') {
|
|
687
|
+
this.eventQueue.add(event);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
this.logger.log('Restored', events.length, 'persisted events');
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
this.logger.warn('Failed to restore persisted events:', err);
|
|
696
|
+
}
|
|
697
|
+
// Crashes: send as a batch; clear only on success.
|
|
698
|
+
try {
|
|
699
|
+
const raw = await this.storage.getItem(storage_1.STORAGE_KEYS.pendingCrashes);
|
|
700
|
+
if (raw) {
|
|
701
|
+
const list = JSON.parse(raw);
|
|
702
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
703
|
+
await this.apiClient.sendCrashesBatch(list.map((e) => e.crash));
|
|
704
|
+
await this.storage.removeItem(storage_1.STORAGE_KEYS.pendingCrashes);
|
|
705
|
+
this.logger.log('Re-sent', list.length, 'persisted crash reports');
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
await this.storage.removeItem(storage_1.STORAGE_KEYS.pendingCrashes);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch (err) {
|
|
713
|
+
// sendCrashesBatch throws on failure → keep the list for next launch.
|
|
714
|
+
this.logger.warn('Failed to re-send persisted crashes:', err);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
476
717
|
setupAppStateListener() {
|
|
477
718
|
let wasInBackground = false;
|
|
478
719
|
this.appStateSubscription = react_native_1.AppState.addEventListener('change', (nextAppState) => {
|
|
@@ -480,13 +721,34 @@ class AppAnalytics {
|
|
|
480
721
|
this.logger.log('App going to background, pausing snapshots + flushing');
|
|
481
722
|
this.stopSnapshotCapture();
|
|
482
723
|
wasInBackground = true;
|
|
483
|
-
|
|
724
|
+
// Persist FIRST: iOS suspends JS within seconds of backgrounding,
|
|
725
|
+
// and a flush stalled on bad network (up to the 15s fetch timeout)
|
|
726
|
+
// would mean the persist never runs before the OS kills the app —
|
|
727
|
+
// exactly the scenario persistence exists for. A possible
|
|
728
|
+
// duplicate (flush succeeded after persisting) is the lesser
|
|
729
|
+
// evil, and the snapshot is refreshed after the flush anyway.
|
|
730
|
+
this.persistPendingEvents()
|
|
731
|
+
.catch(() => { })
|
|
732
|
+
.then(() => this.flush())
|
|
733
|
+
.catch((error) => {
|
|
484
734
|
this.logger.error('Error flushing on app state change:', error);
|
|
735
|
+
})
|
|
736
|
+
.finally(() => {
|
|
737
|
+
// Refresh the snapshot with whatever is still undelivered
|
|
738
|
+
// (empty ⇒ the key is removed, preventing stale re-sends).
|
|
739
|
+
this.persistPendingEvents().catch(() => { });
|
|
485
740
|
});
|
|
486
741
|
}
|
|
487
742
|
else if (nextAppState === 'active' && wasInBackground) {
|
|
488
743
|
wasInBackground = false;
|
|
489
744
|
this.logger.log('App returned to foreground, resuming snapshots');
|
|
745
|
+
// The app survived the background stint — the in-memory buffer is
|
|
746
|
+
// authoritative again. Drop the persisted snapshot so a later
|
|
747
|
+
// crash doesn't re-send events that will be delivered normally
|
|
748
|
+
// from memory (it gets re-written on the next backgrounding).
|
|
749
|
+
if (this.storage) {
|
|
750
|
+
this.storage.removeItem(storage_1.STORAGE_KEYS.pendingEvents).catch(() => { });
|
|
751
|
+
}
|
|
490
752
|
if (this.snapshotCapture && this.sessionId) {
|
|
491
753
|
this.startSnapshotCapture();
|
|
492
754
|
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SDKConfig } from '../types/events';
|
|
2
2
|
export declare const REPLIQO_API_URL = "https://api-repliqo.odmservice.site";
|
|
3
|
-
export declare const DEFAULT_CONFIG: Required<Omit<SDKConfig, 'apiKey' | 'appId'>>;
|
|
3
|
+
export declare const DEFAULT_CONFIG: Required<Omit<SDKConfig, 'apiKey' | 'appId' | 'storage'>>;
|
|
4
4
|
export type ResolvedConfig = SDKConfig & typeof DEFAULT_CONFIG;
|
|
5
5
|
export declare function resolveConfig(config: SDKConfig): ResolvedConfig;
|
package/dist/core/config.js
CHANGED
|
@@ -24,5 +24,9 @@ exports.DEFAULT_CONFIG = {
|
|
|
24
24
|
debug: false,
|
|
25
25
|
};
|
|
26
26
|
function resolveConfig(config) {
|
|
27
|
-
|
|
27
|
+
// Strip keys whose value is explicitly `undefined` — otherwise
|
|
28
|
+
// `{ enableSnapshots: maybeUndefinedVar }` would silently override the
|
|
29
|
+
// default `true` with `undefined` and disable the feature.
|
|
30
|
+
const cleaned = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined));
|
|
31
|
+
return { ...exports.DEFAULT_CONFIG, ...cleaned };
|
|
28
32
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal async key-value storage contract, compatible with
|
|
3
|
+
* @react-native-async-storage/async-storage (and trivially mockable).
|
|
4
|
+
*
|
|
5
|
+
* Used to persist analytics data across app kills:
|
|
6
|
+
* - buffered events that couldn't be flushed before backgrounding
|
|
7
|
+
* - crash reports (a fatal crash kills the app before the report's
|
|
8
|
+
* network request can complete — persistence is the only way those
|
|
9
|
+
* reports survive to the next launch)
|
|
10
|
+
*/
|
|
11
|
+
export interface PersistentStorage {
|
|
12
|
+
getItem(key: string): Promise<string | null>;
|
|
13
|
+
setItem(key: string, value: string): Promise<void>;
|
|
14
|
+
removeItem(key: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Try to auto-load @react-native-async-storage/async-storage. Returns null
|
|
18
|
+
* when the host app doesn't have it installed — persistence is then
|
|
19
|
+
* disabled gracefully (in-memory only, pre-existing behavior).
|
|
20
|
+
*/
|
|
21
|
+
export declare function tryLoadAsyncStorage(): PersistentStorage | null;
|
|
22
|
+
/** Storage keys namespaced to avoid colliding with the host app's data. */
|
|
23
|
+
export declare const STORAGE_KEYS: {
|
|
24
|
+
readonly pendingEvents: "@repliqo/pending_events";
|
|
25
|
+
readonly pendingCrashes: "@repliqo/pending_crashes";
|
|
26
|
+
};
|
|
27
|
+
/** Caps so a long-offline device can't grow storage unboundedly. */
|
|
28
|
+
export declare const MAX_PERSISTED_EVENTS = 500;
|
|
29
|
+
export declare const MAX_PERSISTED_CRASHES = 20;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_PERSISTED_CRASHES = exports.MAX_PERSISTED_EVENTS = exports.STORAGE_KEYS = void 0;
|
|
4
|
+
exports.tryLoadAsyncStorage = tryLoadAsyncStorage;
|
|
5
|
+
/**
|
|
6
|
+
* Try to auto-load @react-native-async-storage/async-storage. Returns null
|
|
7
|
+
* when the host app doesn't have it installed — persistence is then
|
|
8
|
+
* disabled gracefully (in-memory only, pre-existing behavior).
|
|
9
|
+
*/
|
|
10
|
+
function tryLoadAsyncStorage() {
|
|
11
|
+
try {
|
|
12
|
+
const mod = require('@react-native-async-storage/async-storage');
|
|
13
|
+
const storage = mod?.default ?? mod;
|
|
14
|
+
if (storage &&
|
|
15
|
+
typeof storage.getItem === 'function' &&
|
|
16
|
+
typeof storage.setItem === 'function' &&
|
|
17
|
+
typeof storage.removeItem === 'function') {
|
|
18
|
+
return storage;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Not installed — fine.
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/** Storage keys namespaced to avoid colliding with the host app's data. */
|
|
27
|
+
exports.STORAGE_KEYS = {
|
|
28
|
+
pendingEvents: '@repliqo/pending_events',
|
|
29
|
+
pendingCrashes: '@repliqo/pending_crashes',
|
|
30
|
+
};
|
|
31
|
+
/** Caps so a long-offline device can't grow storage unboundedly. */
|
|
32
|
+
exports.MAX_PERSISTED_EVENTS = 500;
|
|
33
|
+
exports.MAX_PERSISTED_CRASHES = 20;
|
package/dist/index.d.ts
CHANGED
|
@@ -5,13 +5,14 @@ export { trackScreenEnter, trackScreenExit, } from './trackers/screen.tracker';
|
|
|
5
5
|
export { ErrorTracker } from './trackers/error.tracker';
|
|
6
6
|
export { SnapshotCapture } from './snapshot/capture';
|
|
7
7
|
export { captureScreenshot } from './snapshot/captureScreenshot';
|
|
8
|
-
export { isNativeScreenCaptureAvailable, captureNativeFrame,
|
|
9
|
-
export type { FullContentResult } from './snapshot/NativeScreenCapture';
|
|
8
|
+
export { isNativeScreenCaptureAvailable, captureNativeFrame, } from './snapshot/NativeScreenCapture';
|
|
10
9
|
export type { EventType, AnalyticsEvent, TouchEventData, NavigationEventData, ScreenVisit, DeviceInfo, SDKConfig, } from './types/events';
|
|
11
10
|
export type { ScreenshotData, SnapshotPayload, } from './types/snapshot';
|
|
12
11
|
export type { CrashReport } from './types/crash';
|
|
13
12
|
export type { SnapshotCaptureConfig } from './snapshot/capture';
|
|
14
13
|
export type { ScreenshotResult } from './snapshot/captureScreenshot';
|
|
15
14
|
export { RepliqoScrollView } from './components/RepliqoScrollView';
|
|
15
|
+
export { RepliqoFlatList } from './components/RepliqoFlatList';
|
|
16
|
+
export type { PersistentStorage } from './core/storage';
|
|
16
17
|
export { DEFAULT_CONFIG } from './core/config';
|
|
17
18
|
export type { ResolvedConfig } from './core/config';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_CONFIG = exports.
|
|
3
|
+
exports.DEFAULT_CONFIG = exports.RepliqoFlatList = exports.RepliqoScrollView = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
|
|
4
4
|
// Main class
|
|
5
5
|
var client_1 = require("./core/client");
|
|
6
6
|
Object.defineProperty(exports, "AppAnalytics", { enumerable: true, get: function () { return client_1.AppAnalytics; } });
|
|
@@ -23,10 +23,11 @@ Object.defineProperty(exports, "captureScreenshot", { enumerable: true, get: fun
|
|
|
23
23
|
var NativeScreenCapture_1 = require("./snapshot/NativeScreenCapture");
|
|
24
24
|
Object.defineProperty(exports, "isNativeScreenCaptureAvailable", { enumerable: true, get: function () { return NativeScreenCapture_1.isNativeScreenCaptureAvailable; } });
|
|
25
25
|
Object.defineProperty(exports, "captureNativeFrame", { enumerable: true, get: function () { return NativeScreenCapture_1.captureNativeFrame; } });
|
|
26
|
-
Object.defineProperty(exports, "captureFullContent", { enumerable: true, get: function () { return NativeScreenCapture_1.captureFullContent; } });
|
|
27
26
|
// Components
|
|
28
27
|
var RepliqoScrollView_1 = require("./components/RepliqoScrollView");
|
|
29
28
|
Object.defineProperty(exports, "RepliqoScrollView", { enumerable: true, get: function () { return RepliqoScrollView_1.RepliqoScrollView; } });
|
|
29
|
+
var RepliqoFlatList_1 = require("./components/RepliqoFlatList");
|
|
30
|
+
Object.defineProperty(exports, "RepliqoFlatList", { enumerable: true, get: function () { return RepliqoFlatList_1.RepliqoFlatList; } });
|
|
30
31
|
// Config
|
|
31
32
|
var config_1 = require("./core/config");
|
|
32
33
|
Object.defineProperty(exports, "DEFAULT_CONFIG", { enumerable: true, get: function () { return config_1.DEFAULT_CONFIG; } });
|
|
@@ -12,50 +12,12 @@ export declare function isNativeScreenCaptureAvailable(): boolean;
|
|
|
12
12
|
*
|
|
13
13
|
* No permissions required. No dialog. No foreground service.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
21
|
-
*
|
|
22
|
-
* Falls back to a viewport capture if no ScrollView is found.
|
|
23
|
-
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
15
|
+
* This is the ONLY native capture path. Full-content capture of
|
|
16
|
+
* off-screen ScrollView content is NOT possible natively (React Native
|
|
17
|
+
* doesn't render off-screen content to the GPU/view tree) — full-content
|
|
18
|
+
* heatmap backgrounds are instead built collaboratively from viewport
|
|
19
|
+
* tiles captured as the user naturally scrolls (see RepliqoScrollView).
|
|
24
20
|
*
|
|
25
21
|
* @returns NativeFrameResult or null if capture failed / not available
|
|
26
22
|
*/
|
|
27
|
-
export
|
|
28
|
-
/** True if the capture includes full scrollable content. False if viewport-only fallback. */
|
|
29
|
-
isFullContent: boolean;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Capture the FULL content of the primary ScrollView on screen,
|
|
33
|
-
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
34
|
-
*
|
|
35
|
-
* Falls back to a viewport capture if no ScrollView is found.
|
|
36
|
-
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
37
|
-
*
|
|
38
|
-
* Known limitation: FlatList/SectionList use RecyclerView which only
|
|
39
|
-
* renders visible items — full-content capture falls back to viewport.
|
|
40
|
-
*
|
|
41
|
-
* @returns FullContentResult with isFullContent flag, or null
|
|
42
|
-
*/
|
|
43
|
-
export declare function captureFullContent(): Promise<FullContentResult | null>;
|
|
44
|
-
export interface ScanTile {
|
|
45
|
-
image: string;
|
|
46
|
-
width: number;
|
|
47
|
-
height: number;
|
|
48
|
-
scrollOffsetY: number;
|
|
49
|
-
viewportHeight: number;
|
|
50
|
-
contentHeight: number;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Perform a fast programmatic scroll-scan of the main ScrollView.
|
|
54
|
-
* Captures the entire content as viewport tiles in ~300-500ms.
|
|
55
|
-
*
|
|
56
|
-
* The scan is invisible to the user — scroll position is saved and
|
|
57
|
-
* restored. Returns an array of tiles covering the full content.
|
|
58
|
-
*
|
|
59
|
-
* @returns Array of tiles, or null if no ScrollView or not available
|
|
60
|
-
*/
|
|
61
|
-
export declare function scanFullContent(): Promise<ScanTile[] | null>;
|
|
23
|
+
export declare function captureNativeFrame(): Promise<NativeFrameResult | null>;
|