From 8be0e6ff1a7beb9cec0c3e1c3d52021a3aca38c3 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Mon, 5 Jan 2026 10:35:46 +0100 Subject: [PATCH] feat(sentry): add anonymous error reporting with privacy controls (#636) * feat(sentry): add anonymous error reporting with privacy controls Integrate @sentry/electron for crash reporting in both main and renderer processes. Key features: - Enabled by default with clear privacy messaging during onboarding - Mid-session toggle via beforeSend hooks (no restart required) - Comprehensive path masking for macOS, Windows, and Linux usernames - Complete event sanitization: stack traces, breadcrumbs, tags, contexts, extra data, request info, and user info (cleared entirely) - Race condition prevention: events dropped until settings are loaded - Shared privacy utilities to eliminate code duplication - Settings toggle in Debug & Logs section with i18n support (en/fr) - New PrivacyStep in onboarding wizard explaining data collection Privacy approach: usernames masked from all paths, project paths remain visible for debugging (documented as intentional behavior). * feat(sentry): move DSN to environment variable for fork protection Previously the Sentry DSN was hardcoded, which caused forks to send errors to the original project's Sentry account. This created cost concerns and data pollution. Changes: - Remove hardcoded DSN from sentry-privacy.ts - Main process reads DSN from SENTRY_DSN env var - Add IPC handler to expose DSN to renderer process - Renderer fetches DSN via IPC (async initialization) - Add SENTRY_DSN and SENTRY_DEV documentation to .env.example Now forks without the env var have Sentry disabled, while official builds can inject it via CI/CD secrets. * fix(sentry): address PR review findings and add sample rate env vars PR Review fixes: - Fix path masking regex to handle paths at end of strings (lookahead) - Add error handling to PrivacyStep when save fails - Add user feedback when Sentry toggle fails in DebugSettings - Add .catch() handler for async Sentry initialization in main.tsx New features: - Add SENTRY_TRACES_SAMPLE_RATE env var (0.0-1.0, default 0.1) - Add SENTRY_PROFILES_SAMPLE_RATE env var (0.0-1.0, default 0.1) - Add getSentryConfig IPC to share config with renderer This allows controlling Sentry sampling via environment variables to prevent filling up error logs with duplicate issues. * fix(sentry): only mark settings loaded on successful load Fixes privacy violation where Sentry would send error reports even if user had opted out. Previously, markSettingsLoaded() was called in finally block regardless of success, causing the store to retain DEFAULT_APP_SETTINGS (sentryEnabled: true) on load failure while marking settings as "loaded". Now markSettingsLoaded() is only called inside the success condition, so if settings fail to load, Sentry's beforeSend drops all events (safe default). --- apps/frontend/.env.example | 28 + apps/frontend/package.json | 1 + apps/frontend/src/main/index.ts | 4 + apps/frontend/src/main/sentry.ts | 167 +++ apps/frontend/src/preload/api/settings-api.ts | 19 +- .../components/onboarding/AuthChoiceStep.tsx | 2 +- .../onboarding/OnboardingWizard.tsx | 11 +- .../components/onboarding/PrivacyStep.tsx | 155 +++ .../renderer/components/onboarding/index.ts | 1 + .../components/settings/DebugSettings.tsx | 45 +- .../renderer/components/ui/error-boundary.tsx | 6 + apps/frontend/src/renderer/index.html | 2 +- .../src/renderer/lib/mocks/settings-mock.ts | 7 + apps/frontend/src/renderer/lib/sentry.ts | 163 +++ apps/frontend/src/renderer/main.tsx | 7 + .../src/renderer/stores/settings-store.ts | 10 + apps/frontend/src/shared/constants/config.ts | 4 +- apps/frontend/src/shared/constants/ipc.ts | 7 +- .../shared/i18n/locales/en/onboarding.json | 22 + .../src/shared/i18n/locales/en/settings.json | 4 + .../shared/i18n/locales/fr/onboarding.json | 22 + .../src/shared/i18n/locales/fr/settings.json | 4 + apps/frontend/src/shared/types/ipc.ts | 6 + apps/frontend/src/shared/types/settings.ts | 2 + .../src/shared/utils/sentry-privacy.ts | 210 ++++ package-lock.json | 971 +++++++++++++++++- 26 files changed, 1836 insertions(+), 44 deletions(-) create mode 100644 apps/frontend/src/main/sentry.ts create mode 100644 apps/frontend/src/renderer/components/onboarding/PrivacyStep.tsx create mode 100644 apps/frontend/src/renderer/lib/sentry.ts create mode 100644 apps/frontend/src/shared/utils/sentry-privacy.ts 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 */}