@switchlabs/verify-ai-react-native 2.5.4 → 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.
@@ -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({
@@ -294,6 +295,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
294
295
  device_os_version: String(Platform.Version),
295
296
  route_name: telemetryContext?.routeName,
296
297
  is_portrait_locked: telemetryContext?.isPortraitLocked,
298
+ host_reference_id: telemetryContext?.hostReferenceId,
297
299
  window_width: windowWidth,
298
300
  window_height: windowHeight,
299
301
  interface_orientation: isLandscape ? 'landscape' : 'portrait',
@@ -350,6 +352,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
350
352
  policy,
351
353
  status,
352
354
  telemetryContext?.isPortraitLocked,
355
+ telemetryContext?.hostReferenceId,
353
356
  telemetryContext?.routeName,
354
357
  terminated,
355
358
  windowHeight,
@@ -437,6 +440,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
437
440
  device_os_version: String(Platform.Version),
438
441
  route_name: telemetryContext?.routeName,
439
442
  is_portrait_locked: telemetryContext?.isPortraitLocked,
443
+ host_reference_id: telemetryContext?.hostReferenceId,
440
444
  android_native_orientation_subscription_active: 0,
441
445
  android_native_orientation_event_count: 0,
442
446
  android_native_orientation_change_count: 0,
@@ -615,7 +619,13 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
615
619
  trackAndroidOrientationEvent('camera_android_accelerometer_stopped', 'android_accelerometer_tracking_stopped', { accelerometer_stop_reason: 'orientation_tracking_stopped' });
616
620
  }
617
621
  };
618
- }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
622
+ }, [
623
+ policy,
624
+ telemetry,
625
+ telemetryContext?.hostReferenceId,
626
+ telemetryContext?.isPortraitLocked,
627
+ telemetryContext?.routeName,
628
+ ]);
619
629
  // Detect orientation changes and remount camera after rotation settles.
620
630
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
621
631
  // animation — the native preview layer initializes with transitional bounds.
@@ -901,28 +911,47 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
901
911
  const captureSequence = captureSequenceRef.current + 1;
902
912
  captureSequenceRef.current = captureSequence;
903
913
  const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
904
- const blockedReason = !cameraRef.current
905
- ? 'camera_ref_null'
906
- : !cameraReadyRef.current
907
- ? 'camera_not_ready'
908
- : status === 'capturing'
909
- ? 'already_capturing'
910
- : status === 'processing'
911
- ? 'already_processing'
912
- : terminated
913
- ? 'terminated'
914
- : exhausted
915
- ? 'exhausted'
916
- : null;
917
- telemetry?.track('camera_capture_request', {
918
- component: 'scanner',
919
- error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
920
- metadata: buildScannerTelemetryMetadata({
921
- capture_attempt_id: captureAttemptId,
922
- capture_sequence: captureSequence,
923
- capture_blocked_reason: blockedReason,
924
- }),
925
- });
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;
931
+ if (telemetry) {
932
+ // Do not begin native capture until the user's shutter intent is on disk.
933
+ // If the process is force-closed during capture or delivery, the next SDK
934
+ // initialization recovers and sends this exact capture_attempt_id.
935
+ const persisted = await telemetry.trackDurably('camera_capture_request', {
936
+ component: 'scanner',
937
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
938
+ metadata: buildScannerTelemetryMetadata({
939
+ capture_attempt_id: captureAttemptId,
940
+ capture_sequence: captureSequence,
941
+ capture_blocked_reason: blockedReason,
942
+ }),
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
+ }
954
+ }
926
955
  if (blockedReason && blockedReason !== 'camera_not_ready')
927
956
  return;
928
957
  if (blockedReason === 'camera_not_ready') {
@@ -1223,6 +1252,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1223
1252
  }
1224
1253
  }
1225
1254
  finally {
1255
+ captureIntentPendingRef.current = false;
1226
1256
  setTorchSuppressedForRetry(false);
1227
1257
  }
1228
1258
  }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
@@ -1,13 +1,24 @@
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;
10
+ private dedupIndex;
3
11
  private flushTimer;
4
12
  private sessionId;
5
13
  private baseUrl;
6
14
  private apiKey;
15
+ private storagePrefix;
7
16
  private disposed;
8
17
  private flushing;
9
18
  private loadedPersisted;
10
19
  private loadPersistedPromise;
20
+ private persistenceChain;
21
+ private eventSequence;
11
22
  constructor(apiKey: string, baseUrl: string);
12
23
  /** Telemetry POST destination — exposed so the scanner can log an init banner
13
24
  * that helps field debugging confirm where events are being sent. */
