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).
This commit is contained in:
Andy
2026-01-05 10:35:46 +01:00
committed by GitHub
parent 234d44f637
commit 8be0e6ff1a
26 changed files with 1836 additions and 44 deletions
+28
View File
@@ -19,6 +19,34 @@
# Shows detailed information about app update checks and downloads # Shows detailed information about app update checks and downloads
# DEBUG_UPDATER=true # 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 # HOW TO USE
# ============================================ # ============================================
+1
View File
@@ -69,6 +69,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@sentry/electron": "^7.5.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-virtual": "^3.13.13", "@tanstack/react-virtual": "^3.13.13",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+4
View File
@@ -12,11 +12,15 @@ import { initializeAppUpdater } from './app-updater';
import { DEFAULT_APP_SETTINGS } from '../shared/constants'; import { DEFAULT_APP_SETTINGS } from '../shared/constants';
import { readSettingsFile } from './settings-utils'; import { readSettingsFile } from './settings-utils';
import { setupErrorLogging } from './app-logger'; import { setupErrorLogging } from './app-logger';
import { initSentryMain } from './sentry';
import type { AppSettings } from '../shared/types'; import type { AppSettings } from '../shared/types';
// Setup error logging early (captures uncaught exceptions) // Setup error logging early (captures uncaught exceptions)
setupErrorLogging(); setupErrorLogging();
// Initialize Sentry for error tracking (respects user's sentryEnabled setting)
initSentryMain();
/** /**
* Load app settings synchronously (for use during startup). * Load app settings synchronously (for use during startup).
* This is a simple merge with defaults - no migrations or auto-detection. * This is a simple merge with defaults - no migrations or auto-detection.
+167
View File
@@ -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)`);
}
+18 -1
View File
@@ -28,6 +28,11 @@ export interface SettingsAPI {
getSourceEnv: () => Promise<IPCResult<SourceEnvConfig>>; getSourceEnv: () => Promise<IPCResult<SourceEnvConfig>>;
updateSourceEnv: (config: { claudeOAuthToken?: string }) => Promise<IPCResult>; updateSourceEnv: (config: { claudeOAuthToken?: string }) => Promise<IPCResult>;
checkSourceToken: () => Promise<IPCResult<SourceEnvCheckResult>>; checkSourceToken: () => Promise<IPCResult<SourceEnvCheckResult>>;
// Sentry error reporting
notifySentryStateChanged: (enabled: boolean) => void;
getSentryDsn: () => Promise<string>;
getSentryConfig: () => Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }>;
} }
export const createSettingsAPI = (): SettingsAPI => ({ export const createSettingsAPI = (): SettingsAPI => ({
@@ -59,5 +64,17 @@ export const createSettingsAPI = (): SettingsAPI => ({
ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE, config), ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE, config),
checkSourceToken: (): Promise<IPCResult<SourceEnvCheckResult>> => checkSourceToken: (): Promise<IPCResult<SourceEnvCheckResult>> =>
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<string> =>
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)
}); });
@@ -133,7 +133,7 @@ export function AuthChoiceStep({ onNext, onBack, onSkip, onAPIKeyPathComplete }:
<AuthOptionCard <AuthOptionCard
icon={<Key className="h-6 w-6" />} icon={<Key className="h-6 w-6" />}
title="Use Custom API Key" 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} onClick={handleAPIKeyChoice}
data-testid="auth-option-apikey" data-testid="auth-option-apikey"
/> />
@@ -16,6 +16,7 @@ import { AuthChoiceStep } from './AuthChoiceStep';
import { OAuthStep } from './OAuthStep'; import { OAuthStep } from './OAuthStep';
import { ClaudeCodeStep } from './ClaudeCodeStep'; import { ClaudeCodeStep } from './ClaudeCodeStep';
import { DevToolsStep } from './DevToolsStep'; import { DevToolsStep } from './DevToolsStep';
import { PrivacyStep } from './PrivacyStep';
import { GraphitiStep } from './GraphitiStep'; import { GraphitiStep } from './GraphitiStep';
import { CompletionStep } from './CompletionStep'; import { CompletionStep } from './CompletionStep';
import { useSettingsStore } from '../../stores/settings-store'; import { useSettingsStore } from '../../stores/settings-store';
@@ -28,7 +29,7 @@ interface OnboardingWizardProps {
} }
// Wizard step identifiers // 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 // Step configuration with translation keys
const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [ const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [
@@ -37,6 +38,7 @@ const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [
{ id: 'oauth', labelKey: 'steps.auth' }, { id: 'oauth', labelKey: 'steps.auth' },
{ id: 'claude-code', labelKey: 'steps.claudeCode' }, { id: 'claude-code', labelKey: 'steps.claudeCode' },
{ id: 'devtools', labelKey: 'steps.devtools' }, { id: 'devtools', labelKey: 'steps.devtools' },
{ id: 'privacy', labelKey: 'steps.privacy' },
{ id: 'graphiti', labelKey: 'steps.memory' }, { id: 'graphiti', labelKey: 'steps.memory' },
{ id: 'completion', labelKey: 'steps.done' } { id: 'completion', labelKey: 'steps.done' }
]; ];
@@ -212,6 +214,13 @@ export function OnboardingWizard({
onBack={goToPreviousStep} onBack={goToPreviousStep}
/> />
); );
case 'privacy':
return (
<PrivacyStep
onNext={goToNextStep}
onBack={goToPreviousStep}
/>
);
case 'graphiti': case 'graphiti':
return ( return (
<GraphitiStep <GraphitiStep
@@ -0,0 +1,155 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Shield, Info, Check, AlertCircle } from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent } from '../ui/card';
import { Switch } from '../ui/switch';
import { Label } from '../ui/label';
import { useSettingsStore } from '../../stores/settings-store';
import { notifySentryStateChanged } from '../../lib/sentry';
interface PrivacyStepProps {
onNext: () => 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<string | null>(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 (
<div className="flex h-full flex-col items-center justify-center px-8 py-6">
<div className="w-full max-w-2xl">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
<Shield className="h-7 w-7" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground tracking-tight">
{t('onboarding:privacy.title')}
</h1>
<p className="mt-2 text-muted-foreground">
{t('onboarding:privacy.subtitle')}
</p>
</div>
<div className="space-y-6">
{/* What we collect */}
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-5">
<div className="flex items-start gap-4">
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<p className="text-sm font-medium text-foreground">
{t('onboarding:privacy.whatWeCollect.title')}
</p>
<ul className="text-sm text-muted-foreground space-y-1.5 list-disc list-inside">
<li>{t('onboarding:privacy.whatWeCollect.crashReports')}</li>
<li>{t('onboarding:privacy.whatWeCollect.errorMessages')}</li>
<li>{t('onboarding:privacy.whatWeCollect.appVersion')}</li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* What we never collect */}
<Card className="border border-success/30 bg-success/10">
<CardContent className="p-5">
<div className="flex items-start gap-4">
<Check className="h-5 w-5 text-success shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<p className="text-sm font-medium text-foreground">
{t('onboarding:privacy.whatWeNeverCollect.title')}
</p>
<ul className="text-sm text-muted-foreground space-y-1.5 list-disc list-inside">
<li>{t('onboarding:privacy.whatWeNeverCollect.code')}</li>
<li>{t('onboarding:privacy.whatWeNeverCollect.filenames')}</li>
<li>{t('onboarding:privacy.whatWeNeverCollect.apiKeys')}</li>
<li>{t('onboarding:privacy.whatWeNeverCollect.personalData')}</li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* Toggle */}
<Card className="border border-border bg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-muted-foreground" />
<div>
<Label htmlFor="sentry-toggle" className="text-sm font-medium text-foreground cursor-pointer">
{t('onboarding:privacy.toggle.label')}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t('onboarding:privacy.toggle.description')}
</p>
</div>
</div>
<Switch
id="sentry-toggle"
checked={sentryEnabled}
onCheckedChange={handleToggle}
/>
</div>
</CardContent>
</Card>
</div>
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 p-3 mt-6 rounded-md bg-destructive/10 text-destructive text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
{error}
</div>
)}
{/* Action Buttons */}
<div className="flex justify-between items-center mt-10 pt-6 border-t border-border">
<Button variant="ghost" onClick={onBack}>
{t('common:back', 'Back')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? t('common:saving', 'Saving...') : t('common:continue', 'Continue')}
</Button>
</div>
</div>
</div>
);
}
@@ -7,6 +7,7 @@ export { OnboardingWizard } from './OnboardingWizard';
export { WelcomeStep } from './WelcomeStep'; export { WelcomeStep } from './WelcomeStep';
export { AuthChoiceStep } from './AuthChoiceStep'; export { AuthChoiceStep } from './AuthChoiceStep';
export { OAuthStep } from './OAuthStep'; export { OAuthStep } from './OAuthStep';
export { PrivacyStep } from './PrivacyStep';
export { MemoryStep } from './MemoryStep'; export { MemoryStep } from './MemoryStep';
export { OllamaModelSelector } from './OllamaModelSelector'; export { OllamaModelSelector } from './OllamaModelSelector';
export { FirstSpecStep } from './FirstSpecStep'; export { FirstSpecStep } from './FirstSpecStep';
@@ -1,8 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import { Label } from '../ui/label';
import { SettingsSection } from './SettingsSection'; import { SettingsSection } from './SettingsSection';
import { useSettingsStore } from '../../stores/settings-store';
import { notifySentryStateChanged } from '../../lib/sentry';
interface DebugInfo { interface DebugInfo {
systemInfo: Record<string, string>; systemInfo: Record<string, string>;
@@ -16,11 +20,28 @@ interface DebugInfo {
*/ */
export function DebugSettings() { export function DebugSettings() {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const { settings, updateSettings } = useSettingsStore();
const [debugInfo, setDebugInfo] = useState<DebugInfo | null>(null); const [debugInfo, setDebugInfo] = useState<DebugInfo | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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 () => { const loadDebugInfo = async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -65,6 +86,28 @@ export function DebugSettings() {
description={t('debug.description', 'Access logs and debug information for troubleshooting')} description={t('debug.description', 'Access logs and debug information for troubleshooting')}
> >
<div className="space-y-6"> <div className="space-y-6">
{/* Error Reporting Toggle */}
<div className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-muted-foreground" />
<div>
<Label htmlFor="sentry-toggle" className="text-sm font-medium text-foreground cursor-pointer">
{t('debug.errorReporting.label', 'Anonymous Error Reporting')}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t('debug.errorReporting.description', 'Send crash reports to help improve Auto Claude. No personal data or code is collected.')}
</p>
</div>
</div>
<Switch
id="sentry-toggle"
checked={settings.sentryEnabled ?? true}
onCheckedChange={handleSentryToggle}
/>
</div>
</div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button <Button
@@ -2,6 +2,7 @@ import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react'; import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from './button'; import { Button } from './button';
import { Card, CardContent } from './card'; import { Card, CardContent } from './card';
import { captureException } from '../../lib/sentry';
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: React.ReactNode; children: React.ReactNode;
@@ -30,6 +31,11 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error('ErrorBoundary caught an error:', error, errorInfo); console.error('ErrorBoundary caught an error:', error, errorInfo);
// Report to Sentry with React component stack
captureException(error, {
componentStack: errorInfo.componentStack,
});
} }
handleReset = (): void => { handleReset = (): void => {
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.githubusercontent.com https://*.supabase.co" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.githubusercontent.com https://*.supabase.co; connect-src 'self' https://*.ingest.us.sentry.io" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
@@ -13,6 +13,13 @@ export const settingsMock = {
saveSettings: async () => ({ success: true }), saveSettings: async () => ({ success: true }),
// Sentry error reporting
notifySentryStateChanged: (_enabled: boolean) => {
console.warn('[browser-mock] notifySentryStateChanged called');
},
getSentryDsn: async () => '', // No DSN in browser mode
getSentryConfig: async () => ({ dsn: '', tracesSampleRate: 0, profilesSampleRate: 0 }),
getCliToolsInfo: async () => ({ getCliToolsInfo: async () => ({
success: true, success: true,
data: { data: {
+163
View File
@@ -0,0 +1,163 @@
/**
* Sentry Error Tracking for Renderer Process
*
* Initializes Sentry with:
* - beforeSend hook that checks settings store (allows mid-session toggle)
* - Path masking for user privacy (shared with main process)
* - Function to notify main process when setting changes
*
* 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
*
* DSN Configuration:
* - DSN is loaded from environment variable via main process IPC
* - If no DSN is configured, Sentry is disabled (safe for forks)
*
* Race Condition Prevention:
* - We track whether settings have been loaded from disk
* - Until settings are loaded, we default to NOT sending events
* - This respects user preference even during early app initialization
*/
import * as Sentry from '@sentry/electron/renderer';
import { useSettingsStore } from '../stores/settings-store';
import {
processEvent,
type SentryErrorEvent
} from '../../shared/utils/sentry-privacy';
// Track whether settings have been loaded from disk
// This prevents sending events before we know user's preference
let settingsLoaded = false;
// Track whether Sentry has been initialized
let sentryInitialized = false;
/**
* Mark settings as loaded
* Called by settings store after initial load from disk
*/
export function markSettingsLoaded(): void {
settingsLoaded = true;
console.log('[Sentry] Settings loaded, error reporting ready');
}
/**
* Check if settings have been loaded
*/
export function areSettingsLoaded(): boolean {
return settingsLoaded;
}
/**
* Initialize Sentry for renderer process
* Should be called early in renderer startup
*
* This is async because we need to fetch the DSN from the main process
*/
export async function initSentryRenderer(): Promise<void> {
// Check if we're in Electron or browser environment
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
if (!isElectron) {
console.log('[Sentry] Not in Electron environment, skipping initialization');
return;
}
// Get full Sentry config from main process (DSN + sample rates from env vars)
let config = { dsn: '', tracesSampleRate: 0, profilesSampleRate: 0 };
try {
config = await window.electronAPI.getSentryConfig();
} catch (error) {
console.warn('[Sentry] Failed to get config from main process:', error);
}
const hasDsn = config.dsn.length > 0;
if (!hasDsn) {
console.log('[Sentry] No DSN configured - error reporting disabled in renderer');
return;
}
Sentry.init({
dsn: config.dsn,
beforeSend(event: Sentry.ErrorEvent) {
// Don't send events until settings are loaded
// This prevents sending events if user had disabled Sentry
if (!settingsLoaded) {
console.log('[Sentry] Settings not loaded yet, dropping event');
return null;
}
// Check current setting at send time (allows mid-session toggle)
try {
const currentSettings = useSettingsStore.getState().settings;
const isEnabled = currentSettings.sentryEnabled ?? true;
if (!isEnabled) {
return null;
}
} catch (error) {
// If settings store fails, don't send event (be conservative)
console.error('[Sentry] Failed to read settings, dropping event:', error);
return null;
}
// Process event with shared privacy utility
return processEvent(event as SentryErrorEvent) as Sentry.ErrorEvent;
},
// Sample rates from main process (configured via environment variables)
tracesSampleRate: config.tracesSampleRate,
profilesSampleRate: config.profilesSampleRate,
// Enable in Electron environment when we have a DSN
enabled: true,
});
sentryInitialized = true;
console.log(`[Sentry] Renderer initialized (traces: ${config.tracesSampleRate}, profiles: ${config.profilesSampleRate})`);
}
/**
* Check if Sentry has been initialized
*/
export function isSentryInitialized(): boolean {
return sentryInitialized;
}
/**
* Notify main process when Sentry setting changes
* Call this whenever the user toggles the setting in the UI
*/
export function notifySentryStateChanged(enabled: boolean): void {
console.log(`[Sentry] Notifying main process: ${enabled ? 'enabled' : 'disabled'}`);
try {
window.electronAPI?.notifySentryStateChanged?.(enabled);
} catch (error) {
console.error('[Sentry] Failed to notify main process:', error);
}
}
/**
* Manually capture an exception with Sentry
* Useful for error boundaries or try/catch blocks
*/
export function captureException(error: Error, context?: Record<string, unknown>): void {
if (!sentryInitialized) {
// Sentry not initialized (no DSN configured), just log
console.error('[Sentry] Not initialized, error not captured:', error);
return;
}
if (context) {
Sentry.withScope((scope) => {
scope.setContext('additional', context);
Sentry.captureException(error);
});
} else {
Sentry.captureException(error);
}
}
+7
View File
@@ -4,6 +4,13 @@ import './lib/browser-mock';
// Initialize i18n before React // Initialize i18n before React
import '../shared/i18n'; import '../shared/i18n';
// Initialize Sentry for error tracking (respects user's sentryEnabled setting)
// Fire-and-forget: React rendering proceeds immediately while Sentry initializes async
import { initSentryRenderer } from './lib/sentry';
initSentryRenderer().catch((err) => {
console.warn('[Sentry] Failed to initialize renderer:', err);
});
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { App } from './App'; import { App } from './App';
@@ -3,6 +3,7 @@ import type { AppSettings } from '../../shared/types';
import type { APIProfile, ProfileFormData, TestConnectionResult, DiscoverModelsResult, ModelInfo } from '@shared/types/profile'; import type { APIProfile, ProfileFormData, TestConnectionResult, DiscoverModelsResult, ModelInfo } from '@shared/types/profile';
import { DEFAULT_APP_SETTINGS } from '../../shared/constants'; import { DEFAULT_APP_SETTINGS } from '../../shared/constants';
import { toast } from '../hooks/use-toast'; import { toast } from '../hooks/use-toast';
import { markSettingsLoaded } from '../lib/sentry';
interface SettingsState { interface SettingsState {
settings: AppSettings; settings: AppSettings;
@@ -342,9 +343,18 @@ export async function loadSettings(): Promise<void> {
onboardingCompleted: migratedSettings.onboardingCompleted onboardingCompleted: migratedSettings.onboardingCompleted
}); });
} }
// Only mark settings as loaded on SUCCESS
// This ensures Sentry respects user's opt-out preference even if settings fail to load
// (If settings fail to load, Sentry's beforeSend drops all events until successful load)
markSettingsLoaded();
} }
// Note: If result.success is false, we intentionally do NOT mark settings as loaded.
// This means Sentry will drop events, which is the safe default for privacy.
} catch (error) { } catch (error) {
store.setError(error instanceof Error ? error.message : 'Failed to load settings'); store.setError(error instanceof Error ? error.message : 'Failed to load settings');
// Note: On exception, we intentionally do NOT mark settings as loaded.
// Sentry's beforeSend will drop events, respecting potential user opt-out.
} finally { } finally {
store.setLoading(false); store.setLoading(false);
} }
+3 -1
View File
@@ -48,7 +48,9 @@ export const DEFAULT_APP_SETTINGS = {
// Beta updates opt-in (receive pre-release versions) // Beta updates opt-in (receive pre-release versions)
betaUpdates: false, betaUpdates: false,
// Language preference (default to English) // Language preference (default to English)
language: 'en' as const language: 'en' as const,
// Anonymous error reporting (Sentry) - enabled by default to help improve the app
sentryEnabled: true
}; };
// ============================================ // ============================================
+6 -1
View File
@@ -494,5 +494,10 @@ export const IPC_CHANNELS = {
// MCP Server health checks // MCP Server health checks
MCP_CHECK_HEALTH: 'mcp:checkHealth', // Quick connectivity check MCP_CHECK_HEALTH: 'mcp:checkHealth', // Quick connectivity check
MCP_TEST_CONNECTION: 'mcp:testConnection' // Full MCP protocol test MCP_TEST_CONNECTION: 'mcp:testConnection', // Full MCP protocol test
// Sentry error reporting
SENTRY_STATE_CHANGED: 'sentry:state-changed', // Notify main process when setting changes
GET_SENTRY_DSN: 'sentry:get-dsn', // Get DSN from main process (env var)
GET_SENTRY_CONFIG: 'sentry:get-config' // Get full Sentry config (DSN + sample rates)
} as const; } as const;
@@ -67,9 +67,31 @@
"auth": "Auth", "auth": "Auth",
"claudeCode": "CLI", "claudeCode": "CLI",
"devtools": "Dev Tools", "devtools": "Dev Tools",
"privacy": "Privacy",
"memory": "Memory", "memory": "Memory",
"done": "Done" "done": "Done"
}, },
"privacy": {
"title": "Help Improve Auto Claude",
"subtitle": "Anonymous error reporting helps us fix bugs faster",
"whatWeCollect": {
"title": "What we collect",
"crashReports": "Crash reports and error stack traces",
"errorMessages": "Error messages (with file paths anonymized)",
"appVersion": "App version and platform info"
},
"whatWeNeverCollect": {
"title": "What we never collect",
"code": "Your code or project files",
"filenames": "Full file paths (usernames are masked)",
"apiKeys": "API keys or tokens",
"personalData": "Personal information or usage data"
},
"toggle": {
"label": "Send anonymous error reports",
"description": "Help us identify and fix issues"
}
},
"claudeCode": { "claudeCode": {
"title": "Claude Code CLI", "title": "Claude Code CLI",
"description": "Install or update the Claude Code CLI to enable AI-powered features", "description": "Install or update the Claude Code CLI to enable AI-powered features",
@@ -312,6 +312,10 @@
"debug": { "debug": {
"title": "Debug & Logs", "title": "Debug & Logs",
"description": "Access logs and debug information for troubleshooting", "description": "Access logs and debug information for troubleshooting",
"errorReporting": {
"label": "Anonymous Error Reporting",
"description": "Send crash reports to help improve Auto Claude. No personal data or code is collected."
},
"openLogsFolder": "Open Logs Folder", "openLogsFolder": "Open Logs Folder",
"copyDebugInfo": "Copy Debug Info", "copyDebugInfo": "Copy Debug Info",
"copied": "Copied!", "copied": "Copied!",
@@ -67,9 +67,31 @@
"auth": "Auth", "auth": "Auth",
"claudeCode": "CLI", "claudeCode": "CLI",
"devtools": "Outils dev", "devtools": "Outils dev",
"privacy": "Confidentialité",
"memory": "Mémoire", "memory": "Mémoire",
"done": "Terminé" "done": "Terminé"
}, },
"privacy": {
"title": "Aidez à améliorer Auto Claude",
"subtitle": "Les rapports d'erreurs anonymes nous aident à corriger les bugs plus rapidement",
"whatWeCollect": {
"title": "Ce que nous collectons",
"crashReports": "Rapports de crash et traces d'erreurs",
"errorMessages": "Messages d'erreur (avec chemins de fichiers anonymisés)",
"appVersion": "Version de l'app et informations système"
},
"whatWeNeverCollect": {
"title": "Ce que nous ne collectons jamais",
"code": "Votre code ou fichiers de projet",
"filenames": "Chemins de fichiers complets (noms d'utilisateur masqués)",
"apiKeys": "Clés API ou jetons",
"personalData": "Informations personnelles ou données d'utilisation"
},
"toggle": {
"label": "Envoyer des rapports d'erreurs anonymes",
"description": "Aidez-nous à identifier et corriger les problèmes"
}
},
"claudeCode": { "claudeCode": {
"title": "Claude Code CLI", "title": "Claude Code CLI",
"description": "Installez ou mettez à jour le CLI Claude Code pour activer les fonctionnalités IA", "description": "Installez ou mettez à jour le CLI Claude Code pour activer les fonctionnalités IA",
@@ -312,6 +312,10 @@
"debug": { "debug": {
"title": "Debug & Logs", "title": "Debug & Logs",
"description": "Accédez aux logs et informations de débogage pour le dépannage", "description": "Accédez aux logs et informations de débogage pour le dépannage",
"errorReporting": {
"label": "Rapports d'erreurs anonymes",
"description": "Envoyer des rapports de crash pour améliorer Auto Claude. Aucune donnée personnelle ni code n'est collecté."
},
"openLogsFolder": "Ouvrir le dossier des logs", "openLogsFolder": "Ouvrir le dossier des logs",
"copyDebugInfo": "Copier les infos de débogage", "copyDebugInfo": "Copier les infos de débogage",
"copied": "Copié !", "copied": "Copié !",
+6
View File
@@ -265,6 +265,12 @@ export interface ElectronAPI {
// App settings // App settings
getSettings: () => Promise<IPCResult<AppSettings>>; getSettings: () => Promise<IPCResult<AppSettings>>;
saveSettings: (settings: Partial<AppSettings>) => Promise<IPCResult>; saveSettings: (settings: Partial<AppSettings>) => Promise<IPCResult>;
// Sentry error reporting
notifySentryStateChanged: (enabled: boolean) => void;
getSentryDsn: () => Promise<string>;
getSentryConfig: () => Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }>;
getCliToolsInfo: () => Promise<IPCResult<{ getCliToolsInfo: () => Promise<IPCResult<{
python: import('./cli').ToolDetectionResult; python: import('./cli').ToolDetectionResult;
git: import('./cli').ToolDetectionResult; git: import('./cli').ToolDetectionResult;
@@ -274,6 +274,8 @@ export interface AppSettings {
customIDEPath?: string; // For 'custom' IDE customIDEPath?: string; // For 'custom' IDE
preferredTerminal?: SupportedTerminal; preferredTerminal?: SupportedTerminal;
customTerminalPath?: string; // For 'custom' terminal customTerminalPath?: string; // For 'custom' terminal
// Anonymous error reporting (Sentry) - enabled by default to help improve the app
sentryEnabled?: boolean;
} }
// Auto-Claude Source Environment Configuration (for auto-claude repo .env) // Auto-Claude Source Environment Configuration (for auto-claude repo .env)
@@ -0,0 +1,210 @@
/**
* Shared Sentry Privacy Utilities
*
* Provides path masking functions for both main and renderer processes
* to ensure user privacy in error reports.
*
* Privacy approach:
* - Usernames are masked from all file paths
* - Project paths remain visible (this is expected for debugging)
* - All event fields are processed: stack traces, breadcrumbs, messages,
* tags, contexts, extra data, and user info
*/
// Using a generic event type to work with both main and renderer Sentry SDKs
// The actual type is Sentry.ErrorEvent but we define a compatible interface
// to avoid importing @sentry/electron which has different exports per process
export interface SentryErrorEvent {
exception?: {
values?: Array<{
stacktrace?: {
frames?: Array<{
filename?: string;
abs_path?: string;
}>;
};
value?: string;
}>;
};
breadcrumbs?: Array<{
message?: string;
data?: Record<string, unknown>;
}>;
message?: string;
tags?: Record<string, string>;
contexts?: Record<string, Record<string, unknown> | null>;
extra?: Record<string, unknown>;
user?: Record<string, unknown>;
request?: {
url?: string;
headers?: Record<string, string>;
data?: unknown;
};
}
/**
* Mask user-specific paths for privacy
*
* Replaces usernames in common OS path patterns:
* - macOS: /Users/username/... becomes /Users/.../
* - Windows: C:\Users\username\... becomes C:\Users\...\
* - Linux: /home/username/... becomes /home/.../
*
* Note: Project paths remain visible for debugging purposes.
* This is intentional - we need to know which file caused the error.
*/
export function maskUserPaths(text: string): string {
if (!text) return text;
// macOS: /Users/username/... or /Users/username (at end of string)
// Uses lookahead to match with or without trailing slash
text = text.replace(/\/Users\/[^/]+(?=\/|$)/g, '/Users/***');
// Windows: C:\Users\username\... or C:\Users\username (at end of string)
// Uses lookahead to match with or without trailing backslash
text = text.replace(/[A-Z]:\\Users\\[^\\]+(?=\\|$)/gi, (match: string) => {
const drive = match[0];
return `${drive}:\\Users\\***`;
});
// Linux: /home/username/... or /home/username (at end of string)
// Uses lookahead to match with or without trailing slash
text = text.replace(/\/home\/[^/]+(?=\/|$)/g, '/home/***');
return text;
}
/**
* Recursively mask paths in an object
* Handles nested objects and arrays
*/
function maskObjectPaths(obj: unknown): unknown {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj === 'string') {
return maskUserPaths(obj);
}
if (Array.isArray(obj)) {
return obj.map(maskObjectPaths);
}
if (typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const key of Object.keys(obj as Record<string, unknown>)) {
result[key] = maskObjectPaths((obj as Record<string, unknown>)[key]);
}
return result;
}
return obj;
}
/**
* Process Sentry event to mask sensitive paths
*
* Comprehensive masking covers:
* - Exception stack traces (filename, abs_path)
* - Exception values (error messages)
* - Breadcrumbs (messages and data)
* - Top-level message
* - Tags (custom tags might contain paths)
* - Contexts (additional context data)
* - Extra data (arbitrary data attached to events)
* - User info (cleared entirely for privacy)
* - Request data (URLs, headers)
*/
export function processEvent<T extends SentryErrorEvent>(event: T): T {
// Mask paths in exception stack traces
if (event.exception?.values) {
for (const exception of event.exception.values) {
if (exception.stacktrace?.frames) {
for (const frame of exception.stacktrace.frames) {
if (frame.filename) {
frame.filename = maskUserPaths(frame.filename);
}
if (frame.abs_path) {
frame.abs_path = maskUserPaths(frame.abs_path);
}
}
}
if (exception.value) {
exception.value = maskUserPaths(exception.value);
}
}
}
// Mask paths in breadcrumbs
if (event.breadcrumbs) {
for (const breadcrumb of event.breadcrumbs) {
if (breadcrumb.message) {
breadcrumb.message = maskUserPaths(breadcrumb.message);
}
if (breadcrumb.data) {
breadcrumb.data = maskObjectPaths(breadcrumb.data) as Record<string, unknown>;
}
}
}
// Mask paths in message
if (event.message) {
event.message = maskUserPaths(event.message);
}
// Mask paths in tags
if (event.tags) {
for (const key of Object.keys(event.tags)) {
if (typeof event.tags[key] === 'string') {
event.tags[key] = maskUserPaths(event.tags[key]);
}
}
}
// Mask paths in contexts (recursively)
if (event.contexts) {
for (const contextKey of Object.keys(event.contexts)) {
const context = event.contexts[contextKey];
if (context && typeof context === 'object') {
event.contexts[contextKey] = maskObjectPaths(context) as Record<string, unknown>;
}
}
}
// Mask paths in extra data (recursively)
if (event.extra) {
event.extra = maskObjectPaths(event.extra) as Record<string, unknown>;
}
// Clear user info entirely for privacy
// We don't collect any user identifiers
if (event.user) {
event.user = {};
}
// Mask paths in request data
if (event.request) {
if (event.request.url) {
event.request.url = maskUserPaths(event.request.url);
}
if (event.request.headers) {
for (const key of Object.keys(event.request.headers)) {
if (typeof event.request.headers[key] === 'string') {
event.request.headers[key] = maskUserPaths(event.request.headers[key]);
}
}
}
if (event.request.data) {
event.request.data = maskObjectPaths(event.request.data);
}
}
return event;
}
/**
* Production trace sample rate
* 10% of transactions are sampled for performance monitoring
*/
export const PRODUCTION_TRACE_SAMPLE_RATE = 0.1;
+934 -37
View File
File diff suppressed because it is too large Load Diff