feat: implement analytics service with PostHog integration

- Create AnalyticsService singleton for centralized analytics tracking
- Implement ConsentManager for privacy-first analytics consent
- Add comprehensive event builders and sanitizers
- Define analytics event types and interfaces
- Support opt-in analytics with local storage persistence
- Include event queue and batch processing
- Add privacy-focused configuration (no session recording, no autocapture)
This commit is contained in:
Vivek R
2025-07-30 19:43:28 +05:30
parent 091feaceeb
commit 464d318f34
4 changed files with 639 additions and 0 deletions

View File

@@ -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<AnalyticsSettings> {
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<void> {
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<void> {
if (!this.settings) {
await this.initialize();
}
this.settings!.enabled = false;
await this.saveSettings();
}
async deleteAllData(): Promise<void> {
// 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<void> {
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)}`;
}
}

150
src/lib/analytics/events.ts Normal file
View File

@@ -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<string, any>) => ({
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';
},
};

250
src/lib/analytics/index.ts Normal file
View File

@@ -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<void> {
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<void> {
await this.consentManager.grantConsent();
const settings = this.consentManager.getSettings();
if (settings) {
this.initializePostHog(settings);
}
}
async disable(): Promise<void> {
await this.consentManager.revokeConsent();
if (typeof posthog !== 'undefined' && posthog.opt_out_capturing) {
posthog.opt_out_capturing();
}
}
async deleteAllData(): Promise<void> {
await this.consentManager.deleteAllData();
if (typeof posthog !== 'undefined' && posthog.reset) {
posthog.reset();
}
}
track(eventName: EventName | string, properties?: Record<string, any>): 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<string, any>): 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<string, any>): Record<string, any> {
const sanitized: Record<string, any> = {};
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;

102
src/lib/analytics/types.ts Normal file
View File

@@ -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<string, any>;
}
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;
}