Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions src/components/mobile/AnalyticsProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React, { createContext, ReactNode, useContext, useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';

import { crashReportingService } from '../../services/crashReporting';
import { mobileAnalyticsService } from '../../services/mobileAnalytics';
import logger from '../../utils/logger';
import { NoiseType, PrivacyConfig } from '../../utils/differentialPrivacy';
import { appLogger as logger } from '../../utils/logger';
import { ErrorBoundary } from '../common/ErrorBoundary';

// ─── Analytics Context ────────────────────────────────────────────────────────
Expand All @@ -17,35 +19,41 @@ const AnalyticsContext = createContext<AnalyticsContextValue | undefined>(undefi

interface AnalyticsProviderProps {
children: ReactNode;
/** Override the default differential privacy configuration. */
privacyConfig?: Partial<PrivacyConfig>;
/** Noise mechanism to use ('laplace' | 'gaussian'). Defaults to 'laplace'. */
noiseType?: NoiseType;
}

export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({
children,
privacyConfig,
noiseType,
}) => {
const appState = useRef(AppState.currentState);

useEffect(() => {
// 1. Initialize services on mount
logger.info('📱 [AnalyticsProvider] Initializing tracking and crash reporting...');
mobileAnalyticsService.init();
logger.infoSync('📱 [AnalyticsProvider] Initializing tracking and crash reporting...');

if (privacyConfig || noiseType) {
mobileAnalyticsService.setPrivacyConfig(privacyConfig ?? {}, noiseType);
}

mobileAnalyticsService.init(privacyConfig);
crashReportingService.init();

// 2. Manage session lifecycle (Foreground vs. Background)
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
// App has come to the foreground
mobileAnalyticsService.startSession();
} else if (appState.current === 'active' && nextAppState.match(/inactive|background/)) {
// App has gone to the background
mobileAnalyticsService.endSession();
}
appState.current = nextAppState;
};

const subscription = AppState.addEventListener('change', handleAppStateChange);

return () => {
subscription.remove();
};
}, []);
return () => subscription.remove();
}, []); // eslint-disable-line react-hooks/exhaustive-deps

