@switchlabs/verify-ai-react-native 2.5.1 → 2.5.2

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.
@@ -52,6 +52,49 @@ function stringifyErrorMessage(value, fallback) {
52
52
  }
53
53
  return fallback;
54
54
  }
55
+ const VERIFY_METADATA_TELEMETRY_KEYS = [
56
+ 'rideId',
57
+ 'vehicleType',
58
+ 'verificationAttemptId',
59
+ 'verificationAttempt',
60
+ 'verificationMaxAttempts',
61
+ 'verificationSource',
62
+ 'torchEnabled',
63
+ 'scannerSessionId',
64
+ ];
65
+ function toTelemetryKey(key) {
66
+ return key
67
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
68
+ .replace(/[^a-zA-Z0-9]+/g, '_')
69
+ .replace(/^_+|_+$/g, '')
70
+ .toLowerCase();
71
+ }
72
+ function compactClientTelemetryMetadata(metadata = {}) {
73
+ const compacted = {};
74
+ for (const [key, value] of Object.entries(metadata)) {
75
+ if (value === null || value === undefined)
76
+ continue;
77
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
78
+ }
79
+ return compacted;
80
+ }
81
+ function buildVerifyTelemetryMetadata(requestMetadata, options) {
82
+ const metadata = {
83
+ ...options?.telemetryMetadata,
84
+ };
85
+ if (options?.idempotencyKey) {
86
+ metadata.idempotency_key = options.idempotencyKey;
87
+ }
88
+ for (const key of VERIFY_METADATA_TELEMETRY_KEYS) {
89
+ const value = requestMetadata?.[key];
90
+ if (typeof value === 'string' ||
91
+ typeof value === 'number' ||
92
+ typeof value === 'boolean') {
93
+ metadata[`verify_metadata_${toTelemetryKey(key)}`] = value;
94
+ }
95
+ }
96
+ return metadata;
97
+ }
55
98
  export class VerifyAIClient {
56
99
  constructor(config) {
57
100
  if (!config.apiKey) {
@@ -101,7 +144,7 @@ export class VerifyAIClient {
101
144
  const message = error instanceof Error ? error.message : stringifyErrorMessage(error, 'VerifyAI request failed');
102
145
  return this.buildRequestError(message, 0, context);
103
146
  }
104
- async executeRequest(path, options = {}) {
147
+ async executeRequest(path, options = {}, telemetryMetadata = {}) {
105
148
  const controller = new AbortController();
106
149
  const timer = setTimeout(() => controller.abort(), this.timeout);
107
150
  const method = (options.method || 'GET').toUpperCase();
@@ -156,6 +199,7 @@ export class VerifyAIClient {
156
199
  ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
157
200
  : normalized;
158
201
  const metadata = {
202
+ ...compactClientTelemetryMetadata(telemetryMetadata),
159
203
  method: context.method,
160
204
  path: context.path,
161
205
  status: normalized.status,
@@ -178,8 +222,8 @@ export class VerifyAIClient {
178
222
  clearTimeout(timer);
179
223
  }
180
224
  }
181
- async request(path, options = {}) {
182
- return this.executeRequest(path, options);
225
+ async request(path, options = {}, telemetryMetadata = {}) {
226
+ return this.executeRequest(path, options, telemetryMetadata);
183
227
  }
184
228
  /**
185
229
  * Submit a photo for AI verification.
@@ -209,11 +253,12 @@ export class VerifyAIClient {
209
253
  headers['Idempotency-Key'] = options.idempotencyKey;
210
254
  }
211
255
  const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
256
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedRequest.metadata, options);
212
257
  return this.request('/verify', {
213
258
  method: 'POST',
214
259
  headers,
215
260
  body: JSON.stringify(enrichedRequest),
216
- });
261
+ }, telemetryMetadata);
217
262
  }
218
263
  /**
219
264
  * Submit a photo for AI verification using multipart/form-data.
@@ -232,7 +277,8 @@ export class VerifyAIClient {
232
277
  name: 'photo.jpg',
233
278
  });
234
279
  formData.append('policy', request.policy);
235
- formData.append('metadata', JSON.stringify(enrichMetadata(request.metadata)));
280
+ const enrichedMetadata = enrichMetadata(request.metadata);
281
+ formData.append('metadata', JSON.stringify(enrichedMetadata));
236
282
  if (request.provider) {
237
283
  formData.append('provider', request.provider);
238
284
  }
@@ -246,12 +292,13 @@ export class VerifyAIClient {
246
292
  if (options?.idempotencyKey) {
247
293
  headers['Idempotency-Key'] = options.idempotencyKey;
248
294
  }
295
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedMetadata, options);
249
296
  try {
250
297
  return await this.executeRequest('/verify', {
251
298
  method: 'POST',
252
299
  headers,
253
300
  body: formData,
254
- });
301
+ }, telemetryMetadata);
255
302
  }
256
303
  catch (error) {
257
304
  // 5xx during multipart can mean the server crashed before reservation
@@ -271,7 +318,7 @@ export class VerifyAIClient {
271
318
  metadata: request.metadata,
272
319
  provider: request.provider,
273
320
  include_image_data: request.include_image_data,
274
- }, options?.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : undefined);
321
+ }, options);
275
322
  }
276
323
  catch {
277
324
  // If the retry itself fails, throw the original error
@@ -20,6 +20,7 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
20
20
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
21
21
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
22
22
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
23
+ const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
23
24
  const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
24
25
  const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
25
26
  const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
@@ -76,6 +77,9 @@ function createScannerError(message, code, name) {
76
77
  function sleep(ms) {
77
78
  return new Promise((resolve) => setTimeout(resolve, ms));
78
79
  }
80
+ function createScannerSessionId() {
81
+ return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
82
+ }
79
83
  function isNativeCameraCaptureError(error) {
80
84
  const message = error.message.toLowerCase();
81
85
  return (error.code === CAMERA_CAPTURE_ERROR_CODE ||
@@ -261,7 +265,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
261
265
  const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
262
266
  physicalOrientationRef.current = physicalOrientation;
263
267
  const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
268
+ const scannerSessionIdRef = useRef(createScannerSessionId());
269
+ const captureSequenceRef = useRef(0);
270
+ const torchRetrySuppressedRef = useRef(false);
271
+ const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
264
272
  const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
273
+ scanner_session_id: scannerSessionIdRef.current,
265
274
  policy,
266
275
  status,
267
276
  camera_ready: cameraReadyRef.current,
@@ -290,6 +299,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
290
299
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
291
300
  last_capture_retry_at: lastCaptureRetryAtRef.current,
292
301
  requested_torch_enabled: enableTorch ? 1 : 0,
302
+ torch_retry_suppressed: torchRetrySuppressedRef.current,
293
303
  android_native_orientation_subscription_active: 0,
294
304
  android_native_orientation_event_count: 0,
295
305
  android_native_orientation_change_count: 0,
@@ -342,6 +352,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
342
352
  ]);
343
353
  telemetryRef.current = telemetry;
344
354
  buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
355
+ const setTorchSuppressedForRetry = useCallback((nextSuppressed) => {
356
+ torchRetrySuppressedRef.current = nextSuppressed;
357
+ setTorchRetrySuppressed(nextSuppressed);
358
+ }, []);
345
359
  useEffect(() => {
346
360
  const reporter = telemetryRef.current;
347
361
  if (reporter) {
@@ -382,7 +396,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
382
396
  cameraStartupAttemptStartedAtRef.current = Date.now();
383
397
  if (opts.log) {
384
398
  console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
385
- `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`);
399
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0} ` +
400
+ `torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`);
386
401
  }
387
402
  const remount = () => {
388
403
  setCameraKey((k) => k + 1);
@@ -875,6 +890,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
875
890
  }
876
891
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
877
892
  const handleCapture = useCallback(async () => {
893
+ const captureSequence = captureSequenceRef.current + 1;
894
+ captureSequenceRef.current = captureSequence;
895
+ const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
878
896
  const blockedReason = !cameraRef.current
879
897
  ? 'camera_ref_null'
880
898
  : !cameraReadyRef.current
@@ -892,6 +910,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
892
910
  component: 'scanner',
893
911
  error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
894
912
  metadata: buildScannerTelemetryMetadata({
913
+ capture_attempt_id: captureAttemptId,
914
+ capture_sequence: captureSequence,
895
915
  capture_blocked_reason: blockedReason,
896
916
  }),
897
917
  });
@@ -912,6 +932,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
912
932
  return;
913
933
  }
914
934
  setStatus('capturing');
935
+ setTorchSuppressedForRetry(false);
915
936
  setResult(null);
916
937
  setLastError(null);
917
938
  terminalResultRef.current = null;
@@ -923,6 +944,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
923
944
  let nativeCaptureAttempts = 0;
924
945
  let captureRetryAttempted = false;
925
946
  let captureRetryReady = false;
947
+ let captureRetryTorchSuppressed = false;
948
+ let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
949
+ let captureRetryRemountBackoffMs = 0;
926
950
  let lastNativeCaptureErrorMessage = null;
927
951
  try {
928
952
  const capturePhysicalOrientation = physicalOrientation;
@@ -931,6 +955,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
931
955
  component: 'scanner',
932
956
  error: 'capture_orientation_context',
933
957
  metadata: buildScannerTelemetryMetadata({
958
+ capture_attempt_id: captureAttemptId,
959
+ capture_sequence: captureSequence,
934
960
  capture_physical_orientation: capturePhysicalOrientation,
935
961
  capture_overlay_rotation_deg: captureOverlayRotationDeg,
936
962
  capture_rotation_applied: 0,
@@ -988,11 +1014,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
988
1014
  lastNativeCaptureErrorMessage = normalized.message;
989
1015
  if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
990
1016
  captureRetryAttempted = true;
1017
+ captureRetryTorchSuppressed = !!enableTorch;
1018
+ captureRetrySettleDelayMs = captureRetryTorchSuppressed
1019
+ ? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
1020
+ : CAMERA_CAPTURE_RETRY_DELAY_MS;
1021
+ captureRetryRemountBackoffMs = captureRetryTorchSuppressed
1022
+ ? CAMERA_CAPTURE_RETRY_DELAY_MS
1023
+ : 0;
991
1024
  const retryAt = new Date().toISOString();
992
1025
  lastCaptureRetryAtRef.current = retryAt;
993
1026
  cameraRemountCountRef.current++;
994
- requestCameraRemount('capture_retry');
995
- await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1027
+ if (captureRetryTorchSuppressed) {
1028
+ setTorchSuppressedForRetry(true);
1029
+ }
1030
+ requestCameraRemount('capture_retry', captureRetryRemountBackoffMs > 0
1031
+ ? { backoffMs: captureRetryRemountBackoffMs }
1032
+ : undefined);
1033
+ await sleep(captureRetrySettleDelayMs);
996
1034
  const captureRetryReadyTimeoutMs = Platform.OS === 'android'
997
1035
  ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
998
1036
  : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
@@ -1002,11 +1040,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1002
1040
  error: normalized,
1003
1041
  errorCode: normalized.code,
1004
1042
  metadata: buildScannerTelemetryMetadata({
1043
+ capture_attempt_id: captureAttemptId,
1044
+ capture_sequence: captureSequence,
1005
1045
  native_capture_attempts: nativeCaptureAttempts,
1006
1046
  capture_retry_attempted: captureRetryAttempted,
1007
1047
  capture_retry_ready: captureRetryReady,
1008
1048
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
1049
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1050
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1009
1051
  capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1052
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1010
1053
  last_native_capture_error: lastNativeCaptureErrorMessage,
1011
1054
  }),
1012
1055
  });
@@ -1074,6 +1117,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1074
1117
  telemetry?.track('image_processed', {
1075
1118
  component: 'scanner',
1076
1119
  metadata: buildScannerTelemetryMetadata({
1120
+ capture_attempt_id: captureAttemptId,
1121
+ capture_sequence: captureSequence,
1077
1122
  original_width: origWidth,
1078
1123
  original_height: origHeight,
1079
1124
  processed_width: processedWidth,
@@ -1083,6 +1128,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1083
1128
  native_capture_attempts: nativeCaptureAttempts,
1084
1129
  capture_retry_attempted: captureRetryAttempted,
1085
1130
  capture_retry_ready: captureRetryReady,
1131
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1132
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1133
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1086
1134
  recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
1087
1135
  last_native_capture_error: lastNativeCaptureErrorMessage,
1088
1136
  }),
@@ -1151,9 +1199,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1151
1199
  component: 'scanner',
1152
1200
  error,
1153
1201
  metadata: buildScannerTelemetryMetadata({
1202
+ capture_attempt_id: captureAttemptId,
1203
+ capture_sequence: captureSequence,
1154
1204
  native_capture_attempts: nativeCaptureAttempts,
1155
1205
  capture_retry_attempted: captureRetryAttempted,
1156
1206
  capture_retry_ready: captureRetryReady,
1207
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1208
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1209
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1157
1210
  is_native_camera_capture_error: isNativeCameraCaptureError(error),
1158
1211
  last_native_capture_error: lastNativeCaptureErrorMessage,
1159
1212
  }),
@@ -1163,7 +1216,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1163
1216
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1164
1217
  }
1165
1218
  }
1166
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1219
+ finally {
1220
+ setTorchSuppressedForRetry(false);
1221
+ }
1222
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
1167
1223
  // Expose capture to parent via ref
1168
1224
  if (captureRef) {
1169
1225
  captureRef.current = handleCapture;
@@ -1175,7 +1231,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1175
1231
  return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
1176
1232
  }
1177
1233
  const showBottomCard = status === 'success' || status === 'error';
1178
- const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
1234
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
1179
1235
  return (_jsx(View, { style: [styles.container, style], children: cameraMounted && (_jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: shouldEnableTorch, onCameraReady: onCameraReady, onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
1180
1236
  setPhysicalOrientation(event.orientation);
1181
1237
  }, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
@@ -57,6 +57,8 @@ export interface VerificationListParams {
57
57
  export interface VerifyOptions {
58
58
  /** Idempotency key to prevent duplicate verifications on retry. */
59
59
  idempotencyKey?: string;
60
+ /** Optional primitive metadata attached to client-side request telemetry. */
61
+ telemetryMetadata?: Record<string, string | number | boolean | null | undefined>;
60
62
  }
61
63
  export interface QueueItem {
62
64
  id: string;
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.5.1";
1
+ export declare const SDK_VERSION = "2.5.2";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.1';
1
+ export const SDK_VERSION = '2.5.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.5.1",
3
+ "version": "2.5.2",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -78,6 +78,65 @@ interface RequestContext {
78
78
  method: string;
79
79
  }
80
80
 
81
+ type ClientTelemetryValue = string | number | boolean | null | undefined;
82
+ type ClientTelemetryMetadata = Record<string, ClientTelemetryValue>;
83
+
84
+ const VERIFY_METADATA_TELEMETRY_KEYS = [
85
+ 'rideId',
86
+ 'vehicleType',
87
+ 'verificationAttemptId',
88
+ 'verificationAttempt',
89
+ 'verificationMaxAttempts',
90
+ 'verificationSource',
91
+ 'torchEnabled',
92
+ 'scannerSessionId',
93
+ ];
94
+
95
+ function toTelemetryKey(key: string): string {
96
+ return key
97
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
98
+ .replace(/[^a-zA-Z0-9]+/g, '_')
99
+ .replace(/^_+|_+$/g, '')
100
+ .toLowerCase();
101
+ }
102
+
103
+ function compactClientTelemetryMetadata(
104
+ metadata: ClientTelemetryMetadata = {},
105
+ ): Record<string, string | number> {
106
+ const compacted: Record<string, string | number> = {};
107
+ for (const [key, value] of Object.entries(metadata)) {
108
+ if (value === null || value === undefined) continue;
109
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
110
+ }
111
+ return compacted;
112
+ }
113
+
114
+ function buildVerifyTelemetryMetadata(
115
+ requestMetadata: Record<string, unknown> | undefined,
116
+ options?: VerifyOptions,
117
+ ): ClientTelemetryMetadata {
118
+ const metadata: ClientTelemetryMetadata = {
119
+ ...options?.telemetryMetadata,
120
+ };
121
+
122
+ if (options?.idempotencyKey) {
123
+ metadata.idempotency_key = options.idempotencyKey;
124
+ }
125
+
126
+ for (const key of VERIFY_METADATA_TELEMETRY_KEYS) {
127
+ const value = requestMetadata?.[key];
128
+ if (
129
+ typeof value === 'string' ||
130
+ typeof value === 'number' ||
131
+ typeof value === 'boolean'
132
+ ) {
133
+ metadata[`verify_metadata_${toTelemetryKey(key)}`] = value;
134
+ }
135
+ }
136
+
137
+ return metadata;
138
+ }
139
+
81
140
  export class VerifyAIClient {
82
141
  private apiKey: string;
83
142
  private baseUrl: string;
@@ -147,7 +206,8 @@ export class VerifyAIClient {
147
206
 
148
207
  private async executeRequest<T>(
149
208
  path: string,
150
- options: RequestInit = {}
209
+ options: RequestInit = {},
210
+ telemetryMetadata: ClientTelemetryMetadata = {},
151
211
  ): Promise<T> {
152
212
  const controller = new AbortController();
153
213
  const timer = setTimeout(() => controller.abort(), this.timeout);
@@ -218,6 +278,7 @@ export class VerifyAIClient {
218
278
  ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
219
279
  : normalized;
220
280
  const metadata: Record<string, string | number> = {
281
+ ...compactClientTelemetryMetadata(telemetryMetadata),
221
282
  method: context.method,
222
283
  path: context.path,
223
284
  status: normalized.status,
@@ -243,9 +304,10 @@ export class VerifyAIClient {
243
304
 
244
305
  private async request<T>(
245
306
  path: string,
246
- options: RequestInit = {}
307
+ options: RequestInit = {},
308
+ telemetryMetadata: ClientTelemetryMetadata = {},
247
309
  ): Promise<T> {
248
- return this.executeRequest<T>(path, options);
310
+ return this.executeRequest<T>(path, options, telemetryMetadata);
249
311
  }
250
312
 
251
313
  /**
@@ -276,11 +338,12 @@ export class VerifyAIClient {
276
338
  headers['Idempotency-Key'] = options.idempotencyKey;
277
339
  }
278
340
  const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
341
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedRequest.metadata, options);
279
342
  return this.request<VerificationResult>('/verify', {
280
343
  method: 'POST',
281
344
  headers,
282
345
  body: JSON.stringify(enrichedRequest),
283
- });
346
+ }, telemetryMetadata);
284
347
  }
285
348
 
286
349
  /**
@@ -300,7 +363,8 @@ export class VerifyAIClient {
300
363
  name: 'photo.jpg',
301
364
  } as unknown as Blob);
302
365
  formData.append('policy', request.policy);
303
- formData.append('metadata', JSON.stringify(enrichMetadata(request.metadata)));
366
+ const enrichedMetadata = enrichMetadata(request.metadata);
367
+ formData.append('metadata', JSON.stringify(enrichedMetadata));
304
368
  if (request.provider) {
305
369
  formData.append('provider', request.provider);
306
370
  }
@@ -316,12 +380,14 @@ export class VerifyAIClient {
316
380
  headers['Idempotency-Key'] = options.idempotencyKey;
317
381
  }
318
382
 
383
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedMetadata, options);
384
+
319
385
  try {
320
386
  return await this.executeRequest<VerificationResult>('/verify', {
321
387
  method: 'POST',
322
388
  headers,
323
389
  body: formData,
324
- });
390
+ }, telemetryMetadata);
325
391
  } catch (error) {
326
392
  // 5xx during multipart can mean the server crashed before reservation
327
393
  // (e.g. Vercel multipart-parse failure) OR after the verification was
@@ -342,7 +408,7 @@ export class VerifyAIClient {
342
408
  provider: request.provider,
343
409
  include_image_data: request.include_image_data,
344
410
  },
345
- options?.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : undefined,
411
+ options,
346
412
  );
347
413
  } catch {
348
414
  // If the retry itself fails, throw the original error
@@ -45,6 +45,7 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
45
45
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
46
46
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
47
47
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
48
+ const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
48
49
  const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
49
50
  const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
50
51
  const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
@@ -153,6 +154,10 @@ function sleep(ms: number): Promise<void> {
153
154
  return new Promise((resolve) => setTimeout(resolve, ms));
154
155
  }
155
156
 
157
+ function createScannerSessionId(): string {
158
+ return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
159
+ }
160
+
156
161
  function isNativeCameraCaptureError(error: ErrorWithDetails): boolean {
157
162
  const message = error.message.toLowerCase();
158
163
  return (
@@ -369,10 +374,15 @@ export function VerifyAIScanner({
369
374
 
370
375
  physicalOrientationRef.current = physicalOrientation;
371
376
  const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
377
+ const scannerSessionIdRef = useRef(createScannerSessionId());
378
+ const captureSequenceRef = useRef(0);
379
+ const torchRetrySuppressedRef = useRef(false);
380
+ const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
372
381
 
373
382
  const buildScannerTelemetryMetadata = useCallback((
374
383
  extra: Record<string, string | number | boolean | null | undefined> = {},
375
384
  ): ScannerTelemetryMetadata => compactTelemetryMetadata({
385
+ scanner_session_id: scannerSessionIdRef.current,
376
386
  policy,
377
387
  status,
378
388
  camera_ready: cameraReadyRef.current,
@@ -401,6 +411,7 @@ export function VerifyAIScanner({
401
411
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
402
412
  last_capture_retry_at: lastCaptureRetryAtRef.current,
403
413
  requested_torch_enabled: enableTorch ? 1 : 0,
414
+ torch_retry_suppressed: torchRetrySuppressedRef.current,
404
415
  android_native_orientation_subscription_active: 0,
405
416
  android_native_orientation_event_count: 0,
406
417
  android_native_orientation_change_count: 0,
@@ -455,6 +466,11 @@ export function VerifyAIScanner({
455
466
  telemetryRef.current = telemetry;
456
467
  buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
457
468
 
469
+ const setTorchSuppressedForRetry = useCallback((nextSuppressed: boolean) => {
470
+ torchRetrySuppressedRef.current = nextSuppressed;
471
+ setTorchRetrySuppressed(nextSuppressed);
472
+ }, []);
473
+
458
474
  useEffect(() => {
459
475
  const reporter = telemetryRef.current;
460
476
  if (reporter) {
@@ -509,7 +525,8 @@ export function VerifyAIScanner({
509
525
  if (opts.log) {
510
526
  console.warn(
511
527
  `VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
512
- `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`,
528
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0} ` +
529
+ `torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`,
513
530
  );
514
531
  }
515
532
 
@@ -1076,6 +1093,9 @@ export function VerifyAIScanner({
1076
1093
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
1077
1094
 
1078
1095
  const handleCapture = useCallback(async () => {
1096
+ const captureSequence = captureSequenceRef.current + 1;
1097
+ captureSequenceRef.current = captureSequence;
1098
+ const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
1079
1099
  const blockedReason =
1080
1100
  !cameraRef.current
1081
1101
  ? 'camera_ref_null'
@@ -1095,6 +1115,8 @@ export function VerifyAIScanner({
1095
1115
  component: 'scanner',
1096
1116
  error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
1097
1117
  metadata: buildScannerTelemetryMetadata({
1118
+ capture_attempt_id: captureAttemptId,
1119
+ capture_sequence: captureSequence,
1098
1120
  capture_blocked_reason: blockedReason,
1099
1121
  }),
1100
1122
  });
@@ -1120,6 +1142,7 @@ export function VerifyAIScanner({
1120
1142
  }
1121
1143
 
1122
1144
  setStatus('capturing');
1145
+ setTorchSuppressedForRetry(false);
1123
1146
  setResult(null);
1124
1147
  setLastError(null);
1125
1148
  terminalResultRef.current = null;
@@ -1132,6 +1155,9 @@ export function VerifyAIScanner({
1132
1155
  let nativeCaptureAttempts = 0;
1133
1156
  let captureRetryAttempted = false;
1134
1157
  let captureRetryReady = false;
1158
+ let captureRetryTorchSuppressed = false;
1159
+ let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
1160
+ let captureRetryRemountBackoffMs = 0;
1135
1161
  let lastNativeCaptureErrorMessage: string | null = null;
1136
1162
 
1137
1163
  try {
@@ -1141,6 +1167,8 @@ export function VerifyAIScanner({
1141
1167
  component: 'scanner',
1142
1168
  error: 'capture_orientation_context',
1143
1169
  metadata: buildScannerTelemetryMetadata({
1170
+ capture_attempt_id: captureAttemptId,
1171
+ capture_sequence: captureSequence,
1144
1172
  capture_physical_orientation: capturePhysicalOrientation,
1145
1173
  capture_overlay_rotation_deg: captureOverlayRotationDeg,
1146
1174
  capture_rotation_applied: 0,
@@ -1209,11 +1237,26 @@ export function VerifyAIScanner({
1209
1237
 
1210
1238
  if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
1211
1239
  captureRetryAttempted = true;
1240
+ captureRetryTorchSuppressed = !!enableTorch;
1241
+ captureRetrySettleDelayMs = captureRetryTorchSuppressed
1242
+ ? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
1243
+ : CAMERA_CAPTURE_RETRY_DELAY_MS;
1244
+ captureRetryRemountBackoffMs = captureRetryTorchSuppressed
1245
+ ? CAMERA_CAPTURE_RETRY_DELAY_MS
1246
+ : 0;
1212
1247
  const retryAt = new Date().toISOString();
1213
1248
  lastCaptureRetryAtRef.current = retryAt;
1214
1249
  cameraRemountCountRef.current++;
1215
- requestCameraRemount('capture_retry');
1216
- await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1250
+ if (captureRetryTorchSuppressed) {
1251
+ setTorchSuppressedForRetry(true);
1252
+ }
1253
+ requestCameraRemount(
1254
+ 'capture_retry',
1255
+ captureRetryRemountBackoffMs > 0
1256
+ ? { backoffMs: captureRetryRemountBackoffMs }
1257
+ : undefined,
1258
+ );
1259
+ await sleep(captureRetrySettleDelayMs);
1217
1260
  const captureRetryReadyTimeoutMs = Platform.OS === 'android'
1218
1261
  ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
1219
1262
  : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
@@ -1223,11 +1266,16 @@ export function VerifyAIScanner({
1223
1266
  error: normalized,
1224
1267
  errorCode: normalized.code,
1225
1268
  metadata: buildScannerTelemetryMetadata({
1269
+ capture_attempt_id: captureAttemptId,
1270
+ capture_sequence: captureSequence,
1226
1271
  native_capture_attempts: nativeCaptureAttempts,
1227
1272
  capture_retry_attempted: captureRetryAttempted,
1228
1273
  capture_retry_ready: captureRetryReady,
1229
1274
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
1275
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1276
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1230
1277
  capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1278
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1231
1279
  last_native_capture_error: lastNativeCaptureErrorMessage,
1232
1280
  }),
1233
1281
  });
@@ -1321,6 +1369,8 @@ export function VerifyAIScanner({
1321
1369
  telemetry?.track('image_processed', {
1322
1370
  component: 'scanner',
1323
1371
  metadata: buildScannerTelemetryMetadata({
1372
+ capture_attempt_id: captureAttemptId,
1373
+ capture_sequence: captureSequence,
1324
1374
  original_width: origWidth,
1325
1375
  original_height: origHeight,
1326
1376
  processed_width: processedWidth,
@@ -1330,6 +1380,9 @@ export function VerifyAIScanner({
1330
1380
  native_capture_attempts: nativeCaptureAttempts,
1331
1381
  capture_retry_attempted: captureRetryAttempted,
1332
1382
  capture_retry_ready: captureRetryReady,
1383
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1384
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1385
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1333
1386
  recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
1334
1387
  last_native_capture_error: lastNativeCaptureErrorMessage,
1335
1388
  }),
@@ -1404,9 +1457,14 @@ export function VerifyAIScanner({
1404
1457
  component: 'scanner',
1405
1458
  error,
1406
1459
  metadata: buildScannerTelemetryMetadata({
1460
+ capture_attempt_id: captureAttemptId,
1461
+ capture_sequence: captureSequence,
1407
1462
  native_capture_attempts: nativeCaptureAttempts,
1408
1463
  capture_retry_attempted: captureRetryAttempted,
1409
1464
  capture_retry_ready: captureRetryReady,
1465
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1466
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1467
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1410
1468
  is_native_camera_capture_error: isNativeCameraCaptureError(error),
1411
1469
  last_native_capture_error: lastNativeCaptureErrorMessage,
1412
1470
  }),
@@ -1417,8 +1475,10 @@ export function VerifyAIScanner({
1417
1475
  if (!terminalRequestError) {
1418
1476
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1419
1477
  }
1478
+ } finally {
1479
+ setTorchSuppressedForRetry(false);
1420
1480
  }
1421
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1481
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
1422
1482
 
1423
1483
  // Expose capture to parent via ref
1424
1484
  if (captureRef) {
@@ -1441,7 +1501,7 @@ export function VerifyAIScanner({
1441
1501
  }
1442
1502
 
1443
1503
  const showBottomCard = status === 'success' || status === 'error';
1444
- const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
1504
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
1445
1505
 
1446
1506
  return (
1447
1507
  <View style={[styles.container, style]}>
@@ -64,6 +64,8 @@ export interface VerificationListParams {
64
64
  export interface VerifyOptions {
65
65
  /** Idempotency key to prevent duplicate verifications on retry. */
66
66
  idempotencyKey?: string;
67
+ /** Optional primitive metadata attached to client-side request telemetry. */
68
+ telemetryMetadata?: Record<string, string | number | boolean | null | undefined>;
67
69
  }
68
70
 
69
71
  export interface QueueItem {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.1';
1
+ export const SDK_VERSION = '2.5.2';