diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example index f01b56f2..d5d24674 100644 --- a/apps/frontend/.env.example +++ b/apps/frontend/.env.example @@ -19,6 +19,34 @@ # Shows detailed information about app update checks and downloads # DEBUG_UPDATER=true +# ============================================ +# SENTRY ERROR REPORTING +# ============================================ + +# Sentry DSN for anonymous error reporting +# If not set, error reporting is completely disabled (safe for forks) +# +# For official builds: Set in CI/CD secrets +# For local testing: Uncomment and add your DSN +# +# SENTRY_DSN=https://your-dsn@sentry.io/project-id + +# Force enable Sentry in development mode (normally disabled in dev) +# Only works when SENTRY_DSN is also set +# SENTRY_DEV=true + +# Trace sample rate for performance monitoring (0.0 to 1.0) +# Controls what percentage of transactions are sampled +# Default: 0.1 (10%) in production, 0 in development +# Set to 0 to disable performance monitoring entirely +# SENTRY_TRACES_SAMPLE_RATE=0.1 + +# Profile sample rate for profiling (0.0 to 1.0) +# Controls what percentage of sampled transactions include profiling data +# Default: 0.1 (10%) in production, 0 in development +# Set to 0 to disable profiling entirely +# SENTRY_PROFILES_SAMPLE_RATE=0.1 + # ============================================ # HOW TO USE # ============================================ diff --git a/apps/frontend/package.json b/apps/frontend/package.json index ef847bd5..5c081348 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -69,6 +69,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@sentry/electron": "^7.5.0", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-virtual": "^3.13.13", "@xterm/addon-fit": "^0.11.0", diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index f236c4a7..e7460a46 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -12,11 +12,15 @@ import { initializeAppUpdater } from './app-updater'; import { DEFAULT_APP_SETTINGS } from '../shared/constants'; import { readSettingsFile } from './settings-utils'; import { setupErrorLogging } from './app-logger'; +import { initSentryMain } from './sentry'; import type { AppSettings } from '../shared/types'; // Setup error logging early (captures uncaught exceptions) setupErrorLogging(); +// Initialize Sentry for error tracking (respects user's sentryEnabled setting) +initSentryMain(); + /** * Load app settings synchronously (for use during startup). * This is a simple merge with defaults - no migrations or auto-detection. diff --git a/apps/frontend/src/main/sentry.ts b/apps/frontend/src/main/sentry.ts new file mode 100644 index 00000000..0ab4e660 --- /dev/null +++ b/apps/frontend/src/main/sentry.ts @@ -0,0 +1,167 @@ +/** + * Sentry Error Tracking for Main Process + * + * Initializes Sentry with: + * - beforeSend hook for mid-session toggle support (no restart needed) + * - Path masking for user privacy (shared with renderer) + * - IPC listener for settings changes from renderer + * + * Privacy Note: + * - Usernames are masked from all file paths + * - Project paths remain visible for debugging (this is expected) + * - Tags, contexts, extra data, and user info are all sanitized + */ + +import * as Sentry from '@sentry/electron/main'; +import { app, ipcMain } from 'electron'; +import { readSettingsFile } from './settings-utils'; +import { DEFAULT_APP_SETTINGS } from '../shared/constants'; +import { IPC_CHANNELS } from '../shared/constants/ipc'; +import { + processEvent, + PRODUCTION_TRACE_SAMPLE_RATE, + type SentryErrorEvent +} from '../shared/utils/sentry-privacy'; + +// In-memory state for current setting (updated via IPC when user toggles) +let sentryEnabledState = true; + +/** + * Get Sentry DSN from environment variable + * + * For local development/testing: + * - Add SENTRY_DSN to your .env file, or + * - Run: SENTRY_DSN=your-dsn npm start + * + * For CI/CD releases: + * - Set SENTRY_DSN as a GitHub Actions secret + * + * For forks: + * - Without SENTRY_DSN, Sentry is disabled (safe for forks) + */ +function getSentryDsn(): string { + return process.env.SENTRY_DSN || ''; +} + +/** + * Get trace sample rate from environment variable + * Controls performance monitoring sampling (0.0 to 1.0) + * Default: 0.1 (10%) in production, 0 in development + */ +function getTracesSampleRate(): number { + const envValue = process.env.SENTRY_TRACES_SAMPLE_RATE; + if (envValue !== undefined) { + const parsed = parseFloat(envValue); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) { + return parsed; + } + } + // Default: 10% in production, 0 in dev + return app.isPackaged ? PRODUCTION_TRACE_SAMPLE_RATE : 0; +} + +/** + * Get profile sample rate from environment variable + * Controls profiling sampling relative to traces (0.0 to 1.0) + * Default: 0.1 (10%) in production, 0 in development + */ +function getProfilesSampleRate(): number { + const envValue = process.env.SENTRY_PROFILES_SAMPLE_RATE; + if (envValue !== undefined) { + const parsed = parseFloat(envValue); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) { + return parsed; + } + } + // Default: 10% in production, 0 in dev + return app.isPackaged ? PRODUCTION_TRACE_SAMPLE_RATE : 0; +} + +// Cache config so renderer can access it via IPC +let cachedDsn: string = ''; +let cachedTracesSampleRate: number = 0; +let cachedProfilesSampleRate: number = 0; + +/** + * Initialize Sentry for the main process + * Called early in app startup, before window creation + */ +export function initSentryMain(): void { + // Get configuration from environment variables + cachedDsn = getSentryDsn(); + cachedTracesSampleRate = getTracesSampleRate(); + cachedProfilesSampleRate = getProfilesSampleRate(); + + // Read initial setting from disk synchronously + const savedSettings = readSettingsFile(); + const settings = { ...DEFAULT_APP_SETTINGS, ...savedSettings }; + sentryEnabledState = settings.sentryEnabled ?? true; + + // Check if we have a DSN - if not, Sentry is effectively disabled + const hasDsn = cachedDsn.length > 0; + const shouldEnable = hasDsn && (app.isPackaged || process.env.SENTRY_DEV === 'true'); + + if (!hasDsn) { + console.log('[Sentry] No SENTRY_DSN configured - error reporting disabled'); + console.log('[Sentry] To enable: set SENTRY_DSN environment variable'); + } + + Sentry.init({ + dsn: cachedDsn, + environment: app.isPackaged ? 'production' : 'development', + release: `auto-claude@${app.getVersion()}`, + + beforeSend(event: Sentry.ErrorEvent) { + if (!sentryEnabledState) { + return null; + } + // Process event with shared privacy utility + return processEvent(event as SentryErrorEvent) as Sentry.ErrorEvent; + }, + + // Sample rates from environment variables (default: 10% in production, 0 in dev) + tracesSampleRate: cachedTracesSampleRate, + profilesSampleRate: cachedProfilesSampleRate, + + // Only enable if we have a DSN and are in production (or SENTRY_DEV is set) + enabled: shouldEnable, + }); + + // Listen for settings changes from renderer process + ipcMain.on(IPC_CHANNELS.SENTRY_STATE_CHANGED, (_event, enabled: boolean) => { + sentryEnabledState = enabled; + console.log(`[Sentry] Error reporting ${enabled ? 'enabled' : 'disabled'} (via IPC)`); + }); + + // IPC handler for renderer to get Sentry config + ipcMain.handle(IPC_CHANNELS.GET_SENTRY_DSN, () => { + return cachedDsn; + }); + + ipcMain.handle(IPC_CHANNELS.GET_SENTRY_CONFIG, () => { + return { + dsn: cachedDsn, + tracesSampleRate: cachedTracesSampleRate, + profilesSampleRate: cachedProfilesSampleRate, + }; + }); + + if (hasDsn) { + console.log(`[Sentry] Main process initialized (enabled: ${sentryEnabledState}, traces: ${cachedTracesSampleRate}, profiles: ${cachedProfilesSampleRate})`); + } +} + +/** + * Get current Sentry enabled state + */ +export function isSentryEnabled(): boolean { + return sentryEnabledState; +} + +/** + * Set Sentry enabled state programmatically + */ +export function setSentryEnabled(enabled: boolean): void { + sentryEnabledState = enabled; + console.log(`[Sentry] Error reporting ${enabled ? 'enabled' : 'disabled'} (programmatic)`); +} diff --git a/apps/frontend/src/preload/api/settings-api.ts b/apps/frontend/src/preload/api/settings-api.ts index 263c32d0..1c1f8752 100644 --- a/apps/frontend/src/preload/api/settings-api.ts +++ b/apps/frontend/src/preload/api/settings-api.ts @@ -28,6 +28,11 @@ export interface SettingsAPI { getSourceEnv: () => Promise>; updateSourceEnv: (config: { claudeOAuthToken?: string }) => Promise; checkSourceToken: () => Promise>; + + // Sentry error reporting + notifySentryStateChanged: (enabled: boolean) => void; + getSentryDsn: () => Promise; + getSentryConfig: () => Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }>; } export const createSettingsAPI = (): SettingsAPI => ({ @@ -59,5 +64,17 @@ export const createSettingsAPI = (): SettingsAPI => ({ ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE, config), checkSourceToken: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN) + ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN), + + // Sentry error reporting - notify main process when setting changes + notifySentryStateChanged: (enabled: boolean): void => + ipcRenderer.send(IPC_CHANNELS.SENTRY_STATE_CHANGED, enabled), + + // Get Sentry DSN from main process (loaded from environment variable) + getSentryDsn: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_DSN), + + // Get full Sentry config from main process (DSN + sample rates) + getSentryConfig: (): Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }> => + ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG) }); diff --git a/apps/frontend/src/renderer/components/onboarding/AuthChoiceStep.tsx b/apps/frontend/src/renderer/components/onboarding/AuthChoiceStep.tsx index 65311e9b..ca0c50be 100644 --- a/apps/frontend/src/renderer/components/onboarding/AuthChoiceStep.tsx +++ b/apps/frontend/src/renderer/components/onboarding/AuthChoiceStep.tsx @@ -133,7 +133,7 @@ export function AuthChoiceStep({ onNext, onBack, onSkip, onAPIKeyPathComplete }: } title="Use Custom API Key" - description="Bring your own API key from Anthropic or a compatible API provider." + description="Bring your own API key from Anthropic or a compatible API provider. ⚠️ Highly experimental — may incur significant costs." onClick={handleAPIKeyChoice} data-testid="auth-option-apikey" /> diff --git a/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx b/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx index 06eb4a59..5eb00c07 100644 --- a/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx +++ b/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx @@ -16,6 +16,7 @@ import { AuthChoiceStep } from './AuthChoiceStep'; import { OAuthStep } from './OAuthStep'; import { ClaudeCodeStep } from './ClaudeCodeStep'; import { DevToolsStep } from './DevToolsStep'; +import { PrivacyStep } from './PrivacyStep'; import { GraphitiStep } from './GraphitiStep'; import { CompletionStep } from './CompletionStep'; import { useSettingsStore } from '../../stores/settings-store'; @@ -28,7 +29,7 @@ interface OnboardingWizardProps { } // Wizard step identifiers -type WizardStepId = 'welcome' | 'auth-choice' | 'oauth' | 'claude-code' | 'devtools' | 'graphiti' | 'completion'; +type WizardStepId = 'welcome' | 'auth-choice' | 'oauth' | 'claude-code' | 'devtools' | 'privacy' | 'graphiti' | 'completion'; // Step configuration with translation keys const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [ @@ -37,6 +38,7 @@ const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [ { id: 'oauth', labelKey: 'steps.auth' }, { id: 'claude-code', labelKey: 'steps.claudeCode' }, { id: 'devtools', labelKey: 'steps.devtools' }, + { id: 'privacy', labelKey: 'steps.privacy' }, { id: 'graphiti', labelKey: 'steps.memory' }, { id: 'completion', labelKey: 'steps.done' } ]; @@ -212,6 +214,13 @@ export function OnboardingWizard({ onBack={goToPreviousStep} /> ); + case 'privacy': + return ( + + ); case 'graphiti': return ( void; + onBack: () => void; +} + +/** + * Onboarding step for anonymous error reporting opt-in. + * Explains what data is collected and what is never collected. + * Enabled by default to help improve the app. + */ +export function PrivacyStep({ onNext, onBack }: PrivacyStepProps) { + const { t } = useTranslation(['onboarding', 'common']); + const { settings, updateSettings } = useSettingsStore(); + const [sentryEnabled, setSentryEnabled] = useState(settings.sentryEnabled ?? true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const handleToggle = (checked: boolean) => { + setSentryEnabled(checked); + setError(null); // Clear error when user interacts + }; + + const handleSave = async () => { + setIsSaving(true); + setError(null); + try { + const result = await window.electronAPI.saveSettings({ sentryEnabled }); + if (result?.success) { + updateSettings({ sentryEnabled }); + notifySentryStateChanged(sentryEnabled); + onNext(); + } else { + setError(t('onboarding:privacy.saveFailed', 'Failed to save privacy settings. Please try again.')); + } + } catch (err) { + setError(t('onboarding:privacy.saveFailed', 'Failed to save privacy settings. Please try again.')); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {t('onboarding:privacy.title')} +

