@switchlabs/verify-ai-react-native 2.5.3 → 2.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/VerifyAIScanner.js +24 -23
- package/lib/components/cameraCaptureRetry.d.ts +12 -0
- package/lib/components/cameraCaptureRetry.js +23 -0
- package/lib/telemetry/TelemetryReporter.d.ts +20 -6
- package/lib/telemetry/TelemetryReporter.js +90 -52
- package/lib/types/index.d.ts +4 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +2 -1
- package/src/components/VerifyAIScanner.tsx +27 -23
- package/src/components/cameraCaptureRetry.ts +37 -0
- package/src/telemetry/TelemetryReporter.ts +109 -65
- package/src/types/index.ts +4 -0
- package/src/version.ts +1 -1
|
@@ -8,6 +8,7 @@ import { SDK_VERSION } from '../version';
|
|
|
8
8
|
import { BikeOverlay } from './BikeOverlay';
|
|
9
9
|
import { ScooterOverlay } from './ScooterOverlay';
|
|
10
10
|
import { ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD, classifyAndroidAccelerometerOrientation, getOverlayRotationDeg, } from './scannerOrientation';
|
|
11
|
+
import { CAMERA_CAPTURE_RETRY_DELAY_MS, getCameraCaptureRetryPlan, } from './cameraCaptureRetry';
|
|
11
12
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
12
13
|
const FALLBACK_QUALITY = 0.65;
|
|
13
14
|
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
@@ -19,10 +20,6 @@ const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
|
19
20
|
const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
20
21
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
21
22
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
22
|
-
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
23
|
-
const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
24
|
-
const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
25
|
-
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
26
23
|
// iOS AVCaptureSession cold start can legitimately exceed 8s on first launch (permission
|
|
27
24
|
// prompt, first-ever session spin-up, thermal/low-power throttling). An 8s watchdog tore
|
|
28
25
|
// down sessions that were seconds from ready and restarted from scratch, which made slow
|
|
@@ -297,6 +294,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
297
294
|
device_os_version: String(Platform.Version),
|
|
298
295
|
route_name: telemetryContext?.routeName,
|
|
299
296
|
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
297
|
+
host_reference_id: telemetryContext?.hostReferenceId,
|
|
300
298
|
window_width: windowWidth,
|
|
301
299
|
window_height: windowHeight,
|
|
302
300
|
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
@@ -917,15 +915,20 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
917
915
|
: exhausted
|
|
918
916
|
? 'exhausted'
|
|
919
917
|
: null;
|
|
920
|
-
telemetry
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
918
|
+
if (telemetry) {
|
|
919
|
+
// Do not begin native capture until the user's shutter intent is on disk.
|
|
920
|
+
// If the process is force-closed during capture or delivery, the next SDK
|
|
921
|
+
// initialization recovers and sends this exact capture_attempt_id.
|
|
922
|
+
await telemetry.trackDurably('camera_capture_request', {
|
|
923
|
+
component: 'scanner',
|
|
924
|
+
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
925
|
+
metadata: buildScannerTelemetryMetadata({
|
|
926
|
+
capture_attempt_id: captureAttemptId,
|
|
927
|
+
capture_sequence: captureSequence,
|
|
928
|
+
capture_blocked_reason: blockedReason,
|
|
929
|
+
}),
|
|
930
|
+
});
|
|
931
|
+
}
|
|
929
932
|
if (blockedReason && blockedReason !== 'camera_not_ready')
|
|
930
933
|
return;
|
|
931
934
|
if (blockedReason === 'camera_not_ready') {
|
|
@@ -958,6 +961,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
958
961
|
let captureRetryTorchSuppressed = false;
|
|
959
962
|
let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
|
|
960
963
|
let captureRetryRemountBackoffMs = 0;
|
|
964
|
+
let captureRetryReadyTimeoutMs = 0;
|
|
961
965
|
let lastNativeCaptureErrorMessage = null;
|
|
962
966
|
try {
|
|
963
967
|
const capturePhysicalOrientation = physicalOrientation;
|
|
@@ -1025,13 +1029,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1025
1029
|
lastNativeCaptureErrorMessage = normalized.message;
|
|
1026
1030
|
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
|
|
1027
1031
|
captureRetryAttempted = true;
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
? CAMERA_CAPTURE_RETRY_DELAY_MS
|
|
1034
|
-
: 0;
|
|
1032
|
+
const retryPlan = getCameraCaptureRetryPlan(Platform.OS, !!enableTorch);
|
|
1033
|
+
captureRetryTorchSuppressed = retryPlan.suppressTorch;
|
|
1034
|
+
captureRetrySettleDelayMs = retryPlan.settleDelayMs;
|
|
1035
|
+
captureRetryRemountBackoffMs = retryPlan.remountBackoffMs;
|
|
1036
|
+
captureRetryReadyTimeoutMs = retryPlan.readyTimeoutMs;
|
|
1035
1037
|
const retryAt = new Date().toISOString();
|
|
1036
1038
|
lastCaptureRetryAtRef.current = retryAt;
|
|
1037
1039
|
cameraRemountCountRef.current++;
|
|
@@ -1042,9 +1044,6 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1042
1044
|
? { backoffMs: captureRetryRemountBackoffMs }
|
|
1043
1045
|
: undefined);
|
|
1044
1046
|
await sleep(captureRetrySettleDelayMs);
|
|
1045
|
-
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
1046
|
-
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
1047
|
-
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
|
|
1048
1047
|
captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
|
|
1049
1048
|
telemetry?.track('camera_capture_retry', {
|
|
1050
1049
|
component: 'scanner',
|
|
@@ -1142,6 +1141,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1142
1141
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1143
1142
|
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1144
1143
|
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
1144
|
+
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1145
1145
|
recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
|
|
1146
1146
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1147
1147
|
}),
|
|
@@ -1218,6 +1218,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1218
1218
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1219
1219
|
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1220
1220
|
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
1221
|
+
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1221
1222
|
is_native_camera_capture_error: isNativeCameraCaptureError(error),
|
|
1222
1223
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1223
1224
|
}),
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
2
|
+
export declare const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
3
|
+
export declare const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
4
|
+
export declare const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
5
|
+
export type CameraCaptureRetryPlatform = 'ios' | 'android' | string;
|
|
6
|
+
export interface CameraCaptureRetryPlan {
|
|
7
|
+
readyTimeoutMs: number;
|
|
8
|
+
remountBackoffMs: number;
|
|
9
|
+
settleDelayMs: number;
|
|
10
|
+
suppressTorch: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function getCameraCaptureRetryPlan(platform: CameraCaptureRetryPlatform, torchRequested: boolean): CameraCaptureRetryPlan;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
2
|
+
export const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
3
|
+
export const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
4
|
+
export const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
5
|
+
export function getCameraCaptureRetryPlan(platform, torchRequested) {
|
|
6
|
+
const suppressTorch = torchRequested;
|
|
7
|
+
return {
|
|
8
|
+
readyTimeoutMs: platform === 'android'
|
|
9
|
+
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
10
|
+
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS,
|
|
11
|
+
// Flutter fully restarts its CameraController after native capture errors.
|
|
12
|
+
// On iOS/Expo Camera, a key-only remount can report ready too quickly while
|
|
13
|
+
// the native capture session is still unable to produce a photo, so force a
|
|
14
|
+
// brief unmount before the retry.
|
|
15
|
+
remountBackoffMs: platform === 'ios' || suppressTorch
|
|
16
|
+
? CAMERA_CAPTURE_RETRY_DELAY_MS
|
|
17
|
+
: 0,
|
|
18
|
+
settleDelayMs: suppressTorch
|
|
19
|
+
? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
|
|
20
|
+
: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
21
|
+
suppressTorch,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
interface TelemetryTrackOptions {
|
|
2
|
+
component?: string;
|
|
3
|
+
error?: unknown;
|
|
4
|
+
errorCode?: string;
|
|
5
|
+
metadata?: Record<string, string | number>;
|
|
6
|
+
}
|
|
1
7
|
export declare class TelemetryReporter {
|
|
2
8
|
private buffer;
|
|
9
|
+
private inFlightBuffer;
|
|
3
10
|
private flushTimer;
|
|
4
11
|
private sessionId;
|
|
5
12
|
private baseUrl;
|
|
@@ -8,6 +15,7 @@ export declare class TelemetryReporter {
|
|
|
8
15
|
private flushing;
|
|
9
16
|
private loadedPersisted;
|
|
10
17
|
private loadPersistedPromise;
|
|
18
|
+
private persistenceChain;
|
|
11
19
|
constructor(apiKey: string, baseUrl: string);
|
|
12
20
|
/** Telemetry POST destination — exposed so the scanner can log an init banner
|
|
13
21
|
* that helps field debugging confirm where events are being sent. */
|
|
@@ -16,12 +24,14 @@ export declare class TelemetryReporter {
|
|
|
16
24
|
getApiKeyPrefix(): string;
|
|
17
25
|
private logDeliveryFailure;
|
|
18
26
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
19
|
-
track(eventType: string, opts?:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
track(eventType: string, opts?: TelemetryTrackOptions): void;
|
|
28
|
+
/**
|
|
29
|
+
* Record an event and wait until it is durably stored before returning.
|
|
30
|
+
*
|
|
31
|
+
* Use this for user intent that must survive an immediate force-close. Network
|
|
32
|
+
* delivery starts after the local write, but callers do not wait on the network.
|
|
33
|
+
*/
|
|
34
|
+
trackDurably(eventType: string, opts?: TelemetryTrackOptions): Promise<void>;
|
|
25
35
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
26
36
|
flush(): Promise<void>;
|
|
27
37
|
/** Dispose — flush remaining events and stop timers. */
|
|
@@ -29,6 +39,8 @@ export declare class TelemetryReporter {
|
|
|
29
39
|
private scheduleFlush;
|
|
30
40
|
private flushNow;
|
|
31
41
|
private clearFlushTimer;
|
|
42
|
+
private recordEvent;
|
|
43
|
+
private persistAfterRecovery;
|
|
32
44
|
/**
|
|
33
45
|
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
34
46
|
* Fire-and-forget — never throws or blocks.
|
|
@@ -37,6 +49,7 @@ export declare class TelemetryReporter {
|
|
|
37
49
|
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
38
50
|
*/
|
|
39
51
|
private persistBuffer;
|
|
52
|
+
private persistBufferAsync;
|
|
40
53
|
/**
|
|
41
54
|
* Load persisted events from a previous session and merge into current buffer.
|
|
42
55
|
* Runs once per instance. If orphaned events are found, emits an
|
|
@@ -45,3 +58,4 @@ export declare class TelemetryReporter {
|
|
|
45
58
|
*/
|
|
46
59
|
private loadPersistedBuffer;
|
|
47
60
|
}
|
|
61
|
+
export {};
|
|
@@ -15,6 +15,7 @@ const CRITICAL_EVENTS = new Set([
|
|
|
15
15
|
'camera_preview_timeout',
|
|
16
16
|
'camera_startup_slow',
|
|
17
17
|
'camera_permission_denied',
|
|
18
|
+
'camera_capture_request',
|
|
18
19
|
'camera_scanner_mounted',
|
|
19
20
|
'camera_scanner_disposed',
|
|
20
21
|
'camera_android_native_orientation_started',
|
|
@@ -30,11 +31,13 @@ const CRITICAL_EVENTS = new Set([
|
|
|
30
31
|
export class TelemetryReporter {
|
|
31
32
|
constructor(apiKey, baseUrl) {
|
|
32
33
|
this.buffer = new Map();
|
|
34
|
+
this.inFlightBuffer = new Map();
|
|
33
35
|
this.flushTimer = null;
|
|
34
36
|
this.disposed = false;
|
|
35
37
|
this.flushing = false;
|
|
36
38
|
this.loadedPersisted = false;
|
|
37
39
|
this.loadPersistedPromise = null;
|
|
40
|
+
this.persistenceChain = Promise.resolve();
|
|
38
41
|
this.apiKey = apiKey;
|
|
39
42
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
40
43
|
this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
@@ -60,61 +63,42 @@ export class TelemetryReporter {
|
|
|
60
63
|
if (this.disposed)
|
|
61
64
|
return;
|
|
62
65
|
try {
|
|
63
|
-
|
|
64
|
-
const errorObj = opts.error instanceof Error ? opts.error : null;
|
|
65
|
-
const errorMessage = errorObj?.message
|
|
66
|
-
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
67
|
-
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
|
|
68
|
-
const existing = this.buffer.get(dedupKey);
|
|
69
|
-
if (existing) {
|
|
70
|
-
existing.event_count++;
|
|
71
|
-
existing.last_occurred_at = now;
|
|
72
|
-
// If the new occurrence is critical, flush immediately
|
|
73
|
-
if (CRITICAL_EVENTS.has(eventType)) {
|
|
74
|
-
this.flushNow();
|
|
75
|
-
}
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const event = {
|
|
79
|
-
event_type: eventType,
|
|
80
|
-
component: opts.component,
|
|
81
|
-
error_message: errorMessage?.slice(0, 1000),
|
|
82
|
-
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
83
|
-
error_code: opts.errorCode,
|
|
84
|
-
metadata: opts.metadata,
|
|
85
|
-
sdk_platform: Platform.OS,
|
|
86
|
-
sdk_version: SDK_VERSION,
|
|
87
|
-
os_name: Platform.OS,
|
|
88
|
-
os_version: String(Platform.Version),
|
|
89
|
-
session_id: this.sessionId,
|
|
90
|
-
event_count: 1,
|
|
91
|
-
first_occurred_at: now,
|
|
92
|
-
last_occurred_at: now,
|
|
93
|
-
};
|
|
94
|
-
this.buffer.set(dedupKey, event);
|
|
66
|
+
this.recordEvent(eventType, opts);
|
|
95
67
|
if (CRITICAL_EVENTS.has(eventType)) {
|
|
96
|
-
|
|
68
|
+
// Critical events are written locally before delivery starts. This keeps
|
|
69
|
+
// them recoverable if the process is killed while the POST is in flight.
|
|
70
|
+
void this.persistAfterRecovery().then(() => this.flushNow());
|
|
97
71
|
}
|
|
98
72
|
else {
|
|
99
73
|
this.scheduleFlush();
|
|
100
|
-
|
|
101
|
-
// Persist immediately once prior-session recovery has completed. If startup
|
|
102
|
-
// recovery is still in flight, write the merged buffer back afterwards.
|
|
103
|
-
if (this.loadedPersisted) {
|
|
104
|
-
this.persistBuffer();
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
void this.loadPersistedBuffer().then(() => {
|
|
108
|
-
if (!this.disposed && this.buffer.size > 0) {
|
|
109
|
-
this.persistBuffer();
|
|
110
|
-
}
|
|
111
|
-
});
|
|
74
|
+
void this.persistAfterRecovery();
|
|
112
75
|
}
|
|
113
76
|
}
|
|
114
77
|
catch {
|
|
115
78
|
// Never throw from telemetry
|
|
116
79
|
}
|
|
117
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Record an event and wait until it is durably stored before returning.
|
|
83
|
+
*
|
|
84
|
+
* Use this for user intent that must survive an immediate force-close. Network
|
|
85
|
+
* delivery starts after the local write, but callers do not wait on the network.
|
|
86
|
+
*/
|
|
87
|
+
async trackDurably(eventType, opts = {}) {
|
|
88
|
+
if (this.disposed)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
await this.loadPersistedBuffer();
|
|
92
|
+
if (this.disposed)
|
|
93
|
+
return;
|
|
94
|
+
this.recordEvent(eventType, opts);
|
|
95
|
+
await this.persistBufferAsync();
|
|
96
|
+
this.flushNow();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Never throw from telemetry or block the host capture flow.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
118
102
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
119
103
|
async flush() {
|
|
120
104
|
// Merge any persisted events from a previous session before flushing
|
|
@@ -125,6 +109,7 @@ export class TelemetryReporter {
|
|
|
125
109
|
const bufferedEntries = Array.from(this.buffer.entries());
|
|
126
110
|
const events = bufferedEntries.map(([, event]) => event);
|
|
127
111
|
this.buffer.clear();
|
|
112
|
+
this.inFlightBuffer = new Map(bufferedEntries);
|
|
128
113
|
this.clearFlushTimer();
|
|
129
114
|
const controller = new AbortController();
|
|
130
115
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
@@ -151,8 +136,10 @@ export class TelemetryReporter {
|
|
|
151
136
|
loggedDeliveryFailure = true;
|
|
152
137
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
153
138
|
}
|
|
154
|
-
|
|
155
|
-
|
|
139
|
+
this.inFlightBuffer.clear();
|
|
140
|
+
// Success — persist the current buffer. Usually empty, but it may contain
|
|
141
|
+
// events recorded while this request was in flight.
|
|
142
|
+
await this.persistBufferAsync();
|
|
156
143
|
}
|
|
157
144
|
catch (error) {
|
|
158
145
|
if (!loggedDeliveryFailure) {
|
|
@@ -175,6 +162,7 @@ export class TelemetryReporter {
|
|
|
175
162
|
this.buffer.set(dedupKey, event);
|
|
176
163
|
}
|
|
177
164
|
}
|
|
165
|
+
this.inFlightBuffer.clear();
|
|
178
166
|
}
|
|
179
167
|
finally {
|
|
180
168
|
clearTimeout(timeout);
|
|
@@ -208,6 +196,42 @@ export class TelemetryReporter {
|
|
|
208
196
|
this.flushTimer = null;
|
|
209
197
|
}
|
|
210
198
|
}
|
|
199
|
+
recordEvent(eventType, opts) {
|
|
200
|
+
const now = new Date().toISOString();
|
|
201
|
+
const errorObj = opts.error instanceof Error ? opts.error : null;
|
|
202
|
+
const errorMessage = errorObj?.message
|
|
203
|
+
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
204
|
+
const occurrenceId = opts.metadata?.capture_attempt_id;
|
|
205
|
+
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
|
|
206
|
+
const existing = this.buffer.get(dedupKey);
|
|
207
|
+
if (existing) {
|
|
208
|
+
existing.event_count++;
|
|
209
|
+
existing.last_occurred_at = now;
|
|
210
|
+
if (opts.metadata)
|
|
211
|
+
existing.metadata = opts.metadata;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.buffer.set(dedupKey, {
|
|
215
|
+
event_type: eventType,
|
|
216
|
+
component: opts.component,
|
|
217
|
+
error_message: errorMessage?.slice(0, 1000),
|
|
218
|
+
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
219
|
+
error_code: opts.errorCode,
|
|
220
|
+
metadata: opts.metadata,
|
|
221
|
+
sdk_platform: Platform.OS,
|
|
222
|
+
sdk_version: SDK_VERSION,
|
|
223
|
+
os_name: Platform.OS,
|
|
224
|
+
os_version: String(Platform.Version),
|
|
225
|
+
session_id: this.sessionId,
|
|
226
|
+
event_count: 1,
|
|
227
|
+
first_occurred_at: now,
|
|
228
|
+
last_occurred_at: now,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async persistAfterRecovery() {
|
|
232
|
+
await this.loadPersistedBuffer();
|
|
233
|
+
await this.persistBufferAsync();
|
|
234
|
+
}
|
|
211
235
|
/**
|
|
212
236
|
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
213
237
|
* Fire-and-forget — never throws or blocks.
|
|
@@ -216,24 +240,38 @@ export class TelemetryReporter {
|
|
|
216
240
|
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
217
241
|
*/
|
|
218
242
|
persistBuffer() {
|
|
243
|
+
void this.persistBufferAsync();
|
|
244
|
+
}
|
|
245
|
+
persistBufferAsync() {
|
|
219
246
|
if (!this.loadedPersisted)
|
|
220
|
-
return;
|
|
247
|
+
return Promise.resolve();
|
|
221
248
|
try {
|
|
222
249
|
const entries = {};
|
|
250
|
+
for (const [key, event] of this.inFlightBuffer) {
|
|
251
|
+
entries[key] = event;
|
|
252
|
+
}
|
|
223
253
|
for (const [key, event] of this.buffer) {
|
|
224
254
|
entries[key] = event;
|
|
225
255
|
}
|
|
226
|
-
|
|
256
|
+
// Serialize writes so an older fire-and-forget snapshot cannot overwrite a
|
|
257
|
+
// newer durable capture marker or resurrect an acknowledged event.
|
|
258
|
+
this.persistenceChain = this.persistenceChain
|
|
259
|
+
.catch(() => { })
|
|
260
|
+
.then(async () => {
|
|
261
|
+
const storage = await getStorage();
|
|
227
262
|
if (Object.keys(entries).length > 0) {
|
|
228
|
-
|
|
263
|
+
await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
|
|
229
264
|
}
|
|
230
265
|
else {
|
|
231
|
-
|
|
266
|
+
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
232
267
|
}
|
|
233
|
-
})
|
|
268
|
+
})
|
|
269
|
+
.catch(() => { });
|
|
270
|
+
return this.persistenceChain;
|
|
234
271
|
}
|
|
235
272
|
catch {
|
|
236
273
|
// Never throw from telemetry
|
|
274
|
+
return Promise.resolve();
|
|
237
275
|
}
|
|
238
276
|
}
|
|
239
277
|
/**
|
package/lib/types/index.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface VerificationResult {
|
|
|
33
33
|
policy: string;
|
|
34
34
|
violation_reasons: string[];
|
|
35
35
|
feedback: string;
|
|
36
|
+
warning_violation_reasons?: string[];
|
|
37
|
+
warning_feedback?: string | null;
|
|
36
38
|
metadata: Record<string, unknown>;
|
|
37
39
|
image_url: string | null;
|
|
38
40
|
/** Base64-encoded image data URI. Only present when `include_image_data` is requested. */
|
|
@@ -85,6 +87,8 @@ export interface ScannerTelemetryContext {
|
|
|
85
87
|
routeName?: string;
|
|
86
88
|
/** Whether the host app keeps this screen portrait-locked. */
|
|
87
89
|
isPortraitLocked?: boolean;
|
|
90
|
+
/** Host-owned identifier used to correlate scanner telemetry with its workflow. */
|
|
91
|
+
hostReferenceId?: string;
|
|
88
92
|
}
|
|
89
93
|
/**
|
|
90
94
|
* Theme customization for the scanner overlay.
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.5.
|
|
1
|
+
export declare const SDK_VERSION = "2.5.5";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.5';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchlabs/verify-ai-react-native",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.5",
|
|
4
4
|
"description": "React Native SDK for Verify AI - photo verification with AI vision processing",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
28
29
|
"build": "tsc",
|
|
29
30
|
"prepublishOnly": "npm run build"
|
|
30
31
|
},
|
|
@@ -32,6 +32,10 @@ import {
|
|
|
32
32
|
getOverlayRotationDeg,
|
|
33
33
|
type ScannerPhysicalOrientation,
|
|
34
34
|
} from './scannerOrientation';
|
|
35
|
+
import {
|
|
36
|
+
CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
37
|
+
getCameraCaptureRetryPlan,
|
|
38
|
+
} from './cameraCaptureRetry';
|
|
35
39
|
|
|
36
40
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
37
41
|
const FALLBACK_QUALITY = 0.65;
|
|
@@ -44,10 +48,6 @@ const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
|
44
48
|
const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
45
49
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
46
50
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
47
|
-
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
48
|
-
const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
49
|
-
const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
50
|
-
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
51
51
|
// iOS AVCaptureSession cold start can legitimately exceed 8s on first launch (permission
|
|
52
52
|
// prompt, first-ever session spin-up, thermal/low-power throttling). An 8s watchdog tore
|
|
53
53
|
// down sessions that were seconds from ready and restarted from scratch, which made slow
|
|
@@ -409,6 +409,7 @@ export function VerifyAIScanner({
|
|
|
409
409
|
device_os_version: String(Platform.Version),
|
|
410
410
|
route_name: telemetryContext?.routeName,
|
|
411
411
|
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
412
|
+
host_reference_id: telemetryContext?.hostReferenceId,
|
|
412
413
|
window_width: windowWidth,
|
|
413
414
|
window_height: windowHeight,
|
|
414
415
|
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
@@ -1125,15 +1126,20 @@ export function VerifyAIScanner({
|
|
|
1125
1126
|
? 'exhausted'
|
|
1126
1127
|
: null;
|
|
1127
1128
|
|
|
1128
|
-
telemetry
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1129
|
+
if (telemetry) {
|
|
1130
|
+
// Do not begin native capture until the user's shutter intent is on disk.
|
|
1131
|
+
// If the process is force-closed during capture or delivery, the next SDK
|
|
1132
|
+
// initialization recovers and sends this exact capture_attempt_id.
|
|
1133
|
+
await telemetry.trackDurably('camera_capture_request', {
|
|
1134
|
+
component: 'scanner',
|
|
1135
|
+
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
1136
|
+
metadata: buildScannerTelemetryMetadata({
|
|
1137
|
+
capture_attempt_id: captureAttemptId,
|
|
1138
|
+
capture_sequence: captureSequence,
|
|
1139
|
+
capture_blocked_reason: blockedReason,
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1137
1143
|
|
|
1138
1144
|
if (blockedReason && blockedReason !== 'camera_not_ready') return;
|
|
1139
1145
|
if (blockedReason === 'camera_not_ready') {
|
|
@@ -1172,6 +1178,7 @@ export function VerifyAIScanner({
|
|
|
1172
1178
|
let captureRetryTorchSuppressed = false;
|
|
1173
1179
|
let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
|
|
1174
1180
|
let captureRetryRemountBackoffMs = 0;
|
|
1181
|
+
let captureRetryReadyTimeoutMs = 0;
|
|
1175
1182
|
let lastNativeCaptureErrorMessage: string | null = null;
|
|
1176
1183
|
|
|
1177
1184
|
try {
|
|
@@ -1251,13 +1258,11 @@ export function VerifyAIScanner({
|
|
|
1251
1258
|
|
|
1252
1259
|
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
|
|
1253
1260
|
captureRetryAttempted = true;
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
? CAMERA_CAPTURE_RETRY_DELAY_MS
|
|
1260
|
-
: 0;
|
|
1261
|
+
const retryPlan = getCameraCaptureRetryPlan(Platform.OS, !!enableTorch);
|
|
1262
|
+
captureRetryTorchSuppressed = retryPlan.suppressTorch;
|
|
1263
|
+
captureRetrySettleDelayMs = retryPlan.settleDelayMs;
|
|
1264
|
+
captureRetryRemountBackoffMs = retryPlan.remountBackoffMs;
|
|
1265
|
+
captureRetryReadyTimeoutMs = retryPlan.readyTimeoutMs;
|
|
1261
1266
|
const retryAt = new Date().toISOString();
|
|
1262
1267
|
lastCaptureRetryAtRef.current = retryAt;
|
|
1263
1268
|
cameraRemountCountRef.current++;
|
|
@@ -1271,9 +1276,6 @@ export function VerifyAIScanner({
|
|
|
1271
1276
|
: undefined,
|
|
1272
1277
|
);
|
|
1273
1278
|
await sleep(captureRetrySettleDelayMs);
|
|
1274
|
-
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
1275
|
-
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
1276
|
-
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
|
|
1277
1279
|
captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
|
|
1278
1280
|
telemetry?.track('camera_capture_retry', {
|
|
1279
1281
|
component: 'scanner',
|
|
@@ -1397,6 +1399,7 @@ export function VerifyAIScanner({
|
|
|
1397
1399
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1398
1400
|
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1399
1401
|
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
1402
|
+
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1400
1403
|
recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
|
|
1401
1404
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1402
1405
|
}),
|
|
@@ -1479,6 +1482,7 @@ export function VerifyAIScanner({
|
|
|
1479
1482
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1480
1483
|
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1481
1484
|
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
1485
|
+
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1482
1486
|
is_native_camera_capture_error: isNativeCameraCaptureError(error),
|
|
1483
1487
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1484
1488
|
}),
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
2
|
+
export const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
3
|
+
export const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
4
|
+
export const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
5
|
+
|
|
6
|
+
export type CameraCaptureRetryPlatform = 'ios' | 'android' | string;
|
|
7
|
+
|
|
8
|
+
export interface CameraCaptureRetryPlan {
|
|
9
|
+
readyTimeoutMs: number;
|
|
10
|
+
remountBackoffMs: number;
|
|
11
|
+
settleDelayMs: number;
|
|
12
|
+
suppressTorch: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getCameraCaptureRetryPlan(
|
|
16
|
+
platform: CameraCaptureRetryPlatform,
|
|
17
|
+
torchRequested: boolean,
|
|
18
|
+
): CameraCaptureRetryPlan {
|
|
19
|
+
const suppressTorch = torchRequested;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
readyTimeoutMs: platform === 'android'
|
|
23
|
+
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
24
|
+
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS,
|
|
25
|
+
// Flutter fully restarts its CameraController after native capture errors.
|
|
26
|
+
// On iOS/Expo Camera, a key-only remount can report ready too quickly while
|
|
27
|
+
// the native capture session is still unable to produce a photo, so force a
|
|
28
|
+
// brief unmount before the retry.
|
|
29
|
+
remountBackoffMs: platform === 'ios' || suppressTorch
|
|
30
|
+
? CAMERA_CAPTURE_RETRY_DELAY_MS
|
|
31
|
+
: 0,
|
|
32
|
+
settleDelayMs: suppressTorch
|
|
33
|
+
? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
|
|
34
|
+
: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
35
|
+
suppressTorch,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -34,12 +34,20 @@ interface BufferedEvent {
|
|
|
34
34
|
dedupKey: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
interface TelemetryTrackOptions {
|
|
38
|
+
component?: string;
|
|
39
|
+
error?: unknown;
|
|
40
|
+
errorCode?: string;
|
|
41
|
+
metadata?: Record<string, string | number>;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/** Event types that flush immediately (critical init failures). */
|
|
38
45
|
const CRITICAL_EVENTS = new Set([
|
|
39
46
|
'camera_init_failure',
|
|
40
47
|
'camera_preview_timeout',
|
|
41
48
|
'camera_startup_slow',
|
|
42
49
|
'camera_permission_denied',
|
|
50
|
+
'camera_capture_request',
|
|
43
51
|
'camera_scanner_mounted',
|
|
44
52
|
'camera_scanner_disposed',
|
|
45
53
|
'camera_android_native_orientation_started',
|
|
@@ -55,6 +63,7 @@ const CRITICAL_EVENTS = new Set([
|
|
|
55
63
|
|
|
56
64
|
export class TelemetryReporter {
|
|
57
65
|
private buffer: Map<string, TelemetryEvent> = new Map();
|
|
66
|
+
private inFlightBuffer: Map<string, TelemetryEvent> = new Map();
|
|
58
67
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
59
68
|
private sessionId: string;
|
|
60
69
|
private baseUrl: string;
|
|
@@ -63,6 +72,7 @@ export class TelemetryReporter {
|
|
|
63
72
|
private flushing = false;
|
|
64
73
|
private loadedPersisted = false;
|
|
65
74
|
private loadPersistedPromise: Promise<void> | null = null;
|
|
75
|
+
private persistenceChain: Promise<void> = Promise.resolve();
|
|
66
76
|
|
|
67
77
|
constructor(apiKey: string, baseUrl: string) {
|
|
68
78
|
this.apiKey = apiKey;
|
|
@@ -94,75 +104,49 @@ export class TelemetryReporter {
|
|
|
94
104
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
95
105
|
track(
|
|
96
106
|
eventType: string,
|
|
97
|
-
opts: {
|
|
98
|
-
component?: string;
|
|
99
|
-
error?: unknown;
|
|
100
|
-
errorCode?: string;
|
|
101
|
-
metadata?: Record<string, string | number>;
|
|
102
|
-
} = {},
|
|
107
|
+
opts: TelemetryTrackOptions = {},
|
|
103
108
|
): void {
|
|
104
109
|
if (this.disposed) return;
|
|
105
110
|
|
|
106
111
|
try {
|
|
107
|
-
|
|
108
|
-
const errorObj = opts.error instanceof Error ? opts.error : null;
|
|
109
|
-
const errorMessage = errorObj?.message
|
|
110
|
-
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
111
|
-
|
|
112
|
-
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
|
|
113
|
-
|
|
114
|
-
const existing = this.buffer.get(dedupKey);
|
|
115
|
-
if (existing) {
|
|
116
|
-
existing.event_count++;
|
|
117
|
-
existing.last_occurred_at = now;
|
|
118
|
-
// If the new occurrence is critical, flush immediately
|
|
119
|
-
if (CRITICAL_EVENTS.has(eventType)) {
|
|
120
|
-
this.flushNow();
|
|
121
|
-
}
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const event: TelemetryEvent = {
|
|
126
|
-
event_type: eventType,
|
|
127
|
-
component: opts.component,
|
|
128
|
-
error_message: errorMessage?.slice(0, 1000),
|
|
129
|
-
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
130
|
-
error_code: opts.errorCode,
|
|
131
|
-
metadata: opts.metadata,
|
|
132
|
-
sdk_platform: Platform.OS,
|
|
133
|
-
sdk_version: SDK_VERSION,
|
|
134
|
-
os_name: Platform.OS,
|
|
135
|
-
os_version: String(Platform.Version),
|
|
136
|
-
session_id: this.sessionId,
|
|
137
|
-
event_count: 1,
|
|
138
|
-
first_occurred_at: now,
|
|
139
|
-
last_occurred_at: now,
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
this.buffer.set(dedupKey, event);
|
|
112
|
+
this.recordEvent(eventType, opts);
|
|
143
113
|
|
|
144
114
|
if (CRITICAL_EVENTS.has(eventType)) {
|
|
145
|
-
|
|
115
|
+
// Critical events are written locally before delivery starts. This keeps
|
|
116
|
+
// them recoverable if the process is killed while the POST is in flight.
|
|
117
|
+
void this.persistAfterRecovery().then(() => this.flushNow());
|
|
146
118
|
} else {
|
|
147
119
|
this.scheduleFlush();
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// Persist immediately once prior-session recovery has completed. If startup
|
|
151
|
-
// recovery is still in flight, write the merged buffer back afterwards.
|
|
152
|
-
if (this.loadedPersisted) {
|
|
153
|
-
this.persistBuffer();
|
|
154
|
-
} else {
|
|
155
|
-
void this.loadPersistedBuffer().then(() => {
|
|
156
|
-
if (!this.disposed && this.buffer.size > 0) {
|
|
157
|
-
this.persistBuffer();
|
|
158
|
-
}
|
|
159
|
-
});
|
|
120
|
+
void this.persistAfterRecovery();
|
|
160
121
|
}
|
|
161
122
|
} catch {
|
|
162
123
|
// Never throw from telemetry
|
|
163
124
|
}
|
|
164
125
|
}
|
|
165
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Record an event and wait until it is durably stored before returning.
|
|
129
|
+
*
|
|
130
|
+
* Use this for user intent that must survive an immediate force-close. Network
|
|
131
|
+
* delivery starts after the local write, but callers do not wait on the network.
|
|
132
|
+
*/
|
|
133
|
+
async trackDurably(
|
|
134
|
+
eventType: string,
|
|
135
|
+
opts: TelemetryTrackOptions = {},
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
if (this.disposed) return;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await this.loadPersistedBuffer();
|
|
141
|
+
if (this.disposed) return;
|
|
142
|
+
this.recordEvent(eventType, opts);
|
|
143
|
+
await this.persistBufferAsync();
|
|
144
|
+
this.flushNow();
|
|
145
|
+
} catch {
|
|
146
|
+
// Never throw from telemetry or block the host capture flow.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
166
150
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
167
151
|
async flush(): Promise<void> {
|
|
168
152
|
// Merge any persisted events from a previous session before flushing
|
|
@@ -174,6 +158,7 @@ export class TelemetryReporter {
|
|
|
174
158
|
const bufferedEntries = Array.from(this.buffer.entries());
|
|
175
159
|
const events = bufferedEntries.map(([, event]) => event);
|
|
176
160
|
this.buffer.clear();
|
|
161
|
+
this.inFlightBuffer = new Map(bufferedEntries);
|
|
177
162
|
this.clearFlushTimer();
|
|
178
163
|
const controller = new AbortController();
|
|
179
164
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
@@ -202,8 +187,10 @@ export class TelemetryReporter {
|
|
|
202
187
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
203
188
|
}
|
|
204
189
|
|
|
205
|
-
|
|
206
|
-
|
|
190
|
+
this.inFlightBuffer.clear();
|
|
191
|
+
// Success — persist the current buffer. Usually empty, but it may contain
|
|
192
|
+
// events recorded while this request was in flight.
|
|
193
|
+
await this.persistBufferAsync();
|
|
207
194
|
} catch (error) {
|
|
208
195
|
if (!loggedDeliveryFailure) {
|
|
209
196
|
const kind = error instanceof Error ? error.name : typeof error;
|
|
@@ -224,6 +211,7 @@ export class TelemetryReporter {
|
|
|
224
211
|
this.buffer.set(dedupKey, event);
|
|
225
212
|
}
|
|
226
213
|
}
|
|
214
|
+
this.inFlightBuffer.clear();
|
|
227
215
|
} finally {
|
|
228
216
|
clearTimeout(timeout);
|
|
229
217
|
this.flushing = false;
|
|
@@ -260,6 +248,45 @@ export class TelemetryReporter {
|
|
|
260
248
|
}
|
|
261
249
|
}
|
|
262
250
|
|
|
251
|
+
private recordEvent(eventType: string, opts: TelemetryTrackOptions): void {
|
|
252
|
+
const now = new Date().toISOString();
|
|
253
|
+
const errorObj = opts.error instanceof Error ? opts.error : null;
|
|
254
|
+
const errorMessage = errorObj?.message
|
|
255
|
+
?? (typeof opts.error === 'string' ? opts.error : undefined);
|
|
256
|
+
const occurrenceId = opts.metadata?.capture_attempt_id;
|
|
257
|
+
const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
|
|
258
|
+
|
|
259
|
+
const existing = this.buffer.get(dedupKey);
|
|
260
|
+
if (existing) {
|
|
261
|
+
existing.event_count++;
|
|
262
|
+
existing.last_occurred_at = now;
|
|
263
|
+
if (opts.metadata) existing.metadata = opts.metadata;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.buffer.set(dedupKey, {
|
|
268
|
+
event_type: eventType,
|
|
269
|
+
component: opts.component,
|
|
270
|
+
error_message: errorMessage?.slice(0, 1000),
|
|
271
|
+
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
272
|
+
error_code: opts.errorCode,
|
|
273
|
+
metadata: opts.metadata,
|
|
274
|
+
sdk_platform: Platform.OS,
|
|
275
|
+
sdk_version: SDK_VERSION,
|
|
276
|
+
os_name: Platform.OS,
|
|
277
|
+
os_version: String(Platform.Version),
|
|
278
|
+
session_id: this.sessionId,
|
|
279
|
+
event_count: 1,
|
|
280
|
+
first_occurred_at: now,
|
|
281
|
+
last_occurred_at: now,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async persistAfterRecovery(): Promise<void> {
|
|
286
|
+
await this.loadPersistedBuffer();
|
|
287
|
+
await this.persistBufferAsync();
|
|
288
|
+
}
|
|
289
|
+
|
|
263
290
|
/**
|
|
264
291
|
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
265
292
|
* Fire-and-forget — never throws or blocks.
|
|
@@ -268,21 +295,38 @@ export class TelemetryReporter {
|
|
|
268
295
|
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
269
296
|
*/
|
|
270
297
|
private persistBuffer(): void {
|
|
271
|
-
|
|
298
|
+
void this.persistBufferAsync();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private persistBufferAsync(): Promise<void> {
|
|
302
|
+
if (!this.loadedPersisted) return Promise.resolve();
|
|
303
|
+
|
|
272
304
|
try {
|
|
273
305
|
const entries: Record<string, TelemetryEvent> = {};
|
|
306
|
+
for (const [key, event] of this.inFlightBuffer) {
|
|
307
|
+
entries[key] = event;
|
|
308
|
+
}
|
|
274
309
|
for (const [key, event] of this.buffer) {
|
|
275
310
|
entries[key] = event;
|
|
276
311
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
312
|
+
|
|
313
|
+
// Serialize writes so an older fire-and-forget snapshot cannot overwrite a
|
|
314
|
+
// newer durable capture marker or resurrect an acknowledged event.
|
|
315
|
+
this.persistenceChain = this.persistenceChain
|
|
316
|
+
.catch(() => { /* keep the write queue alive */ })
|
|
317
|
+
.then(async () => {
|
|
318
|
+
const storage = await getStorage();
|
|
319
|
+
if (Object.keys(entries).length > 0) {
|
|
320
|
+
await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
|
|
321
|
+
} else {
|
|
322
|
+
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
.catch(() => { /* never throw from telemetry */ });
|
|
326
|
+
return this.persistenceChain;
|
|
284
327
|
} catch {
|
|
285
328
|
// Never throw from telemetry
|
|
329
|
+
return Promise.resolve();
|
|
286
330
|
}
|
|
287
331
|
}
|
|
288
332
|
|
package/src/types/index.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface VerificationResult {
|
|
|
37
37
|
policy: string;
|
|
38
38
|
violation_reasons: string[];
|
|
39
39
|
feedback: string;
|
|
40
|
+
warning_violation_reasons?: string[];
|
|
41
|
+
warning_feedback?: string | null;
|
|
40
42
|
metadata: Record<string, unknown>;
|
|
41
43
|
image_url: string | null;
|
|
42
44
|
/** Base64-encoded image data URI. Only present when `include_image_data` is requested. */
|
|
@@ -96,6 +98,8 @@ export interface ScannerTelemetryContext {
|
|
|
96
98
|
routeName?: string;
|
|
97
99
|
/** Whether the host app keeps this screen portrait-locked. */
|
|
98
100
|
isPortraitLocked?: boolean;
|
|
101
|
+
/** Host-owned identifier used to correlate scanner telemetry with its workflow. */
|
|
102
|
+
hostReferenceId?: string;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.5';
|