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