@switchlabs/verify-ai-react-native 2.5.5 → 2.5.6
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 +39 -15
- package/lib/telemetry/TelemetryReporter.d.ts +7 -9
- package/lib/telemetry/TelemetryReporter.js +164 -82
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +27 -3
- package/src/telemetry/TelemetryReporter.ts +162 -87
- package/src/version.ts +1 -1
|
@@ -272,6 +272,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
272
272
|
const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
|
|
273
273
|
const scannerSessionIdRef = useRef(createScannerSessionId());
|
|
274
274
|
const captureSequenceRef = useRef(0);
|
|
275
|
+
const captureIntentPendingRef = useRef(false);
|
|
275
276
|
const torchRetrySuppressedRef = useRef(false);
|
|
276
277
|
const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
|
|
277
278
|
const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
|
|
@@ -351,6 +352,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
351
352
|
policy,
|
|
352
353
|
status,
|
|
353
354
|
telemetryContext?.isPortraitLocked,
|
|
355
|
+
telemetryContext?.hostReferenceId,
|
|
354
356
|
telemetryContext?.routeName,
|
|
355
357
|
terminated,
|
|
356
358
|
windowHeight,
|
|
@@ -438,6 +440,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
438
440
|
device_os_version: String(Platform.Version),
|
|
439
441
|
route_name: telemetryContext?.routeName,
|
|
440
442
|
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
443
|
+
host_reference_id: telemetryContext?.hostReferenceId,
|
|
441
444
|
android_native_orientation_subscription_active: 0,
|
|
442
445
|
android_native_orientation_event_count: 0,
|
|
443
446
|
android_native_orientation_change_count: 0,
|
|
@@ -616,7 +619,13 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
616
619
|
trackAndroidOrientationEvent('camera_android_accelerometer_stopped', 'android_accelerometer_tracking_stopped', { accelerometer_stop_reason: 'orientation_tracking_stopped' });
|
|
617
620
|
}
|
|
618
621
|
};
|
|
619
|
-
}, [
|
|
622
|
+
}, [
|
|
623
|
+
policy,
|
|
624
|
+
telemetry,
|
|
625
|
+
telemetryContext?.hostReferenceId,
|
|
626
|
+
telemetryContext?.isPortraitLocked,
|
|
627
|
+
telemetryContext?.routeName,
|
|
628
|
+
]);
|
|
620
629
|
// Detect orientation changes and remount camera after rotation settles.
|
|
621
630
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
622
631
|
// animation — the native preview layer initializes with transitional bounds.
|
|
@@ -902,24 +911,28 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
902
911
|
const captureSequence = captureSequenceRef.current + 1;
|
|
903
912
|
captureSequenceRef.current = captureSequence;
|
|
904
913
|
const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
|
|
905
|
-
const blockedReason =
|
|
906
|
-
? '
|
|
907
|
-
: !
|
|
908
|
-
? '
|
|
909
|
-
:
|
|
910
|
-
? '
|
|
911
|
-
: status === '
|
|
912
|
-
? '
|
|
913
|
-
:
|
|
914
|
-
? '
|
|
915
|
-
:
|
|
916
|
-
? '
|
|
917
|
-
:
|
|
914
|
+
const blockedReason = captureIntentPendingRef.current
|
|
915
|
+
? 'capture_intent_pending'
|
|
916
|
+
: !cameraRef.current
|
|
917
|
+
? 'camera_ref_null'
|
|
918
|
+
: !cameraReadyRef.current
|
|
919
|
+
? 'camera_not_ready'
|
|
920
|
+
: status === 'capturing'
|
|
921
|
+
? 'already_capturing'
|
|
922
|
+
: status === 'processing'
|
|
923
|
+
? 'already_processing'
|
|
924
|
+
: terminated
|
|
925
|
+
? 'terminated'
|
|
926
|
+
: exhausted
|
|
927
|
+
? 'exhausted'
|
|
928
|
+
: null;
|
|
929
|
+
if (blockedReason == null)
|
|
930
|
+
captureIntentPendingRef.current = true;
|
|
918
931
|
if (telemetry) {
|
|
919
932
|
// Do not begin native capture until the user's shutter intent is on disk.
|
|
920
933
|
// If the process is force-closed during capture or delivery, the next SDK
|
|
921
934
|
// initialization recovers and sends this exact capture_attempt_id.
|
|
922
|
-
await telemetry.trackDurably('camera_capture_request', {
|
|
935
|
+
const persisted = await telemetry.trackDurably('camera_capture_request', {
|
|
923
936
|
component: 'scanner',
|
|
924
937
|
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
925
938
|
metadata: buildScannerTelemetryMetadata({
|
|
@@ -928,6 +941,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
928
941
|
capture_blocked_reason: blockedReason,
|
|
929
942
|
}),
|
|
930
943
|
});
|
|
944
|
+
if (!persisted) {
|
|
945
|
+
telemetry.track('telemetry_persist_failure', {
|
|
946
|
+
component: 'scanner',
|
|
947
|
+
error: 'capture_intent_persist_failed',
|
|
948
|
+
metadata: buildScannerTelemetryMetadata({
|
|
949
|
+
capture_attempt_id: captureAttemptId,
|
|
950
|
+
capture_sequence: captureSequence,
|
|
951
|
+
}),
|
|
952
|
+
});
|
|
953
|
+
}
|
|
931
954
|
}
|
|
932
955
|
if (blockedReason && blockedReason !== 'camera_not_ready')
|
|
933
956
|
return;
|
|
@@ -1229,6 +1252,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1229
1252
|
}
|
|
1230
1253
|
}
|
|
1231
1254
|
finally {
|
|
1255
|
+
captureIntentPendingRef.current = false;
|
|
1232
1256
|
setTorchSuppressedForRetry(false);
|
|
1233
1257
|
}
|
|
1234
1258
|
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
|
|
@@ -7,15 +7,18 @@ interface TelemetryTrackOptions {
|
|
|
7
7
|
export declare class TelemetryReporter {
|
|
8
8
|
private buffer;
|
|
9
9
|
private inFlightBuffer;
|
|
10
|
+
private dedupIndex;
|
|
10
11
|
private flushTimer;
|
|
11
12
|
private sessionId;
|
|
12
13
|
private baseUrl;
|
|
13
14
|
private apiKey;
|
|
15
|
+
private storagePrefix;
|
|
14
16
|
private disposed;
|
|
15
17
|
private flushing;
|
|
16
18
|
private loadedPersisted;
|
|
17
19
|
private loadPersistedPromise;
|
|
18
20
|
private persistenceChain;
|
|
21
|
+
private eventSequence;
|
|
19
22
|
constructor(apiKey: string, baseUrl: string);
|
|
20
23
|
/** Telemetry POST destination — exposed so the scanner can log an init banner
|
|
21
24
|
* that helps field debugging confirm where events are being sent. */
|
|
@@ -31,7 +34,7 @@ export declare class TelemetryReporter {
|
|
|
31
34
|
* Use this for user intent that must survive an immediate force-close. Network
|
|
32
35
|
* delivery starts after the local write, but callers do not wait on the network.
|
|
33
36
|
*/
|
|
34
|
-
trackDurably(eventType: string, opts?: TelemetryTrackOptions): Promise<
|
|
37
|
+
trackDurably(eventType: string, opts?: TelemetryTrackOptions): Promise<boolean>;
|
|
35
38
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
36
39
|
flush(): Promise<void>;
|
|
37
40
|
/** Dispose — flush remaining events and stop timers. */
|
|
@@ -41,15 +44,10 @@ export declare class TelemetryReporter {
|
|
|
41
44
|
private clearFlushTimer;
|
|
42
45
|
private recordEvent;
|
|
43
46
|
private persistAfterRecovery;
|
|
44
|
-
|
|
45
|
-
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
46
|
-
* Fire-and-forget — never throws or blocks.
|
|
47
|
-
*
|
|
48
|
-
* IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
|
|
49
|
-
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
50
|
-
*/
|
|
51
|
-
private persistBuffer;
|
|
47
|
+
private createJournalKey;
|
|
52
48
|
private persistBufferAsync;
|
|
49
|
+
private removeJournalEntries;
|
|
50
|
+
private releaseClaims;
|
|
53
51
|
/**
|
|
54
52
|
* Load persisted events from a previous session and merge into current buffer.
|
|
55
53
|
* Runs once per instance. If orphaned events are found, emits an
|
|
@@ -8,7 +8,22 @@ async function getStorage() {
|
|
|
8
8
|
}
|
|
9
9
|
return _storage;
|
|
10
10
|
}
|
|
11
|
-
const
|
|
11
|
+
const LEGACY_TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
|
|
12
|
+
const TELEMETRY_EVENT_KEY_PREFIX = '@verifyai/telemetry_event/';
|
|
13
|
+
const MAX_EVENTS_PER_REQUEST = 25;
|
|
14
|
+
// Per-event journal keys avoid shared full-buffer replacement. Claims prevent
|
|
15
|
+
// two reporters in the same process from recovering/sending the same event.
|
|
16
|
+
const claimedEventKeys = new Set();
|
|
17
|
+
let legacyBufferClaimed = false;
|
|
18
|
+
function telemetryTargetId(apiKey, baseUrl) {
|
|
19
|
+
let hash = 2166136261;
|
|
20
|
+
const input = `${baseUrl}|${apiKey}`;
|
|
21
|
+
for (let index = 0; index < input.length; index++) {
|
|
22
|
+
hash ^= input.charCodeAt(index);
|
|
23
|
+
hash = Math.imul(hash, 16777619);
|
|
24
|
+
}
|
|
25
|
+
return (hash >>> 0).toString(36);
|
|
26
|
+
}
|
|
12
27
|
/** Event types that flush immediately (critical init failures). */
|
|
13
28
|
const CRITICAL_EVENTS = new Set([
|
|
14
29
|
'camera_init_failure',
|
|
@@ -16,6 +31,7 @@ const CRITICAL_EVENTS = new Set([
|
|
|
16
31
|
'camera_startup_slow',
|
|
17
32
|
'camera_permission_denied',
|
|
18
33
|
'camera_capture_request',
|
|
34
|
+
'telemetry_persist_failure',
|
|
19
35
|
'camera_scanner_mounted',
|
|
20
36
|
'camera_scanner_disposed',
|
|
21
37
|
'camera_android_native_orientation_started',
|
|
@@ -32,14 +48,17 @@ export class TelemetryReporter {
|
|
|
32
48
|
constructor(apiKey, baseUrl) {
|
|
33
49
|
this.buffer = new Map();
|
|
34
50
|
this.inFlightBuffer = new Map();
|
|
51
|
+
this.dedupIndex = new Map();
|
|
35
52
|
this.flushTimer = null;
|
|
36
53
|
this.disposed = false;
|
|
37
54
|
this.flushing = false;
|
|
38
55
|
this.loadedPersisted = false;
|
|
39
56
|
this.loadPersistedPromise = null;
|
|
40
57
|
this.persistenceChain = Promise.resolve();
|
|
58
|
+
this.eventSequence = 0;
|
|
41
59
|
this.apiKey = apiKey;
|
|
42
60
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
61
|
+
this.storagePrefix = `${TELEMETRY_EVENT_KEY_PREFIX}${telemetryTargetId(apiKey, this.baseUrl)}/`;
|
|
43
62
|
this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
44
63
|
// Load any persisted events left behind by a previous session
|
|
45
64
|
this.loadPersistedBuffer();
|
|
@@ -86,17 +105,19 @@ export class TelemetryReporter {
|
|
|
86
105
|
*/
|
|
87
106
|
async trackDurably(eventType, opts = {}) {
|
|
88
107
|
if (this.disposed)
|
|
89
|
-
return;
|
|
108
|
+
return false;
|
|
90
109
|
try {
|
|
91
110
|
await this.loadPersistedBuffer();
|
|
92
111
|
if (this.disposed)
|
|
93
|
-
return;
|
|
112
|
+
return false;
|
|
94
113
|
this.recordEvent(eventType, opts);
|
|
95
|
-
await this.persistBufferAsync();
|
|
114
|
+
const persisted = await this.persistBufferAsync();
|
|
96
115
|
this.flushNow();
|
|
116
|
+
return persisted;
|
|
97
117
|
}
|
|
98
118
|
catch {
|
|
99
119
|
// Never throw from telemetry or block the host capture flow.
|
|
120
|
+
return false;
|
|
100
121
|
}
|
|
101
122
|
}
|
|
102
123
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
@@ -106,14 +127,16 @@ export class TelemetryReporter {
|
|
|
106
127
|
if (this.buffer.size === 0 || this.flushing)
|
|
107
128
|
return;
|
|
108
129
|
this.flushing = true;
|
|
109
|
-
const bufferedEntries = Array.from(this.buffer.entries());
|
|
130
|
+
const bufferedEntries = Array.from(this.buffer.entries()).slice(0, MAX_EVENTS_PER_REQUEST);
|
|
110
131
|
const events = bufferedEntries.map(([, event]) => event);
|
|
111
|
-
|
|
132
|
+
for (const [key] of bufferedEntries)
|
|
133
|
+
this.buffer.delete(key);
|
|
112
134
|
this.inFlightBuffer = new Map(bufferedEntries);
|
|
113
135
|
this.clearFlushTimer();
|
|
114
136
|
const controller = new AbortController();
|
|
115
137
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
116
138
|
let loggedDeliveryFailure = false;
|
|
139
|
+
let delivered = false;
|
|
117
140
|
try {
|
|
118
141
|
const response = await fetch(`${this.baseUrl}/telemetry`, {
|
|
119
142
|
method: 'POST',
|
|
@@ -136,10 +159,19 @@ export class TelemetryReporter {
|
|
|
136
159
|
loggedDeliveryFailure = true;
|
|
137
160
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
138
161
|
}
|
|
162
|
+
const acknowledgedKeys = bufferedEntries.map(([key]) => key);
|
|
163
|
+
const acknowledgedKeySet = new Set(acknowledgedKeys);
|
|
164
|
+
const removed = await this.removeJournalEntries(acknowledgedKeys);
|
|
165
|
+
if (removed) {
|
|
166
|
+
for (const key of acknowledgedKeys)
|
|
167
|
+
claimedEventKeys.delete(key);
|
|
168
|
+
}
|
|
139
169
|
this.inFlightBuffer.clear();
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
170
|
+
for (const [dedupKey, journalKey] of this.dedupIndex) {
|
|
171
|
+
if (acknowledgedKeySet.has(journalKey))
|
|
172
|
+
this.dedupIndex.delete(dedupKey);
|
|
173
|
+
}
|
|
174
|
+
delivered = true;
|
|
143
175
|
}
|
|
144
176
|
catch (error) {
|
|
145
177
|
if (!loggedDeliveryFailure) {
|
|
@@ -171,12 +203,17 @@ export class TelemetryReporter {
|
|
|
171
203
|
this.scheduleFlush();
|
|
172
204
|
}
|
|
173
205
|
}
|
|
206
|
+
// Drain oversized recovered journals in API-sized chunks. A failed chunk is
|
|
207
|
+
// rebuffered and left for the normal retry timer instead of spinning.
|
|
208
|
+
if (delivered && this.buffer.size > 0 && !this.disposed) {
|
|
209
|
+
await this.flush();
|
|
210
|
+
}
|
|
174
211
|
}
|
|
175
212
|
/** Dispose — flush remaining events and stop timers. */
|
|
176
213
|
dispose() {
|
|
177
214
|
this.disposed = true;
|
|
178
215
|
this.clearFlushTimer();
|
|
179
|
-
this.flush();
|
|
216
|
+
void this.flush().finally(() => this.releaseClaims());
|
|
180
217
|
}
|
|
181
218
|
scheduleFlush() {
|
|
182
219
|
if (this.flushTimer)
|
|
@@ -203,21 +240,31 @@ export class TelemetryReporter {
|
|
|
203
240
|
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
204
241
|
const occurrenceId = opts.metadata?.capture_attempt_id;
|
|
205
242
|
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
|
|
206
|
-
const
|
|
243
|
+
const existingJournalKey = this.dedupIndex.get(dedupKey);
|
|
244
|
+
const existing = existingJournalKey
|
|
245
|
+
? this.buffer.get(existingJournalKey)
|
|
246
|
+
: undefined;
|
|
207
247
|
if (existing) {
|
|
208
248
|
existing.event_count++;
|
|
209
249
|
existing.last_occurred_at = now;
|
|
210
250
|
if (opts.metadata)
|
|
211
|
-
existing.metadata = opts.metadata;
|
|
251
|
+
existing.metadata = { ...existing.metadata, ...opts.metadata };
|
|
212
252
|
return;
|
|
213
253
|
}
|
|
214
|
-
this.
|
|
254
|
+
const journalKey = this.createJournalKey();
|
|
255
|
+
this.dedupIndex.set(dedupKey, journalKey);
|
|
256
|
+
claimedEventKeys.add(journalKey);
|
|
257
|
+
const eventMetadata = {
|
|
258
|
+
...opts.metadata,
|
|
259
|
+
telemetry_event_id: journalKey.slice(this.storagePrefix.length),
|
|
260
|
+
};
|
|
261
|
+
this.buffer.set(journalKey, {
|
|
215
262
|
event_type: eventType,
|
|
216
263
|
component: opts.component,
|
|
217
264
|
error_message: errorMessage?.slice(0, 1000),
|
|
218
265
|
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
219
266
|
error_code: opts.errorCode,
|
|
220
|
-
metadata:
|
|
267
|
+
metadata: eventMetadata,
|
|
221
268
|
sdk_platform: Platform.OS,
|
|
222
269
|
sdk_version: SDK_VERSION,
|
|
223
270
|
os_name: Platform.OS,
|
|
@@ -232,48 +279,59 @@ export class TelemetryReporter {
|
|
|
232
279
|
await this.loadPersistedBuffer();
|
|
233
280
|
await this.persistBufferAsync();
|
|
234
281
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
*
|
|
239
|
-
* IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
|
|
240
|
-
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
241
|
-
*/
|
|
242
|
-
persistBuffer() {
|
|
243
|
-
void this.persistBufferAsync();
|
|
282
|
+
createJournalKey() {
|
|
283
|
+
this.eventSequence++;
|
|
284
|
+
return `${this.storagePrefix}${this.sessionId}:${this.eventSequence.toString(36)}`;
|
|
244
285
|
}
|
|
245
|
-
persistBufferAsync() {
|
|
286
|
+
async persistBufferAsync() {
|
|
246
287
|
if (!this.loadedPersisted)
|
|
247
|
-
return Promise.resolve();
|
|
288
|
+
return Promise.resolve(false);
|
|
248
289
|
try {
|
|
249
|
-
const entries =
|
|
250
|
-
|
|
251
|
-
entries
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
entries[key] = event;
|
|
255
|
-
}
|
|
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
|
|
290
|
+
const entries = [
|
|
291
|
+
...this.inFlightBuffer.entries(),
|
|
292
|
+
...this.buffer.entries(),
|
|
293
|
+
];
|
|
294
|
+
const operation = this.persistenceChain
|
|
259
295
|
.catch(() => { })
|
|
260
296
|
.then(async () => {
|
|
261
297
|
const storage = await getStorage();
|
|
262
|
-
if (
|
|
263
|
-
await storage.
|
|
264
|
-
}
|
|
265
|
-
else {
|
|
266
|
-
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
298
|
+
if (entries.length > 0) {
|
|
299
|
+
await storage.multiSet(entries.map(([key, event]) => [key, JSON.stringify(event)]));
|
|
267
300
|
}
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
|
|
301
|
+
});
|
|
302
|
+
this.persistenceChain = operation.catch(() => { });
|
|
303
|
+
await operation;
|
|
304
|
+
return true;
|
|
271
305
|
}
|
|
272
306
|
catch {
|
|
273
307
|
// Never throw from telemetry
|
|
274
|
-
return
|
|
308
|
+
return false;
|
|
275
309
|
}
|
|
276
310
|
}
|
|
311
|
+
async removeJournalEntries(keys) {
|
|
312
|
+
if (keys.length === 0)
|
|
313
|
+
return true;
|
|
314
|
+
try {
|
|
315
|
+
const operation = this.persistenceChain
|
|
316
|
+
.catch(() => { })
|
|
317
|
+
.then(async () => {
|
|
318
|
+
const storage = await getStorage();
|
|
319
|
+
await storage.multiRemove(keys);
|
|
320
|
+
});
|
|
321
|
+
this.persistenceChain = operation.catch(() => { });
|
|
322
|
+
await operation;
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
releaseClaims() {
|
|
330
|
+
for (const key of this.buffer.keys())
|
|
331
|
+
claimedEventKeys.delete(key);
|
|
332
|
+
for (const key of this.inFlightBuffer.keys())
|
|
333
|
+
claimedEventKeys.delete(key);
|
|
334
|
+
}
|
|
277
335
|
/**
|
|
278
336
|
* Load persisted events from a previous session and merge into current buffer.
|
|
279
337
|
* Runs once per instance. If orphaned events are found, emits an
|
|
@@ -288,65 +346,89 @@ export class TelemetryReporter {
|
|
|
288
346
|
return;
|
|
289
347
|
}
|
|
290
348
|
this.loadPersistedPromise = (async () => {
|
|
349
|
+
const claimedDuringRecovery = [];
|
|
291
350
|
try {
|
|
292
351
|
const storage = await getStorage();
|
|
293
|
-
const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
|
|
294
|
-
if (!raw)
|
|
295
|
-
return;
|
|
296
|
-
const entries = JSON.parse(raw);
|
|
297
352
|
let orphanedCount = 0;
|
|
298
353
|
const orphanedTypes = [];
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
354
|
+
const allKeys = await storage.getAllKeys();
|
|
355
|
+
const recoverableKeys = allKeys.filter((key) => {
|
|
356
|
+
if (!key.startsWith(this.storagePrefix))
|
|
357
|
+
return false;
|
|
358
|
+
if (claimedEventKeys.has(key))
|
|
359
|
+
return false;
|
|
360
|
+
claimedEventKeys.add(key);
|
|
361
|
+
claimedDuringRecovery.push(key);
|
|
362
|
+
return true;
|
|
363
|
+
});
|
|
364
|
+
const recoveredPairs = recoverableKeys.length > 0
|
|
365
|
+
? await storage.multiGet(recoverableKeys)
|
|
366
|
+
: [];
|
|
367
|
+
for (const [key, raw] of recoveredPairs) {
|
|
368
|
+
if (!raw) {
|
|
369
|
+
claimedEventKeys.delete(key);
|
|
370
|
+
continue;
|
|
314
371
|
}
|
|
315
|
-
|
|
372
|
+
try {
|
|
373
|
+
const event = JSON.parse(raw);
|
|
374
|
+
orphanedCount++;
|
|
375
|
+
orphanedTypes.push(event.event_type);
|
|
316
376
|
this.buffer.set(key, event);
|
|
317
377
|
}
|
|
378
|
+
catch {
|
|
379
|
+
claimedEventKeys.delete(key);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Migrate the pre-2.5.6 shared snapshot without a remove/rewrite gap:
|
|
383
|
+
// write each event journal entry first, then delete the legacy key.
|
|
384
|
+
if (!legacyBufferClaimed && allKeys.includes(LEGACY_TELEMETRY_PERSIST_KEY)) {
|
|
385
|
+
legacyBufferClaimed = true;
|
|
386
|
+
let migrated = false;
|
|
387
|
+
try {
|
|
388
|
+
const legacyRaw = await storage.getItem(LEGACY_TELEMETRY_PERSIST_KEY);
|
|
389
|
+
if (legacyRaw) {
|
|
390
|
+
const legacyEntries = JSON.parse(legacyRaw);
|
|
391
|
+
const migrationPairs = [];
|
|
392
|
+
for (const event of Object.values(legacyEntries)) {
|
|
393
|
+
const key = this.createJournalKey();
|
|
394
|
+
claimedEventKeys.add(key);
|
|
395
|
+
this.buffer.set(key, event);
|
|
396
|
+
orphanedCount++;
|
|
397
|
+
orphanedTypes.push(event.event_type);
|
|
398
|
+
migrationPairs.push([key, JSON.stringify(event)]);
|
|
399
|
+
}
|
|
400
|
+
if (migrationPairs.length > 0)
|
|
401
|
+
await storage.multiSet(migrationPairs);
|
|
402
|
+
}
|
|
403
|
+
await storage.removeItem(LEGACY_TELEMETRY_PERSIST_KEY);
|
|
404
|
+
migrated = true;
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
if (!migrated)
|
|
408
|
+
legacyBufferClaimed = false;
|
|
409
|
+
}
|
|
318
410
|
}
|
|
319
411
|
// Emit sdk_session_recovered if we recovered orphaned events from a previous session
|
|
320
412
|
if (orphanedCount > 0) {
|
|
321
|
-
const now = new Date().toISOString();
|
|
322
413
|
const uniqueTypes = [...new Set(orphanedTypes)];
|
|
323
|
-
|
|
324
|
-
event_type: 'sdk_session_recovered',
|
|
414
|
+
this.recordEvent('sdk_session_recovered', {
|
|
325
415
|
component: 'TelemetryReporter',
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
sdk_version: SDK_VERSION,
|
|
329
|
-
os_name: Platform.OS,
|
|
330
|
-
os_version: String(Platform.Version),
|
|
331
|
-
session_id: this.sessionId,
|
|
332
|
-
event_count: 1,
|
|
333
|
-
first_occurred_at: now,
|
|
334
|
-
last_occurred_at: now,
|
|
335
|
-
};
|
|
336
|
-
this.buffer.set(`sdk_session_recovered|recovered|${this.sessionId}`, crashEvent);
|
|
416
|
+
error: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
|
|
417
|
+
});
|
|
337
418
|
}
|
|
338
|
-
// Clear persisted data now that it's loaded into memory. The merged
|
|
339
|
-
// buffer will be re-persisted below until a flush succeeds.
|
|
340
|
-
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
341
419
|
}
|
|
342
420
|
catch {
|
|
343
421
|
// Never throw from telemetry
|
|
422
|
+
for (const key of claimedDuringRecovery) {
|
|
423
|
+
if (!this.buffer.has(key))
|
|
424
|
+
claimedEventKeys.delete(key);
|
|
425
|
+
}
|
|
344
426
|
}
|
|
345
427
|
finally {
|
|
346
428
|
this.loadedPersisted = true;
|
|
347
429
|
this.loadPersistedPromise = null;
|
|
348
430
|
if (this.buffer.size > 0 && !this.disposed) {
|
|
349
|
-
this.
|
|
431
|
+
await this.persistBufferAsync();
|
|
350
432
|
this.scheduleFlush();
|
|
351
433
|
}
|
|
352
434
|
}
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.5.
|
|
1
|
+
export declare const SDK_VERSION = "2.5.6";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.6';
|
package/package.json
CHANGED
|
@@ -384,6 +384,7 @@ export function VerifyAIScanner({
|
|
|
384
384
|
const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
|
|
385
385
|
const scannerSessionIdRef = useRef(createScannerSessionId());
|
|
386
386
|
const captureSequenceRef = useRef(0);
|
|
387
|
+
const captureIntentPendingRef = useRef(false);
|
|
387
388
|
const torchRetrySuppressedRef = useRef(false);
|
|
388
389
|
const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
|
|
389
390
|
|
|
@@ -466,6 +467,7 @@ export function VerifyAIScanner({
|
|
|
466
467
|
policy,
|
|
467
468
|
status,
|
|
468
469
|
telemetryContext?.isPortraitLocked,
|
|
470
|
+
telemetryContext?.hostReferenceId,
|
|
469
471
|
telemetryContext?.routeName,
|
|
470
472
|
terminated,
|
|
471
473
|
windowHeight,
|
|
@@ -579,6 +581,7 @@ export function VerifyAIScanner({
|
|
|
579
581
|
device_os_version: String(Platform.Version),
|
|
580
582
|
route_name: telemetryContext?.routeName,
|
|
581
583
|
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
584
|
+
host_reference_id: telemetryContext?.hostReferenceId,
|
|
582
585
|
android_native_orientation_subscription_active: 0,
|
|
583
586
|
android_native_orientation_event_count: 0,
|
|
584
587
|
android_native_orientation_change_count: 0,
|
|
@@ -787,7 +790,13 @@ export function VerifyAIScanner({
|
|
|
787
790
|
);
|
|
788
791
|
}
|
|
789
792
|
};
|
|
790
|
-
}, [
|
|
793
|
+
}, [
|
|
794
|
+
policy,
|
|
795
|
+
telemetry,
|
|
796
|
+
telemetryContext?.hostReferenceId,
|
|
797
|
+
telemetryContext?.isPortraitLocked,
|
|
798
|
+
telemetryContext?.routeName,
|
|
799
|
+
]);
|
|
791
800
|
|
|
792
801
|
// Detect orientation changes and remount camera after rotation settles.
|
|
793
802
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
@@ -1112,7 +1121,9 @@ export function VerifyAIScanner({
|
|
|
1112
1121
|
captureSequenceRef.current = captureSequence;
|
|
1113
1122
|
const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
|
|
1114
1123
|
const blockedReason =
|
|
1115
|
-
|
|
1124
|
+
captureIntentPendingRef.current
|
|
1125
|
+
? 'capture_intent_pending'
|
|
1126
|
+
: !cameraRef.current
|
|
1116
1127
|
? 'camera_ref_null'
|
|
1117
1128
|
: !cameraReadyRef.current
|
|
1118
1129
|
? 'camera_not_ready'
|
|
@@ -1126,11 +1137,13 @@ export function VerifyAIScanner({
|
|
|
1126
1137
|
? 'exhausted'
|
|
1127
1138
|
: null;
|
|
1128
1139
|
|
|
1140
|
+
if (blockedReason == null) captureIntentPendingRef.current = true;
|
|
1141
|
+
|
|
1129
1142
|
if (telemetry) {
|
|
1130
1143
|
// Do not begin native capture until the user's shutter intent is on disk.
|
|
1131
1144
|
// If the process is force-closed during capture or delivery, the next SDK
|
|
1132
1145
|
// initialization recovers and sends this exact capture_attempt_id.
|
|
1133
|
-
await telemetry.trackDurably('camera_capture_request', {
|
|
1146
|
+
const persisted = await telemetry.trackDurably('camera_capture_request', {
|
|
1134
1147
|
component: 'scanner',
|
|
1135
1148
|
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
1136
1149
|
metadata: buildScannerTelemetryMetadata({
|
|
@@ -1139,6 +1152,16 @@ export function VerifyAIScanner({
|
|
|
1139
1152
|
capture_blocked_reason: blockedReason,
|
|
1140
1153
|
}),
|
|
1141
1154
|
});
|
|
1155
|
+
if (!persisted) {
|
|
1156
|
+
telemetry.track('telemetry_persist_failure', {
|
|
1157
|
+
component: 'scanner',
|
|
1158
|
+
error: 'capture_intent_persist_failed',
|
|
1159
|
+
metadata: buildScannerTelemetryMetadata({
|
|
1160
|
+
capture_attempt_id: captureAttemptId,
|
|
1161
|
+
capture_sequence: captureSequence,
|
|
1162
|
+
}),
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1142
1165
|
}
|
|
1143
1166
|
|
|
1144
1167
|
if (blockedReason && blockedReason !== 'camera_not_ready') return;
|
|
@@ -1494,6 +1517,7 @@ export function VerifyAIScanner({
|
|
|
1494
1517
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
1495
1518
|
}
|
|
1496
1519
|
} finally {
|
|
1520
|
+
captureIntentPendingRef.current = false;
|
|
1497
1521
|
setTorchSuppressedForRetry(false);
|
|
1498
1522
|
}
|
|
1499
1523
|
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
|
|
@@ -10,7 +10,24 @@ async function getStorage() {
|
|
|
10
10
|
return _storage;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const LEGACY_TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
|
|
14
|
+
const TELEMETRY_EVENT_KEY_PREFIX = '@verifyai/telemetry_event/';
|
|
15
|
+
const MAX_EVENTS_PER_REQUEST = 25;
|
|
16
|
+
|
|
17
|
+
// Per-event journal keys avoid shared full-buffer replacement. Claims prevent
|
|
18
|
+
// two reporters in the same process from recovering/sending the same event.
|
|
19
|
+
const claimedEventKeys = new Set<string>();
|
|
20
|
+
let legacyBufferClaimed = false;
|
|
21
|
+
|
|
22
|
+
function telemetryTargetId(apiKey: string, baseUrl: string): string {
|
|
23
|
+
let hash = 2166136261;
|
|
24
|
+
const input = `${baseUrl}|${apiKey}`;
|
|
25
|
+
for (let index = 0; index < input.length; index++) {
|
|
26
|
+
hash ^= input.charCodeAt(index);
|
|
27
|
+
hash = Math.imul(hash, 16777619);
|
|
28
|
+
}
|
|
29
|
+
return (hash >>> 0).toString(36);
|
|
30
|
+
}
|
|
14
31
|
|
|
15
32
|
interface TelemetryEvent {
|
|
16
33
|
event_type: string;
|
|
@@ -29,11 +46,6 @@ interface TelemetryEvent {
|
|
|
29
46
|
last_occurred_at: string;
|
|
30
47
|
}
|
|
31
48
|
|
|
32
|
-
interface BufferedEvent {
|
|
33
|
-
event: TelemetryEvent;
|
|
34
|
-
dedupKey: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
49
|
interface TelemetryTrackOptions {
|
|
38
50
|
component?: string;
|
|
39
51
|
error?: unknown;
|
|
@@ -48,6 +60,7 @@ const CRITICAL_EVENTS = new Set([
|
|
|
48
60
|
'camera_startup_slow',
|
|
49
61
|
'camera_permission_denied',
|
|
50
62
|
'camera_capture_request',
|
|
63
|
+
'telemetry_persist_failure',
|
|
51
64
|
'camera_scanner_mounted',
|
|
52
65
|
'camera_scanner_disposed',
|
|
53
66
|
'camera_android_native_orientation_started',
|
|
@@ -64,19 +77,23 @@ const CRITICAL_EVENTS = new Set([
|
|
|
64
77
|
export class TelemetryReporter {
|
|
65
78
|
private buffer: Map<string, TelemetryEvent> = new Map();
|
|
66
79
|
private inFlightBuffer: Map<string, TelemetryEvent> = new Map();
|
|
80
|
+
private dedupIndex: Map<string, string> = new Map();
|
|
67
81
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
82
|
private sessionId: string;
|
|
69
83
|
private baseUrl: string;
|
|
70
84
|
private apiKey: string;
|
|
85
|
+
private storagePrefix: string;
|
|
71
86
|
private disposed = false;
|
|
72
87
|
private flushing = false;
|
|
73
88
|
private loadedPersisted = false;
|
|
74
89
|
private loadPersistedPromise: Promise<void> | null = null;
|
|
75
90
|
private persistenceChain: Promise<void> = Promise.resolve();
|
|
91
|
+
private eventSequence = 0;
|
|
76
92
|
|
|
77
93
|
constructor(apiKey: string, baseUrl: string) {
|
|
78
94
|
this.apiKey = apiKey;
|
|
79
95
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
96
|
+
this.storagePrefix = `${TELEMETRY_EVENT_KEY_PREFIX}${telemetryTargetId(apiKey, this.baseUrl)}/`;
|
|
80
97
|
this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
81
98
|
// Load any persisted events left behind by a previous session
|
|
82
99
|
this.loadPersistedBuffer();
|
|
@@ -133,17 +150,19 @@ export class TelemetryReporter {
|
|
|
133
150
|
async trackDurably(
|
|
134
151
|
eventType: string,
|
|
135
152
|
opts: TelemetryTrackOptions = {},
|
|
136
|
-
): Promise<
|
|
137
|
-
if (this.disposed) return;
|
|
153
|
+
): Promise<boolean> {
|
|
154
|
+
if (this.disposed) return false;
|
|
138
155
|
|
|
139
156
|
try {
|
|
140
157
|
await this.loadPersistedBuffer();
|
|
141
|
-
if (this.disposed) return;
|
|
158
|
+
if (this.disposed) return false;
|
|
142
159
|
this.recordEvent(eventType, opts);
|
|
143
|
-
await this.persistBufferAsync();
|
|
160
|
+
const persisted = await this.persistBufferAsync();
|
|
144
161
|
this.flushNow();
|
|
162
|
+
return persisted;
|
|
145
163
|
} catch {
|
|
146
164
|
// Never throw from telemetry or block the host capture flow.
|
|
165
|
+
return false;
|
|
147
166
|
}
|
|
148
167
|
}
|
|
149
168
|
|
|
@@ -155,15 +174,16 @@ export class TelemetryReporter {
|
|
|
155
174
|
if (this.buffer.size === 0 || this.flushing) return;
|
|
156
175
|
|
|
157
176
|
this.flushing = true;
|
|
158
|
-
const bufferedEntries = Array.from(this.buffer.entries());
|
|
177
|
+
const bufferedEntries = Array.from(this.buffer.entries()).slice(0, MAX_EVENTS_PER_REQUEST);
|
|
159
178
|
const events = bufferedEntries.map(([, event]) => event);
|
|
160
|
-
this.buffer.
|
|
179
|
+
for (const [key] of bufferedEntries) this.buffer.delete(key);
|
|
161
180
|
this.inFlightBuffer = new Map(bufferedEntries);
|
|
162
181
|
this.clearFlushTimer();
|
|
163
182
|
const controller = new AbortController();
|
|
164
183
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
165
184
|
|
|
166
185
|
let loggedDeliveryFailure = false;
|
|
186
|
+
let delivered = false;
|
|
167
187
|
try {
|
|
168
188
|
const response = await fetch(`${this.baseUrl}/telemetry`, {
|
|
169
189
|
method: 'POST',
|
|
@@ -187,10 +207,17 @@ export class TelemetryReporter {
|
|
|
187
207
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
188
208
|
}
|
|
189
209
|
|
|
210
|
+
const acknowledgedKeys = bufferedEntries.map(([key]) => key);
|
|
211
|
+
const acknowledgedKeySet = new Set(acknowledgedKeys);
|
|
212
|
+
const removed = await this.removeJournalEntries(acknowledgedKeys);
|
|
213
|
+
if (removed) {
|
|
214
|
+
for (const key of acknowledgedKeys) claimedEventKeys.delete(key);
|
|
215
|
+
}
|
|
190
216
|
this.inFlightBuffer.clear();
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
217
|
+
for (const [dedupKey, journalKey] of this.dedupIndex) {
|
|
218
|
+
if (acknowledgedKeySet.has(journalKey)) this.dedupIndex.delete(dedupKey);
|
|
219
|
+
}
|
|
220
|
+
delivered = true;
|
|
194
221
|
} catch (error) {
|
|
195
222
|
if (!loggedDeliveryFailure) {
|
|
196
223
|
const kind = error instanceof Error ? error.name : typeof error;
|
|
@@ -219,13 +246,19 @@ export class TelemetryReporter {
|
|
|
219
246
|
this.scheduleFlush();
|
|
220
247
|
}
|
|
221
248
|
}
|
|
249
|
+
|
|
250
|
+
// Drain oversized recovered journals in API-sized chunks. A failed chunk is
|
|
251
|
+
// rebuffered and left for the normal retry timer instead of spinning.
|
|
252
|
+
if (delivered && this.buffer.size > 0 && !this.disposed) {
|
|
253
|
+
await this.flush();
|
|
254
|
+
}
|
|
222
255
|
}
|
|
223
256
|
|
|
224
257
|
/** Dispose — flush remaining events and stop timers. */
|
|
225
258
|
dispose(): void {
|
|
226
259
|
this.disposed = true;
|
|
227
260
|
this.clearFlushTimer();
|
|
228
|
-
this.flush();
|
|
261
|
+
void this.flush().finally(() => this.releaseClaims());
|
|
229
262
|
}
|
|
230
263
|
|
|
231
264
|
private scheduleFlush(): void {
|
|
@@ -256,21 +289,31 @@ export class TelemetryReporter {
|
|
|
256
289
|
const occurrenceId = opts.metadata?.capture_attempt_id;
|
|
257
290
|
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
|
|
258
291
|
|
|
259
|
-
const
|
|
292
|
+
const existingJournalKey = this.dedupIndex.get(dedupKey);
|
|
293
|
+
const existing = existingJournalKey
|
|
294
|
+
? this.buffer.get(existingJournalKey)
|
|
295
|
+
: undefined;
|
|
260
296
|
if (existing) {
|
|
261
297
|
existing.event_count++;
|
|
262
298
|
existing.last_occurred_at = now;
|
|
263
|
-
if (opts.metadata) existing.metadata = opts.metadata;
|
|
299
|
+
if (opts.metadata) existing.metadata = { ...existing.metadata, ...opts.metadata };
|
|
264
300
|
return;
|
|
265
301
|
}
|
|
266
302
|
|
|
267
|
-
this.
|
|
303
|
+
const journalKey = this.createJournalKey();
|
|
304
|
+
this.dedupIndex.set(dedupKey, journalKey);
|
|
305
|
+
claimedEventKeys.add(journalKey);
|
|
306
|
+
const eventMetadata = {
|
|
307
|
+
...opts.metadata,
|
|
308
|
+
telemetry_event_id: journalKey.slice(this.storagePrefix.length),
|
|
309
|
+
};
|
|
310
|
+
this.buffer.set(journalKey, {
|
|
268
311
|
event_type: eventType,
|
|
269
312
|
component: opts.component,
|
|
270
313
|
error_message: errorMessage?.slice(0, 1000),
|
|
271
314
|
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
272
315
|
error_code: opts.errorCode,
|
|
273
|
-
metadata:
|
|
316
|
+
metadata: eventMetadata,
|
|
274
317
|
sdk_platform: Platform.OS,
|
|
275
318
|
sdk_version: SDK_VERSION,
|
|
276
319
|
os_name: Platform.OS,
|
|
@@ -287,49 +330,61 @@ export class TelemetryReporter {
|
|
|
287
330
|
await this.persistBufferAsync();
|
|
288
331
|
}
|
|
289
332
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
*
|
|
294
|
-
* IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
|
|
295
|
-
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
296
|
-
*/
|
|
297
|
-
private persistBuffer(): void {
|
|
298
|
-
void this.persistBufferAsync();
|
|
333
|
+
private createJournalKey(): string {
|
|
334
|
+
this.eventSequence++;
|
|
335
|
+
return `${this.storagePrefix}${this.sessionId}:${this.eventSequence.toString(36)}`;
|
|
299
336
|
}
|
|
300
337
|
|
|
301
|
-
private persistBufferAsync(): Promise<
|
|
302
|
-
if (!this.loadedPersisted) return Promise.resolve();
|
|
338
|
+
private async persistBufferAsync(): Promise<boolean> {
|
|
339
|
+
if (!this.loadedPersisted) return Promise.resolve(false);
|
|
303
340
|
|
|
304
341
|
try {
|
|
305
|
-
const entries
|
|
306
|
-
|
|
307
|
-
entries
|
|
308
|
-
|
|
309
|
-
for (const [key, event] of this.buffer) {
|
|
310
|
-
entries[key] = event;
|
|
311
|
-
}
|
|
342
|
+
const entries = [
|
|
343
|
+
...this.inFlightBuffer.entries(),
|
|
344
|
+
...this.buffer.entries(),
|
|
345
|
+
];
|
|
312
346
|
|
|
313
|
-
|
|
314
|
-
// newer durable capture marker or resurrect an acknowledged event.
|
|
315
|
-
this.persistenceChain = this.persistenceChain
|
|
347
|
+
const operation = this.persistenceChain
|
|
316
348
|
.catch(() => { /* keep the write queue alive */ })
|
|
317
349
|
.then(async () => {
|
|
318
350
|
const storage = await getStorage();
|
|
319
|
-
if (
|
|
320
|
-
await storage.
|
|
321
|
-
|
|
322
|
-
|
|
351
|
+
if (entries.length > 0) {
|
|
352
|
+
await storage.multiSet(
|
|
353
|
+
entries.map(([key, event]): [string, string] => [key, JSON.stringify(event)]),
|
|
354
|
+
);
|
|
323
355
|
}
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
|
|
356
|
+
});
|
|
357
|
+
this.persistenceChain = operation.catch(() => { /* keep queue alive */ });
|
|
358
|
+
await operation;
|
|
359
|
+
return true;
|
|
327
360
|
} catch {
|
|
328
361
|
// Never throw from telemetry
|
|
329
|
-
return
|
|
362
|
+
return false;
|
|
330
363
|
}
|
|
331
364
|
}
|
|
332
365
|
|
|
366
|
+
private async removeJournalEntries(keys: string[]): Promise<boolean> {
|
|
367
|
+
if (keys.length === 0) return true;
|
|
368
|
+
try {
|
|
369
|
+
const operation = this.persistenceChain
|
|
370
|
+
.catch(() => { /* keep the write queue alive */ })
|
|
371
|
+
.then(async () => {
|
|
372
|
+
const storage = await getStorage();
|
|
373
|
+
await storage.multiRemove(keys);
|
|
374
|
+
});
|
|
375
|
+
this.persistenceChain = operation.catch(() => { /* keep queue alive */ });
|
|
376
|
+
await operation;
|
|
377
|
+
return true;
|
|
378
|
+
} catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private releaseClaims(): void {
|
|
384
|
+
for (const key of this.buffer.keys()) claimedEventKeys.delete(key);
|
|
385
|
+
for (const key of this.inFlightBuffer.keys()) claimedEventKeys.delete(key);
|
|
386
|
+
}
|
|
387
|
+
|
|
333
388
|
/**
|
|
334
389
|
* Load persisted events from a previous session and merge into current buffer.
|
|
335
390
|
* Runs once per instance. If orphaned events are found, emits an
|
|
@@ -344,64 +399,84 @@ export class TelemetryReporter {
|
|
|
344
399
|
}
|
|
345
400
|
|
|
346
401
|
this.loadPersistedPromise = (async () => {
|
|
402
|
+
const claimedDuringRecovery: string[] = [];
|
|
347
403
|
try {
|
|
348
404
|
const storage = await getStorage();
|
|
349
|
-
const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
|
|
350
|
-
if (!raw) return;
|
|
351
|
-
|
|
352
|
-
const entries = JSON.parse(raw) as Record<string, TelemetryEvent>;
|
|
353
405
|
let orphanedCount = 0;
|
|
354
406
|
const orphanedTypes: string[] = [];
|
|
355
407
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
408
|
+
const allKeys = await storage.getAllKeys();
|
|
409
|
+
const recoverableKeys = allKeys.filter((key) => {
|
|
410
|
+
if (!key.startsWith(this.storagePrefix)) return false;
|
|
411
|
+
if (claimedEventKeys.has(key)) return false;
|
|
412
|
+
claimedEventKeys.add(key);
|
|
413
|
+
claimedDuringRecovery.push(key);
|
|
414
|
+
return true;
|
|
415
|
+
});
|
|
416
|
+
const recoveredPairs = recoverableKeys.length > 0
|
|
417
|
+
? await storage.multiGet(recoverableKeys)
|
|
418
|
+
: [];
|
|
419
|
+
|
|
420
|
+
for (const [key, raw] of recoveredPairs) {
|
|
421
|
+
if (!raw) {
|
|
422
|
+
claimedEventKeys.delete(key);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const event = JSON.parse(raw) as TelemetryEvent;
|
|
427
|
+
orphanedCount++;
|
|
428
|
+
orphanedTypes.push(event.event_type);
|
|
371
429
|
this.buffer.set(key, event);
|
|
430
|
+
} catch {
|
|
431
|
+
claimedEventKeys.delete(key);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Migrate the pre-2.5.6 shared snapshot without a remove/rewrite gap:
|
|
436
|
+
// write each event journal entry first, then delete the legacy key.
|
|
437
|
+
if (!legacyBufferClaimed && allKeys.includes(LEGACY_TELEMETRY_PERSIST_KEY)) {
|
|
438
|
+
legacyBufferClaimed = true;
|
|
439
|
+
let migrated = false;
|
|
440
|
+
try {
|
|
441
|
+
const legacyRaw = await storage.getItem(LEGACY_TELEMETRY_PERSIST_KEY);
|
|
442
|
+
if (legacyRaw) {
|
|
443
|
+
const legacyEntries = JSON.parse(legacyRaw) as Record<string, TelemetryEvent>;
|
|
444
|
+
const migrationPairs: Array<[string, string]> = [];
|
|
445
|
+
for (const event of Object.values(legacyEntries)) {
|
|
446
|
+
const key = this.createJournalKey();
|
|
447
|
+
claimedEventKeys.add(key);
|
|
448
|
+
this.buffer.set(key, event);
|
|
449
|
+
orphanedCount++;
|
|
450
|
+
orphanedTypes.push(event.event_type);
|
|
451
|
+
migrationPairs.push([key, JSON.stringify(event)]);
|
|
452
|
+
}
|
|
453
|
+
if (migrationPairs.length > 0) await storage.multiSet(migrationPairs);
|
|
454
|
+
}
|
|
455
|
+
await storage.removeItem(LEGACY_TELEMETRY_PERSIST_KEY);
|
|
456
|
+
migrated = true;
|
|
457
|
+
} finally {
|
|
458
|
+
if (!migrated) legacyBufferClaimed = false;
|
|
372
459
|
}
|
|
373
460
|
}
|
|
374
461
|
|
|
375
462
|
// Emit sdk_session_recovered if we recovered orphaned events from a previous session
|
|
376
463
|
if (orphanedCount > 0) {
|
|
377
|
-
const now = new Date().toISOString();
|
|
378
464
|
const uniqueTypes = [...new Set(orphanedTypes)];
|
|
379
|
-
|
|
380
|
-
event_type: 'sdk_session_recovered',
|
|
465
|
+
this.recordEvent('sdk_session_recovered', {
|
|
381
466
|
component: 'TelemetryReporter',
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
sdk_version: SDK_VERSION,
|
|
385
|
-
os_name: Platform.OS,
|
|
386
|
-
os_version: String(Platform.Version),
|
|
387
|
-
session_id: this.sessionId,
|
|
388
|
-
event_count: 1,
|
|
389
|
-
first_occurred_at: now,
|
|
390
|
-
last_occurred_at: now,
|
|
391
|
-
};
|
|
392
|
-
this.buffer.set(`sdk_session_recovered|recovered|${this.sessionId}`, crashEvent);
|
|
467
|
+
error: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
|
|
468
|
+
});
|
|
393
469
|
}
|
|
394
|
-
|
|
395
|
-
// Clear persisted data now that it's loaded into memory. The merged
|
|
396
|
-
// buffer will be re-persisted below until a flush succeeds.
|
|
397
|
-
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
398
470
|
} catch {
|
|
399
471
|
// Never throw from telemetry
|
|
472
|
+
for (const key of claimedDuringRecovery) {
|
|
473
|
+
if (!this.buffer.has(key)) claimedEventKeys.delete(key);
|
|
474
|
+
}
|
|
400
475
|
} finally {
|
|
401
476
|
this.loadedPersisted = true;
|
|
402
477
|
this.loadPersistedPromise = null;
|
|
403
478
|
if (this.buffer.size > 0 && !this.disposed) {
|
|
404
|
-
this.
|
|
479
|
+
await this.persistBufferAsync();
|
|
405
480
|
this.scheduleFlush();
|
|
406
481
|
}
|
|
407
482
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.6';
|