diff --git a/src/lib/analytics/consent.ts b/src/lib/analytics/consent.ts new file mode 100644 index 0000000..b941195 --- /dev/null +++ b/src/lib/analytics/consent.ts @@ -0,0 +1,137 @@ +import type { AnalyticsSettings } from './types'; + +const ANALYTICS_STORAGE_KEY = 'claudia-analytics-settings'; + +export class ConsentManager { + private static instance: ConsentManager; + private settings: AnalyticsSettings | null = null; + + private constructor() {} + + static getInstance(): ConsentManager { + if (!ConsentManager.instance) { + ConsentManager.instance = new ConsentManager(); + } + return ConsentManager.instance; + } + + async initialize(): Promise { + try { + // Try to load from localStorage first + const stored = localStorage.getItem(ANALYTICS_STORAGE_KEY); + if (stored) { + this.settings = JSON.parse(stored); + } else { + // Initialize with default settings + this.settings = { + enabled: false, + hasConsented: false, + }; + } + + // Generate anonymous user ID if not exists + if (this.settings && !this.settings.userId) { + this.settings.userId = this.generateAnonymousId(); + await this.saveSettings(); + } + + // Generate session ID + if (this.settings) { + this.settings.sessionId = this.generateSessionId(); + } + + return this.settings || { + enabled: false, + hasConsented: false, + }; + } catch (error) { + console.error('Failed to initialize consent manager:', error); + // Return default settings on error + return { + enabled: false, + hasConsented: false, + }; + } + } + + async grantConsent(): Promise { + if (!this.settings) { + await this.initialize(); + } + + this.settings!.enabled = true; + this.settings!.hasConsented = true; + this.settings!.consentDate = new Date().toISOString(); + + await this.saveSettings(); + } + + async revokeConsent(): Promise { + if (!this.settings) { + await this.initialize(); + } + + this.settings!.enabled = false; + + await this.saveSettings(); + } + + async deleteAllData(): Promise { + // Clear local storage + localStorage.removeItem(ANALYTICS_STORAGE_KEY); + + // Reset settings with new anonymous ID + this.settings = { + enabled: false, + hasConsented: false, + userId: this.generateAnonymousId(), + sessionId: this.generateSessionId(), + }; + + await this.saveSettings(); + } + + getSettings(): AnalyticsSettings | null { + return this.settings; + } + + hasConsented(): boolean { + return this.settings?.hasConsented || false; + } + + isEnabled(): boolean { + return this.settings?.enabled || false; + } + + getUserId(): string { + return this.settings?.userId || this.generateAnonymousId(); + } + + getSessionId(): string { + return this.settings?.sessionId || this.generateSessionId(); + } + + private async saveSettings(): Promise { + if (!this.settings) return; + + try { + localStorage.setItem(ANALYTICS_STORAGE_KEY, JSON.stringify(this.settings)); + } catch (error) { + console.error('Failed to save analytics settings:', error); + } + } + + private generateAnonymousId(): string { + // Generate a UUID v4 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + private generateSessionId(): string { + // Simple session ID based on timestamp and random value + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } +} \ No newline at end of file diff --git a/src/lib/analytics/events.ts b/src/lib/analytics/events.ts new file mode 100644 index 0000000..6770821 --- /dev/null +++ b/src/lib/analytics/events.ts @@ -0,0 +1,150 @@ +import type { + EventName, + FeatureUsageProperties, + ErrorProperties, + SessionProperties, + ModelProperties, + AgentProperties, + MCPProperties, + SlashCommandProperties, + PerformanceMetrics +} from './types'; + +export const ANALYTICS_EVENTS = { + // Session events + SESSION_CREATED: 'session_created' as EventName, + SESSION_COMPLETED: 'session_completed' as EventName, + SESSION_RESUMED: 'session_resumed' as EventName, + + // Feature usage events + FEATURE_USED: 'feature_used' as EventName, + MODEL_SELECTED: 'model_selected' as EventName, + TAB_CREATED: 'tab_created' as EventName, + TAB_CLOSED: 'tab_closed' as EventName, + FILE_OPENED: 'file_opened' as EventName, + FILE_EDITED: 'file_edited' as EventName, + FILE_SAVED: 'file_saved' as EventName, + AGENT_EXECUTED: 'agent_executed' as EventName, + MCP_SERVER_CONNECTED: 'mcp_server_connected' as EventName, + MCP_SERVER_DISCONNECTED: 'mcp_server_disconnected' as EventName, + SLASH_COMMAND_USED: 'slash_command_used' as EventName, + + // Settings and system events + SETTINGS_CHANGED: 'settings_changed' as EventName, + APP_STARTED: 'app_started' as EventName, + APP_CLOSED: 'app_closed' as EventName, + + // Error events + ERROR_OCCURRED: 'error_occurred' as EventName, +} as const; + +// Event property builders - help ensure consistent event structure +export const eventBuilders = { + session: (props: SessionProperties) => ({ + event: ANALYTICS_EVENTS.SESSION_CREATED, + properties: { + category: 'session', + ...props, + }, + }), + + feature: (feature: string, subfeature?: string, metadata?: Record) => ({ + event: ANALYTICS_EVENTS.FEATURE_USED, + properties: { + category: 'feature', + feature, + subfeature, + ...metadata, + } as FeatureUsageProperties, + }), + + error: (errorType: string, errorCode?: string, context?: string) => ({ + event: ANALYTICS_EVENTS.ERROR_OCCURRED, + properties: { + category: 'error', + error_type: errorType, + error_code: errorCode, + context, + } as ErrorProperties, + }), + + model: (newModel: string, previousModel?: string, source?: string) => ({ + event: ANALYTICS_EVENTS.MODEL_SELECTED, + properties: { + category: 'model', + new_model: newModel, + previous_model: previousModel, + source, + } as ModelProperties, + }), + + agent: (agentType: string, success: boolean, agentName?: string, durationMs?: number) => ({ + event: ANALYTICS_EVENTS.AGENT_EXECUTED, + properties: { + category: 'agent', + agent_type: agentType, + agent_name: agentName, + success, + duration_ms: durationMs, + } as AgentProperties, + }), + + mcp: (serverName: string, success: boolean, serverType?: string) => ({ + event: ANALYTICS_EVENTS.MCP_SERVER_CONNECTED, + properties: { + category: 'mcp', + server_name: serverName, + server_type: serverType, + success, + } as MCPProperties, + }), + + slashCommand: (command: string, success: boolean) => ({ + event: ANALYTICS_EVENTS.SLASH_COMMAND_USED, + properties: { + category: 'slash_command', + command, + success, + } as SlashCommandProperties, + }), + + performance: (metrics: PerformanceMetrics) => ({ + event: ANALYTICS_EVENTS.FEATURE_USED, + properties: { + category: 'performance', + feature: 'system_metrics', + ...metrics, + }, + }), +}; + +// Sanitization helpers to remove PII +export const sanitizers = { + // Remove file paths, keeping only extension + sanitizeFilePath: (path: string): string => { + const ext = path.split('.').pop(); + return ext ? `*.${ext}` : 'unknown'; + }, + + // Remove project names and paths + sanitizeProjectPath: (_path: string): string => { + return 'project'; + }, + + // Sanitize error messages that might contain sensitive info + sanitizeErrorMessage: (message: string): string => { + // Remove file paths + message = message.replace(/\/[\w\-\/\.]+/g, '/***'); + // Remove potential API keys or tokens + message = message.replace(/[a-zA-Z0-9]{20,}/g, '***'); + // Remove email addresses + message = message.replace(/[\w\.-]+@[\w\.-]+\.\w+/g, '***@***.***'); + return message; + }, + + // Sanitize agent names that might contain user info + sanitizeAgentName: (name: string): string => { + // Only keep the type, remove custom names + return name.split('-')[0] || 'custom'; + }, +}; \ No newline at end of file diff --git a/src/lib/analytics/index.ts b/src/lib/analytics/index.ts new file mode 100644 index 0000000..fcbad90 --- /dev/null +++ b/src/lib/analytics/index.ts @@ -0,0 +1,250 @@ +import posthog from 'posthog-js'; +import { ConsentManager } from './consent'; +import { sanitizers } from './events'; +import type { + AnalyticsConfig, + AnalyticsEvent, + EventName, + AnalyticsSettings +} from './types'; + +export * from './types'; +export * from './events'; +export { ConsentManager } from './consent'; + +class AnalyticsService { + private static instance: AnalyticsService; + private initialized = false; + private consentManager: ConsentManager; + private config: AnalyticsConfig; + private eventQueue: AnalyticsEvent[] = []; + private flushInterval: NodeJS.Timeout | null = null; + + private constructor() { + this.consentManager = ConsentManager.getInstance(); + + // Default configuration - pulled from Vite environment variables + this.config = { + apiKey: import.meta.env.VITE_PUBLIC_POSTHOG_KEY || 'phc_YOUR_PROJECT_API_KEY', + apiHost: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com', + persistence: 'localStorage', + autocapture: false, // We'll manually track events + disable_session_recording: true, // Privacy first + opt_out_capturing_by_default: true, // Require explicit opt-in + }; + } + + static getInstance(): AnalyticsService { + if (!AnalyticsService.instance) { + AnalyticsService.instance = new AnalyticsService(); + } + return AnalyticsService.instance; + } + + async initialize(): Promise { + if (this.initialized) return; + + try { + // Initialize consent manager + const settings = await this.consentManager.initialize(); + + // Only initialize PostHog if user has consented + if (settings.hasConsented && settings.enabled) { + this.initializePostHog(settings); + } + + // Start event queue flush interval + this.startFlushInterval(); + + this.initialized = true; + } catch (error) { + console.error('Failed to initialize analytics:', error); + } + } + + private initializePostHog(settings: AnalyticsSettings): void { + try { + posthog.init(this.config.apiKey, { + api_host: this.config.apiHost, + defaults: '2025-05-24', + capture_exceptions: true, + debug: import.meta.env.MODE === 'development', + persistence: this.config.persistence, + autocapture: this.config.autocapture, + disable_session_recording: this.config.disable_session_recording, + opt_out_capturing_by_default: this.config.opt_out_capturing_by_default, + loaded: (ph) => { + // Set user properties + ph.identify(settings.userId, { + anonymous: true, + consent_date: settings.consentDate, + }); + + // Opt in since user has consented + ph.opt_in_capturing(); + + if (this.config.loaded) { + this.config.loaded(ph); + } + }, + }); + } catch (error) { + console.error('Failed to initialize PostHog:', error); + } + } + + async enable(): Promise { + await this.consentManager.grantConsent(); + const settings = this.consentManager.getSettings(); + if (settings) { + this.initializePostHog(settings); + } + } + + async disable(): Promise { + await this.consentManager.revokeConsent(); + if (typeof posthog !== 'undefined' && posthog.opt_out_capturing) { + posthog.opt_out_capturing(); + } + } + + async deleteAllData(): Promise { + await this.consentManager.deleteAllData(); + if (typeof posthog !== 'undefined' && posthog.reset) { + posthog.reset(); + } + } + + track(eventName: EventName | string, properties?: Record): void { + // Check if analytics is enabled + if (!this.consentManager.isEnabled()) { + return; + } + + // Sanitize properties to remove PII + const sanitizedProperties = this.sanitizeProperties(properties || {}); + + // Create event + const event: AnalyticsEvent = { + event: eventName, + properties: sanitizedProperties, + timestamp: Date.now(), + sessionId: this.consentManager.getSessionId(), + userId: this.consentManager.getUserId(), + }; + + // Add to queue + this.eventQueue.push(event); + + // Send immediately if PostHog is initialized + if (typeof posthog !== 'undefined' && typeof posthog.capture === 'function') { + this.flushEvents(); + } + } + + identify(traits?: Record): void { + if (!this.consentManager.isEnabled()) { + return; + } + + const userId = this.consentManager.getUserId(); + const sanitizedTraits = this.sanitizeProperties(traits || {}); + + if (typeof posthog !== 'undefined' && posthog.identify) { + posthog.identify(userId, { + ...sanitizedTraits, + anonymous: true, + }); + } + } + + private sanitizeProperties(properties: Record): Record { + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(properties)) { + // Skip null/undefined values + if (value == null) continue; + + // Apply specific sanitizers based on key + if (key.includes('path') || key.includes('file')) { + sanitized[key] = typeof value === 'string' ? sanitizers.sanitizeFilePath(value) : value; + } else if (key.includes('project')) { + sanitized[key] = typeof value === 'string' ? sanitizers.sanitizeProjectPath(value) : value; + } else if (key.includes('error') || key.includes('message')) { + sanitized[key] = typeof value === 'string' ? sanitizers.sanitizeErrorMessage(value) : value; + } else if (key.includes('agent_name')) { + sanitized[key] = typeof value === 'string' ? sanitizers.sanitizeAgentName(value) : value; + } else { + // For other properties, ensure no PII + if (typeof value === 'string') { + // Remove potential file paths + let cleanValue = value.replace(/\/[\w\-\/\.]+/g, '/***'); + // Remove potential API keys + cleanValue = cleanValue.replace(/[a-zA-Z0-9]{32,}/g, '***'); + // Remove emails + cleanValue = cleanValue.replace(/[\w\.-]+@[\w\.-]+\.\w+/g, '***@***.***'); + sanitized[key] = cleanValue; + } else { + sanitized[key] = value; + } + } + } + + return sanitized; + } + + private flushEvents(): void { + if (this.eventQueue.length === 0) return; + + const events = [...this.eventQueue]; + this.eventQueue = []; + + events.forEach(event => { + if (typeof posthog !== 'undefined' && posthog.capture) { + posthog.capture(event.event, { + ...event.properties, + $session_id: event.sessionId, + timestamp: event.timestamp, + }); + } + }); + } + + private startFlushInterval(): void { + // Flush events every 5 seconds + this.flushInterval = setInterval(() => { + if (this.consentManager.isEnabled()) { + this.flushEvents(); + } + }, 5000); + } + + shutdown(): void { + if (this.flushInterval) { + clearInterval(this.flushInterval); + this.flushInterval = null; + } + + // Flush any remaining events + this.flushEvents(); + } + + // Convenience methods + isEnabled(): boolean { + return this.consentManager.isEnabled(); + } + + hasConsented(): boolean { + return this.consentManager.hasConsented(); + } + + getSettings(): AnalyticsSettings | null { + return this.consentManager.getSettings(); + } +} + +// Export singleton instance +export const analytics = AnalyticsService.getInstance(); + +// Export for direct usage +export default analytics; diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts new file mode 100644 index 0000000..9cecf68 --- /dev/null +++ b/src/lib/analytics/types.ts @@ -0,0 +1,102 @@ +export interface AnalyticsEvent { + event: string; + properties?: { + category?: string; + action?: string; + label?: string; + value?: number; + [key: string]: any; + }; + timestamp: number; + sessionId: string; + userId: string; // anonymous UUID +} + +export interface AnalyticsSettings { + enabled: boolean; + hasConsented: boolean; + consentDate?: string; + userId?: string; + sessionId?: string; +} + +export interface AnalyticsConfig { + apiKey: string; + apiHost?: string; + persistence?: 'localStorage' | 'memory'; + autocapture?: boolean; + disable_session_recording?: boolean; + opt_out_capturing_by_default?: boolean; + loaded?: (posthog: any) => void; +} + +export type EventName = + | 'session_created' + | 'session_completed' + | 'session_resumed' + | 'feature_used' + | 'error_occurred' + | 'model_selected' + | 'tab_created' + | 'tab_closed' + | 'file_opened' + | 'file_edited' + | 'file_saved' + | 'agent_executed' + | 'mcp_server_connected' + | 'mcp_server_disconnected' + | 'slash_command_used' + | 'settings_changed' + | 'app_started' + | 'app_closed'; + +export interface FeatureUsageProperties { + feature: string; + subfeature?: string; + metadata?: Record; +} + +export interface ErrorProperties { + error_type: string; + error_code?: string; + error_message?: string; + context?: string; +} + +export interface SessionProperties { + model?: string; + source?: string; + resumed?: boolean; + checkpoint_id?: string; +} + +export interface ModelProperties { + previous_model?: string; + new_model: string; + source?: string; +} + +export interface AgentProperties { + agent_type: string; + agent_name?: string; + success: boolean; + duration_ms?: number; +} + +export interface MCPProperties { + server_name: string; + server_type?: string; + success: boolean; +} + +export interface SlashCommandProperties { + command: string; + success: boolean; +} + +export interface PerformanceMetrics { + startup_time_ms?: number; + memory_usage_mb?: number; + api_response_time_ms?: number; + render_time_ms?: number; +} \ No newline at end of file