@switchlabs/verify-ai-react-native 2.5.4 → 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.
@@ -294,6 +294,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
294
294
  device_os_version: String(Platform.Version),
295
295
  route_name: telemetryContext?.routeName,
296
296
  is_portrait_locked: telemetryContext?.isPortraitLocked,
297
+ host_reference_id: telemetryContext?.hostReferenceId,
297
298
  window_width: windowWidth,
298
299
  window_height: windowHeight,
299
300
  interface_orientation: isLandscape ? 'landscape' : 'portrait',
@@ -914,15 +915,20 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
914
915
  : exhausted
915
916
  ? 'exhausted'
916
917
  : null;
917
- telemetry?.track('camera_capture_request', {
918
- component: 'scanner',
919
- error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
920
- metadata: buildScannerTelemetryMetadata({
921
- capture_attempt_id: captureAttemptId,
922
- capture_sequence: captureSequence,
923
- capture_blocked_reason: blockedReason,
924
- }),
925
- });
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
+ }
926
932
  if (blockedReason && blockedReason !== 'camera_not_ready')
927
933
  return;
928
934
  if (blockedReason === 'camera_not_ready') {
@@ -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
- component?: string;
21
- error?: unknown;
22
- errorCode?: string;
23
- metadata?: Record<string, string | number>;
24
- }): void;
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
- const now = new Date().toISOString();
64
- const errorObj = opts.error instanceof Error ? opts.error : null;
65
- const errorMessage = errorObj?.message
66
- ?? (typeof opts.error === 'string' ? opts.error : undefined);
67
- const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
68
- const existing = this.buffer.get(dedupKey);
69
- if (existing) {
70
- existing.event_count++;
71
- existing.last_occurred_at = now;
72
- // If the new occurrence is critical, flush immediately
73
- if (CRITICAL_EVENTS.has(eventType)) {
74
- this.flushNow();
75
- }
76
- return;
77
- }
78
- const event = {
79
- event_type: eventType,
80
- component: opts.component,
81
- error_message: errorMessage?.slice(0, 1000),
82
- error_stack: errorObj?.stack?.slice(0, 2000),
83
- error_code: opts.errorCode,
84
- metadata: opts.metadata,
85
- sdk_platform: Platform.OS,
86
- sdk_version: SDK_VERSION,
87
- os_name: Platform.OS,
88
- os_version: String(Platform.Version),
89
- session_id: this.sessionId,
90
- event_count: 1,
91
- first_occurred_at: now,
92
- last_occurred_at: now,
93
- };
94
- this.buffer.set(dedupKey, event);
66
+ this.recordEvent(eventType, opts);
95
67
  if (CRITICAL_EVENTS.has(eventType)) {
96
- this.flushNow();
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
- // Success — clear persisted buffer since events are now server-side
155
- this.persistBuffer(); // buffer is empty at this point, so this clears the key
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; // Don't overwrite orphaned prior-session data before it's loaded
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
- getStorage().then(s => {
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
- s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
263
+ await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
229
264
  }
230
265
  else {
231
- s.removeItem(TELEMETRY_PERSIST_KEY);
266
+ await storage.removeItem(TELEMETRY_PERSIST_KEY);
232
267
  }
233
- }).catch(() => { });
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
  /**
@@ -87,6 +87,8 @@ export interface ScannerTelemetryContext {
87
87
  routeName?: string;
88
88
  /** Whether the host app keeps this screen portrait-locked. */
89
89
  isPortraitLocked?: boolean;
90
+ /** Host-owned identifier used to correlate scanner telemetry with its workflow. */
91
+ hostReferenceId?: string;
90
92
  }
91
93
  /**
92
94
  * Theme customization for the scanner overlay.
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.5.4";
1
+ export declare const SDK_VERSION = "2.5.5";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.4';
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.4",
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",
@@ -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?.track('camera_capture_request', {
1129
- component: 'scanner',
1130
- error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
1131
- metadata: buildScannerTelemetryMetadata({
1132
- capture_attempt_id: captureAttemptId,
1133
- capture_sequence: captureSequence,
1134
- capture_blocked_reason: blockedReason,
1135
- }),
1136
- });
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') {
@@ -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
- const now = new Date().toISOString();
108
- const errorObj = opts.error instanceof Error ? opts.error : null;
109
- const errorMessage = errorObj?.message
110
- ?? (typeof opts.error === 'string' ? opts.error : undefined);
111
-
112
- const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
113
-
114
- const existing = this.buffer.get(dedupKey);
115
- if (existing) {
116
- existing.event_count++;
117
- existing.last_occurred_at = now;
118
- // If the new occurrence is critical, flush immediately
119
- if (CRITICAL_EVENTS.has(eventType)) {
120
- this.flushNow();
121
- }
122
- return;
123
- }
124
-
125
- const event: TelemetryEvent = {
126
- event_type: eventType,
127
- component: opts.component,
128
- error_message: errorMessage?.slice(0, 1000),
129
- error_stack: errorObj?.stack?.slice(0, 2000),
130
- error_code: opts.errorCode,
131
- metadata: opts.metadata,
132
- sdk_platform: Platform.OS,
133
- sdk_version: SDK_VERSION,
134
- os_name: Platform.OS,
135
- os_version: String(Platform.Version),
136
- session_id: this.sessionId,
137
- event_count: 1,
138
- first_occurred_at: now,
139
- last_occurred_at: now,
140
- };
141
-
142
- this.buffer.set(dedupKey, event);
112
+ this.recordEvent(eventType, opts);
143
113
 
144
114
  if (CRITICAL_EVENTS.has(eventType)) {
145
- this.flushNow();
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
- // Success — clear persisted buffer since events are now server-side
206
- this.persistBuffer(); // buffer is empty at this point, so this clears the key
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
- if (!this.loadedPersisted) return; // Don't overwrite orphaned prior-session data before it's loaded
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
- getStorage().then(s => {
278
- if (Object.keys(entries).length > 0) {
279
- s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
280
- } else {
281
- s.removeItem(TELEMETRY_PERSIST_KEY);
282
- }
283
- }).catch(() => { /* never throw from telemetry */ });
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
 
@@ -98,6 +98,8 @@ export interface ScannerTelemetryContext {
98
98
  routeName?: string;
99
99
  /** Whether the host app keeps this screen portrait-locked. */
100
100
  isPortraitLocked?: boolean;
101
+ /** Host-owned identifier used to correlate scanner telemetry with its workflow. */
102
+ hostReferenceId?: string;
101
103
  }
102
104
 
103
105
  /**
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.4';
1
+ export const SDK_VERSION = '2.5.5';