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:
137
src/lib/analytics/consent.ts
Normal file
137
src/lib/analytics/consent.ts
Normal 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
150
src/lib/analytics/events.ts
Normal 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
250
src/lib/analytics/index.ts
Normal 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
102
src/lib/analytics/types.ts
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user