From 3fdc7fd66a41fc3f83209268649594bc0fcf7579 Mon Sep 17 00:00:00 2001 From: softmind <198604584+sheyman546@users.noreply.github.com> Date: Thu, 28 May 2026 06:21:34 +0000 Subject: [PATCH] feat: implement analytics privacy preservation with differential privacy (#407) - Add differentialPrivacy utility with Laplace and Gaussian noise mechanisms, randomized response for booleans, and sanitizeEventProperties helper - Add PrivacyBudgetManager to track and enforce cumulative epsilon budget per session; events are suppressed when the budget is exhausted - Update MobileAnalyticsService to sanitize numeric/boolean event properties with DP noise before forwarding to the analytics backend - Update AnalyticsProvider to accept privacyConfig and noiseType props - Add 23 unit tests covering all mechanisms and edge cases (23/23 passing) --- src/components/mobile/AnalyticsProvider.tsx | 34 ++-- src/services/mobileAnalytics.ts | 133 ++++++++------- src/utils/differentialPrivacy.ts | 132 +++++++++++++++ src/utils/privacyBudgetManager.ts | 80 +++++++++ tests/utils/differentialPrivacy.test.ts | 174 ++++++++++++++++++++ 5 files changed, 485 insertions(+), 68 deletions(-) create mode 100644 src/utils/differentialPrivacy.ts create mode 100644 src/utils/privacyBudgetManager.ts create mode 100644 tests/utils/differentialPrivacy.test.ts diff --git a/src/components/mobile/AnalyticsProvider.tsx b/src/components/mobile/AnalyticsProvider.tsx index 5db8a1cc..5bd46d09 100644 --- a/src/components/mobile/AnalyticsProvider.tsx +++ b/src/components/mobile/AnalyticsProvider.tsx @@ -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 ──────────────────────────────────────────────────────── @@ -17,35 +19,41 @@ const AnalyticsContext = createContext(undefi interface AnalyticsProviderProps { children: ReactNode; + /** Override the default differential privacy configuration. */ + privacyConfig?: Partial; + /** Noise mechanism to use ('laplace' | 'gaussian'). Defaults to 'laplace'. */ + noiseType?: NoiseType; } -export const AnalyticsProvider: React.FC = ({ children }) => { +export const AnalyticsProvider: React.FC = ({ + 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 ( diff --git a/src/services/mobileAnalytics.ts b/src/services/mobileAnalytics.ts index 4223b47b..904720b8 100644 --- a/src/services/mobileAnalytics.ts +++ b/src/services/mobileAnalytics.ts @@ -1,53 +1,88 @@ -import logger from '../utils/logger'; +import { NoiseType, PrivacyConfig, sanitizeEventProperties } from '../utils/differentialPrivacy'; +import { appLogger as logger } from '../utils/logger'; +import { PrivacyBudgetManager } from '../utils/privacyBudgetManager'; 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; private currentSessionId: string | null = null; private currentScreen: string | null = null; + private privacyConfig: PrivacyConfig = DEFAULT_PRIVACY_CONFIG; + private noiseType: NoiseType = DEFAULT_NOISE_TYPE; + private budgetManager: PrivacyBudgetManager = new PrivacyBudgetManager(10); + + // ─── Initialisation ───────────────────────────────────────────────────────── + /** * 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 { + public async init(privacyConfig?: Partial): Promise { if (this.isInitialized) return; + if (privacyConfig) { + this.privacyConfig = { ...DEFAULT_PRIVACY_CONFIG, ...privacyConfig }; + } + try { // In a real implementation: // await analytics().setAnalyticsCollectionEnabled(true); - + this.isInitialized = true; this.startSession(); - logger.info('MobileAnalytics: Initialized successfully'); + logger.infoSync('MobileAnalytics: Initialized successfully'); } catch (error) { - logger.error('MobileAnalytics: Failed to initialize', error); + logger.errorSync('MobileAnalytics: Failed to initialize', error as Error); } } /** - * Start a new tracking session. + * Update the differential privacy configuration at runtime. + * Resets the privacy budget when the config changes. */ + public setPrivacyConfig(config: Partial, 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)}`; - + this.budgetManager.reset(); + this.trackEvent(AnalyticsEvent.SESSION_START, { sessionId: this.currentSessionId, timestamp, }); - - logger.debug(`MobileAnalytics: Session started [${this.currentSessionId}]`); + + logger.infoSync(`MobileAnalytics: Session started [${this.currentSessionId}]`); } - /** - * End the current tracking session. - */ public endSession(): void { if (!this.currentSessionId) return; @@ -57,99 +92,87 @@ class MobileAnalyticsService { }); this.currentSessionId = null; - logger.debug('MobileAnalytics: Session ended'); + logger.infoSync('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 { + if (!this.budgetManager.consume(this.privacyConfig.epsilon)) { + logger.infoSync(`MobileAnalytics: event suppressed (budget exhausted) [${event}]`); + return; + } + + const sanitized = properties + ? sanitizeEventProperties(properties, this.privacyConfig, this.noiseType) + : {}; + const payload = { - ...properties, + ...sanitized, screen: this.currentScreen, sessionId: this.currentSessionId, platform: 'mobile', timestamp: new Date().toISOString(), }; - // Log to console/Metro for development visibility - logger.info(`📊 [Analytics] Event: ${event}`, JSON.stringify(payload, null, 2)); + logger.infoSync(`📊 [Analytics] Event: ${event}`); - // 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(), }; - logger.info(`📱 [Analytics] Screen View: ${screenName}`, payload); + logger.infoSync(`📱 [Analytics] Screen View: ${screenName}`); - // Real SDK implementation: - // analytics().logScreenView({ - // screen_name: screenName, - // screen_class: screenName, - // }); - - // Also track as a generic event for providers that don't have logScreenView this.trackEvent(AnalyticsEvent.SCREEN_VIEW, { screen: screenName, ...payload, }); } - /** - * 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, }; - logger.info(`⏱️ [Analytics] Performance: ${name} = ${value}ms`, payload); + logger.infoSync(`⏱️ [Analytics] Performance: ${name} = ${value}ms`); 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 { - logger.info(`👤 [Analytics] Identify User: ${userId}`, userProperties); - - // Real SDK implementation: + logger.infoSync(`👤 [Analytics] Identify User: ${userId}`); + void userProperties; // await analytics().setUserId(userId); - // if (userProperties) await analytics().setUserProperties(userProperties); } - /** - * Clear user identity (on logout). - */ public async resetUser(): Promise { - logger.info('👤 [Analytics] Reset User identity'); + logger.infoSync('👤 [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; diff --git a/src/utils/differentialPrivacy.ts b/src/utils/differentialPrivacy.ts new file mode 100644 index 00000000..1175c27e --- /dev/null +++ b/src/utils/differentialPrivacy.ts @@ -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, + config: PrivacyConfig, + noiseType: NoiseType = 'laplace' +): Record { + const result: Record = {}; + + 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; +} diff --git a/src/utils/privacyBudgetManager.ts b/src/utils/privacyBudgetManager.ts new file mode 100644 index 00000000..95d61b74 --- /dev/null +++ b/src/utils/privacyBudgetManager.ts @@ -0,0 +1,80 @@ +/** + * PrivacyBudgetManager tracks cumulative epsilon consumption and enforces + * a total privacy budget across analytics queries. + * + * Under basic composition, the total privacy cost of k queries with budgets + * ε₁…εₖ is ε₁ + … + εₖ. This manager refuses queries once the budget is + * exhausted, preventing unbounded privacy leakage. + */ + +import { appLogger as logger } from './logger'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface BudgetStatus { + totalBudget: number; + consumed: number; + remaining: number; + exhausted: boolean; +} + +// ─── Manager ────────────────────────────────────────────────────────────────── + +export class PrivacyBudgetManager { + private totalBudget: number; + private consumed: number = 0; + + /** + * @param totalBudget Maximum cumulative epsilon allowed (e.g. 10.0). + */ + constructor(totalBudget: number) { + if (totalBudget <= 0) throw new Error('totalBudget must be positive'); + this.totalBudget = totalBudget; + } + + /** + * Attempt to consume `epsilon` from the budget. + * + * @returns `true` if the budget was available and has been consumed; + * `false` if the budget is exhausted (query should be suppressed). + */ + consume(epsilon: number): boolean { + if (epsilon <= 0) throw new Error('epsilon must be positive'); + + if (this.consumed + epsilon > this.totalBudget) { + logger.warn( + `PrivacyBudgetManager: budget exhausted. ` + + `Requested ${epsilon}, consumed ${this.consumed}/${this.totalBudget}` + ); + return false; + } + + this.consumed += epsilon; + logger.debug( + `PrivacyBudgetManager: consumed ${epsilon}. Total: ${this.consumed}/${this.totalBudget}` + ); + return true; + } + + /** Returns the current budget status. */ + getStatus(): BudgetStatus { + return { + totalBudget: this.totalBudget, + consumed: this.consumed, + remaining: Math.max(0, this.totalBudget - this.consumed), + exhausted: this.consumed >= this.totalBudget, + }; + } + + /** Reset the consumed budget (e.g. at the start of a new session). */ + reset(): void { + this.consumed = 0; + logger.info('PrivacyBudgetManager: budget reset'); + } +} + +// ─── Singleton ──────────────────────────────────────────────────────────────── + +/** Default session budget: ε = 10 (generous for analytics, tight for sensitive data). */ +export const privacyBudgetManager = new PrivacyBudgetManager(10); +export default privacyBudgetManager; diff --git a/tests/utils/differentialPrivacy.test.ts b/tests/utils/differentialPrivacy.test.ts new file mode 100644 index 00000000..236eda49 --- /dev/null +++ b/tests/utils/differentialPrivacy.test.ts @@ -0,0 +1,174 @@ +import { + addGaussianNoise, + addLaplaceNoise, + applyNoise, + randomizedResponse, + sanitizeEventProperties, +} from '../../src/utils/differentialPrivacy'; +import { PrivacyBudgetManager } from '../../src/utils/privacyBudgetManager'; + +// ─── addLaplaceNoise ────────────────────────────────────────────────────────── + +describe('addLaplaceNoise', () => { + it('returns a number', () => { + expect(typeof addLaplaceNoise(10, 1)).toBe('number'); + }); + + it('adds noise (output differs from input on average)', () => { + const samples = Array.from({ length: 100 }, () => addLaplaceNoise(0, 1)); + const allZero = samples.every(v => v === 0); + expect(allZero).toBe(false); + }); + + it('noise is smaller with larger epsilon (less noise)', () => { + const runs = 500; + const avgAbsLow = + Array.from({ length: runs }, () => Math.abs(addLaplaceNoise(0, 0.1))).reduce( + (a, b) => a + b + ) / runs; + const avgAbsHigh = + Array.from({ length: runs }, () => Math.abs(addLaplaceNoise(0, 10))).reduce((a, b) => a + b) / + runs; + expect(avgAbsLow).toBeGreaterThan(avgAbsHigh); + }); + + it('throws for non-positive epsilon', () => { + expect(() => addLaplaceNoise(5, 0)).toThrow(); + expect(() => addLaplaceNoise(5, -1)).toThrow(); + }); +}); + +// ─── addGaussianNoise ───────────────────────────────────────────────────────── + +describe('addGaussianNoise', () => { + it('returns a number', () => { + expect(typeof addGaussianNoise(10, 1)).toBe('number'); + }); + + it('adds noise', () => { + const samples = Array.from({ length: 100 }, () => addGaussianNoise(0, 1)); + expect(samples.every(v => v === 0)).toBe(false); + }); + + it('throws for non-positive epsilon', () => { + expect(() => addGaussianNoise(5, 0)).toThrow(); + }); + + it('throws for invalid delta', () => { + expect(() => addGaussianNoise(5, 1, 0)).toThrow(); + expect(() => addGaussianNoise(5, 1, 1)).toThrow(); + }); +}); + +// ─── applyNoise ─────────────────────────────────────────────────────────────── + +describe('applyNoise', () => { + const config = { epsilon: 1, delta: 1e-5, sensitivity: 1 }; + + it('applies laplace noise by default', () => { + const result = applyNoise(100, config); + expect(typeof result).toBe('number'); + }); + + it('applies gaussian noise when specified', () => { + const result = applyNoise(100, config, 'gaussian'); + expect(typeof result).toBe('number'); + }); +}); + +// ─── randomizedResponse ─────────────────────────────────────────────────────── + +describe('randomizedResponse', () => { + it('returns a boolean', () => { + expect(typeof randomizedResponse(true, 1)).toBe('boolean'); + }); + + it('returns the true value more often than not for large epsilon', () => { + const runs = 1000; + const trueCount = Array.from({ length: runs }, () => randomizedResponse(true, 10)).filter( + Boolean + ).length; + // With ε=10, p ≈ 0.9999 — should be true almost always + expect(trueCount).toBeGreaterThan(900); + }); + + it('throws for non-positive epsilon', () => { + expect(() => randomizedResponse(true, 0)).toThrow(); + }); +}); + +// ─── sanitizeEventProperties ────────────────────────────────────────────────── + +describe('sanitizeEventProperties', () => { + const config = { epsilon: 1, delta: 1e-5, sensitivity: 1 }; + + it('adds noise to numeric values', () => { + const props = { count: 5, label: 'click' }; + const sanitized = sanitizeEventProperties(props, config); + // label unchanged + expect(sanitized.label).toBe('click'); + // count is a number but may differ + expect(typeof sanitized.count).toBe('number'); + }); + + it('applies randomized response to boolean values', () => { + const props = { active: true }; + const sanitized = sanitizeEventProperties(props, config); + expect(typeof sanitized.active).toBe('boolean'); + }); + + it('passes through null and undefined unchanged', () => { + const props = { a: null, b: undefined }; + const sanitized = sanitizeEventProperties(props, config); + expect(sanitized.a).toBeNull(); + expect(sanitized.b).toBeUndefined(); + }); + + it('passes through string values unchanged', () => { + const props = { screen: 'home' }; + const sanitized = sanitizeEventProperties(props, config); + expect(sanitized.screen).toBe('home'); + }); +}); + +// ─── PrivacyBudgetManager ───────────────────────────────────────────────────── + +describe('PrivacyBudgetManager', () => { + it('allows consumption within budget', () => { + const mgr = new PrivacyBudgetManager(5); + expect(mgr.consume(1)).toBe(true); + expect(mgr.consume(2)).toBe(true); + expect(mgr.getStatus().consumed).toBe(3); + }); + + it('rejects consumption that exceeds budget', () => { + const mgr = new PrivacyBudgetManager(1); + expect(mgr.consume(0.9)).toBe(true); + expect(mgr.consume(0.2)).toBe(false); // would exceed 1.0 + }); + + it('reports exhausted correctly', () => { + const mgr = new PrivacyBudgetManager(1); + mgr.consume(1); + expect(mgr.getStatus().exhausted).toBe(true); + expect(mgr.getStatus().remaining).toBe(0); + }); + + it('resets consumed budget', () => { + const mgr = new PrivacyBudgetManager(5); + mgr.consume(3); + mgr.reset(); + expect(mgr.getStatus().consumed).toBe(0); + expect(mgr.getStatus().exhausted).toBe(false); + }); + + it('throws for non-positive totalBudget', () => { + expect(() => new PrivacyBudgetManager(0)).toThrow(); + expect(() => new PrivacyBudgetManager(-1)).toThrow(); + }); + + it('throws for non-positive epsilon in consume', () => { + const mgr = new PrivacyBudgetManager(5); + expect(() => mgr.consume(0)).toThrow(); + }); +});