return (
<AnalyticsContext.Provider value={{ service: mobileAnalyticsService }}>
Expand Down
78 changes: 46 additions & 32 deletions src/services/mobileAnalytics.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { appLogger } from '../utils/logger';
import { AnalyticsEvent, EventProperties } from '../utils/trackingEvents';

// ─── Privacy defaults ─────────────────────────────────────────────────────────

const DEFAULT_PRIVACY_CONFIG: PrivacyConfig = {
epsilon: 1.0,
delta: 1e-5,
sensitivity: 1,
};

const DEFAULT_NOISE_TYPE: NoiseType = 'laplace';

// ─── Service ──────────────────────────────────────────────────────────────────

/**
* MobileAnalyticsService provides a centralized API for tracking user behavior
* and application performance. It abstracts the underlying analytics provider
* (e.g., Firebase, Segment, Mixpanel) to allow for easy swaps in the future.
*
* All numeric and boolean event properties are sanitized with differential
* privacy noise before being forwarded to the analytics backend.
*/
class MobileAnalyticsService {
private isInitialized: boolean = false;
Expand All @@ -28,11 +43,15 @@ class MobileAnalyticsService {

/**
* Initialize the analytics SDK.
* This is where you would call Firebase.initializeApp() or similar.
* @param privacyConfig Optional override for the differential privacy config.
*/
public async init(): Promise<void> {
public async init(privacyConfig?: Partial<PrivacyConfig>): Promise<void> {
if (this.isInitialized) return;

if (privacyConfig) {
this.privacyConfig = { ...DEFAULT_PRIVACY_CONFIG, ...privacyConfig };
}

try {
// In a real implementation:
// await analytics().setAnalyticsCollectionEnabled(true);
Expand All @@ -46,8 +65,18 @@ class MobileAnalyticsService {
}

/**
* Start a new tracking session.
* Update the differential privacy configuration at runtime.
* Resets the privacy budget when the config changes.
*/
public setPrivacyConfig(config: Partial<PrivacyConfig>, noiseType?: NoiseType): void {
this.privacyConfig = { ...this.privacyConfig, ...config };
if (noiseType) this.noiseType = noiseType;
this.budgetManager.reset();
logger.infoSync('MobileAnalytics: Privacy config updated');
}

// ─── Session management ──────────────────────────────────────────────────────

public startSession(): void {
const timestamp = Date.now();
this.currentSessionId = `sess_${timestamp}_${Math.random().toString(36).substr(2, 9)}`;
Expand All @@ -60,9 +89,6 @@ class MobileAnalyticsService {
appLogger.debug(`MobileAnalytics: Session started [${this.currentSessionId}]`);
}

/**
* End the current tracking session.
*/
public endSession(): void {
if (!this.currentSessionId) return;

Expand All @@ -75,10 +101,13 @@ class MobileAnalyticsService {
appLogger.debug('MobileAnalytics: Session ended');
}

// ─── Core tracking ───────────────────────────────────────────────────────────

/**
* Track a custom event.
* @param event The event name from the AnalyticsEvent enum.
* @param properties Optional metadata to attach to the event.
*
* Numeric and boolean properties are sanitized with differential privacy
* noise. If the privacy budget is exhausted the event is suppressed.
*/
public trackEvent(event: AnalyticsEvent, properties?: EventProperties): void {
// Implement sampling for non-critical events (10% rate)
Expand All @@ -90,7 +119,7 @@ class MobileAnalyticsService {
}

const payload = {
...properties,
...sanitized,
screen: this.currentScreen,
sessionId: this.currentSessionId,
platform: 'mobile',
Expand All @@ -100,20 +129,16 @@ class MobileAnalyticsService {
// Log to console/Metro for development visibility
appLogger.info(`📊 [Analytics] Event: ${event}`, JSON.stringify(payload, null, 2));

// Here you would call the real SDK:
// Real SDK call:
// analytics().logEvent(event, payload);
void payload;
}

/**
* Track a screen view transition.
* @param screenName The name of the screen being viewed.
* @param properties Optional metadata about the screen.
*/
public trackScreen(screenName: string, properties?: EventProperties): void {
const previousScreen = this.currentScreen;
this.currentScreen = screenName;

const payload = {
const payload: EventProperties = {
...properties,
previous_screen: previousScreen,
timestamp: new Date().toISOString(),
Expand All @@ -134,13 +159,8 @@ class MobileAnalyticsService {
});
}

/**
* Log performance metrics.
* @param name The metric name.
* @param value The value (usually in milliseconds).
*/
public trackPerformance(name: string, value: number, properties?: EventProperties): void {
const payload = {
const payload: EventProperties = {
metric_name: name,
metric_value: value,
...properties,
Expand All @@ -151,28 +171,22 @@ class MobileAnalyticsService {
this.trackEvent(AnalyticsEvent.PERFORMANCE_METRIC, payload);
}

/**
* Set user identity for tracking across sessions.
* @param userId The unique user ID from the backend.
* @param userProperties Key-value pairs of user traits.
*/
public async identifyUser(userId: string, userProperties?: EventProperties): Promise<void> {
appLogger.info(`👤 [Analytics] Identify User: ${userId}`, userProperties);

// Real SDK implementation:
// await analytics().setUserId(userId);
// if (userProperties) await analytics().setUserProperties(userProperties);
}

/**
* Clear user identity (on logout).
*/
public async resetUser(): Promise<void> {
appLogger.info('👤 [Analytics] Reset User identity');
// await analytics().setUserId(null);
}

public getBudgetStatus() {
return this.budgetManager.getStatus();
}
}

// Export a singleton instance
export const mobileAnalyticsService = new MobileAnalyticsService();
export default mobileAnalyticsService;
132 changes: 132 additions & 0 deletions src/utils/differentialPrivacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Differential Privacy utilities for analytics data.
*
* Implements the Laplace and Gaussian mechanisms to add calibrated noise
* to numeric values before they are sent to analytics backends, providing
* (ε, δ)-differential privacy guarantees.
*/

// ─── Types ────────────────────────────────────────────────────────────────────

export interface PrivacyConfig {
/** Privacy budget (epsilon). Smaller = more private. Typical range: 0.1–10. */
epsilon: number;
/** Delta for Gaussian mechanism (probability of privacy breach). Typical: 1e-5. */
delta?: number;
/** Sensitivity: max change a single user can cause in the output. */
sensitivity?: number;
}

export type NoiseType = 'laplace' | 'gaussian';

// ─── Core Noise Mechanisms ────────────────────────────────────────────────────

/**
* Sample from a Laplace distribution with mean 0 and scale b.
* Uses the inverse CDF method: X = -b * sign(U) * ln(1 - 2|U|)
*/
function sampleLaplace(scale: number): number {
// Uniform in (-0.5, 0.5) to avoid log(0)
const u = Math.random() - 0.5;
return -scale * Math.sign(u) * Math.log(1 - 2 * Math.abs(u));
}

/**
* Sample from a standard normal distribution using the Box-Muller transform.
*/
function sampleGaussian(): number {
const u1 = Math.random();
const u2 = Math.random();
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// ─── Public API ───────────────────────────────────────────────────────────────

/**
* Add Laplace noise to a numeric value.
* Provides ε-differential privacy with sensitivity / epsilon as the scale.
*
* @param value The true numeric value.
* @param epsilon Privacy budget (ε > 0).
* @param sensitivity L1 sensitivity of the query (default 1).
* @returns The noisy value.
*/
export function addLaplaceNoise(value: number, epsilon: number, sensitivity = 1): number {
if (epsilon <= 0) throw new Error('epsilon must be positive');
const scale = sensitivity / epsilon;
return value + sampleLaplace(scale);
}

/**
* Add Gaussian noise to a numeric value.
* Provides (ε, δ)-differential privacy.
*
* @param value The true numeric value.
* @param epsilon Privacy budget (ε > 0).
* @param delta Failure probability (0 < δ < 1, default 1e-5).
* @param sensitivity L2 sensitivity of the query (default 1).
* @returns The noisy value.
*/
export function addGaussianNoise(
value: number,
epsilon: number,
delta = 1e-5,
sensitivity = 1
): number {
if (epsilon <= 0) throw new Error('epsilon must be positive');
if (delta <= 0 || delta >= 1) throw new Error('delta must be in (0, 1)');
// σ = sensitivity * sqrt(2 * ln(1.25/δ)) / ε
const sigma = (sensitivity * Math.sqrt(2 * Math.log(1.25 / delta))) / epsilon;
return value + sampleGaussian() * sigma;
}

/**
* Apply differential privacy noise to a numeric value using the specified mechanism.
*/
export function applyNoise(
value: number,
config: PrivacyConfig,
type: NoiseType = 'laplace'
): number {
const { epsilon, delta = 1e-5, sensitivity = 1 } = config;
return type === 'gaussian'
? addGaussianNoise(value, epsilon, delta, sensitivity)
: addLaplaceNoise(value, epsilon, sensitivity);
}

/**
* Randomized response for boolean values.
* With probability p = e^ε / (1 + e^ε), report the true value; otherwise flip it.
* Provides ε-local differential privacy.
*/
export function randomizedResponse(value: boolean, epsilon: number): boolean {
if (epsilon <= 0) throw new Error('epsilon must be positive');
const p = Math.exp(epsilon) / (1 + Math.exp(epsilon));
return Math.random() < p ? value : !value;
}

/**
* Sanitize an EventProperties object by adding noise to numeric fields
* and applying randomized response to boolean fields.
*
* String fields are passed through unchanged (they are not numeric queries).
*/
export function sanitizeEventProperties(
properties: Record<string, string | number | boolean | null | undefined>,
config: PrivacyConfig,
noiseType: NoiseType = 'laplace'
): Record<string, string | number | boolean | null | undefined> {
const result: Record<string, string | number | boolean | null | undefined> = {};

for (const [key, value] of Object.entries(properties)) {
if (typeof value === 'number') {
result[key] = applyNoise(value, config, noiseType);
} else if (typeof value === 'boolean') {
result[key] = randomizedResponse(value, config.epsilon);
} else {
result[key] = value;
}
}

return result;
}
Loading
Loading