@switchlabs/verify-ai-react-native 2.5.5 → 2.5.7

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/README.md CHANGED
@@ -1,6 +1,32 @@
1
1
  # @switchlabs/verify-ai-react-native
2
2
 
3
- React Native SDK for Verify AI photo verification.
3
+ Send a photo and a policy, get back a pass/fail verdict with a confidence
4
+ value, the criteria that failed, and human-readable reasoning. Built for
5
+ end-of-ride parking compliance, vehicle and equipment condition and damage
6
+ grading, proof of delivery, and rental return checks.
7
+
8
+ The SDK wraps live camera capture (no gallery-picker path in the scanner
9
+ flow), on-device pre-screening, an offline queue, and the REST API.
10
+
11
+ ```tsx
12
+ const result = await verify({ image: base64, policy: 'scooter_parking' });
13
+ if (result && !result.is_compliant) {
14
+ console.log(result.violation_reasons, result.feedback);
15
+ }
16
+ ```
17
+
18
+ **Try it before you install anything:** upload a photo at
19
+ [verify.switchlabs.dev/demo](https://verify.switchlabs.dev/demo). No account,
20
+ no card.
21
+
22
+ **Get a key:** [free sandbox](https://verify.switchlabs.dev/login?mode=signup)
23
+ with $5 of credit and no credit card. Keys start with `vai_` and are sent in an
24
+ `X-API-Key` header.
25
+
26
+ **Pricing:** $0.008 per standard verification for the first 100,000 in a
27
+ calendar month, $0.006 from 100,001 to 1,000,000, and $0.005 above 1,000,000.
28
+ No platform fee, no monthly minimum, no annual contract.
29
+ [Full pricing](https://verify.switchlabs.dev/pricing).
4
30
 
5
31
  ## Install
6
32
 
@@ -2,6 +2,13 @@ import { TelemetryReporter } from '../telemetry/TelemetryReporter';
2
2
  import { SDK_VERSION } from '../version';
3
3
  const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
4
4
  const DEFAULT_TIMEOUT = 60000;
5
+ const MAX_IN_PROGRESS_RETRIES = 10;
6
+ function retryAfterMs(value) {
7
+ if (value !== null && /^\d+$/.test(value.trim()))
8
+ return Number(value) * 1000;
9
+ const date = value ? Date.parse(value) : NaN;
10
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 1000;
11
+ }
5
12
  const VEHICLE_TYPE_LABELS = {
6
13
  scooter: 'Scooter',
7
14
  'e-bike': 'E-Bike',
@@ -150,17 +157,34 @@ export class VerifyAIClient {
150
157
  const method = (options.method || 'GET').toUpperCase();
151
158
  const url = `${this.baseUrl}${path}`;
152
159
  const context = { path, url, method };
160
+ const deadline = Date.now() + this.timeout;
153
161
  try {
154
- const response = await fetch(url, {
155
- ...options,
156
- signal: controller.signal,
157
- headers: {
158
- 'X-API-Key': this.apiKey,
159
- ...options.headers,
160
- },
161
- });
162
- const rawBody = await response.text();
163
- const body = this.parseResponseBody(rawBody);
162
+ let response;
163
+ let body;
164
+ let retries = 0;
165
+ while (true) {
166
+ response = await fetch(url, {
167
+ ...options,
168
+ signal: controller.signal,
169
+ headers: {
170
+ 'X-API-Key': this.apiKey,
171
+ ...options.headers,
172
+ },
173
+ });
174
+ const rawBody = await response.text();
175
+ body = this.parseResponseBody(rawBody);
176
+ const code = body && typeof body === 'object' ? body.code : undefined;
177
+ const key = new Headers(options.headers).get('Idempotency-Key');
178
+ const delay = retryAfterMs(response.headers.get('Retry-After'));
179
+ // Only replay an identical, explicitly keyed verification reservation.
180
+ // Keep one timeout budget and report errors only after retries exhaust.
181
+ if (path !== '/verify' || method !== 'POST' || !key || response.status !== 409 ||
182
+ code !== 'idempotency_in_progress' || retries >= MAX_IN_PROGRESS_RETRIES ||
183
+ delay >= deadline - Date.now() || controller.signal.aborted)
184
+ break;
185
+ retries += 1;
186
+ await new Promise(resolve => setTimeout(resolve, delay));
187
+ }
164
188
  if (!response.ok) {
165
189
  const errorBody = (body && typeof body === 'object' ? body : null);
166
190
  throw this.buildRequestError(stringifyErrorMessage(errorBody?.error, `Request failed with status ${response.status}`), response.status, context, {
@@ -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
- }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
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 = !cameraRef.current
906
- ? 'camera_ref_null'
907
- : !cameraReadyRef.current
908
- ? 'camera_not_ready'
909
- : status === 'capturing'
910
- ? 'already_capturing'
911
- : status === 'processing'
912
- ? 'already_processing'
913
- : terminated
914
- ? 'terminated'
915
- : exhausted
916
- ? 'exhausted'
917
- : null;
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<void>;
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 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',
@@ -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
- this.buffer.clear();
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
- // Success — persist the current buffer. Usually empty, but it may contain
141
- // events recorded while this request was in flight.
142
- await this.persistBufferAsync();
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 existing = this.buffer.get(dedupKey);
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.buffer.set(dedupKey, {
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: opts.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
- * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
237
- * Fire-and-forget — never throws or blocks.
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
- for (const [key, event] of this.inFlightBuffer) {
251
- entries[key] = event;
252
- }
253
- for (const [key, event] of this.buffer) {
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 (Object.keys(entries).length > 0) {
263
- await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
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
- .catch(() => { });
270
- return this.persistenceChain;
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 Promise.resolve();
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
- for (const [key, event] of Object.entries(entries)) {
300
- if (event.session_id === this.sessionId)
301
- continue; // Same session, skip
302
- orphanedCount++;
303
- orphanedTypes.push(event.event_type);
304
- // Merge orphaned events into the current buffer
305
- const existing = this.buffer.get(key);
306
- if (existing) {
307
- existing.event_count += event.event_count;
308
- if (event.first_occurred_at < existing.first_occurred_at) {
309
- existing.first_occurred_at = event.first_occurred_at;
310
- }
311
- if (event.last_occurred_at > existing.last_occurred_at) {
312
- existing.last_occurred_at = event.last_occurred_at;
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
- else {
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
- const crashEvent = {
324
- event_type: 'sdk_session_recovered',
414
+ this.recordEvent('sdk_session_recovered', {
325
415
  component: 'TelemetryReporter',
326
- error_message: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
327
- sdk_platform: Platform.OS,
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.persistBuffer();
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.5";
1
+ export declare const SDK_VERSION = "2.5.7";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.5';
1
+ export const SDK_VERSION = '2.5.7';
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.5.5",
3
+ "version": "2.5.7",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
- "repository": {
6
- "type": "git",
7
- "url": "git+https://github.com/levyelectric/verify.git",
8
- "directory": "packages/verify-ai-react-native"
5
+ "homepage": "https://verify.switchlabs.dev/docs/sdks/react-native",
6
+ "bugs": {
7
+ "url": "https://verify.switchlabs.dev/contact"
9
8
  },
10
9
  "main": "./lib/index.js",
11
10
  "types": "./lib/index.d.ts",
@@ -35,7 +34,12 @@
35
34
  "photo-verification",
36
35
  "ai-vision",
37
36
  "parking-compliance",
38
- "damage-detection"
37
+ "damage-detection",
38
+ "proof-of-delivery",
39
+ "image-verification",
40
+ "vehicle-inspection",
41
+ "expo",
42
+ "photo-verification-api"
39
43
  ],
40
44
  "author": "Switch Labs",
41
45
  "license": "MIT",
@@ -86,5 +90,10 @@
86
90
  "react-native-fast-tflite": "^1.6.1",
87
91
  "typescript": "^5.5.0",
88
92
  "vitest": "^4.1.0"
93
+ },
94
+ "repository": {
95
+ "type": "git",
96
+ "url": "git+https://github.com/levyelectric/verify.git",
97
+ "directory": "packages/verify-ai-react-native"
89
98
  }
90
99
  }
@@ -14,6 +14,13 @@ import { SDK_VERSION } from '../version';
14
14
 
15
15
  const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
16
16
  const DEFAULT_TIMEOUT = 60000;
17
+ const MAX_IN_PROGRESS_RETRIES = 10;
18
+
19
+ function retryAfterMs(value: string | null): number {
20
+ if (value !== null && /^\d+$/.test(value.trim())) return Number(value) * 1000;
21
+ const date = value ? Date.parse(value) : NaN;
22
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 1000;
23
+ }
17
24
 
18
25
  const VEHICLE_TYPE_LABELS: Record<string, string> = {
19
26
  scooter: 'Scooter',
@@ -214,19 +221,35 @@ export class VerifyAIClient {
214
221
  const method = (options.method || 'GET').toUpperCase();
215
222
  const url = `${this.baseUrl}${path}`;
216
223
  const context: RequestContext = { path, url, method };
224
+ const deadline = Date.now() + this.timeout;
217
225
 
218
226
  try {
219
- const response = await fetch(url, {
220
- ...options,
221
- signal: controller.signal,
222
- headers: {
223
- 'X-API-Key': this.apiKey,
224
- ...options.headers,
225
- },
226
- });
227
-
228
- const rawBody = await response.text();
229
- const body = this.parseResponseBody(rawBody);
227
+ let response: Response;
228
+ let body: unknown;
229
+ let retries = 0;
230
+ while (true) {
231
+ response = await fetch(url, {
232
+ ...options,
233
+ signal: controller.signal,
234
+ headers: {
235
+ 'X-API-Key': this.apiKey,
236
+ ...options.headers,
237
+ },
238
+ });
239
+
240
+ const rawBody = await response.text();
241
+ body = this.parseResponseBody(rawBody);
242
+ const code = body && typeof body === 'object' ? (body as VerifyAIError).code : undefined;
243
+ const key = new Headers(options.headers).get('Idempotency-Key');
244
+ const delay = retryAfterMs(response.headers.get('Retry-After'));
245
+ // Only replay an identical, explicitly keyed verification reservation.
246
+ // Keep one timeout budget and report errors only after retries exhaust.
247
+ if (path !== '/verify' || method !== 'POST' || !key || response.status !== 409 ||
248
+ code !== 'idempotency_in_progress' || retries >= MAX_IN_PROGRESS_RETRIES ||
249
+ delay >= deadline - Date.now() || controller.signal.aborted) break;
250
+ retries += 1;
251
+ await new Promise(resolve => setTimeout(resolve, delay));
252
+ }
230
253
 
231
254
  if (!response.ok) {
232
255
  const errorBody = (body && typeof body === 'object' ? body : null) as VerifyAIError | null;
@@ -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
- }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
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
- !cameraRef.current
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 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,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<void> {
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.clear();
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
- // Success — persist the current buffer. Usually empty, but it may contain
192
- // events recorded while this request was in flight.
193
- await this.persistBufferAsync();
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 existing = this.buffer.get(dedupKey);
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.buffer.set(dedupKey, {
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: opts.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
- * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
292
- * Fire-and-forget — never throws or blocks.
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<void> {
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: Record<string, TelemetryEvent> = {};
306
- for (const [key, event] of this.inFlightBuffer) {
307
- entries[key] = event;
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
- // Serialize writes so an older fire-and-forget snapshot cannot overwrite a
314
- // newer durable capture marker or resurrect an acknowledged event.
315
- this.persistenceChain = this.persistenceChain
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 (Object.keys(entries).length > 0) {
320
- await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
321
- } else {
322
- await storage.removeItem(TELEMETRY_PERSIST_KEY);
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
- .catch(() => { /* never throw from telemetry */ });
326
- return this.persistenceChain;
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 Promise.resolve();
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
- for (const [key, event] of Object.entries(entries)) {
357
- if (event.session_id === this.sessionId) continue; // Same session, skip
358
- orphanedCount++;
359
- orphanedTypes.push(event.event_type);
360
- // Merge orphaned events into the current buffer
361
- const existing = this.buffer.get(key);
362
- if (existing) {
363
- existing.event_count += event.event_count;
364
- if (event.first_occurred_at < existing.first_occurred_at) {
365
- existing.first_occurred_at = event.first_occurred_at;
366
- }
367
- if (event.last_occurred_at > existing.last_occurred_at) {
368
- existing.last_occurred_at = event.last_occurred_at;
369
- }
370
- } 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);
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
- const crashEvent: TelemetryEvent = {
380
- event_type: 'sdk_session_recovered',
465
+ this.recordEvent('sdk_session_recovered', {
381
466
  component: 'TelemetryReporter',
382
- error_message: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
383
- sdk_platform: Platform.OS,
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.persistBuffer();
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.5';
1
+ export const SDK_VERSION = '2.5.7';