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:
Sergei 2026-01-31 16:20:48 -08:00
parent d289dd79a1
commit 1e9ebd14ff
4 changed files with 731 additions and 3 deletions

View File

@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useBLE } from '@/contexts/BLEContext';
import { analytics } from '@/services/analytics';
import {
AppColors,
BorderRadius,
@ -37,13 +38,24 @@ export default function AddSensorScreen() {
} = useBLE();
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
const [scanStartTime, setScanStartTime] = useState<number | null>(null);
// Select all devices by default when scan completes
useEffect(() => {
if (foundDevices.length > 0 && !isScanning) {
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
useFocusEffect(
@ -85,6 +97,12 @@ export default function AddSensorScreen() {
// Clear any previous errors
clearError();
// Track scan start
if (id) {
analytics.trackSensorScanStart(id);
setScanStartTime(Date.now());
}
// Perform scan
await scanDevices();
} catch (error: any) {
@ -109,6 +127,15 @@ export default function AddSensorScreen() {
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
router.push({
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,

View File

@ -17,6 +17,7 @@ import { router, useLocalSearchParams } from 'expo-router';
import * as Device from 'expo-device';
import { useBLE } from '@/contexts/BLEContext';
import { api } from '@/services/api';
import { analytics } from '@/services/analytics';
import * as wifiPasswordStore from '@/services/wifiPasswordStore';
import type { WiFiNetwork } from '@/services/ble';
import type {
@ -112,6 +113,7 @@ export default function SetupWiFiScreen() {
const [isPaused, setIsPaused] = useState(false);
const setupInProgressRef = useRef(false);
const shouldCancelRef = useRef(false);
const [setupStartTime, setSetupStartTime] = useState<number | null>(null);
// Saved WiFi passwords map (SSID -> password)
// 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
const updateSensorStatus = useCallback((
@ -394,9 +416,33 @@ export default function SetupWiFiScreen() {
);
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');
}
}, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor]);
}, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor, id, setupStartTime]);
// Start batch setup
const handleStartBatchSetup = async () => {
@ -422,6 +468,9 @@ export default function SetupWiFiScreen() {
// Continue with setup even if save fails
}
// Track setup start time
setSetupStartTime(Date.now());
// Initialize sensor states
const initialStates = selectedDevices.map(createSensorState);
setSensors(initialStates);
@ -441,6 +490,18 @@ export default function SetupWiFiScreen() {
const handleRetry = (deviceId: string) => {
const index = sensors.findIndex(s => s.deviceId === deviceId);
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 =>
s.deviceId === deviceId
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
@ -454,6 +515,18 @@ export default function SetupWiFiScreen() {
// Skip failed sensor
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 =>
s.deviceId === deviceId
? { ...s, status: 'skipped' as SensorSetupStatus }
@ -482,6 +555,16 @@ export default function SetupWiFiScreen() {
text: 'Cancel',
style: 'destructive',
onPress: () => {
// Track cancellation
if (id) {
analytics.trackSensorSetupCancelled({
beneficiaryId: id,
currentSensorIndex: currentIndex,
totalSensors: sensors.length,
reason: 'user_cancelled',
});
}
shouldCancelRef.current = true;
setupInProgressRef.current = false;
// Disconnect all devices

View 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
View 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();