@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
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ApiClient = void 0;
|
|
4
|
+
const batch_queue_1 = require("./batch-queue");
|
|
5
|
+
/** Abort any request that hasn't completed within this window. */
|
|
6
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
4
7
|
class ApiClient {
|
|
5
8
|
constructor(baseUrl, apiKey, logger) {
|
|
6
9
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
@@ -13,9 +16,61 @@ class ApiClient {
|
|
|
13
16
|
'x-api-key': this.apiKey,
|
|
14
17
|
};
|
|
15
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* fetch with a hard timeout. Without it, a single stalled request keeps
|
|
21
|
+
* the BatchQueue's in-flight lock held indefinitely — no flush can run,
|
|
22
|
+
* the buffer fills up and starts evicting events (silent data loss).
|
|
23
|
+
*/
|
|
24
|
+
async fetchWithTimeout(url, init) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
27
|
+
try {
|
|
28
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Classify a non-ok batch response. Permanent PAYLOAD errors (the batch
|
|
36
|
+
* itself will never be accepted: 400 malformed, 413 too large, 422
|
|
37
|
+
* unprocessable, 404 gone) surface as NonRetryableFlushError so the
|
|
38
|
+
* queue drops the poison batch instead of retrying it forever.
|
|
39
|
+
*
|
|
40
|
+
* 401/403 are NOT the payload's fault — they mean the API key is wrong
|
|
41
|
+
* or was rotated. Treat them as transient (retry with backoff) so a key
|
|
42
|
+
* rotation doesn't silently destroy every live SDK's buffered data.
|
|
43
|
+
* 408/429 are transient by definition.
|
|
44
|
+
*/
|
|
45
|
+
batchError(context, status) {
|
|
46
|
+
const message = `${context}: ${status}`;
|
|
47
|
+
const isPermanent = status >= 400 &&
|
|
48
|
+
status < 500 &&
|
|
49
|
+
status !== 401 &&
|
|
50
|
+
status !== 403 &&
|
|
51
|
+
status !== 408 &&
|
|
52
|
+
status !== 429;
|
|
53
|
+
return isPermanent
|
|
54
|
+
? new batch_queue_1.NonRetryableFlushError(message)
|
|
55
|
+
: new Error(message);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse a 2xx response body, tolerating malformed JSON. The server
|
|
59
|
+
* already accepted the data — throwing here would make the queue retry
|
|
60
|
+
* an already-persisted batch and create duplicates.
|
|
61
|
+
*/
|
|
62
|
+
async safeJson(response, fallback) {
|
|
63
|
+
try {
|
|
64
|
+
return (await response.json());
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
this.logger.warn('Response body was not valid JSON; treating as success');
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
16
71
|
async startSession(deviceId, deviceInfo) {
|
|
17
72
|
try {
|
|
18
|
-
const response = await
|
|
73
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/sessions/start`, {
|
|
19
74
|
method: 'POST',
|
|
20
75
|
headers: this.getHeaders(),
|
|
21
76
|
body: JSON.stringify({ deviceId, deviceInfo }),
|
|
@@ -36,7 +91,7 @@ class ApiClient {
|
|
|
36
91
|
}
|
|
37
92
|
async endSession(sessionId) {
|
|
38
93
|
try {
|
|
39
|
-
const response = await
|
|
94
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/sessions/${sessionId}/end`, {
|
|
40
95
|
method: 'PATCH',
|
|
41
96
|
headers: this.getHeaders(),
|
|
42
97
|
});
|
|
@@ -51,19 +106,41 @@ class ApiClient {
|
|
|
51
106
|
this.logger.error('Error ending session:', error);
|
|
52
107
|
}
|
|
53
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Attach the host app's user identity to a session.
|
|
111
|
+
* Fire-and-forget: errors are logged but never thrown.
|
|
112
|
+
*/
|
|
113
|
+
async identifySession(sessionId, userId, traits) {
|
|
114
|
+
try {
|
|
115
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/sessions/${sessionId}/identify`, {
|
|
116
|
+
method: 'PATCH',
|
|
117
|
+
headers: this.getHeaders(),
|
|
118
|
+
body: JSON.stringify({ userId, traits }),
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const text = await response.text();
|
|
122
|
+
this.logger.warn('Identify failed:', response.status, text?.substring(0, 200));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.logger.log('Session identified as user:', userId);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
this.logger.warn('Error identifying session:', error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
54
131
|
async sendEventsBatch(sessionId, events) {
|
|
55
132
|
try {
|
|
56
|
-
const response = await
|
|
133
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/events/batch`, {
|
|
57
134
|
method: 'POST',
|
|
58
135
|
headers: this.getHeaders(),
|
|
59
136
|
body: JSON.stringify({ sessionId, events }),
|
|
60
137
|
});
|
|
61
138
|
if (!response.ok) {
|
|
62
|
-
const text = await response.text();
|
|
139
|
+
const text = await response.text().catch(() => '');
|
|
63
140
|
this.logger.error('Failed to send events batch:', response.status, text);
|
|
64
|
-
throw
|
|
141
|
+
throw this.batchError('Failed to send events batch', response.status);
|
|
65
142
|
}
|
|
66
|
-
const data = await response.
|
|
143
|
+
const data = await this.safeJson(response, { count: events.length });
|
|
67
144
|
this.logger.log('Events batch sent, count:', data.count);
|
|
68
145
|
return { count: data.count };
|
|
69
146
|
}
|
|
@@ -74,15 +151,15 @@ class ApiClient {
|
|
|
74
151
|
}
|
|
75
152
|
async sendScreenVisitsBatch(visits) {
|
|
76
153
|
try {
|
|
77
|
-
const response = await
|
|
154
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/screens/visit/batch`, {
|
|
78
155
|
method: 'POST',
|
|
79
156
|
headers: this.getHeaders(),
|
|
80
157
|
body: JSON.stringify({ visits }),
|
|
81
158
|
});
|
|
82
159
|
if (!response.ok) {
|
|
83
|
-
const text = await response.text();
|
|
160
|
+
const text = await response.text().catch(() => '');
|
|
84
161
|
this.logger.error('Failed to send screen visits batch:', response.status, text);
|
|
85
|
-
throw
|
|
162
|
+
throw this.batchError('Failed to send screen visits batch', response.status);
|
|
86
163
|
}
|
|
87
164
|
this.logger.log('Screen visits batch sent, count:', visits.length);
|
|
88
165
|
}
|
|
@@ -93,17 +170,17 @@ class ApiClient {
|
|
|
93
170
|
}
|
|
94
171
|
async sendSnapshotsBatch(snapshots) {
|
|
95
172
|
try {
|
|
96
|
-
const response = await
|
|
173
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/snapshots/batch`, {
|
|
97
174
|
method: 'POST',
|
|
98
175
|
headers: this.getHeaders(),
|
|
99
176
|
body: JSON.stringify({ snapshots }),
|
|
100
177
|
});
|
|
101
178
|
if (!response.ok) {
|
|
102
|
-
const text = await response.text();
|
|
179
|
+
const text = await response.text().catch(() => '');
|
|
103
180
|
this.logger.error('Failed to send snapshots batch:', response.status, text);
|
|
104
|
-
throw
|
|
181
|
+
throw this.batchError('Failed to send snapshots batch', response.status);
|
|
105
182
|
}
|
|
106
|
-
const data = await response.
|
|
183
|
+
const data = await this.safeJson(response, { count: snapshots.length });
|
|
107
184
|
this.logger.log('Snapshots batch sent, count:', data.count);
|
|
108
185
|
return { count: data.count };
|
|
109
186
|
}
|
|
@@ -112,28 +189,34 @@ class ApiClient {
|
|
|
112
189
|
throw error;
|
|
113
190
|
}
|
|
114
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Send a single crash report. Never throws; returns true when the
|
|
194
|
+
* server accepted it (used to clear the persisted pending copy).
|
|
195
|
+
*/
|
|
115
196
|
async sendCrashReport(crash) {
|
|
116
197
|
try {
|
|
117
|
-
const response = await
|
|
198
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/crashes`, {
|
|
118
199
|
method: 'POST',
|
|
119
200
|
headers: this.getHeaders(),
|
|
120
201
|
body: JSON.stringify(crash),
|
|
121
202
|
});
|
|
122
203
|
if (!response.ok) {
|
|
123
|
-
const text = await response.text();
|
|
204
|
+
const text = await response.text().catch(() => '');
|
|
124
205
|
this.logger.error('Failed to send crash report:', response.status, text);
|
|
125
|
-
return;
|
|
206
|
+
return false;
|
|
126
207
|
}
|
|
127
208
|
this.logger.log('Crash report sent:', crash.type, crash.message);
|
|
209
|
+
return true;
|
|
128
210
|
}
|
|
129
211
|
catch (error) {
|
|
130
212
|
// Fire-and-forget: catch errors silently, never throw
|
|
131
213
|
this.logger.error('Error sending crash report:', error);
|
|
214
|
+
return false;
|
|
132
215
|
}
|
|
133
216
|
}
|
|
134
217
|
async sendCrashesBatch(crashes) {
|
|
135
218
|
try {
|
|
136
|
-
const response = await
|
|
219
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/crashes/batch`, {
|
|
137
220
|
method: 'POST',
|
|
138
221
|
headers: this.getHeaders(),
|
|
139
222
|
body: JSON.stringify({ crashes }),
|
|
@@ -184,7 +267,7 @@ class ApiClient {
|
|
|
184
267
|
if (typeof windowHeight === 'number' && Number.isFinite(windowHeight)) {
|
|
185
268
|
body.windowHeight = Math.round(windowHeight);
|
|
186
269
|
}
|
|
187
|
-
const response = await
|
|
270
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/screen-tiles`, {
|
|
188
271
|
method: 'POST',
|
|
189
272
|
headers: this.getHeaders(),
|
|
190
273
|
body: JSON.stringify(body),
|
|
@@ -200,7 +283,7 @@ class ApiClient {
|
|
|
200
283
|
}
|
|
201
284
|
async uploadScreenCapture(screenName, image, width, height, isFullContent) {
|
|
202
285
|
try {
|
|
203
|
-
const response = await
|
|
286
|
+
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/screen-captures`, {
|
|
204
287
|
method: 'POST',
|
|
205
288
|
headers: this.getHeaders(),
|
|
206
289
|
body: JSON.stringify({
|
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by an onFlush callback to signal that the batch was PERMANENTLY
|
|
3
|
+
* rejected (e.g. HTTP 400/413 — malformed or oversized payload). The queue
|
|
4
|
+
* drops the batch instead of retrying it forever, which would otherwise
|
|
5
|
+
* block the queue and evict everything behind it.
|
|
6
|
+
*/
|
|
7
|
+
export declare class NonRetryableFlushError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Thrown by an onFlush callback that delivers items in MULTIPLE requests
|
|
12
|
+
* (e.g. one events batch per session) when only a PREFIX of the items was
|
|
13
|
+
* consumed. Without it the queue would treat the flush as all-or-nothing:
|
|
14
|
+
* a transient failure after a successful sub-batch would RE-SEND the
|
|
15
|
+
* delivered items on retry (duplicates), and a poison sub-batch would
|
|
16
|
+
* DROP items belonging to never-attempted sub-batches.
|
|
17
|
+
*
|
|
18
|
+
* `consumedCount` = items delivered or permanently dropped, counted from
|
|
19
|
+
* the FRONT of the flushed array (sub-batches must be processed in order).
|
|
20
|
+
* `transient` = whether the stop reason was retryable (applies backoff and
|
|
21
|
+
* keeps the remaining items for retry).
|
|
22
|
+
*/
|
|
23
|
+
export declare class PartialFlushError extends Error {
|
|
24
|
+
readonly consumedCount: number;
|
|
25
|
+
readonly transient: boolean;
|
|
26
|
+
constructor(consumedCount: number, transient: boolean, message?: string);
|
|
27
|
+
}
|
|
1
28
|
export declare class BatchQueue<T> {
|
|
2
29
|
private buffer;
|
|
3
30
|
private maxSize;
|
|
@@ -5,14 +32,39 @@ export declare class BatchQueue<T> {
|
|
|
5
32
|
private flushIntervalMs;
|
|
6
33
|
private onFlush;
|
|
7
34
|
private intervalId;
|
|
8
|
-
|
|
35
|
+
/**
|
|
36
|
+
* In-flight flush promise. flush() returns THIS promise when a flush is
|
|
37
|
+
* already running (instead of resolving immediately), so callers like
|
|
38
|
+
* endSession() that `await flush()` actually wait for delivery.
|
|
39
|
+
*/
|
|
40
|
+
private inFlight;
|
|
41
|
+
/**
|
|
42
|
+
* Items evicted from the FRONT of the buffer while a flush was in flight.
|
|
43
|
+
* Evicted items are the oldest — i.e. part of the in-flight batch — so
|
|
44
|
+
* after a successful flush we must remove (sent - alreadyEvicted) items,
|
|
45
|
+
* or we'd delete unsent NEW items that shifted to the front.
|
|
46
|
+
*/
|
|
47
|
+
private evictedDuringFlush;
|
|
48
|
+
/** Number of items captured by the in-flight flush (0 when idle). */
|
|
49
|
+
private inFlightCount;
|
|
9
50
|
private consecutiveFailures;
|
|
10
51
|
private ticksToSkip;
|
|
11
52
|
private static readonly MAX_BACKOFF_TICKS;
|
|
12
53
|
constructor(maxSize: number | undefined, flushIntervalMs: number | undefined, onFlush: (items: T[]) => Promise<void>, maxBufferSize?: number);
|
|
13
54
|
add(item: T): void;
|
|
55
|
+
/**
|
|
56
|
+
* Flush the buffer. If a flush is already in flight, returns its promise
|
|
57
|
+
* (never a silent no-op). Never rejects — failures are kept in the buffer
|
|
58
|
+
* and retried with backoff by the auto-flush timer.
|
|
59
|
+
*/
|
|
14
60
|
flush(): Promise<void>;
|
|
61
|
+
private doFlush;
|
|
62
|
+
private applyBackoff;
|
|
15
63
|
startAutoFlush(): void;
|
|
16
64
|
stopAutoFlush(): void;
|
|
17
65
|
get pending(): number;
|
|
66
|
+
/** Read-only snapshot of the buffered items (for persistence). */
|
|
67
|
+
peekAll(): readonly T[];
|
|
68
|
+
/** Remove every buffered item (after persisting, or on session discard). */
|
|
69
|
+
clear(): void;
|
|
18
70
|
}
|
|
@@ -1,11 +1,60 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BatchQueue = void 0;
|
|
3
|
+
exports.BatchQueue = exports.PartialFlushError = exports.NonRetryableFlushError = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Thrown by an onFlush callback to signal that the batch was PERMANENTLY
|
|
6
|
+
* rejected (e.g. HTTP 400/413 — malformed or oversized payload). The queue
|
|
7
|
+
* drops the batch instead of retrying it forever, which would otherwise
|
|
8
|
+
* block the queue and evict everything behind it.
|
|
9
|
+
*/
|
|
10
|
+
class NonRetryableFlushError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'NonRetryableFlushError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.NonRetryableFlushError = NonRetryableFlushError;
|
|
17
|
+
/**
|
|
18
|
+
* Thrown by an onFlush callback that delivers items in MULTIPLE requests
|
|
19
|
+
* (e.g. one events batch per session) when only a PREFIX of the items was
|
|
20
|
+
* consumed. Without it the queue would treat the flush as all-or-nothing:
|
|
21
|
+
* a transient failure after a successful sub-batch would RE-SEND the
|
|
22
|
+
* delivered items on retry (duplicates), and a poison sub-batch would
|
|
23
|
+
* DROP items belonging to never-attempted sub-batches.
|
|
24
|
+
*
|
|
25
|
+
* `consumedCount` = items delivered or permanently dropped, counted from
|
|
26
|
+
* the FRONT of the flushed array (sub-batches must be processed in order).
|
|
27
|
+
* `transient` = whether the stop reason was retryable (applies backoff and
|
|
28
|
+
* keeps the remaining items for retry).
|
|
29
|
+
*/
|
|
30
|
+
class PartialFlushError extends Error {
|
|
31
|
+
constructor(consumedCount, transient, message) {
|
|
32
|
+
super(message ?? `Partial flush: ${consumedCount} consumed`);
|
|
33
|
+
this.consumedCount = consumedCount;
|
|
34
|
+
this.transient = transient;
|
|
35
|
+
this.name = 'PartialFlushError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.PartialFlushError = PartialFlushError;
|
|
4
39
|
class BatchQueue {
|
|
5
40
|
constructor(maxSize = 20, flushIntervalMs = 30000, onFlush, maxBufferSize = 1000) {
|
|
6
41
|
this.buffer = [];
|
|
7
42
|
this.intervalId = null;
|
|
8
|
-
|
|
43
|
+
/**
|
|
44
|
+
* In-flight flush promise. flush() returns THIS promise when a flush is
|
|
45
|
+
* already running (instead of resolving immediately), so callers like
|
|
46
|
+
* endSession() that `await flush()` actually wait for delivery.
|
|
47
|
+
*/
|
|
48
|
+
this.inFlight = null;
|
|
49
|
+
/**
|
|
50
|
+
* Items evicted from the FRONT of the buffer while a flush was in flight.
|
|
51
|
+
* Evicted items are the oldest — i.e. part of the in-flight batch — so
|
|
52
|
+
* after a successful flush we must remove (sent - alreadyEvicted) items,
|
|
53
|
+
* or we'd delete unsent NEW items that shifted to the front.
|
|
54
|
+
*/
|
|
55
|
+
this.evictedDuringFlush = 0;
|
|
56
|
+
/** Number of items captured by the in-flight flush (0 when idle). */
|
|
57
|
+
this.inFlightCount = 0;
|
|
9
58
|
// Exponential backoff state: on each failure we double the skip count,
|
|
10
59
|
// capped at 8 ticks. A successful flush resets it to 0.
|
|
11
60
|
this.consecutiveFailures = 0;
|
|
@@ -18,40 +67,86 @@ class BatchQueue {
|
|
|
18
67
|
add(item) {
|
|
19
68
|
// Evict oldest items if buffer is at max capacity
|
|
20
69
|
if (this.buffer.length >= this.maxBufferSize) {
|
|
21
|
-
|
|
70
|
+
const evictCount = this.buffer.length - this.maxBufferSize + 1;
|
|
71
|
+
this.buffer.splice(0, evictCount);
|
|
72
|
+
if (this.inFlight) {
|
|
73
|
+
this.evictedDuringFlush += evictCount;
|
|
74
|
+
}
|
|
22
75
|
}
|
|
23
76
|
this.buffer.push(item);
|
|
24
77
|
if (this.buffer.length >= this.maxSize) {
|
|
25
78
|
this.flush();
|
|
26
79
|
}
|
|
27
80
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Flush the buffer. If a flush is already in flight, returns its promise
|
|
83
|
+
* (never a silent no-op). Never rejects — failures are kept in the buffer
|
|
84
|
+
* and retried with backoff by the auto-flush timer.
|
|
85
|
+
*/
|
|
86
|
+
flush() {
|
|
87
|
+
if (this.inFlight) {
|
|
88
|
+
return this.inFlight;
|
|
89
|
+
}
|
|
90
|
+
if (this.buffer.length === 0) {
|
|
91
|
+
return Promise.resolve();
|
|
31
92
|
}
|
|
32
|
-
this.
|
|
93
|
+
this.inFlight = this.doFlush().finally(() => {
|
|
94
|
+
this.inFlight = null;
|
|
95
|
+
});
|
|
96
|
+
return this.inFlight;
|
|
97
|
+
}
|
|
98
|
+
async doFlush() {
|
|
99
|
+
this.evictedDuringFlush = 0;
|
|
33
100
|
const items = [...this.buffer];
|
|
101
|
+
this.inFlightCount = items.length;
|
|
102
|
+
/** Remove `count` in-flight items from the buffer front, net of any
|
|
103
|
+
* that were already evicted (or cleared) while the flush ran. */
|
|
104
|
+
const removeConsumed = (count) => {
|
|
105
|
+
const toRemove = Math.max(0, Math.min(count, items.length) - this.evictedDuringFlush);
|
|
106
|
+
this.buffer.splice(0, toRemove);
|
|
107
|
+
// Consumed items absorb the eviction credit first.
|
|
108
|
+
this.evictedDuringFlush = Math.max(0, this.evictedDuringFlush - Math.min(count, items.length));
|
|
109
|
+
};
|
|
34
110
|
try {
|
|
35
111
|
await this.onFlush(items);
|
|
36
|
-
|
|
37
|
-
// New items might have been added during the async flush,
|
|
38
|
-
// so we splice out only the count we sent.
|
|
39
|
-
this.buffer.splice(0, items.length);
|
|
40
|
-
// Successful flush resets the backoff state.
|
|
112
|
+
removeConsumed(items.length);
|
|
41
113
|
this.consecutiveFailures = 0;
|
|
42
114
|
this.ticksToSkip = 0;
|
|
43
115
|
}
|
|
44
|
-
catch {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof PartialFlushError) {
|
|
118
|
+
// A prefix of the items was delivered/dropped by a multi-request
|
|
119
|
+
// flush; keep only the unconsumed remainder.
|
|
120
|
+
removeConsumed(err.consumedCount);
|
|
121
|
+
if (err.transient) {
|
|
122
|
+
this.applyBackoff();
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
this.consecutiveFailures = 0;
|
|
126
|
+
this.ticksToSkip = 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (err instanceof NonRetryableFlushError) {
|
|
130
|
+
// Poison batch: the server permanently rejected it. Drop it so it
|
|
131
|
+
// can't block the queue forever.
|
|
132
|
+
removeConsumed(items.length);
|
|
133
|
+
this.consecutiveFailures = 0;
|
|
134
|
+
this.ticksToSkip = 0;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
// Transient failure: keep events in buffer for retry with
|
|
138
|
+
// exponential backoff (1, 2, 4, 8 ticks skipped).
|
|
139
|
+
this.applyBackoff();
|
|
140
|
+
}
|
|
50
141
|
}
|
|
51
142
|
finally {
|
|
52
|
-
this.
|
|
143
|
+
this.inFlightCount = 0;
|
|
53
144
|
}
|
|
54
145
|
}
|
|
146
|
+
applyBackoff() {
|
|
147
|
+
this.consecutiveFailures++;
|
|
148
|
+
this.ticksToSkip = Math.min(Math.pow(2, this.consecutiveFailures - 1), BatchQueue.MAX_BACKOFF_TICKS);
|
|
149
|
+
}
|
|
55
150
|
startAutoFlush() {
|
|
56
151
|
if (this.intervalId !== null) {
|
|
57
152
|
return;
|
|
@@ -75,6 +170,18 @@ class BatchQueue {
|
|
|
75
170
|
get pending() {
|
|
76
171
|
return this.buffer.length;
|
|
77
172
|
}
|
|
173
|
+
/** Read-only snapshot of the buffered items (for persistence). */
|
|
174
|
+
peekAll() {
|
|
175
|
+
return [...this.buffer];
|
|
176
|
+
}
|
|
177
|
+
/** Remove every buffered item (after persisting, or on session discard). */
|
|
178
|
+
clear() {
|
|
179
|
+
this.buffer.length = 0;
|
|
180
|
+
// If a flush is in flight, all of its items just left the buffer —
|
|
181
|
+
// account for them as evicted so the flush's completion splice does
|
|
182
|
+
// not delete NEW items added after this clear().
|
|
183
|
+
this.evictedDuringFlush = this.inFlightCount;
|
|
184
|
+
}
|
|
78
185
|
}
|
|
79
186
|
exports.BatchQueue = BatchQueue;
|
|
80
187
|
BatchQueue.MAX_BACKOFF_TICKS = 8;
|
package/dist/types/events.d.ts
CHANGED
|
@@ -50,5 +50,17 @@ export interface SDKConfig {
|
|
|
50
50
|
/** Enable full-content screen captures for heatmap backgrounds. Defaults to true when enableSnapshots is true. */
|
|
51
51
|
enableHeatmapCapture?: boolean;
|
|
52
52
|
enableCrashTracking?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Async key-value storage for offline persistence (events buffered at
|
|
55
|
+
* backgrounding + crash reports). Pass AsyncStorage from
|
|
56
|
+
* @react-native-async-storage/async-storage, or any compatible object.
|
|
57
|
+
* If omitted, the SDK tries to auto-load AsyncStorage; if unavailable,
|
|
58
|
+
* persistence is disabled (in-memory only).
|
|
59
|
+
*/
|
|
60
|
+
storage?: {
|
|
61
|
+
getItem(key: string): Promise<string | null>;
|
|
62
|
+
setItem(key: string, value: string): Promise<void>;
|
|
63
|
+
removeItem(key: string): Promise<void>;
|
|
64
|
+
};
|
|
53
65
|
debug?: boolean;
|
|
54
66
|
}
|
package/package.json
CHANGED
|
@@ -1,64 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@repliqo/sdk-react-native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Session replay & analytics SDK for React Native. Captures screenshots (including modals/alerts), navigation, screen visits, and custom events.",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"types": "dist/index.d.ts",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "https://github.com/SbtnAvls/app-analytics-api",
|
|
10
|
-
"directory": "packages/sdk-react-native"
|
|
11
|
-
},
|
|
12
|
-
"homepage": "https://repliqo.odmservice.site",
|
|
13
|
-
"license": "MIT",
|
|
14
|
-
"keywords": [
|
|
15
|
-
"react-native",
|
|
16
|
-
"analytics",
|
|
17
|
-
"session-replay",
|
|
18
|
-
"screen-capture",
|
|
19
|
-
"heatmaps",
|
|
20
|
-
"uxcam",
|
|
21
|
-
"repliqo"
|
|
22
|
-
],
|
|
23
|
-
"files": [
|
|
24
|
-
"dist/",
|
|
25
|
-
"android/src/",
|
|
26
|
-
"android/build.gradle",
|
|
27
|
-
"src/",
|
|
28
|
-
"INTEGRATION_GUIDE.md"
|
|
29
|
-
],
|
|
30
|
-
"scripts": {
|
|
31
|
-
"build": "tsc",
|
|
32
|
-
"test": "jest",
|
|
33
|
-
"prepublishOnly": "npm run build"
|
|
34
|
-
},
|
|
35
|
-
"publishConfig": {
|
|
36
|
-
"access": "public"
|
|
37
|
-
},
|
|
38
|
-
"peerDependencies": {
|
|
39
|
-
"@react-navigation/native": ">=6.0.0",
|
|
40
|
-
"react": ">=17.0.0",
|
|
41
|
-
"react-native": ">=0.68.0",
|
|
42
|
-
"react-native-view-shot": ">=3.0.0"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"react
|
|
60
|
-
"react-native
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@repliqo/sdk-react-native",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Session replay & analytics SDK for React Native. Captures screenshots (including modals/alerts), navigation, screen visits, and custom events.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/SbtnAvls/app-analytics-api",
|
|
10
|
+
"directory": "packages/sdk-react-native"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://repliqo.odmservice.site",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"react-native",
|
|
16
|
+
"analytics",
|
|
17
|
+
"session-replay",
|
|
18
|
+
"screen-capture",
|
|
19
|
+
"heatmaps",
|
|
20
|
+
"uxcam",
|
|
21
|
+
"repliqo"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/",
|
|
25
|
+
"android/src/",
|
|
26
|
+
"android/build.gradle",
|
|
27
|
+
"src/",
|
|
28
|
+
"INTEGRATION_GUIDE.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc",
|
|
32
|
+
"test": "jest",
|
|
33
|
+
"prepublishOnly": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@react-navigation/native": ">=6.0.0",
|
|
40
|
+
"react": ">=17.0.0",
|
|
41
|
+
"react-native": ">=0.68.0",
|
|
42
|
+
"react-native-view-shot": ">=3.0.0",
|
|
43
|
+
"@react-native-async-storage/async-storage": ">=1.17.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"react-native-view-shot": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@react-navigation/native": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@react-native-async-storage/async-storage": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@react-navigation/native": "^6.1.0",
|
|
58
|
+
"@types/jest": "^29.5.0",
|
|
59
|
+
"@types/react": "^18.2.0",
|
|
60
|
+
"@types/react-native": "^0.72.0",
|
|
61
|
+
"jest": "^29.7.0",
|
|
62
|
+
"react": "^18.2.0",
|
|
63
|
+
"react-native": "^0.73.0",
|
|
64
|
+
"react-native-view-shot": "^4.0.3",
|
|
65
|
+
"ts-jest": "^29.1.0",
|
|
66
|
+
"typescript": "^5.3.0"
|
|
67
|
+
}
|
|
68
|
+
}
|