@@ -16,12 +27,14 @@ export declare class TelemetryReporter {
16
27
  getApiKeyPrefix(): string;
17
28
  private logDeliveryFailure;
18
29
  /** Track an error event. Fire-and-forget — never throws. */
19
- track(eventType: string, opts?: {
20
- component?: string;
21
- error?: unknown;
22
- errorCode?: string;
23
- metadata?: Record<string, string | number>;
24
- }): void;
30
+ track(eventType: string, opts?: TelemetryTrackOptions): void;
31
+ /**
32
+ * Record an event and wait until it is durably stored before returning.
33
+ *
34
+ * Use this for user intent that must survive an immediate force-close. Network
35
+ * delivery starts after the local write, but callers do not wait on the network.
36
+ */
37
+ trackDurably(eventType: string, opts?: TelemetryTrackOptions): Promise<boolean>;
25
38
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
26
39
  flush(): Promise<void>;
27
40
  /** Dispose — flush remaining events and stop timers. */
@@ -29,14 +42,12 @@ export declare class TelemetryReporter {
29
42
  private scheduleFlush;
30
43
  private flushNow;
31
44
  private clearFlushTimer;
32
- /**
33
- * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
34
- * Fire-and-forget — never throws or blocks.
35
- *
36
- * IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
37
- * to avoid overwriting orphaned events from a previous session before recovery.
38
- */
39
- private persistBuffer;
45
+ private recordEvent;
46
+ private persistAfterRecovery;
47
+ private createJournalKey;
48
+ private persistBufferAsync;
49
+ private removeJournalEntries;
50
+ private releaseClaims;
40
51
  /**
41
52
  * Load persisted events from a previous session and merge into current buffer.
42
53
  * Runs once per instance. If orphaned events are found, emits an
@@ -45,3 +56,4 @@ export declare class TelemetryReporter {
45
56
  */
46
57
  private loadPersistedBuffer;
47
58
  }
59
+ export {};
@@ -8,13 +8,30 @@ async function getStorage() {
8
8
  }
9
9
  return _storage;
10
10
  }
11
- const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
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',
15
30
  'camera_preview_timeout',
16
31
  'camera_startup_slow',
17
32
  'camera_permission_denied',
33
+ 'camera_capture_request',
34
+ 'telemetry_persist_failure',
18
35
  'camera_scanner_mounted',
19
36
  'camera_scanner_disposed',
20
37
  'camera_android_native_orientation_started',
