@switchlabs/verify-ai-react-native 2.5.4 → 2.5.5
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/lib/components/VerifyAIScanner.js +15 -9
- package/lib/telemetry/TelemetryReporter.d.ts +20 -6
- package/lib/telemetry/TelemetryReporter.js +90 -52
- package/lib/types/index.d.ts +2 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +15 -9
- package/src/telemetry/TelemetryReporter.ts +109 -65
- package/src/types/index.ts +2 -0
- package/src/version.ts +1 -1
|
@@ -294,6 +294,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
294
294
|
device_os_version: String(Platform.Version),
|
|
295
295
|
route_name: telemetryContext?.routeName,
|
|
296
296
|
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
297
|
+
host_reference_id: telemetryContext?.hostReferenceId,
|
|
297
298
|
window_width: windowWidth,
|
|
298
299
|
window_height: windowHeight,
|
|
299
300
|
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
@@ -914,15 +915,20 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
914
915
|
: exhausted
|
|
915
916
|
? 'exhausted'
|
|
916
917
|
: null;
|
|
917
|
-
telemetry
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
918
|
+
if (telemetry) {
|
|
919
|
+
// Do not begin native capture until the user's shutter intent is on disk.
|
|
920
|
+
// If the process is force-closed during capture or delivery, the next SDK
|
|
921
|
+
// initialization recovers and sends this exact capture_attempt_id.
|
|
922
|
+
await telemetry.trackDurably('camera_capture_request', {
|
|
923
|
+
component: 'scanner',
|
|
924
|
+
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
925
|
+
metadata: buildScannerTelemetryMetadata({
|
|
926
|
+
capture_attempt_id: captureAttemptId,
|
|
927
|
+
capture_sequence: captureSequence,
|
|
928
|
+
capture_blocked_reason: blockedReason,
|
|
929
|
+
}),
|
|
930
|
+
});
|
|
931
|
+
}
|
|
926
932
|
if (blockedReason && blockedReason !== 'camera_not_ready')
|
|
927
933
|
return;
|
|
928
934
|
if (blockedReason === 'camera_not_ready') {
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
interface TelemetryTrackOptions {
|
|
2
|
+
component?: string;
|
|
3
|
+
error?: unknown;
|
|
4
|
+
errorCode?: string;
|
|
5
|
+
metadata?: Record<string, string | number>;
|
|
6
|
+
}
|
|
1
7
|
export declare class TelemetryReporter {
|
|
2
8
|
private buffer;
|
|
9
|
+
private inFlightBuffer;
|
|
3
10
|
private flushTimer;
|
|
4
11
|
private sessionId;
|
|
5
12
|
private baseUrl;
|
|
@@ -8,6 +15,7 @@ export declare class TelemetryReporter {
|
|
|
8
15
|
private flushing;
|
|
9
16
|
private loadedPersisted;
|
|
10
17
|
private loadPersistedPromise;
|
|
18
|
+
private persistenceChain;
|
|
11
19
|
constructor(apiKey: string, baseUrl: string);
|
|
12
20
|
/** Telemetry POST destination — exposed so the scanner can log an init banner
|
|
13
21
|
* that helps field debugging confirm where events are being sent. */
|
|
@@ -16,12 +24,14 @@ export declare class TelemetryReporter {
|
|
|
16
24
|
getApiKeyPrefix(): string;
|
|
17
25
|
private logDeliveryFailure;
|
|
18
26
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
19
|
-
track(eventType: string, opts?:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
track(eventType: string, opts?: TelemetryTrackOptions): void;
|
|
28
|
+
/**
|
|
29
|
+
* Record an event and wait until it is durably stored before returning.
|
|
30
|
+
*
|
|
31
|
+
* Use this for user intent that must survive an immediate force-close. Network
|
|
32
|
+
* delivery starts after the local write, but callers do not wait on the network.
|
|
33
|
+
*/
|
|
34
|
+
trackDurably(eventType: string, opts?: TelemetryTrackOptions): Promise<void>;
|
|
25
35
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
26
36
|
flush(): Promise<void>;
|
|
27
37
|
/** Dispose — flush remaining events and stop timers. */
|
|
@@ -29,6 +39,8 @@ export declare class TelemetryReporter {
|
|
|
29
39
|
private scheduleFlush;
|
|
30
40
|
private flushNow;
|
|
31
41
|
private clearFlushTimer;
|
|
42
|
+
private recordEvent;
|
|
43
|
+
private persistAfterRecovery;
|
|
32
44
|
/**
|
|
33
45
|
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
34
46
|
* Fire-and-forget — never throws or blocks.
|
|
@@ -37,6 +49,7 @@ export declare class TelemetryReporter {
|
|
|
37
49
|
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
38
50
|
*/
|
|
39
51
|
private persistBuffer;
|
|
52
|
+
private persistBufferAsync;
|
|
40
53
|
/**
|
|
41
54
|
* Load persisted events from a previous session and merge into current buffer.
|
|
42
55
|
* Runs once per instance. If orphaned events are found, emits an
|
|
@@ -45,3 +58,4 @@ export declare class TelemetryReporter {
|
|
|
45
58
|
*/
|
|
46
59
|
private loadPersistedBuffer;
|
|
47
60
|
}
|
|
61
|
+
export {};
|
|
@@ -15,6 +15,7 @@ const CRITICAL_EVENTS = new Set([
|
|
|
15
15
|
'camera_preview_timeout',
|
|
16
16
|
'camera_startup_slow',
|
|
17
17
|
'camera_permission_denied',
|
|
18
|
+
'camera_capture_request',
|
|
18
19
|
'camera_scanner_mounted',
|
|
19
20
|
'camera_scanner_disposed',
|
|
20
21
|
'camera_android_native_orientation_started',
|
|
@@ -30,11 +31,13 @@ const CRITICAL_EVENTS = new Set([
|
|
|
30
31
|
export class TelemetryReporter {
|
|
31
32
|
constructor(apiKey, baseUrl) {
|
|
32
33
|
this.buffer = new Map();
|
|
34
|
+
this.inFlightBuffer = new Map();
|
|
33
35
|
this.flushTimer = null;
|
|
34
36
|
this.disposed = false;
|
|
35
37
|
this.flushing = false;
|
|
36
38
|
this.loadedPersisted = false;
|
|
37
39
|
this.loadPersistedPromise = null;
|
|
40
|
+
this.persistenceChain = Promise.resolve();
|
|
38
41
|
this.apiKey = apiKey;
|
|
39
42
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
40
43
|
this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
@@ -60,61 +63,42 @@ export class TelemetryReporter {
|
|
|
60
63
|
if (this.disposed)
|
|
61
64
|
return;
|
|
62
65
|
try {
|
|
63
|
-
|
|
64
|
-
const errorObj = opts.error instanceof Error ? opts.error : null;
|
|
65
|
-
const errorMessage = errorObj?.message
|
|
66
|
-
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
67
|
-
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
|
|
68
|
-
const existing = this.buffer.get(dedupKey);
|
|
69
|
-
if (existing) {
|
|
70
|
-
existing.event_count++;
|
|
71
|
-
existing.last_occurred_at = now;
|
|
72
|
-
// If the new occurrence is critical, flush immediately
|
|
73
|
-
if (CRITICAL_EVENTS.has(eventType)) {
|
|
74
|
-
this.flushNow();
|
|
75
|
-
}
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const event = {
|
|
79
|
-
event_type: eventType,
|
|
80
|
-
component: opts.component,
|
|
81
|
-
error_message: errorMessage?.slice(0, 1000),
|
|
82
|
-
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
83
|
-
error_code: opts.errorCode,
|
|
84
|
-
metadata: opts.metadata,
|
|
85
|
-
sdk_platform: Platform.OS,
|
|
86
|
-
sdk_version: SDK_VERSION,
|
|
87
|
-
os_name: Platform.OS,
|
|
88
|
-
os_version: String(Platform.Version),
|
|
89
|
-
session_id: this.sessionId,
|
|
90
|
-
event_count: 1,
|
|
91
|
-
first_occurred_at: now,
|
|
92
|
-
last_occurred_at: now,
|
|
93
|
-
};
|
|
94
|
-
this.buffer.set(dedupKey, event);
|
|
66
|
+
this.recordEvent(eventType, opts);
|
|
95
67
|
if (CRITICAL_EVENTS.has(eventType)) {
|
|
96
|
-
|
|
68
|
+
// Critical events are written locally before delivery starts. This keeps
|
|
69
|
+
// them recoverable if the process is killed while the POST is in flight.
|
|
70
|
+
void this.persistAfterRecovery().then(() => this.flushNow());
|
|
97
71
|
}
|
|
98
72
|
else {
|
|
99
73
|
this.scheduleFlush();
|
|
100
|
-
|
|
101
|
-
// Persist immediately once prior-session recovery has completed. If startup
|
|
102
|
-
// recovery is still in flight, write the merged buffer back afterwards.
|
|
103
|
-
if (this.loadedPersisted) {
|
|
104
|
-
this.persistBuffer();
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
void this.loadPersistedBuffer().then(() => {
|
|
108
|
-
if (!this.disposed && this.buffer.size > 0) {
|
|
109
|
-
this.persistBuffer();
|
|
110
|
-
}
|
|
111
|
-
});
|
|
74
|
+
void this.persistAfterRecovery();
|
|
112
75
|
}
|
|
113
76
|
}
|
|
114
77
|
catch {
|
|
115
78
|
// Never throw from telemetry
|
|
116
79
|
}
|
|
117
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Record an event and wait until it is durably stored before returning.
|
|
83
|
+
*
|
|
84
|
+
* Use this for user intent that must survive an immediate force-close. Network
|
|
85
|
+
* delivery starts after the local write, but callers do not wait on the network.
|
|
86
|
+
*/
|
|
87
|
+
async trackDurably(eventType, opts = {}) {
|
|
88
|
+
if (this.disposed)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
await this.loadPersistedBuffer();
|
|
92
|
+
if (this.disposed)
|
|
93
|
+
return;
|
|
94
|
+
this.recordEvent(eventType, opts);
|
|
95
|
+
await this.persistBufferAsync();
|
|
96
|
+
this.flushNow();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Never throw from telemetry or block the host capture flow.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
118
102
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
119
103
|
async flush() {
|
|
120
104
|
// Merge any persisted events from a previous session before flushing
|
|
@@ -125,6 +109,7 @@ export class TelemetryReporter {
|
|
|
125
109
|
const bufferedEntries = Array.from(this.buffer.entries());
|
|
126
110
|
const events = bufferedEntries.map(([, event]) => event);
|
|
127
111
|
this.buffer.clear();
|
|
112
|
+
this.inFlightBuffer = new Map(bufferedEntries);
|
|
128
113
|
this.clearFlushTimer();
|
|
129
114
|
const controller = new AbortController();
|
|
130
115
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
@@ -151,8 +136,10 @@ export class TelemetryReporter {
|
|
|
151
136
|
loggedDeliveryFailure = true;
|
|
152
137
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
153
138
|
}
|
|
154
|
-
|
|
155
|
-
|
|
139
|
+
this.inFlightBuffer.clear();
|
|
140
|
+
// Success — persist the current buffer. Usually empty, but it may contain
|
|
141
|
+
// events recorded while this request was in flight.
|
|
142
|
+
await this.persistBufferAsync();
|
|
156
143
|
}
|
|
157
144
|
catch (error) {
|
|
158
145
|
if (!loggedDeliveryFailure) {
|
|
@@ -175,6 +162,7 @@ export class TelemetryReporter {
|
|
|
175
162
|
this.buffer.set(dedupKey, event);
|
|
176
163
|
}
|
|
177
164
|
}
|
|
165
|
+
this.inFlightBuffer.clear();
|
|
178
166
|
}
|
|
179
167
|
finally {
|
|
180
168
|
clearTimeout(timeout);
|
|
@@ -208,6 +196,42 @@ export class TelemetryReporter {
|
|
|
208
196
|
this.flushTimer = null;
|
|
209
197
|
}
|
|
210
198
|
}
|
|
199
|
+
recordEvent(eventType, opts) {
|
|
200
|
+
const now = new Date().toISOString();
|
|
201
|
+
const errorObj = opts.error instanceof Error ? opts.error : null;
|
|
202
|
+
const errorMessage = errorObj?.message
|
|
203
|
+
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
204
|
+
const occurrenceId = opts.metadata?.capture_attempt_id;
|
|
205
|
+
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
|
|
206
|
+
const existing = this.buffer.get(dedupKey);
|
|
207
|
+
if (existing) {
|
|
208
|
+
existing.event_count++;
|
|
209
|
+
existing.last_occurred_at = now;
|
|
210
|
+
if (opts.metadata)
|
|
211
|
+
existing.metadata = opts.metadata;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.buffer.set(dedupKey, {
|
|
215
|
+
event_type: eventType,
|
|
216
|
+
component: opts.component,
|
|
217
|
+
error_message: errorMessage?.slice(0, 1000),
|
|
218
|
+
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
219
|
+
error_code: opts.errorCode,
|
|
220
|
+
metadata: opts.metadata,
|
|
221
|
+
sdk_platform: Platform.OS,
|
|
222
|
+
sdk_version: SDK_VERSION,
|
|
223
|
+
os_name: Platform.OS,
|
|
224
|
+
os_version: String(Platform.Version),
|
|
225
|
+
session_id: this.sessionId,
|
|
226
|
+
event_count: 1,
|
|
227
|
+
first_occurred_at: now,
|
|
228
|
+
last_occurred_at: now,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async persistAfterRecovery() {
|
|
232
|
+
await this.loadPersistedBuffer();
|
|
233
|
+
await this.persistBufferAsync();
|
|
234
|
+
}
|
|
211
235
|
/**
|
|
212
236
|
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
213
237
|
* Fire-and-forget — never throws or blocks.
|
|
@@ -216,24 +240,38 @@ export class TelemetryReporter {
|
|
|
216
240
|
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
217
241
|
*/
|
|
218
242
|
persistBuffer() {
|
|
243
|
+
void this.persistBufferAsync();
|
|
244
|
+
}
|
|
245
|
+
persistBufferAsync() {
|
|
219
246
|
if (!this.loadedPersisted)
|
|
220
|
-
return;
|
|
247
|
+
return Promise.resolve();
|
|
221
248
|
try {
|
|
222
249
|
const entries = {};
|
|
250
|
+
for (const [key, event] of this.inFlightBuffer) {
|
|
251
|
+
entries[key] = event;
|
|
252
|
+
}
|
|
223
253
|
for (const [key, event] of this.buffer) {
|
|
224
254
|
entries[key] = event;
|
|
225
255
|
}
|
|
226
|
-
|
|
256
|
+
// Serialize writes so an older fire-and-forget snapshot cannot overwrite a
|
|
257
|
+
// newer durable capture marker or resurrect an acknowledged event.
|
|
258
|
+
this.persistenceChain = this.persistenceChain
|
|
259
|
+
.catch(() => { })
|
|
260
|
+
.then(async () => {
|
|
261
|
+
const storage = await getStorage();
|
|
227
262
|
if (Object.keys(entries).length > 0) {
|
|
228
|
-
|
|
263
|
+
await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
|
|
229
264
|
}
|
|
230
265
|
else {
|
|
231
|
-
|
|
266
|
+
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
232
267
|
}
|
|
233
|
-
})
|
|
268
|
+
})
|
|
269
|
+
.catch(() => { });
|
|
270
|
+
return this.persistenceChain;
|
|
234
271
|
}
|
|
235
272
|
catch {
|
|
236
273
|
// Never throw from telemetry
|
|
274
|
+
return Promise.resolve();
|
|
237
275
|
}
|
|
238
276
|
}
|
|
239
277
|
/**
|
package/lib/types/index.d.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface ScannerTelemetryContext {
|
|
|
87
87
|
routeName?: string;
|
|
88
88
|
/** Whether the host app keeps this screen portrait-locked. */
|
|
89
89
|
isPortraitLocked?: boolean;
|
|
90
|
+
/** Host-owned identifier used to correlate scanner telemetry with its workflow. */
|
|
91
|
+
hostReferenceId?: string;
|
|
90
92
|
}
|
|
91
93
|
/**
|
|
92
94
|
* Theme customization for the scanner overlay.
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.5.
|
|
1
|
+
export declare const SDK_VERSION = "2.5.5";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.5';
|
package/package.json
CHANGED
|
@@ -409,6 +409,7 @@ export function VerifyAIScanner({
|
|
|
409
409
|
device_os_version: String(Platform.Version),
|
|
410
410
|
route_name: telemetryContext?.routeName,
|
|
411
411
|
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
412
|
+
host_reference_id: telemetryContext?.hostReferenceId,
|
|
412
413
|
window_width: windowWidth,
|
|
413
414
|
window_height: windowHeight,
|
|
414
415
|
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
@@ -1125,15 +1126,20 @@ export function VerifyAIScanner({
|
|
|
1125
1126
|
? 'exhausted'
|
|
1126
1127
|
: null;
|
|
1127
1128
|
|
|
1128
|
-
telemetry
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1129
|
+
if (telemetry) {
|
|
1130
|
+
// Do not begin native capture until the user's shutter intent is on disk.
|
|
1131
|
+
// If the process is force-closed during capture or delivery, the next SDK
|
|
1132
|
+
// initialization recovers and sends this exact capture_attempt_id.
|
|
1133
|
+
await telemetry.trackDurably('camera_capture_request', {
|
|
1134
|
+
component: 'scanner',
|
|
1135
|
+
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
1136
|
+
metadata: buildScannerTelemetryMetadata({
|
|
1137
|
+
capture_attempt_id: captureAttemptId,
|
|
1138
|
+
capture_sequence: captureSequence,
|
|
1139
|
+
capture_blocked_reason: blockedReason,
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1137
1143
|
|
|
1138
1144
|
if (blockedReason && blockedReason !== 'camera_not_ready') return;
|
|
1139
1145
|
if (blockedReason === 'camera_not_ready') {
|
|
@@ -34,12 +34,20 @@ interface BufferedEvent {
|
|
|
34
34
|
dedupKey: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
interface TelemetryTrackOptions {
|
|
38
|
+
component?: string;
|
|
39
|
+
error?: unknown;
|
|
40
|
+
errorCode?: string;
|
|
41
|
+
metadata?: Record<string, string | number>;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/** Event types that flush immediately (critical init failures). */
|
|
38
45
|
const CRITICAL_EVENTS = new Set([
|
|
39
46
|
'camera_init_failure',
|
|
40
47
|
'camera_preview_timeout',
|
|
41
48
|
'camera_startup_slow',
|
|
42
49
|
'camera_permission_denied',
|
|
50
|
+
'camera_capture_request',
|
|
43
51
|
'camera_scanner_mounted',
|
|
44
52
|
'camera_scanner_disposed',
|
|
45
53
|
'camera_android_native_orientation_started',
|
|
@@ -55,6 +63,7 @@ const CRITICAL_EVENTS = new Set([
|
|
|
55
63
|
|
|
56
64
|
export class TelemetryReporter {
|
|
57
65
|
private buffer: Map<string, TelemetryEvent> = new Map();
|
|
66
|
+
private inFlightBuffer: Map<string, TelemetryEvent> = new Map();
|
|
58
67
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
59
68
|
private sessionId: string;
|
|
60
69
|
private baseUrl: string;
|
|
@@ -63,6 +72,7 @@ export class TelemetryReporter {
|
|
|
63
72
|
private flushing = false;
|
|
64
73
|
private loadedPersisted = false;
|
|
65
74
|
private loadPersistedPromise: Promise<void> | null = null;
|
|
75
|
+
private persistenceChain: Promise<void> = Promise.resolve();
|
|
66
76
|
|
|
67
77
|
constructor(apiKey: string, baseUrl: string) {
|
|
68
78
|
this.apiKey = apiKey;
|
|
@@ -94,75 +104,49 @@ export class TelemetryReporter {
|
|
|
94
104
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
95
105
|
track(
|
|
96
106
|
eventType: string,
|
|
97
|
-
opts: {
|
|
98
|
-
component?: string;
|
|
99
|
-
error?: unknown;
|
|
100
|
-
errorCode?: string;
|
|
101
|
-
metadata?: Record<string, string | number>;
|
|
102
|
-
} = {},
|
|
107
|
+
opts: TelemetryTrackOptions = {},
|
|
103
108
|
): void {
|
|
104
109
|
if (this.disposed) return;
|
|
105
110
|
|
|
106
111
|
try {
|
|
107
|
-
|
|
108
|
-
const errorObj = opts.error instanceof Error ? opts.error : null;
|
|
109
|
-
const errorMessage = errorObj?.message
|
|
110
|
-
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
111
|
-
|
|
112
|
-
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
|
|
113
|
-
|
|
114
|
-
const existing = this.buffer.get(dedupKey);
|
|
115
|
-
if (existing) {
|
|
116
|
-
existing.event_count++;
|
|
117
|
-
existing.last_occurred_at = now;
|
|
118
|
-
// If the new occurrence is critical, flush immediately
|
|
119
|
-
if (CRITICAL_EVENTS.has(eventType)) {
|
|
120
|
-
this.flushNow();
|
|
121
|
-
}
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const event: TelemetryEvent = {
|
|
126
|
-
event_type: eventType,
|
|
127
|
-
component: opts.component,
|
|
128
|
-
error_message: errorMessage?.slice(0, 1000),
|
|
129
|
-
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
130
|
-
error_code: opts.errorCode,
|
|
131
|
-
metadata: opts.metadata,
|
|
132
|
-
sdk_platform: Platform.OS,
|
|
133
|
-
sdk_version: SDK_VERSION,
|
|
134
|
-
os_name: Platform.OS,
|
|
135
|
-
os_version: String(Platform.Version),
|
|
136
|
-
session_id: this.sessionId,
|
|
137
|
-
event_count: 1,
|
|
138
|
-
first_occurred_at: now,
|
|
139
|
-
last_occurred_at: now,
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
this.buffer.set(dedupKey, event);
|
|
112
|
+
this.recordEvent(eventType, opts);
|
|
143
113
|
|
|
144
114
|
if (CRITICAL_EVENTS.has(eventType)) {
|
|
145
|
-
|
|
115
|
+
// Critical events are written locally before delivery starts. This keeps
|
|
116
|
+
// them recoverable if the process is killed while the POST is in flight.
|
|
117
|
+
void this.persistAfterRecovery().then(() => this.flushNow());
|
|
146
118
|
} else {
|
|
147
119
|
this.scheduleFlush();
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// Persist immediately once prior-session recovery has completed. If startup
|
|
151
|
-
// recovery is still in flight, write the merged buffer back afterwards.
|
|
152
|
-
if (this.loadedPersisted) {
|
|
153
|
-
this.persistBuffer();
|
|
154
|
-
} else {
|
|
155
|
-
void this.loadPersistedBuffer().then(() => {
|
|
156
|
-
if (!this.disposed && this.buffer.size > 0) {
|
|
157
|
-
this.persistBuffer();
|
|
158
|
-
}
|
|
159
|
-
});
|
|
120
|
+
void this.persistAfterRecovery();
|
|
160
121
|
}
|
|
161
122
|
} catch {
|
|
162
123
|
// Never throw from telemetry
|
|
163
124
|
}
|
|
164
125
|
}
|
|
165
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Record an event and wait until it is durably stored before returning.
|
|
129
|
+
*
|
|
130
|
+
* Use this for user intent that must survive an immediate force-close. Network
|
|
131
|
+
* delivery starts after the local write, but callers do not wait on the network.
|
|
132
|
+
*/
|
|
133
|
+
async trackDurably(
|
|
134
|
+
eventType: string,
|
|
135
|
+
opts: TelemetryTrackOptions = {},
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
if (this.disposed) return;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await this.loadPersistedBuffer();
|
|
141
|
+
if (this.disposed) return;
|
|
142
|
+
this.recordEvent(eventType, opts);
|
|
143
|
+
await this.persistBufferAsync();
|
|
144
|
+
this.flushNow();
|
|
145
|
+
} catch {
|
|
146
|
+
// Never throw from telemetry or block the host capture flow.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
166
150
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
167
151
|
async flush(): Promise<void> {
|
|
168
152
|
// Merge any persisted events from a previous session before flushing
|
|
@@ -174,6 +158,7 @@ export class TelemetryReporter {
|
|
|
174
158
|
const bufferedEntries = Array.from(this.buffer.entries());
|
|
175
159
|
const events = bufferedEntries.map(([, event]) => event);
|
|
176
160
|
this.buffer.clear();
|
|
161
|
+
this.inFlightBuffer = new Map(bufferedEntries);
|
|
177
162
|
this.clearFlushTimer();
|
|
178
163
|
const controller = new AbortController();
|
|
179
164
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
@@ -202,8 +187,10 @@ export class TelemetryReporter {
|
|
|
202
187
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
203
188
|
}
|
|
204
189
|
|
|
205
|
-
|
|
206
|
-
|
|
190
|
+
this.inFlightBuffer.clear();
|
|
191
|
+
// Success — persist the current buffer. Usually empty, but it may contain
|
|
192
|
+
// events recorded while this request was in flight.
|
|
193
|
+
await this.persistBufferAsync();
|
|
207
194
|
} catch (error) {
|
|
208
195
|
if (!loggedDeliveryFailure) {
|
|
209
196
|
const kind = error instanceof Error ? error.name : typeof error;
|
|
@@ -224,6 +211,7 @@ export class TelemetryReporter {
|
|
|
224
211
|
this.buffer.set(dedupKey, event);
|
|
225
212
|
}
|
|
226
213
|
}
|
|
214
|
+
this.inFlightBuffer.clear();
|
|
227
215
|
} finally {
|
|
228
216
|
clearTimeout(timeout);
|
|
229
217
|
this.flushing = false;
|
|
@@ -260,6 +248,45 @@ export class TelemetryReporter {
|
|
|
260
248
|
}
|
|
261
249
|
}
|
|
262
250
|
|
|
251
|
+
private recordEvent(eventType: string, opts: TelemetryTrackOptions): void {
|
|
252
|
+
const now = new Date().toISOString();
|
|
253
|
+
const errorObj = opts.error instanceof Error ? opts.error : null;
|
|
254
|
+
const errorMessage = errorObj?.message
|
|
255
|
+
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
256
|
+
const occurrenceId = opts.metadata?.capture_attempt_id;
|
|
257
|
+
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
|
|
258
|
+
|
|
259
|
+
const existing = this.buffer.get(dedupKey);
|
|
260
|
+
if (existing) {
|
|
261
|
+
existing.event_count++;
|
|
262
|
+
existing.last_occurred_at = now;
|
|
263
|
+
if (opts.metadata) existing.metadata = opts.metadata;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.buffer.set(dedupKey, {
|
|
268
|
+
event_type: eventType,
|
|
269
|
+
component: opts.component,
|
|
270
|
+
error_message: errorMessage?.slice(0, 1000),
|
|
271
|
+
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
272
|
+
error_code: opts.errorCode,
|
|
273
|
+
metadata: opts.metadata,
|
|
274
|
+
sdk_platform: Platform.OS,
|
|
275
|
+
sdk_version: SDK_VERSION,
|
|
276
|
+
os_name: Platform.OS,
|
|
277
|
+
os_version: String(Platform.Version),
|
|
278
|
+
session_id: this.sessionId,
|
|
279
|
+
event_count: 1,
|
|
280
|
+
first_occurred_at: now,
|
|
281
|
+
last_occurred_at: now,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async persistAfterRecovery(): Promise<void> {
|
|
286
|
+
await this.loadPersistedBuffer();
|
|
287
|
+
await this.persistBufferAsync();
|
|
288
|
+
}
|
|
289
|
+
|
|
263
290
|
/**
|
|
264
291
|
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
265
292
|
* Fire-and-forget — never throws or blocks.
|
|
@@ -268,21 +295,38 @@ export class TelemetryReporter {
|
|
|
268
295
|
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
269
296
|
*/
|
|
270
297
|
private persistBuffer(): void {
|
|
271
|
-
|
|
298
|
+
void this.persistBufferAsync();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private persistBufferAsync(): Promise<void> {
|
|
302
|
+
if (!this.loadedPersisted) return Promise.resolve();
|
|
303
|
+
|
|
272
304
|
try {
|
|
273
305
|
const entries: Record<string, TelemetryEvent> = {};
|
|
306
|
+
for (const [key, event] of this.inFlightBuffer) {
|
|
307
|
+
entries[key] = event;
|
|
308
|
+
}
|
|
274
309
|
for (const [key, event] of this.buffer) {
|
|
275
310
|
entries[key] = event;
|
|
276
311
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
312
|
+
|
|
313
|
+
// Serialize writes so an older fire-and-forget snapshot cannot overwrite a
|
|
314
|
+
// newer durable capture marker or resurrect an acknowledged event.
|
|
315
|
+
this.persistenceChain = this.persistenceChain
|
|
316
|
+
.catch(() => { /* keep the write queue alive */ })
|
|
317
|
+
.then(async () => {
|
|
318
|
+
const storage = await getStorage();
|
|
319
|
+
if (Object.keys(entries).length > 0) {
|
|
320
|
+
await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
|
|
321
|
+
} else {
|
|
322
|
+
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
.catch(() => { /* never throw from telemetry */ });
|
|
326
|
+
return this.persistenceChain;
|
|
284
327
|
} catch {
|
|
285
328
|
// Never throw from telemetry
|
|
329
|
+
return Promise.resolve();
|
|
286
330
|
}
|
|
287
331
|
}
|
|
288
332
|
|
package/src/types/index.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface ScannerTelemetryContext {
|
|
|
98
98
|
routeName?: string;
|
|
99
99
|
/** Whether the host app keeps this screen portrait-locked. */
|
|
100
100
|
isPortraitLocked?: boolean;
|
|
101
|
+
/** Host-owned identifier used to correlate scanner telemetry with its workflow. */
|
|
102
|
+
hostReferenceId?: string;
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
/**
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.5';
|