+

+ {t('onboarding:privacy.subtitle')} +

+
+ +
+ {/* What we collect */} + + +
+ +
+

+ {t('onboarding:privacy.whatWeCollect.title')} +

+
    +
  • {t('onboarding:privacy.whatWeCollect.crashReports')}
  • +
  • {t('onboarding:privacy.whatWeCollect.errorMessages')}
  • +
  • {t('onboarding:privacy.whatWeCollect.appVersion')}
  • +
+
+
+
+
+ + {/* What we never collect */} + + +
+ +
+

+ {t('onboarding:privacy.whatWeNeverCollect.title')} +

+
    +
  • {t('onboarding:privacy.whatWeNeverCollect.code')}
  • +
  • {t('onboarding:privacy.whatWeNeverCollect.filenames')}
  • +
  • {t('onboarding:privacy.whatWeNeverCollect.apiKeys')}
  • +
  • {t('onboarding:privacy.whatWeNeverCollect.personalData')}
  • +
+
+
+
+
+ + {/* Toggle */} + + +
+
+ +
+ +

+ {t('onboarding:privacy.toggle.description')} +

+
+
+ +
+
+
+
+ + {/* Error Display */} + {error && ( +
+ + {error} +
+ )} + + {/* Action Buttons */} +
+ + +
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/onboarding/index.ts b/apps/frontend/src/renderer/components/onboarding/index.ts index a3bbf242..3044c1b7 100644 --- a/apps/frontend/src/renderer/components/onboarding/index.ts +++ b/apps/frontend/src/renderer/components/onboarding/index.ts @@ -7,6 +7,7 @@ export { OnboardingWizard } from './OnboardingWizard'; export { WelcomeStep } from './WelcomeStep'; export { AuthChoiceStep } from './AuthChoiceStep'; export { OAuthStep } from './OAuthStep'; +export { PrivacyStep } from './PrivacyStep'; export { MemoryStep } from './MemoryStep'; export { OllamaModelSelector } from './OllamaModelSelector'; export { FirstSpecStep } from './FirstSpecStep'; diff --git a/apps/frontend/src/renderer/components/settings/DebugSettings.tsx b/apps/frontend/src/renderer/components/settings/DebugSettings.tsx index f97fc0bb..e8aab443 100644 --- a/apps/frontend/src/renderer/components/settings/DebugSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/DebugSettings.tsx @@ -1,8 +1,12 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Bug, FolderOpen, Copy, FileText, RefreshCw, Loader2, Check, AlertCircle } from 'lucide-react'; +import { Bug, FolderOpen, Copy, FileText, RefreshCw, Loader2, Check, AlertCircle, Shield } from 'lucide-react'; import { Button } from '../ui/button'; +import { Switch } from '../ui/switch'; +import { Label } from '../ui/label'; import { SettingsSection } from './SettingsSection'; +import { useSettingsStore } from '../../stores/settings-store'; +import { notifySentryStateChanged } from '../../lib/sentry'; interface DebugInfo { systemInfo: Record; @@ -16,11 +20,28 @@ interface DebugInfo { */ export function DebugSettings() { const { t } = useTranslation('settings'); + const { settings, updateSettings } = useSettingsStore(); const [debugInfo, setDebugInfo] = useState(null); const [isLoading, setIsLoading] = useState(false); const [copySuccess, setCopySuccess] = useState(false); const [error, setError] = useState(null); + // Handle Sentry toggle + const handleSentryToggle = async (checked: boolean) => { + setError(null); + try { + const result = await window.electronAPI.saveSettings({ sentryEnabled: checked }); + if (result.success) { + updateSettings({ sentryEnabled: checked }); + notifySentryStateChanged(checked); + } else { + setError(t('debug.errorReporting.saveFailed', 'Failed to save error reporting setting')); + } + } catch (err) { + setError(t('debug.errorReporting.saveFailed', 'Failed to save error reporting setting')); + } + }; + const loadDebugInfo = async () => { setIsLoading(true); setError(null); @@ -65,6 +86,28 @@ export function DebugSettings() { description={t('debug.description', 'Access logs and debug information for troubleshooting')} >
+ {/* Error Reporting Toggle */} +
+
+
+ +
+ +

+ {t('debug.errorReporting.description', 'Send crash reports to help improve Auto Claude. No personal data or code is collected.')} +

+
+
+ +
+
+ {/* Quick Actions */}