@@ -30,13 +47,18 @@ const CRITICAL_EVENTS = new Set([
30
47
  export class TelemetryReporter {
31
48
  constructor(apiKey, baseUrl) {
32
49
  this.buffer = new Map();
50
+ this.inFlightBuffer = new Map();
51
+ this.dedupIndex = new Map();
33
52
  this.flushTimer = null;
34
53
  this.disposed = false;
35
54
  this.flushing = false;
36
55
  this.loadedPersisted = false;
37
56
  this.loadPersistedPromise = null;
57
+ this.persistenceChain = Promise.resolve();
58
+ this.eventSequence = 0;
38
59
  this.apiKey = apiKey;
39
60
  this.baseUrl = baseUrl.replace(/\/$/, '');
61
+ this.storagePrefix = `${TELEMETRY_EVENT_KEY_PREFIX}${telemetryTargetId(apiKey, this.baseUrl)}/`;
40
62
  this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
41
63
  // Load any persisted events left behind by a previous session
42
64
  this.loadPersistedBuffer();
@@ -60,61 +82,44 @@ export class TelemetryReporter {
60
82
  if (this.disposed)
61
83
  return;
62
84
  try {
63
- const now = new Date().toISOString();
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);
85
+ this.recordEvent(eventType, opts);
95
86
  if (CRITICAL_EVENTS.has(eventType)) {
96
- this.flushNow();
87
+ // Critical events are written locally before delivery starts. This keeps
88
+ // them recoverable if the process is killed while the POST is in flight.
89
+ void this.persistAfterRecovery().then(() => this.flushNow());
97
90
  }
98
91
  else {
99
92
  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
- });
93
+ void this.persistAfterRecovery();
112
94
  }
113
95
  }
114
96
  catch {
115
97
  // Never throw from telemetry
116
98
  }
117
99
  }
100
+ /**
101
+ * Record an event and wait until it is durably stored before returning.
102
+ *
103
+ * Use this for user intent that must survive an immediate force-close. Network
104
+ * delivery starts after the local write, but callers do not wait on the network.
105
+ */
106
+ async trackDurably(eventType, opts = {}) {
107
+ if (this.disposed)
108
+ return false;
109
+ try {
110
+ await this.loadPersistedBuffer();
111
+ if (this.disposed)
112
+ return false;
113
+ this.recordEvent(eventType, opts);
114
+ const persisted = await this.persistBufferAsync();
115
+ this.flushNow();
116
+ return persisted;
117
+ }
118
+ catch {
119
+ // Never throw from telemetry or block the host capture flow.
120
+ return false;
121
+ }
122
+ }
118
123
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
119
124
  async flush() {
120
125
  // Merge any persisted events from a previous session before flushing
@@ -122,13 +127,16 @@ export class TelemetryReporter {
122
127
  if (this.buffer.size === 0 || this.flushing)
123
128
  return;
124
129
  this.flushing = true;
125
- const bufferedEntries = Array.from(this.buffer.entries());
130
+ const bufferedEntries = Array.from(this.buffer.entries()).slice(0, MAX_EVENTS_PER_REQUEST);
126
131
  const events = bufferedEntries.map(([, event]) => event);
127
- this.buffer.clear();
132
+ for (const [key] of bufferedEntries)
133
+ this.buffer.delete(key);
134
+ this.inFlightBuffer = new Map(bufferedEntries);
128
135
  this.clearFlushTimer();
129
136
  const controller = new AbortController();
130
137
  const timeout = setTimeout(() => controller.abort(), 10000);
131
138
  let loggedDeliveryFailure = false;
139
+ let delivered = false;
132
140
  try {
133
141
  const response = await fetch(`${this.baseUrl}/telemetry`, {
134
142
  method: 'POST',
@@ -151,8 +159,19 @@ export class TelemetryReporter {
151
159
  loggedDeliveryFailure = true;
152
160
  throw new Error(`Telemetry request failed with status ${response.status}`);
153
161
  }
154
- // Success — clear persisted buffer since events are now server-side
155
- this.persistBuffer(); // buffer is empty at this point, so this clears the key
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
+ }
169
+ this.inFlightBuffer.clear();
170
+ for (const [dedupKey, journalKey] of this.dedupIndex) {
171
+ if (acknowledgedKeySet.has(journalKey))
172
+ this.dedupIndex.delete(dedupKey);
173
+ }
174
+ delivered = true;
156
175
  }
157
176
  catch (error) {
158
177
  if (!loggedDeliveryFailure) {
@@ -175,6 +194,7 @@ export class TelemetryReporter {
175
194
  this.buffer.set(dedupKey, event);
176
195
  }
177
196
  }
197
+ this.inFlightBuffer.clear();
178
198
  }
179
199
  finally {
180
200
  clearTimeout(timeout);
@@ -183,12 +203,17 @@ export class TelemetryReporter {
183
203
  this.scheduleFlush();
184
204
  }
185
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
+ }
186
211
  }
187
212
  /** Dispose — flush remaining events and stop timers. */
188
213
  dispose() {
189
214
  this.disposed = true;
190
215
  this.clearFlushTimer();
191
- this.flush();
216
+ void this.flush().finally(() => this.releaseClaims());
192
217
  }
193
218
  scheduleFlush() {
194
219
  if (this.flushTimer)
@@ -208,34 +233,105 @@ export class TelemetryReporter {
208
233
  this.flushTimer = null;
209
234
  }
210
235
  }
211
- /**
212
- * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
213
- * Fire-and-forget — never throws or blocks.
214
- *
215
- * IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
216
- * to avoid overwriting orphaned events from a previous session before recovery.
217
- */
218
- persistBuffer() {
236
+ recordEvent(eventType, opts) {
237
+ const now = new Date().toISOString();
238
+ const errorObj = opts.error instanceof Error ? opts.error : null;
239
+ const errorMessage = errorObj?.message
240
+ ?? (typeof opts.error === 'string' ? opts.error : undefined);
241
+ const occurrenceId = opts.metadata?.capture_attempt_id;
242
+ const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
243
+ const existingJournalKey = this.dedupIndex.get(dedupKey);
244
+ const existing = existingJournalKey
245
+ ? this.buffer.get(existingJournalKey)
246
+ : undefined;
247
+ if (existing) {
248
+ existing.event_count++;
249
+ existing.last_occurred_at = now;
250
+ if (opts.metadata)
251
+ existing.metadata = { ...existing.metadata, ...opts.metadata };
252
+ return;
253
+ }
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, {
262
+ event_type: eventType,
263
+ component: opts.component,
264
+ error_message: errorMessage?.slice(0, 1000),
265
+ error_stack: errorObj?.stack?.slice(0, 2000),
266
+ error_code: opts.errorCode,
267
+ metadata: eventMetadata,
268
+ sdk_platform: Platform.OS,
269
+ sdk_version: SDK_VERSION,
270
+ os_name: Platform.OS,
271
+ os_version: String(Platform.Version),
272
+ session_id: this.sessionId,
273
+ event_count: 1,
274
+ first_occurred_at: now,
275
+ last_occurred_at: now,
276
+ });
277
+ }
278
+ async persistAfterRecovery() {
279
+ await this.loadPersistedBuffer();
280
+ await this.persistBufferAsync();
281
+ }
282
+ createJournalKey() {
283
+ this.eventSequence++;
284
+ return `${this.storagePrefix}${this.sessionId}:${this.eventSequence.toString(36)}`;
285
+ }
286
+ async persistBufferAsync() {
219
287
  if (!this.loadedPersisted)
220
- return; // Don't overwrite orphaned prior-session data before it's loaded
288
+ return Promise.resolve(false);
221
289
  try {
222
- const entries = {};
223
- for (const [key, event] of this.buffer) {
224
- entries[key] = event;
225
- }
226
- getStorage().then(s => {
227
- if (Object.keys(entries).length > 0) {
228
- s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
229
- }
230
- else {
231
- s.removeItem(TELEMETRY_PERSIST_KEY);
290
+ const entries = [
291
+ ...this.inFlightBuffer.entries(),
292
+ ...this.buffer.entries(),
293
+ ];
294
+ const operation = this.persistenceChain
295
+ .catch(() => { })
296
+ .then(async () => {
297
+ const storage = await getStorage();
298
+ if (entries.length > 0) {
299
+ await storage.multiSet(entries.map(([key, event]) => [key, JSON.stringify(event)]));
232
300
  }
233
- }).catch(() => { });
301
+ });
302
+ this.persistenceChain = operation.catch(() => { });
303
+ await operation;
304
+ return true;
234
305
  }
235
306
  catch {
236
307
  // Never throw from telemetry
308
+ return false;
309
+ }
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;
237
327
  }
238
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
+ }
239
335
  /**
240
336
  * Load persisted events from a previous session and merge into current buffer.
241
337
  * Runs once per instance. If orphaned events are found, emits an
@@ -250,65 +346,89 @@ export class TelemetryReporter {
250
346
  return;
251
347
  }
252
348
  this.loadPersistedPromise = (async () => {
349
+ const claimedDuringRecovery = [];
253
350
  try {
254
351
  const storage = await getStorage();
255
- const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
256
- if (!raw)
257
- return;
258
- const entries = JSON.parse(raw);
259
352
  let orphanedCount = 0;
260
353
  const orphanedTypes = [];
261
- for (const [key, event] of Object.entries(entries)) {
262
- if (event.session_id === this.sessionId)
263
- continue; // Same session, skip
264
- orphanedCount++;
265
- orphanedTypes.push(event.event_type);
266
- // Merge orphaned events into the current buffer
267
- const existing = this.buffer.get(key);
268
- if (existing) {
269
- existing.event_count += event.event_count;
270
- if (event.first_occurred_at < existing.first_occurred_at) {
271
- existing.first_occurred_at = event.first_occurred_at;
272
- }
273
- if (event.last_occurred_at > existing.last_occurred_at) {
274
- existing.last_occurred_at = event.last_occurred_at;
275
- }
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;
276
371
  }
277
- else {
372
+ try {
373
+ const event = JSON.parse(raw);
374
+ orphanedCount++;
375
+ orphanedTypes.push(event.event_type);
278
376
  this.buffer.set(key, event);
279
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
+ }
280
410
  }
281
411
  // Emit sdk_session_recovered if we recovered orphaned events from a previous session
282
412
  if (orphanedCount > 0) {
283
- const now = new Date().toISOString();
284
413
  const uniqueTypes = [...new Set(orphanedTypes)];
285
- const crashEvent = {
286
- event_type: 'sdk_session_recovered',
414
+ this.recordEvent('sdk_session_recovered', {
287
415
  component: 'TelemetryReporter',
288
- error_message: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
289
- sdk_platform: Platform.OS,
290
- sdk_version: SDK_VERSION,
291
- os_name: Platform.OS,
292
- os_version: String(Platform.Version),
293
- session_id: this.sessionId,
294
- event_count: 1,
295
- first_occurred_at: now,
296
- last_occurred_at: now,
297
- };
298
- 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
+ });
299
418
  }
300
- // Clear persisted data now that it's loaded into memory. The merged
301
- // buffer will be re-persisted below until a flush succeeds.
302
- await storage.removeItem(TELEMETRY_PERSIST_KEY);
303
419
  }
304
420
  catch {
305
421
  // Never throw from telemetry
422
+ for (const key of claimedDuringRecovery) {
423
+ if (!this.buffer.has(key))
424
+ claimedEventKeys.delete(key);
425
+ }
306
426
  }
307
427
  finally {
308
428
  this.loadedPersisted = true;
309
429
  this.loadPersistedPromise = null;
310
430
  if (this.buffer.size > 0 && !this.disposed) {
311
- this.persistBuffer();
431
+ await this.persistBufferAsync();
312
432
  this.scheduleFlush();
313
433
  }
314
434
  }
@@ -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.4";
1
+ export declare const SDK_VERSION = "2.5.6";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.4';
1
+ export const SDK_VERSION = '2.5.6';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.5.4",
3
+ "version": "2.5.6",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
 
@@ -409,6 +410,7 @@ export function VerifyAIScanner({
409
410
  device_os_version: String(Platform.Version),
410
411
  route_name: telemetryContext?.routeName,
411
412
  is_portrait_locked: telemetryContext?.isPortraitLocked,
413
+ host_reference_id: telemetryContext?.hostReferenceId,
412
414
  window_width: windowWidth,
413
415
  window_height: windowHeight,
414
416
  interface_orientation: isLandscape ? 'landscape' : 'portrait',
@@ -465,6 +467,7 @@ export function VerifyAIScanner({
465
467
  policy,
466
468
  status,
467
469
  telemetryContext?.isPortraitLocked,
470
+ telemetryContext?.hostReferenceId,
468
471
  telemetryContext?.routeName,
469
472
  terminated,
470
473
  windowHeight,
@@ -578,6 +581,7 @@ export function VerifyAIScanner({
578
581
  device_os_version: String(Platform.Version),
579
582
  route_name: telemetryContext?.routeName,
580
583
  is_portrait_locked: telemetryContext?.isPortraitLocked,
584
+ host_reference_id: telemetryContext?.hostReferenceId,
581
585
  android_native_orientation_subscription_active: 0,
582
586
  android_native_orientation_event_count: 0,
583
587
  android_native_orientation_change_count: 0,
@@ -786,7 +790,13 @@ export function VerifyAIScanner({
786
790
  );
787
791
  }
788
792
  };
789
- }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
793
+ }, [
794
+ policy,
795
+ telemetry,
796
+ telemetryContext?.hostReferenceId,
797
+ telemetryContext?.isPortraitLocked,
798
+ telemetryContext?.routeName,
799
+ ]);
790
800
 
791
801
  // Detect orientation changes and remount camera after rotation settles.
792
802
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
@@ -1111,7 +1121,9 @@ export function VerifyAIScanner({
1111
1121
  captureSequenceRef.current = captureSequence;
1112
1122
  const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
1113
1123
  const blockedReason =
1114
- !cameraRef.current
1124
+ captureIntentPendingRef.current
1125
+ ? 'capture_intent_pending'
1126
+ : !cameraRef.current
1115
1127
  ? 'camera_ref_null'
1116
1128
  : !cameraReadyRef.current
1117
1129
  ? 'camera_not_ready'
@@ -1125,15 +1137,32 @@ export function VerifyAIScanner({
1125
1137
  ? 'exhausted'
1126
1138
  : null;
1127
1139
 
1128
- telemetry?.track('camera_capture_request', {
1129
- component: 'scanner',
1130
- error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
1131
- metadata: buildScannerTelemetryMetadata({
1132
- capture_attempt_id: captureAttemptId,
1133
- capture_sequence: captureSequence,
1134
- capture_blocked_reason: blockedReason,
1135
- }),
1136
- });
1140
+ if (blockedReason == null) captureIntentPendingRef.current = true;
1141
+
1142
+ if (telemetry) {
1143
+ // Do not begin native capture until the user's shutter intent is on disk.
1144
+ // If the process is force-closed during capture or delivery, the next SDK
1145
+ // initialization recovers and sends this exact capture_attempt_id.
1146
+ const persisted = await telemetry.trackDurably('camera_capture_request', {
1147
+ component: 'scanner',
1148
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
1149
+ metadata: buildScannerTelemetryMetadata({
1150
+ capture_attempt_id: captureAttemptId,
1151
+ capture_sequence: captureSequence,
1152
+ capture_blocked_reason: blockedReason,
1153
+ }),
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
+ }
1165
+ }
1137
1166
 
1138
1167
  if (blockedReason && blockedReason !== 'camera_not_ready') return;
1139
1168
  if (blockedReason === 'camera_not_ready') {
@@ -1488,6 +1517,7 @@ export function VerifyAIScanner({
1488
1517
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1489
1518
  }
1490
1519
  } finally {
1520
+ captureIntentPendingRef.current = false;
1491
1521
  setTorchSuppressedForRetry(false);
1492
1522
  }
1493
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 TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
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,9 +46,11 @@ interface TelemetryEvent {
29
46
  last_occurred_at: string;
30
47
  }
31
48
 
32
- interface BufferedEvent {
33
- event: TelemetryEvent;
34
- dedupKey: string;
49
+ interface TelemetryTrackOptions {
50
+ component?: string;
51
+ error?: unknown;
52
+ errorCode?: string;
53
+ metadata?: Record<string, string | number>;
35
54
  }
36
55
 
37
56
  /** Event types that flush immediately (critical init failures). */
@@ -40,6 +59,8 @@ const CRITICAL_EVENTS = new Set([
40
59
  'camera_preview_timeout',
41
60
  'camera_startup_slow',
42
61
  'camera_permission_denied',
62
+ 'camera_capture_request',
63
+ 'telemetry_persist_failure',
43
64
  'camera_scanner_mounted',
44
65
  'camera_scanner_disposed',
45
66
  'camera_android_native_orientation_started',
@@ -55,18 +76,24 @@ const CRITICAL_EVENTS = new Set([
55
76
 
56
77
  export class TelemetryReporter {
57
78
  private buffer: Map<string, TelemetryEvent> = new Map();
79
+ private inFlightBuffer: Map<string, TelemetryEvent> = new Map();
80
+ private dedupIndex: Map<string, string> = new Map();
58
81
  private flushTimer: ReturnType<typeof setTimeout> | null = null;
59
82
  private sessionId: string;
60
83
  private baseUrl: string;
61
84
  private apiKey: string;
85
+ private storagePrefix: string;
62
86
  private disposed = false;
63
87
  private flushing = false;
64
88
  private loadedPersisted = false;
65
89
  private loadPersistedPromise: Promise<void> | null = null;
90
+ private persistenceChain: Promise<void> = Promise.resolve();
91
+ private eventSequence = 0;
66
92
 
67
93
  constructor(apiKey: string, baseUrl: string) {
68
94
  this.apiKey = apiKey;
69
95
  this.baseUrl = baseUrl.replace(/\/$/, '');
96
+ this.storagePrefix = `${TELEMETRY_EVENT_KEY_PREFIX}${telemetryTargetId(apiKey, this.baseUrl)}/`;
70
97
  this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
71
98
  // Load any persisted events left behind by a previous session
72
99
  this.loadPersistedBuffer();
@@ -94,75 +121,51 @@ export class TelemetryReporter {
94
121
  /** Track an error event. Fire-and-forget — never throws. */
95
122
  track(
96
123
  eventType: string,
97
- opts: {
98
- component?: string;
99
- error?: unknown;
100
- errorCode?: string;
101
- metadata?: Record<string, string | number>;
102
- } = {},
124
+ opts: TelemetryTrackOptions = {},
103
125
  ): void {
104
126
  if (this.disposed) return;
105
127
 
106
128
  try {
107
- const now = new Date().toISOString();
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);
129
+ this.recordEvent(eventType, opts);
143
130
 
144
131
  if (CRITICAL_EVENTS.has(eventType)) {
145
- this.flushNow();
132
+ // Critical events are written locally before delivery starts. This keeps
133
+ // them recoverable if the process is killed while the POST is in flight.
134
+ void this.persistAfterRecovery().then(() => this.flushNow());
146
135
  } else {
147
136
  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
- });
137
+ void this.persistAfterRecovery();
160
138
  }
161
139
  } catch {
162
140
  // Never throw from telemetry
163
141
  }
164
142
  }
165
143
 
144
+ /**
145
+ * Record an event and wait until it is durably stored before returning.
146
+ *
147
+ * Use this for user intent that must survive an immediate force-close. Network
148
+ * delivery starts after the local write, but callers do not wait on the network.
149
+ */
150
+ async trackDurably(
151
+ eventType: string,
152
+ opts: TelemetryTrackOptions = {},
153
+ ): Promise<boolean> {
154
+ if (this.disposed) return false;
155
+
156
+ try {
157
+ await this.loadPersistedBuffer();
158
+ if (this.disposed) return false;
159
+ this.recordEvent(eventType, opts);
160
+ const persisted = await this.persistBufferAsync();
161
+ this.flushNow();
162
+ return persisted;
163
+ } catch {
164
+ // Never throw from telemetry or block the host capture flow.
165
+ return false;
166
+ }
167
+ }
168
+
166
169
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
167
170
  async flush(): Promise<void> {
168
171
  // Merge any persisted events from a previous session before flushing
@@ -171,14 +174,16 @@ export class TelemetryReporter {
171
174
  if (this.buffer.size === 0 || this.flushing) return;
172
175
 
173
176
  this.flushing = true;
174
- const bufferedEntries = Array.from(this.buffer.entries());
177
+ const bufferedEntries = Array.from(this.buffer.entries()).slice(0, MAX_EVENTS_PER_REQUEST);
175
178
  const events = bufferedEntries.map(([, event]) => event);
176
- this.buffer.clear();
179
+ for (const [key] of bufferedEntries) this.buffer.delete(key);
180
+ this.inFlightBuffer = new Map(bufferedEntries);
177
181
  this.clearFlushTimer();
178
182
  const controller = new AbortController();
179
183
  const timeout = setTimeout(() => controller.abort(), 10000);
180
184
 
181
185
  let loggedDeliveryFailure = false;
186
+ let delivered = false;
182
187
  try {
183
188
  const response = await fetch(`${this.baseUrl}/telemetry`, {
184
189
  method: 'POST',
@@ -202,8 +207,17 @@ export class TelemetryReporter {
202
207
  throw new Error(`Telemetry request failed with status ${response.status}`);
203
208
  }
204
209
 
205
- // Success — clear persisted buffer since events are now server-side
206
- this.persistBuffer(); // buffer is empty at this point, so this clears the key
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
+ }
216
+ this.inFlightBuffer.clear();
217
+ for (const [dedupKey, journalKey] of this.dedupIndex) {
218
+ if (acknowledgedKeySet.has(journalKey)) this.dedupIndex.delete(dedupKey);
219
+ }
220
+ delivered = true;
207
221
  } catch (error) {
208
222
  if (!loggedDeliveryFailure) {
209
223
  const kind = error instanceof Error ? error.name : typeof error;
@@ -224,6 +238,7 @@ export class TelemetryReporter {
224
238
  this.buffer.set(dedupKey, event);
225
239
  }
226
240
  }
241
+ this.inFlightBuffer.clear();
227
242
  } finally {
228
243
  clearTimeout(timeout);
229
244
  this.flushing = false;
@@ -231,13 +246,19 @@ export class TelemetryReporter {
231
246
  this.scheduleFlush();
232
247
  }
233
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
+ }
234
255
  }
235
256
 
236
257
  /** Dispose — flush remaining events and stop timers. */
237
258
  dispose(): void {
238
259
  this.disposed = true;
239
260
  this.clearFlushTimer();
240
- this.flush();
261
+ void this.flush().finally(() => this.releaseClaims());
241
262
  }
242
263
 
243
264
  private scheduleFlush(): void {
@@ -260,32 +281,110 @@ export class TelemetryReporter {
260
281
  }
261
282
  }
262
283
 
263
- /**
264
- * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
265
- * Fire-and-forget — never throws or blocks.
266
- *
267
- * IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
268
- * to avoid overwriting orphaned events from a previous session before recovery.
269
- */
270
- private persistBuffer(): void {
271
- if (!this.loadedPersisted) return; // Don't overwrite orphaned prior-session data before it's loaded
284
+ private recordEvent(eventType: string, opts: TelemetryTrackOptions): void {
285
+ const now = new Date().toISOString();
286
+ const errorObj = opts.error instanceof Error ? opts.error : null;
287
+ const errorMessage = errorObj?.message
288
+ ?? (typeof opts.error === 'string' ? opts.error : undefined);
289
+ const occurrenceId = opts.metadata?.capture_attempt_id;
290
+ const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
291
+
292
+ const existingJournalKey = this.dedupIndex.get(dedupKey);
293
+ const existing = existingJournalKey
294
+ ? this.buffer.get(existingJournalKey)
295
+ : undefined;
296
+ if (existing) {
297
+ existing.event_count++;
298
+ existing.last_occurred_at = now;
299
+ if (opts.metadata) existing.metadata = { ...existing.metadata, ...opts.metadata };
300
+ return;
301
+ }
302
+
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, {
311
+ event_type: eventType,
312
+ component: opts.component,
313
+ error_message: errorMessage?.slice(0, 1000),
314
+ error_stack: errorObj?.stack?.slice(0, 2000),
315
+ error_code: opts.errorCode,
316
+ metadata: eventMetadata,
317
+ sdk_platform: Platform.OS,
318
+ sdk_version: SDK_VERSION,
319
+ os_name: Platform.OS,
320
+ os_version: String(Platform.Version),
321
+ session_id: this.sessionId,
322
+ event_count: 1,
323
+ first_occurred_at: now,
324
+ last_occurred_at: now,
325
+ });
326
+ }
327
+
328
+ private async persistAfterRecovery(): Promise<void> {
329
+ await this.loadPersistedBuffer();
330
+ await this.persistBufferAsync();
331
+ }
332
+
333
+ private createJournalKey(): string {
334
+ this.eventSequence++;
335
+ return `${this.storagePrefix}${this.sessionId}:${this.eventSequence.toString(36)}`;
336
+ }
337
+
338
+ private async persistBufferAsync(): Promise<boolean> {
339
+ if (!this.loadedPersisted) return Promise.resolve(false);
340
+
272
341
  try {
273
- const entries: Record<string, TelemetryEvent> = {};
274
- for (const [key, event] of this.buffer) {
275
- entries[key] = event;
276
- }
277
- getStorage().then(s => {
278
- if (Object.keys(entries).length > 0) {
279
- s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
280
- } else {
281
- s.removeItem(TELEMETRY_PERSIST_KEY);
282
- }
283
- }).catch(() => { /* never throw from telemetry */ });
342
+ const entries = [
343
+ ...this.inFlightBuffer.entries(),
344
+ ...this.buffer.entries(),
345
+ ];
346
+
347
+ const operation = this.persistenceChain
348
+ .catch(() => { /* keep the write queue alive */ })
349
+ .then(async () => {
350
+ const storage = await getStorage();
351
+ if (entries.length > 0) {
352
+ await storage.multiSet(
353
+ entries.map(([key, event]): [string, string] => [key, JSON.stringify(event)]),
354
+ );
355
+ }
356
+ });
357
+ this.persistenceChain = operation.catch(() => { /* keep queue alive */ });
358
+ await operation;
359
+ return true;
284
360
  } catch {
285
361
  // Never throw from telemetry
362
+ return false;
363
+ }
364
+ }
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;
286
380
  }
287
381
  }
288
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
+
289
388
  /**
290
389
  * Load persisted events from a previous session and merge into current buffer.
291
390
  * Runs once per instance. If orphaned events are found, emits an
@@ -300,64 +399,84 @@ export class TelemetryReporter {
300
399
  }
301
400
 
302
401
  this.loadPersistedPromise = (async () => {
402
+ const claimedDuringRecovery: string[] = [];
303
403
  try {
304
404
  const storage = await getStorage();
305
- const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
306
- if (!raw) return;
307
-
308
- const entries = JSON.parse(raw) as Record<string, TelemetryEvent>;
309
405
  let orphanedCount = 0;
310
406
  const orphanedTypes: string[] = [];
311
407
 
312
- for (const [key, event] of Object.entries(entries)) {
313
- if (event.session_id === this.sessionId) continue; // Same session, skip
314
- orphanedCount++;
315
- orphanedTypes.push(event.event_type);
316
- // Merge orphaned events into the current buffer
317
- const existing = this.buffer.get(key);
318
- if (existing) {
319
- existing.event_count += event.event_count;
320
- if (event.first_occurred_at < existing.first_occurred_at) {
321
- existing.first_occurred_at = event.first_occurred_at;
322
- }
323
- if (event.last_occurred_at > existing.last_occurred_at) {
324
- existing.last_occurred_at = event.last_occurred_at;
325
- }
326
- } else {
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);
327
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;
328
459
  }
329
460
  }
330
461
 
331
462
  // Emit sdk_session_recovered if we recovered orphaned events from a previous session
332
463
  if (orphanedCount > 0) {
333
- const now = new Date().toISOString();
334
464
  const uniqueTypes = [...new Set(orphanedTypes)];
335
- const crashEvent: TelemetryEvent = {
336
- event_type: 'sdk_session_recovered',
465
+ this.recordEvent('sdk_session_recovered', {
337
466
  component: 'TelemetryReporter',
338
- error_message: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
339
- sdk_platform: Platform.OS,
340
- sdk_version: SDK_VERSION,
341
- os_name: Platform.OS,
342
- os_version: String(Platform.Version),
343
- session_id: this.sessionId,
344
- event_count: 1,
345
- first_occurred_at: now,
346
- last_occurred_at: now,
347
- };
348
- 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
+ });
349
469
  }
350
-
351
- // Clear persisted data now that it's loaded into memory. The merged
352
- // buffer will be re-persisted below until a flush succeeds.
353
- await storage.removeItem(TELEMETRY_PERSIST_KEY);
354
470
  } catch {
355
471
  // Never throw from telemetry
472
+ for (const key of claimedDuringRecovery) {
473
+ if (!this.buffer.has(key)) claimedEventKeys.delete(key);
474
+ }
356
475
  } finally {
357
476
  this.loadedPersisted = true;
358
477
  this.loadPersistedPromise = null;
359
478
  if (this.buffer.size > 0 && !this.disposed) {
360
- this.persistBuffer();
479
+ await this.persistBufferAsync();
361
480
  this.scheduleFlush();
362
481
  }
363
482
  }
@@ -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.4';
1
+ export const SDK_VERSION = '2.5.6';