@switchlabs/verify-ai-react-native 2.4.22 → 2.5.0
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 -6
- package/lib/ml/featureExtractor.d.ts +1 -1
- package/lib/ml/featureExtractor.js +12 -4
- package/lib/ml/modelManager.d.ts +15 -2
- package/lib/ml/modelManager.js +20 -3
- package/lib/ml/policyEngine.js +71 -0
- package/lib/ml/types.d.ts +17 -1
- package/lib/telemetry/TelemetryReporter.d.ts +6 -0
- package/lib/telemetry/TelemetryReporter.js +30 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +6 -1
- package/src/components/VerifyAIScanner.tsx +27 -6
- package/src/ml/featureExtractor.ts +12 -4
- package/src/ml/modelManager.ts +26 -3
- package/src/ml/policyEngine.ts +86 -0
- package/src/ml/types.ts +34 -1
- package/src/telemetry/TelemetryReporter.ts +34 -1
- package/src/version.ts +1 -1
|
@@ -4,6 +4,7 @@ import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, AppState,
|
|
|
4
4
|
import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
5
5
|
import { VerifyAIRequestError } from '../client';
|
|
6
6
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
7
|
+
import { SDK_VERSION } from '../version';
|
|
7
8
|
import { BikeOverlay } from './BikeOverlay';
|
|
8
9
|
import { ScooterOverlay } from './ScooterOverlay';
|
|
9
10
|
import { ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD, classifyAndroidAccelerometerOrientation, getOverlayRotationDeg, } from './scannerOrientation';
|
|
@@ -19,8 +20,10 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
|
19
20
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
20
21
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
21
22
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
22
|
-
const
|
|
23
|
-
const
|
|
23
|
+
const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
24
|
+
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
25
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
|
|
26
|
+
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
|
|
24
27
|
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
25
28
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
26
29
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
@@ -326,11 +329,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
326
329
|
telemetryRef.current = telemetry;
|
|
327
330
|
buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
|
|
328
331
|
useEffect(() => {
|
|
329
|
-
telemetryRef.current
|
|
332
|
+
const reporter = telemetryRef.current;
|
|
333
|
+
if (reporter) {
|
|
334
|
+
console.log(`VerifyAI[${SDK_VERSION}]: scanner telemetry attached ` +
|
|
335
|
+
`baseUrl=${reporter.getBaseUrl()} apiKeyPrefix=${reporter.getApiKeyPrefix()}…`);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
console.warn(`VerifyAI[${SDK_VERSION}]: scanner telemetry is not attached; field ` +
|
|
339
|
+
'diagnostics will only appear in local device logs. Pass a ' +
|
|
340
|
+
'`TelemetryReporter` into `<VerifyAIScanner telemetry={…} />` or ' +
|
|
341
|
+
'wrap the tree in `<TelemetryContext.Provider value={reporter}>` ' +
|
|
342
|
+
'to enable server-side diagnostics.');
|
|
343
|
+
}
|
|
344
|
+
reporter?.track('camera_scanner_mounted', {
|
|
330
345
|
component: 'scanner',
|
|
331
346
|
error: 'scanner_mounted',
|
|
332
347
|
metadata: buildScannerTelemetryMetadataRef.current?.({
|
|
333
|
-
scanner_telemetry_attached:
|
|
348
|
+
scanner_telemetry_attached: reporter ? 1 : 0,
|
|
334
349
|
}),
|
|
335
350
|
});
|
|
336
351
|
return () => {
|
|
@@ -871,7 +886,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
871
886
|
cameraReadyRef.current = false;
|
|
872
887
|
setCameraKey((key) => key + 1);
|
|
873
888
|
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
874
|
-
|
|
889
|
+
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
890
|
+
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
891
|
+
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
|
|
892
|
+
captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
|
|
875
893
|
telemetry?.track('camera_capture_retry', {
|
|
876
894
|
component: 'scanner',
|
|
877
895
|
error: normalized,
|
|
@@ -881,7 +899,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
881
899
|
capture_retry_attempted: captureRetryAttempted,
|
|
882
900
|
capture_retry_ready: captureRetryReady,
|
|
883
901
|
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
884
|
-
capture_retry_ready_timeout_ms:
|
|
902
|
+
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
885
903
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
886
904
|
}),
|
|
887
905
|
});
|
|
@@ -6,7 +6,7 @@ import type { FeatureVector } from './types';
|
|
|
6
6
|
/** Ontology class name mapping (must match ontology.ts) */
|
|
7
7
|
export declare const ONTOLOGY_CLASS_NAMES: Record<number, string>;
|
|
8
8
|
/** Detection model class index -> ontology class ID mapping. */
|
|
9
|
-
export declare const MODEL_OUTPUT_CLASS_IDS: readonly [1, 2, 3, 4, 5, 20, 21, 22, 23, 24, 40, 41, 42, 43, 44, 45, 46, 47, 48, 60, 61, 62, 63, 64, 65, 66, 80, 81, 82, 83, 84, 85, 86, 87];
|
|
9
|
+
export declare const MODEL_OUTPUT_CLASS_IDS: readonly [1, 2, 3, 4, 5, 6, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 60, 61, 62, 63, 64, 65, 66, 80, 81, 82, 83, 84, 85, 86, 87];
|
|
10
10
|
interface RawDetection {
|
|
11
11
|
classId: number;
|
|
12
12
|
confidence: number;
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
* Feature extractor — converts raw detections into a structured FeatureVector.
|
|
3
3
|
* Must produce identical output to the Dart and server TypeScript implementations.
|
|
4
4
|
*/
|
|
5
|
-
const SCHEMA_VERSION = '
|
|
5
|
+
const SCHEMA_VERSION = '2.0.0';
|
|
6
6
|
/** Vehicle class IDs (must match ontology.ts) */
|
|
7
|
-
const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5]);
|
|
7
|
+
const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5, 6]);
|
|
8
8
|
/** Surface class IDs */
|
|
9
9
|
const SURFACE_CLASS_IDS = new Set([60, 61, 62, 63, 64, 65, 66]);
|
|
10
10
|
/** Ontology class name mapping (must match ontology.ts) */
|
|
11
11
|
export const ONTOLOGY_CLASS_NAMES = {
|
|
12
|
-
1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle',
|
|
12
|
+
1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle', 6: 'car',
|
|
13
13
|
20: 'kickstand', 21: 'handlebar', 22: 'wheel', 23: 'saddle', 24: 'basket',
|
|
14
|
+
// Car panels (ontology v2 — damage intelligence)
|
|
15
|
+
25: 'car_hood', 26: 'car_roof', 27: 'car_trunk',
|
|
16
|
+
28: 'car_front_bumper', 29: 'car_rear_bumper',
|
|
17
|
+
30: 'car_door_fl', 31: 'car_door_fr', 32: 'car_door_rl', 33: 'car_door_rr',
|
|
18
|
+
34: 'car_fender_fl', 35: 'car_fender_fr',
|
|
19
|
+
36: 'car_quarter_rl', 37: 'car_quarter_rr',
|
|
20
|
+
38: 'car_rocker', 39: 'car_mirror',
|
|
14
21
|
40: 'bike_rack', 41: 'curb', 42: 'parking_bay', 43: 'green_bay',
|
|
15
22
|
44: 'tactile_paving', 45: 'bollard', 46: 'fence', 47: 'pole', 48: 'yellow_lines',
|
|
16
23
|
60: 'sidewalk', 61: 'road', 62: 'grass', 63: 'crosswalk',
|
|
@@ -20,8 +27,9 @@ export const ONTOLOGY_CLASS_NAMES = {
|
|
|
20
27
|
};
|
|
21
28
|
/** Detection model class index -> ontology class ID mapping. */
|
|
22
29
|
export const MODEL_OUTPUT_CLASS_IDS = [
|
|
23
|
-
1, 2, 3, 4, 5,
|
|
30
|
+
1, 2, 3, 4, 5, 6,
|
|
24
31
|
20, 21, 22, 23, 24,
|
|
32
|
+
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
|
|
25
33
|
40, 41, 42, 43, 44, 45, 46, 47, 48,
|
|
26
34
|
60, 61, 62, 63, 64, 65, 66,
|
|
27
35
|
80, 81, 82, 83, 84, 85, 86, 87,
|
package/lib/ml/modelManager.d.ts
CHANGED
|
@@ -15,8 +15,21 @@ export declare class ModelManager {
|
|
|
15
15
|
private _currentBundle;
|
|
16
16
|
constructor(config: ModelManagerConfig);
|
|
17
17
|
get currentBundle(): BundleManifest | null;
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Check for bundle updates from the server.
|
|
20
|
+
*
|
|
21
|
+
* `opts` lets callers narrow which bundle they receive when the server
|
|
22
|
+
* has per-asset / per-region / per-app-version targeted bundles
|
|
23
|
+
* configured (see `app/api/v1/models/latest`). All fields are optional;
|
|
24
|
+
* omitting them keeps the legacy behaviour of receiving the wildcard
|
|
25
|
+
* bundle for the policy.
|
|
26
|
+
*/
|
|
27
|
+
checkForUpdates(policyId: string, platform: 'ios' | 'android', opts?: {
|
|
28
|
+
assetType?: string;
|
|
29
|
+
region?: string;
|
|
30
|
+
appVersion?: string;
|
|
31
|
+
deviceId?: string;
|
|
32
|
+
}): Promise<BundleManifest | null>;
|
|
20
33
|
/** Download model artifacts and verify integrity. */
|
|
21
34
|
private downloadArtifacts;
|
|
22
35
|
private readFileSha256;
|
package/lib/ml/modelManager.js
CHANGED
|
@@ -26,8 +26,16 @@ export class ModelManager {
|
|
|
26
26
|
get currentBundle() {
|
|
27
27
|
return this._currentBundle;
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Check for bundle updates from the server.
|
|
31
|
+
*
|
|
32
|
+
* `opts` lets callers narrow which bundle they receive when the server
|
|
33
|
+
* has per-asset / per-region / per-app-version targeted bundles
|
|
34
|
+
* configured (see `app/api/v1/models/latest`). All fields are optional;
|
|
35
|
+
* omitting them keeps the legacy behaviour of receiving the wildcard
|
|
36
|
+
* bundle for the policy.
|
|
37
|
+
*/
|
|
38
|
+
async checkForUpdates(policyId, platform, opts) {
|
|
31
39
|
try {
|
|
32
40
|
const headers = {
|
|
33
41
|
'X-API-Key': this.apiKey,
|
|
@@ -36,7 +44,16 @@ export class ModelManager {
|
|
|
36
44
|
if (this.cachedETag) {
|
|
37
45
|
headers['If-None-Match'] = this.cachedETag;
|
|
38
46
|
}
|
|
39
|
-
const
|
|
47
|
+
const qs = new URLSearchParams({ policy: policyId, platform });
|
|
48
|
+
if (opts?.assetType)
|
|
49
|
+
qs.set('asset_type', opts.assetType);
|
|
50
|
+
if (opts?.region)
|
|
51
|
+
qs.set('region', opts.region);
|
|
52
|
+
if (opts?.appVersion)
|
|
53
|
+
qs.set('app_version', opts.appVersion);
|
|
54
|
+
if (opts?.deviceId)
|
|
55
|
+
qs.set('device_id', opts.deviceId);
|
|
56
|
+
const url = `${this.baseUrl}/models/latest?${qs.toString()}`;
|
|
40
57
|
const response = await fetch(url, { headers });
|
|
41
58
|
if (response.status === 304) {
|
|
42
59
|
return this._currentBundle;
|
package/lib/ml/policyEngine.js
CHANGED
|
@@ -5,6 +5,24 @@
|
|
|
5
5
|
* This file shares the same logic as lib/verify-ai/ml/policy-engine.ts
|
|
6
6
|
* but is self-contained for the SDK package.
|
|
7
7
|
*/
|
|
8
|
+
// Severity ordering for `severity_gte`. Must match server TS + Dart impls.
|
|
9
|
+
const SEVERITY_ORDER = ['none', 'light', 'medium', 'severe'];
|
|
10
|
+
function severityRank(s) {
|
|
11
|
+
if (typeof s !== 'string')
|
|
12
|
+
return -1;
|
|
13
|
+
return SEVERITY_ORDER.indexOf(s);
|
|
14
|
+
}
|
|
15
|
+
function compareNumber(a, op, b) {
|
|
16
|
+
switch (op) {
|
|
17
|
+
case 'eq': return a === b;
|
|
18
|
+
case 'neq': return a !== b;
|
|
19
|
+
case 'gt': return a > b;
|
|
20
|
+
case 'gte': return a >= b;
|
|
21
|
+
case 'lt': return a < b;
|
|
22
|
+
case 'lte': return a <= b;
|
|
23
|
+
default: return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
8
26
|
// ─── Field Resolution ───
|
|
9
27
|
function resolveField(features, field) {
|
|
10
28
|
const parts = field.split('.');
|
|
@@ -67,9 +85,62 @@ function applyOperator(fieldValue, operator, value) {
|
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
87
|
function evaluateCondition(features, condition) {
|
|
88
|
+
if (condition.operator === 'severity_gte' ||
|
|
89
|
+
condition.operator === 'panel_has_damage' ||
|
|
90
|
+
condition.operator === 'aggregate_count') {
|
|
91
|
+
return applyDamageOperator(features, condition);
|
|
92
|
+
}
|
|
70
93
|
const fieldValue = resolveField(features, condition.field);
|
|
71
94
|
return applyOperator(fieldValue, condition.operator, condition.value);
|
|
72
95
|
}
|
|
96
|
+
function applyDamageOperator(features, condition) {
|
|
97
|
+
const findings = Array.isArray(features.damage_findings)
|
|
98
|
+
? features.damage_findings
|
|
99
|
+
: [];
|
|
100
|
+
switch (condition.operator) {
|
|
101
|
+
case 'severity_gte': {
|
|
102
|
+
const minRank = severityRank(condition.value);
|
|
103
|
+
if (minRank < 0)
|
|
104
|
+
return false;
|
|
105
|
+
if (condition.field && condition.field !== 'damage_findings') {
|
|
106
|
+
const f = resolveField(features, condition.field);
|
|
107
|
+
return severityRank(f) >= minRank;
|
|
108
|
+
}
|
|
109
|
+
return findings.some((finding) => severityRank(finding.severity) >= minRank);
|
|
110
|
+
}
|
|
111
|
+
case 'panel_has_damage': {
|
|
112
|
+
const panelName = typeof condition.value === 'string'
|
|
113
|
+
? condition.value
|
|
114
|
+
: typeof condition.field === 'string' &&
|
|
115
|
+
condition.field !== 'damage_findings' &&
|
|
116
|
+
condition.field !== ''
|
|
117
|
+
? condition.field
|
|
118
|
+
: null;
|
|
119
|
+
if (!panelName)
|
|
120
|
+
return false;
|
|
121
|
+
return findings.some((finding) => finding.panel === panelName && finding.severity !== 'none');
|
|
122
|
+
}
|
|
123
|
+
case 'aggregate_count': {
|
|
124
|
+
const v = condition.value;
|
|
125
|
+
if (!v || typeof v !== 'object' || typeof v.value !== 'number' || !v.op)
|
|
126
|
+
return false;
|
|
127
|
+
const where = v.where || {};
|
|
128
|
+
const minSeverityRank = where.severity_gte !== undefined ? severityRank(where.severity_gte) : -1;
|
|
129
|
+
const count = findings.filter((finding) => {
|
|
130
|
+
if (where.panel && finding.panel !== where.panel)
|
|
131
|
+
return false;
|
|
132
|
+
if (where.damage_type && finding.damage_type !== where.damage_type)
|
|
133
|
+
return false;
|
|
134
|
+
if (minSeverityRank >= 0 && severityRank(finding.severity) < minSeverityRank)
|
|
135
|
+
return false;
|
|
136
|
+
return true;
|
|
137
|
+
}).length;
|
|
138
|
+
return compareNumber(count, v.op, v.value);
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
73
144
|
function evaluateRule(features, rule) {
|
|
74
145
|
const passed = rule.conditions.every((c) => evaluateCondition(features, c));
|
|
75
146
|
return {
|
package/lib/ml/types.d.ts
CHANGED
|
@@ -32,6 +32,18 @@ export interface ImageQuality {
|
|
|
32
32
|
is_dark: boolean;
|
|
33
33
|
has_vehicle: boolean;
|
|
34
34
|
}
|
|
35
|
+
export type DamageSeverity = 'none' | 'light' | 'medium' | 'severe';
|
|
36
|
+
export type DamageType = 'scratch' | 'dent' | 'paint_chip' | 'crack' | 'broken' | 'missing' | 'rust' | 'tear' | 'stain' | 'glass_damage' | 'other';
|
|
37
|
+
export interface DamageFinding {
|
|
38
|
+
finding_id: string;
|
|
39
|
+
panel: string;
|
|
40
|
+
damage_type: DamageType;
|
|
41
|
+
severity: DamageSeverity;
|
|
42
|
+
severity_score: number;
|
|
43
|
+
bbox: [number, number, number, number];
|
|
44
|
+
area_pct: number;
|
|
45
|
+
confidence: number;
|
|
46
|
+
}
|
|
35
47
|
export interface FeatureVector {
|
|
36
48
|
schema_version: string;
|
|
37
49
|
detections: Detection[];
|
|
@@ -39,8 +51,12 @@ export interface FeatureVector {
|
|
|
39
51
|
image_quality: ImageQuality;
|
|
40
52
|
vehicle_on_surface: string | null;
|
|
41
53
|
vehicle_near: string[];
|
|
54
|
+
/** Damage observations (schema v2). Defaults to [] when damage mode disabled. */
|
|
55
|
+
damage_findings?: DamageFinding[];
|
|
56
|
+
/** Panels visible in this frame (schema v2). */
|
|
57
|
+
panel_inventory?: string[];
|
|
42
58
|
}
|
|
43
|
-
export type ComparisonOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not_contains' | 'exists' | 'not_exists' | 'in' | 'not_in' | 'overlaps';
|
|
59
|
+
export type ComparisonOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not_contains' | 'exists' | 'not_exists' | 'in' | 'not_in' | 'overlaps' | 'severity_gte' | 'panel_has_damage' | 'aggregate_count';
|
|
44
60
|
export interface Condition {
|
|
45
61
|
field: string;
|
|
46
62
|
operator: ComparisonOperator;
|
|
@@ -9,6 +9,12 @@ export declare class TelemetryReporter {
|
|
|
9
9
|
private loadedPersisted;
|
|
10
10
|
private loadPersistedPromise;
|
|
11
11
|
constructor(apiKey: string, baseUrl: string);
|
|
12
|
+
/** Telemetry POST destination — exposed so the scanner can log an init banner
|
|
13
|
+
* that helps field debugging confirm where events are being sent. */
|
|
14
|
+
getBaseUrl(): string;
|
|
15
|
+
/** First 6 characters of the API key, for log banners. Never returns the full key. */
|
|
16
|
+
getApiKeyPrefix(): string;
|
|
17
|
+
private logDeliveryFailure;
|
|
12
18
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
13
19
|
track(eventType: string, opts?: {
|
|
14
20
|
component?: string;
|
|
@@ -40,6 +40,20 @@ export class TelemetryReporter {
|
|
|
40
40
|
// Load any persisted events left behind by a previous session
|
|
41
41
|
this.loadPersistedBuffer();
|
|
42
42
|
}
|
|
43
|
+
/** Telemetry POST destination — exposed so the scanner can log an init banner
|
|
44
|
+
* that helps field debugging confirm where events are being sent. */
|
|
45
|
+
getBaseUrl() {
|
|
46
|
+
return this.baseUrl;
|
|
47
|
+
}
|
|
48
|
+
/** First 6 characters of the API key, for log banners. Never returns the full key. */
|
|
49
|
+
getApiKeyPrefix() {
|
|
50
|
+
return this.apiKey.length >= 6 ? this.apiKey.slice(0, 6) : this.apiKey;
|
|
51
|
+
}
|
|
52
|
+
logDeliveryFailure(kind, detail, eventCount) {
|
|
53
|
+
const trimmed = detail.length > 200 ? detail.slice(0, 200) : detail;
|
|
54
|
+
console.warn(`VerifyAI[${SDK_VERSION}]: telemetry POST failed kind=${kind} ` +
|
|
55
|
+
`events=${eventCount} url=${this.baseUrl}/telemetry detail=${trimmed}`);
|
|
56
|
+
}
|
|
43
57
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
44
58
|
track(eventType, opts = {}) {
|
|
45
59
|
if (this.disposed)
|
|
@@ -113,6 +127,7 @@ export class TelemetryReporter {
|
|
|
113
127
|
this.clearFlushTimer();
|
|
114
128
|
const controller = new AbortController();
|
|
115
129
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
130
|
+
let loggedDeliveryFailure = false;
|
|
116
131
|
try {
|
|
117
132
|
const response = await fetch(`${this.baseUrl}/telemetry`, {
|
|
118
133
|
method: 'POST',
|
|
@@ -124,12 +139,26 @@ export class TelemetryReporter {
|
|
|
124
139
|
signal: controller.signal,
|
|
125
140
|
});
|
|
126
141
|
if (!response.ok) {
|
|
142
|
+
let body = '';
|
|
143
|
+
try {
|
|
144
|
+
body = await response.text();
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// ignore
|
|
148
|
+
}
|
|
149
|
+
this.logDeliveryFailure(`http_${response.status}`, body, events.length);
|
|
150
|
+
loggedDeliveryFailure = true;
|
|
127
151
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
128
152
|
}
|
|
129
153
|
// Success — clear persisted buffer since events are now server-side
|
|
130
154
|
this.persistBuffer(); // buffer is empty at this point, so this clears the key
|
|
131
155
|
}
|
|
132
|
-
catch {
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (!loggedDeliveryFailure) {
|
|
158
|
+
const kind = error instanceof Error ? error.name : typeof error;
|
|
159
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
160
|
+
this.logDeliveryFailure(kind, detail, events.length);
|
|
161
|
+
}
|
|
133
162
|
for (const [dedupKey, event] of bufferedEntries) {
|
|
134
163
|
const existing = this.buffer.get(dedupKey);
|
|
135
164
|
if (existing) {
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.
|
|
1
|
+
export declare const SDK_VERSION = "2.5.0";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.
|
|
1
|
+
export const SDK_VERSION = '2.5.0';
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchlabs/verify-ai-react-native",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "React Native SDK for Verify AI - photo verification with AI vision processing",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/levyelectric/verify.git",
|
|
8
|
+
"directory": "packages/verify-ai-react-native"
|
|
9
|
+
},
|
|
5
10
|
"main": "./lib/index.js",
|
|
6
11
|
"types": "./lib/index.d.ts",
|
|
7
12
|
"exports": {
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import { VerifyAIRequestError } from '../client';
|
|
24
24
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
25
25
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
26
|
+
import { SDK_VERSION } from '../version';
|
|
26
27
|
import { BikeOverlay } from './BikeOverlay';
|
|
27
28
|
import { ScooterOverlay } from './ScooterOverlay';
|
|
28
29
|
import {
|
|
@@ -44,8 +45,10 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
|
44
45
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
45
46
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
46
47
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
47
|
-
const
|
|
48
|
-
const
|
|
48
|
+
const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
49
|
+
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
50
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
|
|
51
|
+
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
|
|
49
52
|
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
50
53
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
51
54
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
@@ -439,11 +442,26 @@ export function VerifyAIScanner({
|
|
|
439
442
|
buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
|
|
440
443
|
|
|
441
444
|
useEffect(() => {
|
|
442
|
-
telemetryRef.current
|
|
445
|
+
const reporter = telemetryRef.current;
|
|
446
|
+
if (reporter) {
|
|
447
|
+
console.log(
|
|
448
|
+
`VerifyAI[${SDK_VERSION}]: scanner telemetry attached ` +
|
|
449
|
+
`baseUrl=${reporter.getBaseUrl()} apiKeyPrefix=${reporter.getApiKeyPrefix()}…`,
|
|
450
|
+
);
|
|
451
|
+
} else {
|
|
452
|
+
console.warn(
|
|
453
|
+
`VerifyAI[${SDK_VERSION}]: scanner telemetry is not attached; field ` +
|
|
454
|
+
'diagnostics will only appear in local device logs. Pass a ' +
|
|
455
|
+
'`TelemetryReporter` into `<VerifyAIScanner telemetry={…} />` or ' +
|
|
456
|
+
'wrap the tree in `<TelemetryContext.Provider value={reporter}>` ' +
|
|
457
|
+
'to enable server-side diagnostics.',
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
reporter?.track('camera_scanner_mounted', {
|
|
443
461
|
component: 'scanner',
|
|
444
462
|
error: 'scanner_mounted',
|
|
445
463
|
metadata: buildScannerTelemetryMetadataRef.current?.({
|
|
446
|
-
scanner_telemetry_attached:
|
|
464
|
+
scanner_telemetry_attached: reporter ? 1 : 0,
|
|
447
465
|
}),
|
|
448
466
|
});
|
|
449
467
|
|
|
@@ -1063,7 +1081,10 @@ export function VerifyAIScanner({
|
|
|
1063
1081
|
cameraReadyRef.current = false;
|
|
1064
1082
|
setCameraKey((key) => key + 1);
|
|
1065
1083
|
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
1066
|
-
|
|
1084
|
+
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
1085
|
+
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
1086
|
+
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
|
|
1087
|
+
captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
|
|
1067
1088
|
telemetry?.track('camera_capture_retry', {
|
|
1068
1089
|
component: 'scanner',
|
|
1069
1090
|
error: normalized,
|
|
@@ -1073,7 +1094,7 @@ export function VerifyAIScanner({
|
|
|
1073
1094
|
capture_retry_attempted: captureRetryAttempted,
|
|
1074
1095
|
capture_retry_ready: captureRetryReady,
|
|
1075
1096
|
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
1076
|
-
capture_retry_ready_timeout_ms:
|
|
1097
|
+
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1077
1098
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1078
1099
|
}),
|
|
1079
1100
|
});
|
|
@@ -5,17 +5,24 @@
|
|
|
5
5
|
|
|
6
6
|
import type { FeatureVector, Detection, ImageQuality } from './types';
|
|
7
7
|
|
|
8
|
-
const SCHEMA_VERSION = '
|
|
8
|
+
const SCHEMA_VERSION = '2.0.0';
|
|
9
9
|
|
|
10
10
|
/** Vehicle class IDs (must match ontology.ts) */
|
|
11
|
-
const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5]);
|
|
11
|
+
const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5, 6]);
|
|
12
12
|
/** Surface class IDs */
|
|
13
13
|
const SURFACE_CLASS_IDS = new Set([60, 61, 62, 63, 64, 65, 66]);
|
|
14
14
|
|
|
15
15
|
/** Ontology class name mapping (must match ontology.ts) */
|
|
16
16
|
export const ONTOLOGY_CLASS_NAMES: Record<number, string> = {
|
|
17
|
-
1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle',
|
|
17
|
+
1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle', 6: 'car',
|
|
18
18
|
20: 'kickstand', 21: 'handlebar', 22: 'wheel', 23: 'saddle', 24: 'basket',
|
|
19
|
+
// Car panels (ontology v2 — damage intelligence)
|
|
20
|
+
25: 'car_hood', 26: 'car_roof', 27: 'car_trunk',
|
|
21
|
+
28: 'car_front_bumper', 29: 'car_rear_bumper',
|
|
22
|
+
30: 'car_door_fl', 31: 'car_door_fr', 32: 'car_door_rl', 33: 'car_door_rr',
|
|
23
|
+
34: 'car_fender_fl', 35: 'car_fender_fr',
|
|
24
|
+
36: 'car_quarter_rl', 37: 'car_quarter_rr',
|
|
25
|
+
38: 'car_rocker', 39: 'car_mirror',
|
|
19
26
|
40: 'bike_rack', 41: 'curb', 42: 'parking_bay', 43: 'green_bay',
|
|
20
27
|
44: 'tactile_paving', 45: 'bollard', 46: 'fence', 47: 'pole', 48: 'yellow_lines',
|
|
21
28
|
60: 'sidewalk', 61: 'road', 62: 'grass', 63: 'crosswalk',
|
|
@@ -26,8 +33,9 @@ export const ONTOLOGY_CLASS_NAMES: Record<number, string> = {
|
|
|
26
33
|
|
|
27
34
|
/** Detection model class index -> ontology class ID mapping. */
|
|
28
35
|
export const MODEL_OUTPUT_CLASS_IDS = [
|
|
29
|
-
1, 2, 3, 4, 5,
|
|
36
|
+
1, 2, 3, 4, 5, 6,
|
|
30
37
|
20, 21, 22, 23, 24,
|
|
38
|
+
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
|
|
31
39
|
40, 41, 42, 43, 44, 45, 46, 47, 48,
|
|
32
40
|
60, 61, 62, 63, 64, 65, 66,
|
|
33
41
|
80, 81, 82, 83, 84, 85, 86, 87,
|
package/src/ml/modelManager.ts
CHANGED
|
@@ -62,8 +62,25 @@ export class ModelManager {
|
|
|
62
62
|
return this._currentBundle;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Check for bundle updates from the server.
|
|
67
|
+
*
|
|
68
|
+
* `opts` lets callers narrow which bundle they receive when the server
|
|
69
|
+
* has per-asset / per-region / per-app-version targeted bundles
|
|
70
|
+
* configured (see `app/api/v1/models/latest`). All fields are optional;
|
|
71
|
+
* omitting them keeps the legacy behaviour of receiving the wildcard
|
|
72
|
+
* bundle for the policy.
|
|
73
|
+
*/
|
|
74
|
+
async checkForUpdates(
|
|
75
|
+
policyId: string,
|
|
76
|
+
platform: 'ios' | 'android',
|
|
77
|
+
opts?: {
|
|
78
|
+
assetType?: string;
|
|
79
|
+
region?: string;
|
|
80
|
+
appVersion?: string;
|
|
81
|
+
deviceId?: string;
|
|
82
|
+
},
|
|
83
|
+
): Promise<BundleManifest | null> {
|
|
67
84
|
try {
|
|
68
85
|
const headers: Record<string, string> = {
|
|
69
86
|
'X-API-Key': this.apiKey,
|
|
@@ -74,7 +91,13 @@ export class ModelManager {
|
|
|
74
91
|
headers['If-None-Match'] = this.cachedETag;
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
const
|
|
94
|
+
const qs = new URLSearchParams({ policy: policyId, platform });
|
|
95
|
+
if (opts?.assetType) qs.set('asset_type', opts.assetType);
|
|
96
|
+
if (opts?.region) qs.set('region', opts.region);
|
|
97
|
+
if (opts?.appVersion) qs.set('app_version', opts.appVersion);
|
|
98
|
+
if (opts?.deviceId) qs.set('device_id', opts.deviceId);
|
|
99
|
+
|
|
100
|
+
const url = `${this.baseUrl}/models/latest?${qs.toString()}`;
|
|
78
101
|
const response = await fetch(url, { headers });
|
|
79
102
|
|
|
80
103
|
if (response.status === 304) {
|
package/src/ml/policyEngine.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
|
+
DamageFinding,
|
|
11
|
+
DamageSeverity,
|
|
10
12
|
FeatureVector,
|
|
11
13
|
PolicyAST,
|
|
12
14
|
PolicyResult,
|
|
@@ -17,6 +19,35 @@ import type {
|
|
|
17
19
|
ComparisonOperator,
|
|
18
20
|
} from './types';
|
|
19
21
|
|
|
22
|
+
// Severity ordering for `severity_gte`. Must match server TS + Dart impls.
|
|
23
|
+
const SEVERITY_ORDER: DamageSeverity[] = ['none', 'light', 'medium', 'severe'];
|
|
24
|
+
function severityRank(s: unknown): number {
|
|
25
|
+
if (typeof s !== 'string') return -1;
|
|
26
|
+
return SEVERITY_ORDER.indexOf(s as DamageSeverity);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface AggregateCountValue {
|
|
30
|
+
op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte';
|
|
31
|
+
value: number;
|
|
32
|
+
where?: {
|
|
33
|
+
panel?: string;
|
|
34
|
+
damage_type?: string;
|
|
35
|
+
severity_gte?: DamageSeverity;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareNumber(a: number, op: AggregateCountValue['op'], b: number): boolean {
|
|
40
|
+
switch (op) {
|
|
41
|
+
case 'eq': return a === b;
|
|
42
|
+
case 'neq': return a !== b;
|
|
43
|
+
case 'gt': return a > b;
|
|
44
|
+
case 'gte': return a >= b;
|
|
45
|
+
case 'lt': return a < b;
|
|
46
|
+
case 'lte': return a <= b;
|
|
47
|
+
default: return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
20
51
|
// ─── Field Resolution ───
|
|
21
52
|
|
|
22
53
|
function resolveField(features: FeatureVector, field: string): unknown {
|
|
@@ -77,10 +108,65 @@ function applyOperator(fieldValue: unknown, operator: ComparisonOperator, value:
|
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
function evaluateCondition(features: FeatureVector, condition: Condition): boolean {
|
|
111
|
+
if (
|
|
112
|
+
condition.operator === 'severity_gte' ||
|
|
113
|
+
condition.operator === 'panel_has_damage' ||
|
|
114
|
+
condition.operator === 'aggregate_count'
|
|
115
|
+
) {
|
|
116
|
+
return applyDamageOperator(features, condition);
|
|
117
|
+
}
|
|
80
118
|
const fieldValue = resolveField(features, condition.field);
|
|
81
119
|
return applyOperator(fieldValue, condition.operator, condition.value);
|
|
82
120
|
}
|
|
83
121
|
|
|
122
|
+
function applyDamageOperator(features: FeatureVector, condition: Condition): boolean {
|
|
123
|
+
const findings: DamageFinding[] = Array.isArray(features.damage_findings)
|
|
124
|
+
? features.damage_findings
|
|
125
|
+
: [];
|
|
126
|
+
|
|
127
|
+
switch (condition.operator) {
|
|
128
|
+
case 'severity_gte': {
|
|
129
|
+
const minRank = severityRank(condition.value);
|
|
130
|
+
if (minRank < 0) return false;
|
|
131
|
+
if (condition.field && condition.field !== 'damage_findings') {
|
|
132
|
+
const f = resolveField(features, condition.field);
|
|
133
|
+
return severityRank(f) >= minRank;
|
|
134
|
+
}
|
|
135
|
+
return findings.some((finding) => severityRank(finding.severity) >= minRank);
|
|
136
|
+
}
|
|
137
|
+
case 'panel_has_damage': {
|
|
138
|
+
const panelName =
|
|
139
|
+
typeof condition.value === 'string'
|
|
140
|
+
? condition.value
|
|
141
|
+
: typeof condition.field === 'string' &&
|
|
142
|
+
condition.field !== 'damage_findings' &&
|
|
143
|
+
condition.field !== ''
|
|
144
|
+
? condition.field
|
|
145
|
+
: null;
|
|
146
|
+
if (!panelName) return false;
|
|
147
|
+
return findings.some(
|
|
148
|
+
(finding) => finding.panel === panelName && finding.severity !== 'none',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
case 'aggregate_count': {
|
|
152
|
+
const v = condition.value as AggregateCountValue | null | undefined;
|
|
153
|
+
if (!v || typeof v !== 'object' || typeof v.value !== 'number' || !v.op) return false;
|
|
154
|
+
const where = v.where || {};
|
|
155
|
+
const minSeverityRank =
|
|
156
|
+
where.severity_gte !== undefined ? severityRank(where.severity_gte) : -1;
|
|
157
|
+
const count = findings.filter((finding) => {
|
|
158
|
+
if (where.panel && finding.panel !== where.panel) return false;
|
|
159
|
+
if (where.damage_type && finding.damage_type !== where.damage_type) return false;
|
|
160
|
+
if (minSeverityRank >= 0 && severityRank(finding.severity) < minSeverityRank) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}).length;
|
|
163
|
+
return compareNumber(count, v.op, v.value);
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
84
170
|
function evaluateRule(features: FeatureVector, rule: Rule): RuleResult {
|
|
85
171
|
const passed = rule.conditions.every((c) => evaluateCondition(features, c));
|
|
86
172
|
return {
|
package/src/ml/types.ts
CHANGED
|
@@ -39,6 +39,33 @@ export interface ImageQuality {
|
|
|
39
39
|
has_vehicle: boolean;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// ─── Damage Findings (schema v2) ───
|
|
43
|
+
|
|
44
|
+
export type DamageSeverity = 'none' | 'light' | 'medium' | 'severe';
|
|
45
|
+
export type DamageType =
|
|
46
|
+
| 'scratch'
|
|
47
|
+
| 'dent'
|
|
48
|
+
| 'paint_chip'
|
|
49
|
+
| 'crack'
|
|
50
|
+
| 'broken'
|
|
51
|
+
| 'missing'
|
|
52
|
+
| 'rust'
|
|
53
|
+
| 'tear'
|
|
54
|
+
| 'stain'
|
|
55
|
+
| 'glass_damage'
|
|
56
|
+
| 'other';
|
|
57
|
+
|
|
58
|
+
export interface DamageFinding {
|
|
59
|
+
finding_id: string;
|
|
60
|
+
panel: string;
|
|
61
|
+
damage_type: DamageType;
|
|
62
|
+
severity: DamageSeverity;
|
|
63
|
+
severity_score: number;
|
|
64
|
+
bbox: [number, number, number, number];
|
|
65
|
+
area_pct: number;
|
|
66
|
+
confidence: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
export interface FeatureVector {
|
|
43
70
|
schema_version: string;
|
|
44
71
|
detections: Detection[];
|
|
@@ -46,6 +73,10 @@ export interface FeatureVector {
|
|
|
46
73
|
image_quality: ImageQuality;
|
|
47
74
|
vehicle_on_surface: string | null;
|
|
48
75
|
vehicle_near: string[];
|
|
76
|
+
/** Damage observations (schema v2). Defaults to [] when damage mode disabled. */
|
|
77
|
+
damage_findings?: DamageFinding[];
|
|
78
|
+
/** Panels visible in this frame (schema v2). */
|
|
79
|
+
panel_inventory?: string[];
|
|
49
80
|
}
|
|
50
81
|
|
|
51
82
|
// ─── Policy AST Types ───
|
|
@@ -54,7 +85,9 @@ export type ComparisonOperator =
|
|
|
54
85
|
| 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
|
|
55
86
|
| 'contains' | 'not_contains'
|
|
56
87
|
| 'exists' | 'not_exists'
|
|
57
|
-
| 'in' | 'not_in' | 'overlaps'
|
|
88
|
+
| 'in' | 'not_in' | 'overlaps'
|
|
89
|
+
// AST v1.1 — damage operators
|
|
90
|
+
| 'severity_gte' | 'panel_has_damage' | 'aggregate_count';
|
|
58
91
|
|
|
59
92
|
export interface Condition {
|
|
60
93
|
field: string;
|
|
@@ -71,6 +71,25 @@ export class TelemetryReporter {
|
|
|
71
71
|
this.loadPersistedBuffer();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Telemetry POST destination — exposed so the scanner can log an init banner
|
|
75
|
+
* that helps field debugging confirm where events are being sent. */
|
|
76
|
+
getBaseUrl(): string {
|
|
77
|
+
return this.baseUrl;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** First 6 characters of the API key, for log banners. Never returns the full key. */
|
|
81
|
+
getApiKeyPrefix(): string {
|
|
82
|
+
return this.apiKey.length >= 6 ? this.apiKey.slice(0, 6) : this.apiKey;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private logDeliveryFailure(kind: string, detail: string, eventCount: number): void {
|
|
86
|
+
const trimmed = detail.length > 200 ? detail.slice(0, 200) : detail;
|
|
87
|
+
console.warn(
|
|
88
|
+
`VerifyAI[${SDK_VERSION}]: telemetry POST failed kind=${kind} ` +
|
|
89
|
+
`events=${eventCount} url=${this.baseUrl}/telemetry detail=${trimmed}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
74
93
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
75
94
|
track(
|
|
76
95
|
eventType: string,
|
|
@@ -158,6 +177,7 @@ export class TelemetryReporter {
|
|
|
158
177
|
const controller = new AbortController();
|
|
159
178
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
160
179
|
|
|
180
|
+
let loggedDeliveryFailure = false;
|
|
161
181
|
try {
|
|
162
182
|
const response = await fetch(`${this.baseUrl}/telemetry`, {
|
|
163
183
|
method: 'POST',
|
|
@@ -170,12 +190,25 @@ export class TelemetryReporter {
|
|
|
170
190
|
});
|
|
171
191
|
|
|
172
192
|
if (!response.ok) {
|
|
193
|
+
let body = '';
|
|
194
|
+
try {
|
|
195
|
+
body = await response.text();
|
|
196
|
+
} catch {
|
|
197
|
+
// ignore
|
|
198
|
+
}
|
|
199
|
+
this.logDeliveryFailure(`http_${response.status}`, body, events.length);
|
|
200
|
+
loggedDeliveryFailure = true;
|
|
173
201
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
174
202
|
}
|
|
175
203
|
|
|
176
204
|
// Success — clear persisted buffer since events are now server-side
|
|
177
205
|
this.persistBuffer(); // buffer is empty at this point, so this clears the key
|
|
178
|
-
} catch {
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (!loggedDeliveryFailure) {
|
|
208
|
+
const kind = error instanceof Error ? error.name : typeof error;
|
|
209
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
210
|
+
this.logDeliveryFailure(kind, detail, events.length);
|
|
211
|
+
}
|
|
179
212
|
for (const [dedupKey, event] of bufferedEntries) {
|
|
180
213
|
const existing = this.buffer.get(dedupKey);
|
|
181
214
|
if (existing) {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.
|
|
1
|
+
export const SDK_VERSION = '2.5.0';
|