Add sensor setup analytics tracking
Implemented comprehensive analytics system for tracking sensor setup process including scan events, setup steps, and completion metrics. Features: - Analytics service with event tracking for sensor setup - Metrics calculation (success rate, duration, common errors) - Integration in add-sensor and setup-wifi screens - Tracks: scan start/complete, setup start/complete, individual steps, retries, skips, and cancellations - Comprehensive test coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d289dd79a1
commit
1e9ebd14ff
@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
import { router, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
||||||
import { useBLE } from '@/contexts/BLEContext';
|
import { useBLE } from '@/contexts/BLEContext';
|
||||||
|
import { analytics } from '@/services/analytics';
|
||||||
import {
|
import {
|
||||||
AppColors,
|
AppColors,
|
||||||
BorderRadius,
|
BorderRadius,
|
||||||
@ -37,13 +38,24 @@ export default function AddSensorScreen() {
|
|||||||
} = useBLE();
|
} = useBLE();
|
||||||
|
|
||||||
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
|
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
|
||||||
|
const [scanStartTime, setScanStartTime] = useState<number | null>(null);
|
||||||
|
|
||||||
// Select all devices by default when scan completes
|
// Select all devices by default when scan completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (foundDevices.length > 0 && !isScanning) {
|
if (foundDevices.length > 0 && !isScanning) {
|
||||||
setSelectedDevices(new Set(foundDevices.map(d => d.id)));
|
setSelectedDevices(new Set(foundDevices.map(d => d.id)));
|
||||||
|
|
||||||
|
// Track scan completion
|
||||||
|
if (scanStartTime && id) {
|
||||||
|
analytics.trackSensorScanComplete({
|
||||||
|
beneficiaryId: id,
|
||||||
|
scanDuration: Date.now() - scanStartTime,
|
||||||
|
sensorsFound: foundDevices.length,
|
||||||
|
selectedCount: foundDevices.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [foundDevices, isScanning]);
|
}
|
||||||
|
}, [foundDevices, isScanning, scanStartTime, id]);
|
||||||
|
|
||||||
// Cleanup: Stop scan when screen loses focus or component unmounts
|
// Cleanup: Stop scan when screen loses focus or component unmounts
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@ -85,6 +97,12 @@ export default function AddSensorScreen() {
|
|||||||
// Clear any previous errors
|
// Clear any previous errors
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
|
// Track scan start
|
||||||
|
if (id) {
|
||||||
|
analytics.trackSensorScanStart(id);
|
||||||
|
setScanStartTime(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
// Perform scan
|
// Perform scan
|
||||||
await scanDevices();
|
await scanDevices();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -109,6 +127,15 @@ export default function AddSensorScreen() {
|
|||||||
|
|
||||||
const devices = foundDevices.filter(d => selectedDevices.has(d.id));
|
const devices = foundDevices.filter(d => selectedDevices.has(d.id));
|
||||||
|
|
||||||
|
// Track setup initiation
|
||||||
|
if (id && scanStartTime) {
|
||||||
|
analytics.trackSensorSetupStart({
|
||||||
|
beneficiaryId: id,
|
||||||
|
sensorCount: selectedCount,
|
||||||
|
scanDuration: Date.now() - scanStartTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to Setup WiFi screen with selected devices
|
// Navigate to Setup WiFi screen with selected devices
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
|
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { router, useLocalSearchParams } from 'expo-router';
|
|||||||
import * as Device from 'expo-device';
|
import * as Device from 'expo-device';
|
||||||
import { useBLE } from '@/contexts/BLEContext';
|
import { useBLE } from '@/contexts/BLEContext';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
|
import { analytics } from '@/services/analytics';
|
||||||
import * as wifiPasswordStore from '@/services/wifiPasswordStore';
|
import * as wifiPasswordStore from '@/services/wifiPasswordStore';
|
||||||
import type { WiFiNetwork } from '@/services/ble';
|
import type { WiFiNetwork } from '@/services/ble';
|
||||||
import type {
|
import type {
|
||||||
@ -112,6 +113,7 @@ export default function SetupWiFiScreen() {
|
|||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const setupInProgressRef = useRef(false);
|
const setupInProgressRef = useRef(false);
|
||||||
const shouldCancelRef = useRef(false);
|
const shouldCancelRef = useRef(false);
|
||||||
|
const [setupStartTime, setSetupStartTime] = useState<number | null>(null);
|
||||||
|
|
||||||
// Saved WiFi passwords map (SSID -> password)
|
// Saved WiFi passwords map (SSID -> password)
|
||||||
// Using useState to trigger re-renders when passwords are loaded
|
// Using useState to trigger re-renders when passwords are loaded
|
||||||
@ -202,7 +204,27 @@ export default function SetupWiFiScreen() {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
}, []);
|
|
||||||
|
// Track step progress
|
||||||
|
if (id) {
|
||||||
|
const sensorIndex = sensors.findIndex(s => s.deviceId === deviceId);
|
||||||
|
const stepStatusMap: Record<SensorSetupStep['status'], 'started' | 'completed' | 'failed'> = {
|
||||||
|
pending: 'started',
|
||||||
|
in_progress: 'started',
|
||||||
|
completed: 'completed',
|
||||||
|
failed: 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
analytics.trackSensorSetupStep({
|
||||||
|
beneficiaryId: id,
|
||||||
|
sensorIndex,
|
||||||
|
totalSensors: sensors.length,
|
||||||
|
step: stepName,
|
||||||
|
stepStatus: stepStatusMap[stepStatus],
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [id, sensors]);
|
||||||
|
|
||||||
// Update sensor status
|
// Update sensor status
|
||||||
const updateSensorStatus = useCallback((
|
const updateSensorStatus = useCallback((
|
||||||
@ -394,9 +416,33 @@ export default function SetupWiFiScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (allProcessed || shouldCancelRef.current) {
|
if (allProcessed || shouldCancelRef.current) {
|
||||||
|
// Track setup completion
|
||||||
|
if (id && setupStartTime) {
|
||||||
|
const successCount = finalSensors.filter(s => s.status === 'success').length;
|
||||||
|
const failureCount = finalSensors.filter(s => s.status === 'error').length;
|
||||||
|
const skippedCount = finalSensors.filter(s => s.status === 'skipped').length;
|
||||||
|
const totalDuration = Date.now() - setupStartTime;
|
||||||
|
|
||||||
|
// Calculate average sensor setup time (only successful ones)
|
||||||
|
const successfulSensors = finalSensors.filter(s => s.status === 'success' && s.startTime && s.endTime);
|
||||||
|
const averageSensorSetupTime = successfulSensors.length > 0
|
||||||
|
? successfulSensors.reduce((sum, s) => sum + (s.endTime! - s.startTime!), 0) / successfulSensors.length
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
analytics.trackSensorSetupComplete({
|
||||||
|
beneficiaryId: id,
|
||||||
|
totalSensors: finalSensors.length,
|
||||||
|
successCount,
|
||||||
|
failureCount,
|
||||||
|
skippedCount,
|
||||||
|
totalDuration,
|
||||||
|
averageSensorSetupTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setPhase('results');
|
setPhase('results');
|
||||||
}
|
}
|
||||||
}, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor]);
|
}, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor, id, setupStartTime]);
|
||||||
|
|
||||||
// Start batch setup
|
// Start batch setup
|
||||||
const handleStartBatchSetup = async () => {
|
const handleStartBatchSetup = async () => {
|
||||||
@ -422,6 +468,9 @@ export default function SetupWiFiScreen() {
|
|||||||
// Continue with setup even if save fails
|
// Continue with setup even if save fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track setup start time
|
||||||
|
setSetupStartTime(Date.now());
|
||||||
|
|
||||||
// Initialize sensor states
|
// Initialize sensor states
|
||||||
const initialStates = selectedDevices.map(createSensorState);
|
const initialStates = selectedDevices.map(createSensorState);
|
||||||
setSensors(initialStates);
|
setSensors(initialStates);
|
||||||
@ -441,6 +490,18 @@ export default function SetupWiFiScreen() {
|
|||||||
const handleRetry = (deviceId: string) => {
|
const handleRetry = (deviceId: string) => {
|
||||||
const index = sensors.findIndex(s => s.deviceId === deviceId);
|
const index = sensors.findIndex(s => s.deviceId === deviceId);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
const sensor = sensors[index];
|
||||||
|
|
||||||
|
// Track retry
|
||||||
|
if (id && sensor.error) {
|
||||||
|
analytics.trackSensorSetupRetry({
|
||||||
|
beneficiaryId: id,
|
||||||
|
sensorId: deviceId,
|
||||||
|
sensorName: sensor.deviceName,
|
||||||
|
previousError: sensor.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setSensors(prev => prev.map(s =>
|
setSensors(prev => prev.map(s =>
|
||||||
s.deviceId === deviceId
|
s.deviceId === deviceId
|
||||||
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
||||||
@ -454,6 +515,18 @@ export default function SetupWiFiScreen() {
|
|||||||
|
|
||||||
// Skip failed sensor
|
// Skip failed sensor
|
||||||
const handleSkip = (deviceId: string) => {
|
const handleSkip = (deviceId: string) => {
|
||||||
|
const sensor = sensors.find(s => s.deviceId === deviceId);
|
||||||
|
|
||||||
|
// Track skip
|
||||||
|
if (id && sensor?.error) {
|
||||||
|
analytics.trackSensorSetupSkip({
|
||||||
|
beneficiaryId: id,
|
||||||
|
sensorId: deviceId,
|
||||||
|
sensorName: sensor.deviceName,
|
||||||
|
error: sensor.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setSensors(prev => prev.map(s =>
|
setSensors(prev => prev.map(s =>
|
||||||
s.deviceId === deviceId
|
s.deviceId === deviceId
|
||||||
? { ...s, status: 'skipped' as SensorSetupStatus }
|
? { ...s, status: 'skipped' as SensorSetupStatus }
|
||||||
@ -482,6 +555,16 @@ export default function SetupWiFiScreen() {
|
|||||||
text: 'Cancel',
|
text: 'Cancel',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
|
// Track cancellation
|
||||||
|
if (id) {
|
||||||
|
analytics.trackSensorSetupCancelled({
|
||||||
|
beneficiaryId: id,
|
||||||
|
currentSensorIndex: currentIndex,
|
||||||
|
totalSensors: sensors.length,
|
||||||
|
reason: 'user_cancelled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
shouldCancelRef.current = true;
|
shouldCancelRef.current = true;
|
||||||
setupInProgressRef.current = false;
|
setupInProgressRef.current = false;
|
||||||
// Disconnect all devices
|
// Disconnect all devices
|
||||||
|
|||||||
358
services/__tests__/analytics.test.ts
Normal file
358
services/__tests__/analytics.test.ts
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* Analytics Service Tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { analytics } from '../analytics';
|
||||||
|
|
||||||
|
describe('Analytics Service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear events before each test
|
||||||
|
analytics.clearEvents();
|
||||||
|
analytics.setEnabled(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic Event Tracking', () => {
|
||||||
|
it('should track a generic event', () => {
|
||||||
|
analytics.track('test_event', { foo: 'bar' });
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('test_event');
|
||||||
|
expect(events[0].properties).toEqual({ foo: 'bar' });
|
||||||
|
expect(events[0].timestamp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track events without properties', () => {
|
||||||
|
analytics.track('simple_event');
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('simple_event');
|
||||||
|
expect(events[0].properties).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not track events when disabled', () => {
|
||||||
|
analytics.setEnabled(false);
|
||||||
|
analytics.track('disabled_event');
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sensor Scan Events', () => {
|
||||||
|
it('should track sensor scan start', () => {
|
||||||
|
analytics.trackSensorScanStart('beneficiary_123');
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('sensor_scan_start');
|
||||||
|
expect(events[0].properties).toEqual({ beneficiaryId: 'beneficiary_123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track sensor scan completion', () => {
|
||||||
|
analytics.trackSensorScanComplete({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
scanDuration: 5000,
|
||||||
|
sensorsFound: 3,
|
||||||
|
selectedCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('sensor_scan_complete');
|
||||||
|
expect(events[0].properties).toMatchObject({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
scanDuration: 5000,
|
||||||
|
sensorsFound: 3,
|
||||||
|
selectedCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sensor Setup Events', () => {
|
||||||
|
it('should track setup start', () => {
|
||||||
|
analytics.trackSensorSetupStart({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
sensorCount: 2,
|
||||||
|
scanDuration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('sensor_setup_start');
|
||||||
|
expect(events[0].properties).toMatchObject({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
sensorCount: 2,
|
||||||
|
scanDuration: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track setup steps', () => {
|
||||||
|
analytics.trackSensorSetupStep({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
sensorIndex: 0,
|
||||||
|
totalSensors: 2,
|
||||||
|
step: 'connect',
|
||||||
|
stepStatus: 'started',
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.trackSensorSetupStep({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
sensorIndex: 0,
|
||||||
|
totalSensors: 2,
|
||||||
|
step: 'connect',
|
||||||
|
stepStatus: 'completed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[0].properties?.step).toBe('connect');
|
||||||
|
expect(events[0].properties?.stepStatus).toBe('started');
|
||||||
|
expect(events[1].properties?.stepStatus).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track setup step failures with error', () => {
|
||||||
|
analytics.trackSensorSetupStep({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
sensorIndex: 0,
|
||||||
|
totalSensors: 2,
|
||||||
|
step: 'wifi',
|
||||||
|
stepStatus: 'failed',
|
||||||
|
error: 'Connection timeout',
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events[0].properties?.error).toBe('Connection timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track setup completion', () => {
|
||||||
|
analytics.trackSensorSetupComplete({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
totalSensors: 3,
|
||||||
|
successCount: 2,
|
||||||
|
failureCount: 1,
|
||||||
|
skippedCount: 0,
|
||||||
|
totalDuration: 45000,
|
||||||
|
averageSensorSetupTime: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('sensor_setup_complete');
|
||||||
|
expect(events[0].properties).toMatchObject({
|
||||||
|
totalSensors: 3,
|
||||||
|
successCount: 2,
|
||||||
|
failureCount: 1,
|
||||||
|
skippedCount: 0,
|
||||||
|
totalDuration: 45000,
|
||||||
|
averageSensorSetupTime: 15000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track retry attempts', () => {
|
||||||
|
analytics.trackSensorSetupRetry({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
sensorId: 'sensor_1',
|
||||||
|
sensorName: 'WP_123',
|
||||||
|
previousError: 'Connection failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('sensor_setup_retry');
|
||||||
|
expect(events[0].properties).toMatchObject({
|
||||||
|
sensorId: 'sensor_1',
|
||||||
|
previousError: 'Connection failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track skip actions', () => {
|
||||||
|
analytics.trackSensorSetupSkip({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
sensorId: 'sensor_1',
|
||||||
|
sensorName: 'WP_123',
|
||||||
|
error: 'Connection failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('sensor_setup_skip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track cancellation', () => {
|
||||||
|
analytics.trackSensorSetupCancelled({
|
||||||
|
beneficiaryId: 'beneficiary_123',
|
||||||
|
currentSensorIndex: 1,
|
||||||
|
totalSensors: 3,
|
||||||
|
reason: 'user_cancelled',
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('sensor_setup_cancelled');
|
||||||
|
expect(events[0].properties?.reason).toBe('user_cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Event Filtering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
analytics.track('event_a', { value: 1 });
|
||||||
|
analytics.track('event_b', { value: 2 });
|
||||||
|
analytics.track('event_a', { value: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter events by name', () => {
|
||||||
|
const eventAEvents = analytics.getEventsByName('event_a');
|
||||||
|
expect(eventAEvents).toHaveLength(2);
|
||||||
|
expect(eventAEvents[0].properties?.value).toBe(1);
|
||||||
|
expect(eventAEvents[1].properties?.value).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for non-existent event name', () => {
|
||||||
|
const events = analytics.getEventsByName('non_existent');
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter events by time range', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const events = analytics.getEventsInRange(now - 1000, now + 1000);
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for out-of-range queries', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const events = analytics.getEventsInRange(now - 10000, now - 5000);
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Event Storage Management', () => {
|
||||||
|
it('should clear all events', () => {
|
||||||
|
analytics.track('event_1');
|
||||||
|
analytics.track('event_2');
|
||||||
|
expect(analytics.getEvents()).toHaveLength(2);
|
||||||
|
|
||||||
|
analytics.clearEvents();
|
||||||
|
expect(analytics.getEvents()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim events when exceeding max storage', () => {
|
||||||
|
// Create 1000 events (the max)
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
analytics.track(`event_${i}`);
|
||||||
|
}
|
||||||
|
expect(analytics.getEvents()).toHaveLength(1000);
|
||||||
|
|
||||||
|
// Add one more - should trim oldest
|
||||||
|
analytics.track('event_1001');
|
||||||
|
const events = analytics.getEvents();
|
||||||
|
expect(events).toHaveLength(1000);
|
||||||
|
expect(events[0].name).toBe('event_1'); // First event was removed
|
||||||
|
expect(events[events.length - 1].name).toBe('event_1001'); // Last event is newest
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sensor Setup Metrics', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Simulate successful setup
|
||||||
|
analytics.trackSensorSetupComplete({
|
||||||
|
beneficiaryId: 'beneficiary_1',
|
||||||
|
totalSensors: 3,
|
||||||
|
successCount: 3,
|
||||||
|
failureCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
totalDuration: 30000,
|
||||||
|
averageSensorSetupTime: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate partial failure
|
||||||
|
analytics.trackSensorSetupComplete({
|
||||||
|
beneficiaryId: 'beneficiary_2',
|
||||||
|
totalSensors: 2,
|
||||||
|
successCount: 1,
|
||||||
|
failureCount: 1,
|
||||||
|
skippedCount: 0,
|
||||||
|
totalDuration: 25000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add some step failures
|
||||||
|
analytics.trackSensorSetupStep({
|
||||||
|
beneficiaryId: 'beneficiary_2',
|
||||||
|
sensorIndex: 1,
|
||||||
|
totalSensors: 2,
|
||||||
|
step: 'wifi',
|
||||||
|
stepStatus: 'failed',
|
||||||
|
error: 'Connection timeout',
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.trackSensorSetupStep({
|
||||||
|
beneficiaryId: 'beneficiary_2',
|
||||||
|
sensorIndex: 1,
|
||||||
|
totalSensors: 2,
|
||||||
|
step: 'wifi',
|
||||||
|
stepStatus: 'failed',
|
||||||
|
error: 'Connection timeout',
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.trackSensorSetupStep({
|
||||||
|
beneficiaryId: 'beneficiary_1',
|
||||||
|
sensorIndex: 0,
|
||||||
|
totalSensors: 3,
|
||||||
|
step: 'attach',
|
||||||
|
stepStatus: 'failed',
|
||||||
|
error: 'API error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate total setups', () => {
|
||||||
|
const metrics = analytics.getSensorSetupMetrics();
|
||||||
|
expect(metrics.totalSetups).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate success rate', () => {
|
||||||
|
const metrics = analytics.getSensorSetupMetrics();
|
||||||
|
// 4 successful out of 5 total sensors = 80%
|
||||||
|
expect(metrics.successRate).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate average duration', () => {
|
||||||
|
const metrics = analytics.getSensorSetupMetrics();
|
||||||
|
// (30000 + 25000) / 2 = 27500
|
||||||
|
expect(metrics.averageDuration).toBe(27500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate average sensors per setup', () => {
|
||||||
|
const metrics = analytics.getSensorSetupMetrics();
|
||||||
|
// (3 + 2) / 2 = 2.5
|
||||||
|
expect(metrics.averageSensorsPerSetup).toBe(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify common errors', () => {
|
||||||
|
const metrics = analytics.getSensorSetupMetrics();
|
||||||
|
expect(metrics.commonErrors).toHaveLength(2);
|
||||||
|
expect(metrics.commonErrors[0].error).toBe('Connection timeout');
|
||||||
|
expect(metrics.commonErrors[0].count).toBe(2);
|
||||||
|
expect(metrics.commonErrors[1].error).toBe('API error');
|
||||||
|
expect(metrics.commonErrors[1].count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty metrics gracefully', () => {
|
||||||
|
analytics.clearEvents();
|
||||||
|
const metrics = analytics.getSensorSetupMetrics();
|
||||||
|
expect(metrics).toEqual({
|
||||||
|
totalSetups: 0,
|
||||||
|
successRate: 0,
|
||||||
|
averageDuration: 0,
|
||||||
|
averageSensorsPerSetup: 0,
|
||||||
|
commonErrors: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter metrics by time range', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const metrics = analytics.getSensorSetupMetrics(1000); // Last second
|
||||||
|
expect(metrics.totalSetups).toBe(2); // All events are recent
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
260
services/analytics.ts
Normal file
260
services/analytics.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Analytics Service
|
||||||
|
*
|
||||||
|
* Simple, lightweight analytics tracking for sensor setup and other app events.
|
||||||
|
* Logs events locally for now - can be extended to send to analytics platforms
|
||||||
|
* (Mixpanel, Amplitude, etc.) in the future.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AnalyticsEvent {
|
||||||
|
name: string;
|
||||||
|
timestamp: number;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorSetupStartProperties {
|
||||||
|
beneficiaryId: string;
|
||||||
|
sensorCount: number;
|
||||||
|
scanDuration?: number; // milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorSetupProgressProperties {
|
||||||
|
beneficiaryId: string;
|
||||||
|
sensorIndex: number;
|
||||||
|
totalSensors: number;
|
||||||
|
step: 'connect' | 'unlock' | 'wifi' | 'attach' | 'reboot';
|
||||||
|
stepStatus: 'started' | 'completed' | 'failed';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorSetupCompleteProperties {
|
||||||
|
beneficiaryId: string;
|
||||||
|
totalSensors: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
totalDuration: number; // milliseconds
|
||||||
|
averageSensorSetupTime?: number; // milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorScanProperties {
|
||||||
|
beneficiaryId: string;
|
||||||
|
scanDuration: number; // milliseconds
|
||||||
|
sensorsFound: number;
|
||||||
|
selectedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnalyticsService {
|
||||||
|
private events: AnalyticsEvent[] = [];
|
||||||
|
private readonly maxStoredEvents = 1000;
|
||||||
|
private isEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a generic event
|
||||||
|
*/
|
||||||
|
track(eventName: string, properties?: Record<string, any>): void {
|
||||||
|
if (!this.isEnabled) return;
|
||||||
|
|
||||||
|
const event: AnalyticsEvent = {
|
||||||
|
name: eventName,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
properties,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.events.push(event);
|
||||||
|
this.trimEvents();
|
||||||
|
|
||||||
|
// Log to console in development
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('[Analytics]', eventName, properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Sensor Setup Events ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track when user starts scanning for sensors
|
||||||
|
*/
|
||||||
|
trackSensorScanStart(beneficiaryId: string): void {
|
||||||
|
this.track('sensor_scan_start', { beneficiaryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track scan results
|
||||||
|
*/
|
||||||
|
trackSensorScanComplete(properties: SensorScanProperties): void {
|
||||||
|
this.track('sensor_scan_complete', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track when batch setup begins
|
||||||
|
*/
|
||||||
|
trackSensorSetupStart(properties: SensorSetupStartProperties): void {
|
||||||
|
this.track('sensor_setup_start', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track individual step progress
|
||||||
|
*/
|
||||||
|
trackSensorSetupStep(properties: SensorSetupProgressProperties): void {
|
||||||
|
this.track('sensor_setup_step', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track setup completion
|
||||||
|
*/
|
||||||
|
trackSensorSetupComplete(properties: SensorSetupCompleteProperties): void {
|
||||||
|
this.track('sensor_setup_complete', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track when user cancels setup
|
||||||
|
*/
|
||||||
|
trackSensorSetupCancelled(properties: {
|
||||||
|
beneficiaryId: string;
|
||||||
|
currentSensorIndex: number;
|
||||||
|
totalSensors: number;
|
||||||
|
reason?: string;
|
||||||
|
}): void {
|
||||||
|
this.track('sensor_setup_cancelled', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track retry attempt
|
||||||
|
*/
|
||||||
|
trackSensorSetupRetry(properties: {
|
||||||
|
beneficiaryId: string;
|
||||||
|
sensorId: string;
|
||||||
|
sensorName: string;
|
||||||
|
previousError: string;
|
||||||
|
}): void {
|
||||||
|
this.track('sensor_setup_retry', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track skip action
|
||||||
|
*/
|
||||||
|
trackSensorSetupSkip(properties: {
|
||||||
|
beneficiaryId: string;
|
||||||
|
sensorId: string;
|
||||||
|
sensorName: string;
|
||||||
|
error: string;
|
||||||
|
}): void {
|
||||||
|
this.track('sensor_setup_skip', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Utility Methods ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all stored events
|
||||||
|
*/
|
||||||
|
getEvents(): AnalyticsEvent[] {
|
||||||
|
return [...this.events];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get events filtered by name
|
||||||
|
*/
|
||||||
|
getEventsByName(eventName: string): AnalyticsEvent[] {
|
||||||
|
return this.events.filter(e => e.name === eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get events within time range
|
||||||
|
*/
|
||||||
|
getEventsInRange(startTime: number, endTime: number): AnalyticsEvent[] {
|
||||||
|
return this.events.filter(
|
||||||
|
e => e.timestamp >= startTime && e.timestamp <= endTime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all stored events
|
||||||
|
*/
|
||||||
|
clearEvents(): void {
|
||||||
|
this.events = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable tracking
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this.isEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim events array to max size (FIFO)
|
||||||
|
*/
|
||||||
|
private trimEvents(): void {
|
||||||
|
if (this.events.length > this.maxStoredEvents) {
|
||||||
|
this.events = this.events.slice(-this.maxStoredEvents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sensor setup metrics summary
|
||||||
|
*/
|
||||||
|
getSensorSetupMetrics(timeRangeMs?: number): {
|
||||||
|
totalSetups: number;
|
||||||
|
successRate: number;
|
||||||
|
averageDuration: number;
|
||||||
|
averageSensorsPerSetup: number;
|
||||||
|
commonErrors: { error: string; count: number }[];
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
const startTime = timeRangeMs ? now - timeRangeMs : 0;
|
||||||
|
|
||||||
|
const setupEvents = this.getEventsInRange(startTime, now).filter(
|
||||||
|
e => e.name === 'sensor_setup_complete'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (setupEvents.length === 0) {
|
||||||
|
return {
|
||||||
|
totalSetups: 0,
|
||||||
|
successRate: 0,
|
||||||
|
averageDuration: 0,
|
||||||
|
averageSensorsPerSetup: 0,
|
||||||
|
commonErrors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSetups = setupEvents.length;
|
||||||
|
let totalSuccess = 0;
|
||||||
|
let totalDuration = 0;
|
||||||
|
let totalSensors = 0;
|
||||||
|
const errorCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
setupEvents.forEach(event => {
|
||||||
|
const props = event.properties as SensorSetupCompleteProperties;
|
||||||
|
totalSuccess += props.successCount;
|
||||||
|
totalDuration += props.totalDuration;
|
||||||
|
totalSensors += props.totalSensors;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect errors from step events
|
||||||
|
const stepEvents = this.getEventsInRange(startTime, now).filter(
|
||||||
|
e => e.name === 'sensor_setup_step' && e.properties?.stepStatus === 'failed'
|
||||||
|
);
|
||||||
|
|
||||||
|
stepEvents.forEach(event => {
|
||||||
|
const error = event.properties?.error || 'Unknown error';
|
||||||
|
errorCounts[error] = (errorCounts[error] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonErrors = Object.entries(errorCounts)
|
||||||
|
.map(([error, count]) => ({ error, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSetups,
|
||||||
|
successRate: totalSensors > 0 ? (totalSuccess / totalSensors) * 100 : 0,
|
||||||
|
averageDuration: totalSetups > 0 ? totalDuration / totalSetups : 0,
|
||||||
|
averageSensorsPerSetup: totalSetups > 0 ? totalSensors / totalSetups : 0,
|
||||||
|
commonErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const analytics = new AnalyticsService();
|
||||||
Loading…
x
Reference in New Issue
Block a user