@repliqo/sdk-react-native 0.3.8 → 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 -242
- package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -94
- 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 -0
- package/dist/core/client.js +304 -16
- 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 +2 -0
- package/dist/index.js +3 -1
- 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 -628
- package/src/core/config.ts +38 -30
- package/src/core/storage.ts +51 -0
- package/src/index.ts +54 -50
- package/src/snapshot/NativeScreenCapture.ts +62 -62
- 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/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');
|
|
@@ -365,6 +501,14 @@ class AppAnalytics {
|
|
|
365
501
|
})();
|
|
366
502
|
}
|
|
367
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
|
+
}
|
|
368
512
|
this.currentScreen = screenName;
|
|
369
513
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
370
514
|
this.scrollOffsetY = 0;
|
|
@@ -447,6 +591,129 @@ class AppAnalytics {
|
|
|
447
591
|
isInitialized() {
|
|
448
592
|
return true;
|
|
449
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
|
+
}
|
|
450
717
|
setupAppStateListener() {
|
|
451
718
|
let wasInBackground = false;
|
|
452
719
|
this.appStateSubscription = react_native_1.AppState.addEventListener('change', (nextAppState) => {
|
|
@@ -454,13 +721,34 @@ class AppAnalytics {
|
|
|
454
721
|
this.logger.log('App going to background, pausing snapshots + flushing');
|
|
455
722
|
this.stopSnapshotCapture();
|
|
456
723
|
wasInBackground = true;
|
|
457
|
-
|
|
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) => {
|
|
458
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(() => { });
|
|
459
740
|
});
|
|
460
741
|
}
|
|
461
742
|
else if (nextAppState === 'active' && wasInBackground) {
|
|
462
743
|
wasInBackground = false;
|
|
463
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
|
+
}
|
|
464
752
|
if (this.snapshotCapture && this.sessionId) {
|
|
465
753
|
this.startSnapshotCapture();
|
|
466
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
|
@@ -12,5 +12,7 @@ export type { CrashReport } from './types/crash';
|
|
|
12
12
|
export type { SnapshotCaptureConfig } from './snapshot/capture';
|
|
13
13
|
export type { ScreenshotResult } from './snapshot/captureScreenshot';
|
|
14
14
|
export { RepliqoScrollView } from './components/RepliqoScrollView';
|
|
15
|
+
export { RepliqoFlatList } from './components/RepliqoFlatList';
|
|
16
|
+
export type { PersistentStorage } from './core/storage';
|
|
15
17
|
export { DEFAULT_CONFIG } from './core/config';
|
|
16
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.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;
|
|
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; } });
|
|
@@ -26,6 +26,8 @@ Object.defineProperty(exports, "captureNativeFrame", { enumerable: true, get: fu
|
|
|
26
26
|
// Components
|
|
27
27
|
var RepliqoScrollView_1 = require("./components/RepliqoScrollView");
|
|
28
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; } });
|
|
29
31
|
// Config
|
|
30
32
|
var config_1 = require("./core/config");
|
|
31
33
|
Object.defineProperty(exports, "DEFAULT_CONFIG", { enumerable: true, get: function () { return config_1.DEFAULT_CONFIG; } });
|
|
@@ -22,6 +22,13 @@ export declare class SnapshotCapture {
|
|
|
22
22
|
private log;
|
|
23
23
|
private warn;
|
|
24
24
|
constructor(config: SnapshotCaptureConfig, onSnapshot: (snapshot: SnapshotPayload) => void, provider?: ScreenshotProvider, log?: LogFn, warn?: LogFn);
|
|
25
|
+
/**
|
|
26
|
+
* Generation counter. Incremented on every start()/stop() so that timer
|
|
27
|
+
* chains from a PREVIOUS generation cannot reschedule themselves. Without
|
|
28
|
+
* it, a fast stop()→start() while a tick's capture is still in flight
|
|
29
|
+
* leaves two parallel timer chains alive (double capture rate).
|
|
30
|
+
*/
|
|
31
|
+
private epoch;
|
|
25
32
|
start(): void;
|
|
26
33
|
stop(): void;
|
|
27
34
|
captureNow(): Promise<void>;
|
package/dist/snapshot/capture.js
CHANGED
|
@@ -14,6 +14,13 @@ class SnapshotCapture {
|
|
|
14
14
|
this.currentScreen = 'unknown';
|
|
15
15
|
this.capturing = false;
|
|
16
16
|
this.running = false;
|
|
17
|
+
/**
|
|
18
|
+
* Generation counter. Incremented on every start()/stop() so that timer
|
|
19
|
+
* chains from a PREVIOUS generation cannot reschedule themselves. Without
|
|
20
|
+
* it, a fast stop()→start() while a tick's capture is still in flight
|
|
21
|
+
* leaves two parallel timer chains alive (double capture rate).
|
|
22
|
+
*/
|
|
23
|
+
this.epoch = 0;
|
|
17
24
|
this.config = config;
|
|
18
25
|
this.onSnapshot = onSnapshot;
|
|
19
26
|
this.provider = provider;
|
|
@@ -24,11 +31,13 @@ class SnapshotCapture {
|
|
|
24
31
|
if (this.running)
|
|
25
32
|
return;
|
|
26
33
|
this.running = true;
|
|
34
|
+
this.epoch++;
|
|
27
35
|
this.log('Snapshot capture started (interval:', this.config.captureInterval, 'ms)');
|
|
28
|
-
this.scheduleNext();
|
|
36
|
+
this.scheduleNext(this.epoch);
|
|
29
37
|
}
|
|
30
38
|
stop() {
|
|
31
39
|
this.running = false;
|
|
40
|
+
this.epoch++;
|
|
32
41
|
if (this.timeoutId !== null) {
|
|
33
42
|
clearTimeout(this.timeoutId);
|
|
34
43
|
this.timeoutId = null;
|
|
@@ -38,13 +47,16 @@ class SnapshotCapture {
|
|
|
38
47
|
async captureNow() {
|
|
39
48
|
await this.tick();
|
|
40
49
|
}
|
|
41
|
-
scheduleNext() {
|
|
42
|
-
if (!this.running)
|
|
50
|
+
scheduleNext(epoch) {
|
|
51
|
+
if (!this.running || epoch !== this.epoch)
|
|
43
52
|
return;
|
|
44
53
|
this.timeoutId = setTimeout(() => {
|
|
54
|
+
// Orphan timer from a previous generation — do nothing.
|
|
55
|
+
if (epoch !== this.epoch)
|
|
56
|
+
return;
|
|
45
57
|
Promise.resolve(this.tick()).finally(() => {
|
|
46
|
-
if (this.running) {
|
|
47
|
-
this.scheduleNext();
|
|
58
|
+
if (this.running && epoch === this.epoch) {
|
|
59
|
+
this.scheduleNext(epoch);
|
|
48
60
|
}
|
|
49
61
|
});
|
|
50
62
|
}, this.config.captureInterval);
|
|
@@ -5,6 +5,7 @@ export declare class ErrorTracker {
|
|
|
5
5
|
private sessionId;
|
|
6
6
|
private currentScreen;
|
|
7
7
|
private originalHandler;
|
|
8
|
+
private wrapperHandler;
|
|
8
9
|
private isActive;
|
|
9
10
|
constructor(onCrash: CrashCallback);
|
|
10
11
|
setSessionId(id: string | null): void;
|
|
@@ -16,7 +17,31 @@ export declare class ErrorTracker {
|
|
|
16
17
|
*/
|
|
17
18
|
reportError(error: Error, metadata?: Record<string, any>): void;
|
|
18
19
|
private setupGlobalErrorHandler;
|
|
20
|
+
/**
|
|
21
|
+
* Handle an unhandled promise rejection. Guarded on isActive because the
|
|
22
|
+
* underlying trackers can't always be unregistered.
|
|
23
|
+
*/
|
|
24
|
+
private handleUnhandledRejection;
|
|
25
|
+
/**
|
|
26
|
+
* Register with the mechanisms React Native ACTUALLY uses to surface
|
|
27
|
+
* unhandled promise rejections:
|
|
28
|
+
*
|
|
29
|
+
* 1. Hermes' native promise rejection tracker (Hermes engine).
|
|
30
|
+
* 2. The `promise` polyfill's rejection-tracking module (JSC and older
|
|
31
|
+
* RN versions).
|
|
32
|
+
*
|
|
33
|
+
* A bare global callback (the old approach) is never invoked by RN — it
|
|
34
|
+
* silently captured nothing.
|
|
35
|
+
*
|
|
36
|
+
* KNOWN LIMITATION: these trackers hold a single handler — registering
|
|
37
|
+
* REPLACES whatever was installed before (RN's dev LogBox warning, or
|
|
38
|
+
* another SDK like Sentry if it initialized first), and there is no
|
|
39
|
+
* unregister API, so after stop() an inert handler remains installed.
|
|
40
|
+
* If you run multiple error-tracking SDKs, initialize Repliqo FIRST so
|
|
41
|
+
* the other SDK's handler wins, or disable enableCrashTracking.
|
|
42
|
+
*/
|
|
19
43
|
private setupUnhandledRejectionHandler;
|
|
44
|
+
private teardownUnhandledRejectionHandler;
|
|
20
45
|
private restoreGlobalErrorHandler;
|
|
21
46
|
}
|
|
22
47
|
export {};
|