/** * Frontend Analytics Module * * Provides privacy-respecting analytics by routing events through the backend. * The backend uses posthog-rs which handles the PostHog API correctly. * * What we track: * - Feature usage (which features users interact with) * - Screen/view navigation * - Onboarding funnel completion * - Error types (without sensitive details) * * What we never track: * - Conversation content * - Code or file contents * - API keys or credentials * - Tool arguments or outputs * - Personal identifiable information */ import { sendTelemetryEvent } from '../api'; let telemetryEnabled: boolean | null = null; export function setTelemetryEnabled(enabled: boolean): void { telemetryEnabled = enabled; } function canTrack(): boolean { return telemetryEnabled === true; } async function sendEvent( eventName: string, properties: Record = {} ): Promise { if (!canTrack()) return; try { await sendTelemetryEvent({ body: { event_name: eventName, properties: properties as Record, }, }); } catch (error) { console.debug('[Analytics] Failed to send event:', error); } } // ============================================================================ // Event Types // ============================================================================ /** * Frontend-specific analytics events. * * NOTE: The backend (posthog.rs) already tracks: * - session_started (extensions, provider, model, tokens, session count, etc.) * - error (provider errors like rate_limit, auth, etc.) * * Frontend events focus on what the backend can't see: * - UI navigation patterns * - Onboarding funnel (where users drop off during setup) * - Frontend-only crashes (React errors, unhandled rejections) */ export type AnalyticsEvent = | { name: 'page_view'; properties: { page: string; referrer?: string } } | { name: 'onboarding_started'; properties: Record } | { name: 'onboarding_provider_selected'; properties: { method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other'; }; } | { name: 'onboarding_completed'; properties: { provider: string; model?: string; duration_seconds?: number }; } | { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } } | { name: 'onboarding_setup_failed'; properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local'; error_message?: string }; } | { name: 'error_occurred'; properties: { error_type: string; component?: string; page?: string; action?: string; stack_summary?: string; recoverable: boolean; }; } | { name: 'app_crashed'; properties: { error_type: string; component?: string; page?: string } } | { name: 'app_reloaded'; properties: { reason?: string } } | { name: 'model_changed'; properties: { provider: string; model: string } } | { name: 'settings_tab_viewed'; properties: { tab: string } } | { name: 'setting_toggled'; properties: { setting: string; enabled: boolean } } | { name: 'telemetry_preference_set'; properties: { enabled: boolean; location: 'settings' | 'onboarding' | 'modal' }; } | { name: 'schedule_created'; properties: { source_type: 'file' | 'deeplink'; success: boolean; error_details?: string }; } | { name: 'schedule_deleted'; properties: { success: boolean; error_details?: string } } | { name: 'schedule_run_now'; properties: { success: boolean; error_details?: string } } | { name: 'recipe_created'; properties: { success: boolean; error_details?: string } } | { name: 'recipe_imported'; properties: { success: boolean; error_details?: string } } | { name: 'recipe_edited'; properties: { success: boolean; error_details?: string } } | { name: 'recipe_deleted'; properties: { success: boolean; error_details?: string } } | { name: 'recipe_started'; properties: { success: boolean; error_details?: string; in_new_window?: boolean }; } | { name: 'recipe_deeplink_copied'; properties: { success: boolean; error_details?: string } } | { name: 'recipe_yaml_copied'; properties: { success: boolean; error_details?: string } } | { name: 'recipe_exported_to_file'; properties: { success: boolean; error_details?: string } } | { name: 'recipe_scheduled'; properties: { success: boolean; error_details?: string; action: 'add' | 'edit' | 'remove' }; } | { name: 'recipe_slash_command_set'; properties: { success: boolean; error_details?: string; action: 'add' | 'edit' | 'remove' }; } | { name: 'extension_added'; properties: { extension_name?: string; is_builtin: boolean; success: boolean; error_details?: string; }; } | { name: 'extension_enabled'; properties: { extension_name?: string; is_builtin: boolean; success: boolean; error_details?: string; }; } | { name: 'extension_disabled'; properties: { extension_name?: string; is_builtin: boolean; success: boolean; error_details?: string; }; } | { name: 'extension_deleted'; properties: { extension_name?: string; is_builtin: boolean; success: boolean; error_details?: string; }; } // Chat input bar features | { name: 'input_file_attached'; properties: { file_type: 'file' | 'directory' } } | { name: 'input_voice_dictation'; properties: { action: 'start' | 'stop' | 'transcribed' | 'error' | 'auto_submit'; duration_seconds?: number; error_type?: string; }; } | { name: 'input_mode_changed'; properties: { from_mode: string; to_mode: string } } | { name: 'input_diagnostics_opened'; properties: Record } | { name: 'input_create_recipe_opened'; properties: Record } | { name: 'input_edit_recipe_opened'; properties: Record } // Auto-update tracking events | { name: 'update_check_started'; properties: { trigger: 'startup' | 'manual'; current_version: string }; } | { name: 'update_check_completed'; properties: { result: 'available' | 'not_available' | 'error'; current_version: string; latest_version?: string; using_fallback: boolean; error_type?: string; }; } | { name: 'update_download_started'; properties: { version: string; method: 'electron-updater' | 'github-fallback'; }; } | { name: 'update_download_progress'; properties: { milestone: 25 | 50 | 75 | 100; version: string; method: 'electron-updater' | 'github-fallback'; }; } | { name: 'update_download_completed'; properties: { success: boolean; version: string; method: 'electron-updater' | 'github-fallback'; duration_seconds?: number; error_type?: string; }; } | { name: 'update_install_initiated'; properties: { version: string; method: 'electron-updater' | 'github-fallback'; action: 'quit_and_install' | 'open_folder_and_quit' | 'open_folder_only'; }; }; // NOTE: slash_command_used is tracked by the backend (posthog.rs) with command_type info export function trackEvent(event: T): void { sendEvent(event.name, event.properties); } export function trackPageView(page: string, referrer?: string): void { trackEvent({ name: 'page_view', properties: { page, referrer }, }); } export function trackError( errorType: string, options: { component?: string; page?: string; action?: string; stackSummary?: string; recoverable?: boolean; } = {} ): void { trackEvent({ name: 'error_occurred', properties: { error_type: errorType, component: options.component, page: options.page, action: options.action, stack_summary: options.stackSummary, recoverable: options.recoverable ?? false, }, }); } export function trackErrorWithContext( error: unknown, context: { component?: string; page?: string; action?: string; recoverable?: boolean; } = {} ): void { trackError(getErrorType(error), { ...context, stackSummary: getStackSummary(error), }); } let onboardingStartTime: number | null = null; export function trackOnboardingStarted(): void { onboardingStartTime = Date.now(); trackEvent({ name: 'onboarding_started', properties: {} }); } export function trackOnboardingProviderSelected( method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other' ): void { trackEvent({ name: 'onboarding_provider_selected', properties: { method }, }); } export function trackOnboardingCompleted(provider: string, model?: string): void { const durationSeconds = onboardingStartTime ? Math.round((Date.now() - onboardingStartTime) / 1000) : undefined; trackEvent({ name: 'onboarding_completed', properties: { provider, model, duration_seconds: durationSeconds }, }); onboardingStartTime = null; } export function trackOnboardingAbandoned(step: string): void { const durationSeconds = onboardingStartTime ? Math.round((Date.now() - onboardingStartTime) / 1000) : undefined; trackEvent({ name: 'onboarding_abandoned', properties: { step, duration_seconds: durationSeconds }, }); onboardingStartTime = null; } export function trackOnboardingSetupFailed( provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local', errorMessage?: string ): void { trackEvent({ name: 'onboarding_setup_failed', properties: { provider, error_message: errorMessage }, }); } export function trackModelChanged(provider: string, model: string): void { trackEvent({ name: 'model_changed', properties: { provider, model }, }); } export function trackSettingsTabViewed(tab: string): void { trackEvent({ name: 'settings_tab_viewed', properties: { tab }, }); } export function trackSettingToggled(setting: string, enabled: boolean): void { trackEvent({ name: 'setting_toggled', properties: { setting, enabled }, }); } export function trackTelemetryPreference( enabled: boolean, location: 'settings' | 'onboarding' | 'modal' ): void { // Always send this event, even if telemetry is disabled // This is the one exception - we need to know opt-out rates sendEvent('telemetry_preference_set', { enabled, location }); } export function getErrorType(error: unknown): string { if (error instanceof Error) { const name = error.name || 'Error'; const message = error.message.split('\n')[0].slice(0, 200); return `${name}: ${message}`; } return String(error).slice(0, 200); } export function getStackSummary(error: unknown): string | undefined { if (!(error instanceof Error) || !error.stack) { return undefined; } // Extract just the function/component names from the stack // Skip error message, take top 4 frames const lines = error.stack.split('\n').slice(1, 5); const frames = lines .map((line) => { // Match function names like "at ComponentName" or "at Object.functionName" const match = line.match(/at\s+([A-Za-z0-9_$.]+)/); return match ? match[1] : null; }) .filter(Boolean); return frames.length > 0 ? frames.join(' > ') : undefined; } // ============================================================================ // Extension Tracking // ============================================================================ // Only track names for builtin extensions (privacy protection for user-created extensions) function getTrackableExtensionName(extensionName: string, isBuiltin: boolean): string | undefined { return isBuiltin ? extensionName : undefined; } export function trackExtensionAdded( extensionName: string, success: boolean, errorDetails?: string, isBuiltin: boolean = false ): void { trackEvent({ name: 'extension_added', properties: { extension_name: getTrackableExtensionName(extensionName, isBuiltin), is_builtin: isBuiltin, success, error_details: errorDetails, }, }); } export function trackExtensionEnabled( extensionName: string, success: boolean, errorDetails?: string, isBuiltin: boolean = false ): void { trackEvent({ name: 'extension_enabled', properties: { extension_name: getTrackableExtensionName(extensionName, isBuiltin), is_builtin: isBuiltin, success, error_details: errorDetails, }, }); } export function trackExtensionDisabled( extensionName: string, success: boolean, errorDetails?: string, isBuiltin: boolean = false ): void { trackEvent({ name: 'extension_disabled', properties: { extension_name: getTrackableExtensionName(extensionName, isBuiltin), is_builtin: isBuiltin, success, error_details: errorDetails, }, }); } export function trackExtensionDeleted( extensionName: string, success: boolean, errorDetails?: string, isBuiltin: boolean = false ): void { trackEvent({ name: 'extension_deleted', properties: { extension_name: getTrackableExtensionName(extensionName, isBuiltin), is_builtin: isBuiltin, success, error_details: errorDetails, }, }); } // ============================================================================ // Schedule/Recipe Tracking // ============================================================================ export function trackScheduleCreated( sourceType: 'file' | 'deeplink', success: boolean, errorDetails?: string ): void { trackEvent({ name: 'schedule_created', properties: { source_type: sourceType, success, error_details: errorDetails }, }); } export function trackScheduleDeleted(success: boolean, errorDetails?: string): void { trackEvent({ name: 'schedule_deleted', properties: { success, error_details: errorDetails }, }); } export function trackScheduleRunNow(success: boolean, errorDetails?: string): void { trackEvent({ name: 'schedule_run_now', properties: { success, error_details: errorDetails }, }); } // ============================================================================ // Recipe Tracking // ============================================================================ export function trackRecipeCreated(success: boolean, errorDetails?: string): void { trackEvent({ name: 'recipe_created', properties: { success, error_details: errorDetails }, }); } export function trackRecipeImported(success: boolean, errorDetails?: string): void { trackEvent({ name: 'recipe_imported', properties: { success, error_details: errorDetails }, }); } export function trackRecipeEdited(success: boolean, errorDetails?: string): void { trackEvent({ name: 'recipe_edited', properties: { success, error_details: errorDetails }, }); } export function trackRecipeDeleted(success: boolean, errorDetails?: string): void { trackEvent({ name: 'recipe_deleted', properties: { success, error_details: errorDetails }, }); } export function trackRecipeStarted( success: boolean, errorDetails?: string, inNewWindow?: boolean ): void { trackEvent({ name: 'recipe_started', properties: { success, error_details: errorDetails, in_new_window: inNewWindow }, }); } export function trackRecipeDeeplinkCopied(success: boolean, errorDetails?: string): void { trackEvent({ name: 'recipe_deeplink_copied', properties: { success, error_details: errorDetails }, }); } export function trackRecipeYamlCopied(success: boolean, errorDetails?: string): void { trackEvent({ name: 'recipe_yaml_copied', properties: { success, error_details: errorDetails }, }); } export function trackRecipeExportedToFile(success: boolean, errorDetails?: string): void { trackEvent({ name: 'recipe_exported_to_file', properties: { success, error_details: errorDetails }, }); } export function trackRecipeScheduled( success: boolean, action: 'add' | 'edit' | 'remove', errorDetails?: string ): void { trackEvent({ name: 'recipe_scheduled', properties: { success, action, error_details: errorDetails }, }); } export function trackRecipeSlashCommandSet( success: boolean, action: 'add' | 'edit' | 'remove', errorDetails?: string ): void { trackEvent({ name: 'recipe_slash_command_set', properties: { success, action, error_details: errorDetails }, }); } // NOTE: slash_command_used is tracked by the backend (posthog.rs) with richer info: // - command_type: "builtin" | "recipe" | "unknown" // - command_name: only for builtin commands (e.g., "compact", "summarize") // - success: true for builtin/recipe, false for unknown // ============================================================================ // Chat Input Bar Feature Tracking // ============================================================================ export function trackFileAttached(fileType: 'file' | 'directory'): void { trackEvent({ name: 'input_file_attached', properties: { file_type: fileType }, }); } export function trackVoiceDictation( action: 'start' | 'stop' | 'transcribed' | 'error' | 'auto_submit', durationSeconds?: number, errorType?: string ): void { trackEvent({ name: 'input_voice_dictation', properties: { action, duration_seconds: durationSeconds, error_type: errorType }, }); } export function trackModeChanged(fromMode: string, toMode: string): void { trackEvent({ name: 'input_mode_changed', properties: { from_mode: fromMode, to_mode: toMode }, }); } export function trackDiagnosticsOpened(): void { trackEvent({ name: 'input_diagnostics_opened', properties: {}, }); } export function trackCreateRecipeOpened(): void { trackEvent({ name: 'input_create_recipe_opened', properties: {}, }); } export function trackEditRecipeOpened(): void { trackEvent({ name: 'input_edit_recipe_opened', properties: {}, }); } // ============================================================================ // Auto-Update Tracking // ============================================================================ type UpdateMethod = 'electron-updater' | 'github-fallback'; let updateDownloadStartTime: number | null = null; let currentUpdateVersion: string | null = null; let currentUpdateMethod: UpdateMethod | null = null; let reportedMilestones: Set<25 | 50 | 75 | 100> = new Set(); export function trackUpdateCheckStarted( trigger: 'startup' | 'manual', currentVersion: string ): void { trackEvent({ name: 'update_check_started', properties: { trigger, current_version: currentVersion }, }); } export function trackUpdateCheckCompleted( result: 'available' | 'not_available' | 'error', currentVersion: string, options: { latestVersion?: string; usingFallback: boolean; errorType?: string; } ): void { trackEvent({ name: 'update_check_completed', properties: { result, current_version: currentVersion, latest_version: options.latestVersion, using_fallback: options.usingFallback, error_type: options.errorType, }, }); } export function trackUpdateDownloadStarted(version: string, method: UpdateMethod): void { updateDownloadStartTime = Date.now(); currentUpdateVersion = version; currentUpdateMethod = method; reportedMilestones = new Set(); trackEvent({ name: 'update_download_started', properties: { version, method }, }); } export function trackUpdateDownloadProgress(percent: number): void { if (!currentUpdateVersion || !currentUpdateMethod) return; const milestones: Array<25 | 50 | 75 | 100> = [25, 50, 75, 100]; for (const milestone of milestones) { if (percent >= milestone && !reportedMilestones.has(milestone)) { reportedMilestones.add(milestone); trackEvent({ name: 'update_download_progress', properties: { milestone, version: currentUpdateVersion, method: currentUpdateMethod, }, }); } } } export function trackUpdateDownloadCompleted( success: boolean, version: string, method: UpdateMethod, errorType?: string ): void { const durationSeconds = updateDownloadStartTime ? Math.round((Date.now() - updateDownloadStartTime) / 1000) : undefined; trackEvent({ name: 'update_download_completed', properties: { success, version, method, duration_seconds: durationSeconds, error_type: errorType, }, }); // Reset state updateDownloadStartTime = null; currentUpdateVersion = null; currentUpdateMethod = null; reportedMilestones = new Set(); } export function trackUpdateInstallInitiated( version: string, method: UpdateMethod, action: 'quit_and_install' | 'open_folder_and_quit' | 'open_folder_only' ): void { trackEvent({ name: 'update_install_initiated', properties: { version, method, action }, }); }