@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
|
@@ -6,7 +6,32 @@ class ErrorTracker {
|
|
|
6
6
|
this.sessionId = null;
|
|
7
7
|
this.currentScreen = null;
|
|
8
8
|
this.originalHandler = null;
|
|
9
|
+
this.wrapperHandler = null;
|
|
9
10
|
this.isActive = false;
|
|
11
|
+
/**
|
|
12
|
+
* Handle an unhandled promise rejection. Guarded on isActive because the
|
|
13
|
+
* underlying trackers can't always be unregistered.
|
|
14
|
+
*/
|
|
15
|
+
this.handleUnhandledRejection = (_id, error) => {
|
|
16
|
+
if (!this.isActive)
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
21
|
+
const crash = {
|
|
22
|
+
sessionId: this.sessionId || undefined,
|
|
23
|
+
type: 'unhandled_rejection',
|
|
24
|
+
message,
|
|
25
|
+
stackTrace: stack || undefined,
|
|
26
|
+
screenName: this.currentScreen || undefined,
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
};
|
|
29
|
+
this.onCrash(crash);
|
|
30
|
+
}
|
|
31
|
+
catch (_e) {
|
|
32
|
+
// Never crash the app due to error reporting
|
|
33
|
+
}
|
|
34
|
+
};
|
|
10
35
|
this.onCrash = onCrash;
|
|
11
36
|
}
|
|
12
37
|
setSessionId(id) {
|
|
@@ -25,8 +50,11 @@ class ErrorTracker {
|
|
|
25
50
|
stop() {
|
|
26
51
|
if (!this.isActive)
|
|
27
52
|
return;
|
|
53
|
+
// Flip the flag FIRST — the rejection trackers can't always be
|
|
54
|
+
// unregistered (Hermes), so their callbacks guard on isActive.
|
|
28
55
|
this.isActive = false;
|
|
29
56
|
this.restoreGlobalErrorHandler();
|
|
57
|
+
this.teardownUnhandledRejectionHandler();
|
|
30
58
|
}
|
|
31
59
|
/**
|
|
32
60
|
* Manual error reporting - allows consumers to report caught errors.
|
|
@@ -53,66 +81,115 @@ class ErrorTracker {
|
|
|
53
81
|
const ErrorUtils = global.ErrorUtils;
|
|
54
82
|
if (ErrorUtils) {
|
|
55
83
|
this.originalHandler = ErrorUtils.getGlobalHandler();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
this.wrapperHandler = (error, isFatal) => {
|
|
85
|
+
if (this.isActive) {
|
|
86
|
+
try {
|
|
87
|
+
const crash = {
|
|
88
|
+
sessionId: this.sessionId || undefined,
|
|
89
|
+
type: isFatal ? 'native_crash' : 'js_error',
|
|
90
|
+
message: error.message,
|
|
91
|
+
stackTrace: error.stack || undefined,
|
|
92
|
+
screenName: this.currentScreen || undefined,
|
|
93
|
+
metadata: { isFatal },
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
this.onCrash(crash);
|
|
97
|
+
}
|
|
98
|
+
catch (_e) {
|
|
99
|
+
// Never crash the app due to error reporting
|
|
100
|
+
}
|
|
71
101
|
}
|
|
72
102
|
// Always call original handler so RN can handle the error
|
|
73
103
|
if (this.originalHandler) {
|
|
74
104
|
this.originalHandler(error, isFatal);
|
|
75
105
|
}
|
|
76
|
-
}
|
|
106
|
+
};
|
|
107
|
+
ErrorUtils.setGlobalHandler(this.wrapperHandler);
|
|
77
108
|
}
|
|
78
109
|
}
|
|
79
110
|
catch (_e) {
|
|
80
111
|
// Never crash the app due to error handler setup
|
|
81
112
|
}
|
|
82
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Register with the mechanisms React Native ACTUALLY uses to surface
|
|
116
|
+
* unhandled promise rejections:
|
|
117
|
+
*
|
|
118
|
+
* 1. Hermes' native promise rejection tracker (Hermes engine).
|
|
119
|
+
* 2. The `promise` polyfill's rejection-tracking module (JSC and older
|
|
120
|
+
* RN versions).
|
|
121
|
+
*
|
|
122
|
+
* A bare global callback (the old approach) is never invoked by RN — it
|
|
123
|
+
* silently captured nothing.
|
|
124
|
+
*
|
|
125
|
+
* KNOWN LIMITATION: these trackers hold a single handler — registering
|
|
126
|
+
* REPLACES whatever was installed before (RN's dev LogBox warning, or
|
|
127
|
+
* another SDK like Sentry if it initialized first), and there is no
|
|
128
|
+
* unregister API, so after stop() an inert handler remains installed.
|
|
129
|
+
* If you run multiple error-tracking SDKs, initialize Repliqo FIRST so
|
|
130
|
+
* the other SDK's handler wins, or disable enableCrashTracking.
|
|
131
|
+
*/
|
|
83
132
|
setupUnhandledRejectionHandler() {
|
|
133
|
+
// Kept for manual invocation / test hooks.
|
|
84
134
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Never crash the app due to error reporting
|
|
102
|
-
}
|
|
103
|
-
};
|
|
135
|
+
global.__analyticsUnhandledRejection =
|
|
136
|
+
this.handleUnhandledRejection;
|
|
137
|
+
}
|
|
138
|
+
catch (_e) {
|
|
139
|
+
/* ignore */
|
|
140
|
+
}
|
|
141
|
+
// Hermes: native promise rejection tracking.
|
|
142
|
+
try {
|
|
143
|
+
const hermes = global.HermesInternal;
|
|
144
|
+
if (hermes?.hasPromise?.() && hermes?.enablePromiseRejectionTracker) {
|
|
145
|
+
hermes.enablePromiseRejectionTracker({
|
|
146
|
+
allRejections: true,
|
|
147
|
+
onUnhandled: this.handleUnhandledRejection,
|
|
148
|
+
onHandled: () => { },
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
104
151
|
}
|
|
105
152
|
}
|
|
106
153
|
catch (_e) {
|
|
107
|
-
|
|
154
|
+
/* ignore — fall through to the polyfill */
|
|
155
|
+
}
|
|
156
|
+
// JSC / promise polyfill path.
|
|
157
|
+
try {
|
|
158
|
+
const rejectionTracking = require('promise/setimmediate/rejection-tracking');
|
|
159
|
+
rejectionTracking.enable({
|
|
160
|
+
allRejections: true,
|
|
161
|
+
onUnhandled: this.handleUnhandledRejection,
|
|
162
|
+
onHandled: () => { },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (_e) {
|
|
166
|
+
// Module unavailable (e.g. jest without the polyfill) — the global
|
|
167
|
+
// hook above still allows manual reporting.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
teardownUnhandledRejectionHandler() {
|
|
171
|
+
try {
|
|
172
|
+
delete global.__analyticsUnhandledRejection;
|
|
173
|
+
}
|
|
174
|
+
catch (_e) {
|
|
175
|
+
/* ignore */
|
|
108
176
|
}
|
|
177
|
+
// Hermes offers no unregister API and disabling the polyfill tracker
|
|
178
|
+
// could clobber another library's handler — the isActive guard in
|
|
179
|
+
// handleUnhandledRejection makes lingering registrations inert.
|
|
109
180
|
}
|
|
110
181
|
restoreGlobalErrorHandler() {
|
|
111
182
|
try {
|
|
112
183
|
const ErrorUtils = global.ErrorUtils;
|
|
113
184
|
if (ErrorUtils && this.originalHandler) {
|
|
114
|
-
|
|
185
|
+
// Only restore if WE are still the installed handler — another
|
|
186
|
+
// library may have wrapped us after start(), and restoring would
|
|
187
|
+
// clobber its handler.
|
|
188
|
+
if (ErrorUtils.getGlobalHandler() === this.wrapperHandler) {
|
|
189
|
+
ErrorUtils.setGlobalHandler(this.originalHandler);
|
|
190
|
+
}
|
|
115
191
|
this.originalHandler = null;
|
|
192
|
+
this.wrapperHandler = null;
|
|
116
193
|
}
|
|
117
194
|
}
|
|
118
195
|
catch (_e) {
|
|
@@ -57,6 +57,17 @@ function useNavigationTracker() {
|
|
|
57
57
|
// AppAnalytics not initialized, silently ignore
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
else if (currentRouteName && !previousRouteName) {
|
|
61
|
+
// First route seen without onReady having fired (some apps only wire
|
|
62
|
+
// onStateChange). Without this, currentScreen stays null until the
|
|
63
|
+
// SECOND navigation and early touches/visits go unattributed.
|
|
64
|
+
try {
|
|
65
|
+
client_1.AppAnalytics.getInstance().onScreenEnter(currentRouteName);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// AppAnalytics not initialized, silently ignore
|
|
69
|
+
}
|
|
70
|
+
}
|
|
60
71
|
routeNameRef.current = currentRouteName;
|
|
61
72
|
}, []);
|
|
62
73
|
return {
|
|
@@ -11,10 +11,39 @@ export declare class ApiClient {
|
|
|
11
11
|
private logger;
|
|
12
12
|
constructor(baseUrl: string, apiKey: string, logger: Logger);
|
|
13
13
|
private getHeaders;
|
|
14
|
+
/**
|
|
15
|
+
* fetch with a hard timeout. Without it, a single stalled request keeps
|
|
16
|
+
* the BatchQueue's in-flight lock held indefinitely — no flush can run,
|
|
17
|
+
* the buffer fills up and starts evicting events (silent data loss).
|
|
18
|
+
*/
|
|
19
|
+
private fetchWithTimeout;
|
|
20
|
+
/**
|
|
21
|
+
* Classify a non-ok batch response. Permanent PAYLOAD errors (the batch
|
|
22
|
+
* itself will never be accepted: 400 malformed, 413 too large, 422
|
|
23
|
+
* unprocessable, 404 gone) surface as NonRetryableFlushError so the
|
|
24
|
+
* queue drops the poison batch instead of retrying it forever.
|
|
25
|
+
*
|
|
26
|
+
* 401/403 are NOT the payload's fault — they mean the API key is wrong
|
|
27
|
+
* or was rotated. Treat them as transient (retry with backoff) so a key
|
|
28
|
+
* rotation doesn't silently destroy every live SDK's buffered data.
|
|
29
|
+
* 408/429 are transient by definition.
|
|
30
|
+
*/
|
|
31
|
+
private batchError;
|
|
32
|
+
/**
|
|
33
|
+
* Parse a 2xx response body, tolerating malformed JSON. The server
|
|
34
|
+
* already accepted the data — throwing here would make the queue retry
|
|
35
|
+
* an already-persisted batch and create duplicates.
|
|
36
|
+
*/
|
|
37
|
+
private safeJson;
|
|
14
38
|
startSession(deviceId: string, deviceInfo?: DeviceInfo): Promise<{
|
|
15
39
|
id: string;
|
|
16
40
|
}>;
|
|
17
41
|
endSession(sessionId: string): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Attach the host app's user identity to a session.
|
|
44
|
+
* Fire-and-forget: errors are logged but never thrown.
|
|
45
|
+
*/
|
|
46
|
+
identifySession(sessionId: string, userId: string, traits?: Record<string, unknown>): Promise<void>;
|
|
18
47
|
sendEventsBatch(sessionId: string, events: AnalyticsEvent[]): Promise<{
|
|
19
48
|
count: number;
|
|
20
49
|
}>;
|
|
@@ -22,7 +51,11 @@ export declare class ApiClient {
|
|
|
22
51
|
sendSnapshotsBatch(snapshots: SnapshotPayload[]): Promise<{
|
|
23
52
|
count: number;
|
|
24
53
|
}>;
|
|
25
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Send a single crash report. Never throws; returns true when the
|
|
56
|
+
* server accepted it (used to clear the persisted pending copy).
|
|
57
|
+
*/
|
|
58
|
+
sendCrashReport(crash: CrashReport): Promise<boolean>;
|
|
26
59
|
sendCrashesBatch(crashes: CrashReport[]): Promise<{
|
|
27
60
|
count: number;
|
|
28
61
|
}>;
|
|
@@ -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
|
}
|