feat: add Sentry error tracking and update docs
- Integrate @sentry/electron and @sentry/react for crash reporting - Add Sentry Vite plugin for source maps - Add error tracking to main process, renderer, and IPC layer - Exclude source maps from packaged builds - Update README with new screenshots - Add Sentry opt-out toggle in settings - Update release workflow with Sentry config
This commit is contained in:
parent
115e1d2d0c
commit
e005671123
32 changed files with 1677 additions and 200 deletions
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
|
|
@ -36,6 +36,11 @@ jobs:
|
|||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Build app
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
|
||||
- name: Create GitHub Release
|
||||
|
|
@ -106,6 +111,11 @@ jobs:
|
|||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Build app (macOS ${{ matrix.arch }})
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify packaged inputs (macOS ${{ matrix.arch }})
|
||||
|
|
@ -173,6 +183,11 @@ jobs:
|
|||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Build app (Windows)
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify packaged inputs (Windows)
|
||||
|
|
@ -241,6 +256,11 @@ jobs:
|
|||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Build app (Linux)
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify packaged inputs (Linux)
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -1,11 +1,13 @@
|
|||
<p align="center">
|
||||
<a href="docs/screenshots/1.jpg"><img src="docs/screenshots/1.jpg" width="95" alt="Kanban Board" /></a>
|
||||
<a href="docs/screenshots/7.png"><img src="docs/screenshots/7.png" width="95" alt="Code Review" /></a>
|
||||
<a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="95" alt="Team View" /></a>
|
||||
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="100" />
|
||||
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.jpg" width="95" alt="Agent Comments" /></a>
|
||||
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="95" alt="Create Team" /></a>
|
||||
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="80" alt="Settings" /></a>
|
||||
<a href="docs/screenshots/1.jpg"><img src="docs/screenshots/1.jpg" width="75" alt="Kanban Board" /></a>
|
||||
<a href="docs/screenshots/7.png"><img src="docs/screenshots/7.png" width="75" alt="Code Review" /></a>
|
||||
<a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="75" alt="Team View" /></a>
|
||||
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>
|
||||
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="80" />
|
||||
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>
|
||||
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.jpg" width="75" alt="Agent Comments" /></a>
|
||||
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>
|
||||
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Claude Agent Teams UI</h1>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB |
BIN
docs/screenshots/3.png
Normal file
BIN
docs/screenshots/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 588 KiB |
BIN
docs/screenshots/8.png
Normal file
BIN
docs/screenshots/8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 666 KiB |
BIN
docs/screenshots/9.png
Normal file
BIN
docs/screenshots/9.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
|
|
@ -1,4 +1,5 @@
|
|||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
|
@ -32,14 +33,36 @@ function nativeModuleStub(): Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// Sentry source map upload — only active in CI when SENTRY_AUTH_TOKEN is set.
|
||||
const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
|
||||
project: process.env.SENTRY_PROJECT ?? 'electron',
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
release: { name: `claude-agent-teams-ui@${pkg.version}` },
|
||||
sourcemaps: {
|
||||
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'],
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: bundledDeps
|
||||
}),
|
||||
nativeModuleStub()
|
||||
nativeModuleStub(),
|
||||
...sentryPlugins,
|
||||
],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
// Inject DSN at compile time — process.env.SENTRY_DSN is NOT available
|
||||
// at runtime in packaged Electron apps (only during CI build).
|
||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
|
|
@ -48,6 +71,7 @@ export default defineConfig({
|
|||
}
|
||||
},
|
||||
build: {
|
||||
sourcemap: 'hidden',
|
||||
outDir: 'dist-electron/main',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
|
@ -94,6 +118,11 @@ export default defineConfig({
|
|||
optimizeDeps: {
|
||||
include: ['@codemirror/language-data']
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
// Pass SENTRY_DSN to renderer as VITE_SENTRY_DSN (Vite replaces at compile time)
|
||||
'import.meta.env.VITE_SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
|
|
@ -101,8 +130,9 @@ export default defineConfig({
|
|||
'@main': resolve(__dirname, 'src/main')
|
||||
}
|
||||
},
|
||||
plugins: [react()],
|
||||
plugins: [react(), ...sentryPlugins],
|
||||
build: {
|
||||
sourcemap: 'hidden',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html')
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@
|
|||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.10.0",
|
||||
"@sentry/react": "^10.45.0",
|
||||
"@tanstack/react-virtual": "^3.10.8",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/markdown": "^3.20.1",
|
||||
|
|
@ -162,6 +164,7 @@
|
|||
"@electron/rebuild": "^4.0.3",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sentry/vite-plugin": "^5.1.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
|
|
@ -212,7 +215,8 @@
|
|||
"files": [
|
||||
"out/renderer/**",
|
||||
"dist-electron/**",
|
||||
"package.json"
|
||||
"package.json",
|
||||
"!**/*.map"
|
||||
],
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
|
|
|
|||
1092
pnpm-lock.yaml
1092
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,10 @@
|
|||
// On Windows this saturates all threads, blocking the event loop.
|
||||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||
|
||||
// Sentry must be the first import to capture early errors.
|
||||
import './sentry';
|
||||
import { syncTelemetryFlag } from './sentry';
|
||||
|
||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||
|
|
@ -1320,6 +1324,9 @@ void app.whenReady().then(() => {
|
|||
// Apply configuration settings
|
||||
const config = configManager.getConfig();
|
||||
|
||||
// Sync Sentry telemetry opt-in flag from persisted config
|
||||
syncTelemetryFlag(config.general.telemetryEnabled);
|
||||
|
||||
// Apply launch-at-login setting only in packaged builds.
|
||||
// In dev, macOS may deny this (and Electron logs a noisy error to stderr).
|
||||
// Also guard by platform: Electron only supports this on macOS/Windows.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
*/
|
||||
|
||||
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { syncTelemetryFlag } from '@main/sentry';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile } from 'child_process';
|
||||
|
|
@ -171,6 +172,14 @@ async function handleUpdateConfig(
|
|||
|
||||
configManager.updateConfig(validation.section, validation.data);
|
||||
|
||||
// Sync Sentry opt-in when general.telemetryEnabled changes
|
||||
if (
|
||||
validation.section === 'general' &&
|
||||
Object.prototype.hasOwnProperty.call(validation.data, 'telemetryEnabled')
|
||||
) {
|
||||
syncTelemetryFlag(configManager.getConfig().general.telemetryEnabled);
|
||||
}
|
||||
|
||||
if (isClaudeRootUpdate && onClaudeRootPathUpdated) {
|
||||
const nextClaudeRootPath = (validation.data as { claudeRootPath?: string | null })
|
||||
.claudeRootPath;
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
|||
'agentLanguage',
|
||||
'autoExpandAIGroups',
|
||||
'useNativeTitleBar',
|
||||
'telemetryEnabled',
|
||||
];
|
||||
|
||||
const result: Partial<GeneralConfig> = {};
|
||||
|
|
@ -372,6 +373,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
|||
}
|
||||
result.useNativeTitleBar = value;
|
||||
break;
|
||||
case 'telemetryEnabled':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `general.${key} must be a boolean` };
|
||||
}
|
||||
result.telemetryEnabled = value;
|
||||
break;
|
||||
default:
|
||||
return { valid: false, error: `Unsupported general key: ${key}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* and returns IpcResult<T> for consistent renderer-side handling.
|
||||
*/
|
||||
|
||||
import { addMainBreadcrumb } from '@main/sentry';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { IpcResult } from '@shared/types/ipc';
|
||||
|
|
@ -13,6 +14,7 @@ export function createIpcWrapper(logPrefix: string) {
|
|||
const log = createLogger(logPrefix);
|
||||
|
||||
return async function wrap<T>(operation: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
|
||||
addMainBreadcrumb('ipc', `${logPrefix}:${operation}`);
|
||||
try {
|
||||
const data = await fn();
|
||||
return { success: true, data };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||
import { addMainBreadcrumb } from '@main/sentry';
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||
|
|
@ -876,16 +877,17 @@ async function handleCreateTeam(
|
|||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('create', () =>
|
||||
getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||
return wrapTeamHandler('create', () => {
|
||||
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
||||
return getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||
try {
|
||||
event.sender.send(TEAM_PROVISIONING_PROGRESS, progress);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Failed to emit provisioning progress: ${message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLaunchTeam(
|
||||
|
|
@ -979,8 +981,9 @@ async function handleLaunchTeam(
|
|||
);
|
||||
}
|
||||
|
||||
return wrapTeamHandler('launch', () =>
|
||||
getTeamProvisioningService().launchTeam(
|
||||
return wrapTeamHandler('launch', () => {
|
||||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||
return getTeamProvisioningService().launchTeam(
|
||||
{
|
||||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
|
|
@ -1005,8 +1008,8 @@ async function handleLaunchTeam(
|
|||
logger.warn(`Failed to emit launch provisioning progress: ${message}`);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleValidateCliArgs(
|
||||
|
|
@ -2055,6 +2058,7 @@ async function handleStopTeam(
|
|||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('stop', async () => {
|
||||
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
|
||||
getTeamProvisioningService().stopTeam(validated.value!);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
79
src/main/sentry.ts
Normal file
79
src/main/sentry.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Sentry initialisation for the Electron **main** process.
|
||||
*
|
||||
* Must be imported at the very top of `src/main/index.ts` (and `standalone.ts`)
|
||||
* so that Sentry captures errors from the earliest point possible.
|
||||
*
|
||||
* When `SENTRY_DSN` is not set (dev / self-builds), everything is a no-op.
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import {
|
||||
isValidDsn,
|
||||
SENTRY_ENVIRONMENT,
|
||||
SENTRY_RELEASE,
|
||||
TRACES_SAMPLE_RATE,
|
||||
} from '@shared/utils/sentryConfig';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Module-level flag that `beforeSend` checks.
|
||||
// Updated by `syncTelemetryFlag()` once ConfigManager is ready.
|
||||
// Defaults to `true` so early crash reports are NOT silently dropped;
|
||||
// if the user later turns telemetry off, the flag flips to `false`.
|
||||
let telemetryAllowed = true;
|
||||
|
||||
/**
|
||||
* Call once ConfigManager is initialised to sync the opt-in flag.
|
||||
* Also call whenever the config changes (e.g. user toggles telemetry in Settings).
|
||||
*/
|
||||
export function syncTelemetryFlag(enabled: boolean): void {
|
||||
telemetryAllowed = enabled;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
let initialized = false;
|
||||
|
||||
if (isValidDsn(dsn)) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
release: SENTRY_RELEASE,
|
||||
environment: SENTRY_ENVIRONMENT,
|
||||
tracesSampleRate: TRACES_SAMPLE_RATE,
|
||||
sendDefaultPii: false,
|
||||
|
||||
beforeSend(event) {
|
||||
return telemetryAllowed ? event : null;
|
||||
},
|
||||
});
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers (no-op when Sentry is not configured)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Record a breadcrumb visible in subsequent error events. */
|
||||
export function addMainBreadcrumb(
|
||||
category: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
): void {
|
||||
if (!initialized) return;
|
||||
Sentry.addBreadcrumb({ category, message, data, level: 'info' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a synchronous or async function in a Sentry performance span.
|
||||
* Returns the function's return value transparently.
|
||||
*/
|
||||
export function startMainSpan<T>(name: string, op: string, fn: () => T): T {
|
||||
if (!initialized) return fn();
|
||||
return Sentry.startSpan({ name, op }, fn);
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import {
|
|||
import { calculateMetrics } from '@main/utils/jsonl';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { startMainSpan } from '../../sentry';
|
||||
import type { WaterfallData, WaterfallItem } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:ChunkBuilder');
|
||||
|
|
@ -80,6 +81,7 @@ export class ChunkBuilder {
|
|||
subagents: Process[] = [],
|
||||
options?: { includeSidechain?: boolean }
|
||||
): EnhancedChunk[] {
|
||||
return startMainSpan('chunks.build', 'build', () => {
|
||||
const chunks: EnhancedChunk[] = [];
|
||||
|
||||
// Filter to main thread messages (non-sidechain)
|
||||
|
|
@ -155,6 +157,7 @@ export class ChunkBuilder {
|
|||
);
|
||||
|
||||
return chunks;
|
||||
}); // startMainSpan
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile
|
|||
import { parseJsonlFile } from '@main/utils/jsonl';
|
||||
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { startMainSpan } from '../../sentry';
|
||||
import {
|
||||
extractMarkdownPlainText,
|
||||
findMarkdownSearchMatches,
|
||||
|
|
@ -62,6 +64,7 @@ export class SessionSearcher {
|
|||
query: string,
|
||||
maxResults: number = 50
|
||||
): Promise<SearchSessionsResult> {
|
||||
return startMainSpan('session.search', 'search', async () => {
|
||||
const startedAt = Date.now();
|
||||
const results: SearchResult[] = [];
|
||||
let sessionsSearched = 0;
|
||||
|
|
@ -164,7 +167,10 @@ export class SessionSearcher {
|
|||
break;
|
||||
}
|
||||
|
||||
if (stageBoundary < sessionFiles.length && results.length >= SSH_FAST_SEARCH_MIN_RESULTS) {
|
||||
if (
|
||||
stageBoundary < sessionFiles.length &&
|
||||
results.length >= SSH_FAST_SEARCH_MIN_RESULTS
|
||||
) {
|
||||
isPartial = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -191,6 +197,7 @@ export class SessionSearcher {
|
|||
logger.error(`Error searching sessions for project ${projectId}:`, error);
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
}); // startMainSpan
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -210,6 +210,8 @@ export interface GeneralConfig {
|
|||
useNativeTitleBar: boolean;
|
||||
/** Paths manually added via "Select Folder" that persist across app restarts */
|
||||
customProjectPaths: string[];
|
||||
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
|
||||
telemetryEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface DisplayConfig {
|
||||
|
|
@ -293,6 +295,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
autoExpandAIGroups: false,
|
||||
useNativeTitleBar: false,
|
||||
customProjectPaths: [],
|
||||
telemetryEnabled: true,
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from '@main/utils/jsonl';
|
||||
import * as path from 'path';
|
||||
|
||||
import { startMainSpan } from '../../sentry';
|
||||
import { type ProjectScanner } from '../discovery/ProjectScanner';
|
||||
|
||||
/**
|
||||
|
|
@ -74,8 +75,10 @@ export class SessionParser {
|
|||
* Parse a JSONL file at the given path.
|
||||
*/
|
||||
async parseSessionFile(filePath: string): Promise<ParsedSession> {
|
||||
return startMainSpan('session.parse', 'parse', async () => {
|
||||
const messages = await parseJsonlFile(filePath, this.projectScanner.getFileSystemProvider());
|
||||
return this.processMessages(messages);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
* - CORS_ORIGIN: CORS origin policy (default '*')
|
||||
*/
|
||||
|
||||
// Note: Sentry is NOT imported here. @sentry/electron/main requires Electron
|
||||
// runtime which is unavailable in standalone (pure Node.js) mode. Standalone
|
||||
// error tracking can be added later with @sentry/node if needed.
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
import { captureRendererException, isSentryRendererActive } from '@renderer/sentry';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
type BugReportContext,
|
||||
|
|
@ -43,6 +44,14 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
logger.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
this.setState({ errorInfo });
|
||||
|
||||
// Report to Sentry when telemetry is active
|
||||
if (isSentryRendererActive()) {
|
||||
captureRendererException(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
...this.getBugReportContext(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export interface SafeConfig {
|
|||
agentLanguage: string;
|
||||
autoExpandAIGroups: boolean;
|
||||
useNativeTitleBar: boolean;
|
||||
telemetryEnabled: boolean;
|
||||
};
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
|
|
@ -172,6 +173,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
|
||||
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
|
||||
useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false,
|
||||
telemetryEnabled: displayConfig?.general?.telemetryEnabled ?? true,
|
||||
},
|
||||
notifications: {
|
||||
enabled: displayConfig?.notifications?.enabled ?? true,
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ export function useSettingsHandlers({
|
|||
agentLanguage: 'system',
|
||||
autoExpandAIGroups: false,
|
||||
useNativeTitleBar: false,
|
||||
telemetryEnabled: true,
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
|
|||
|
|
@ -674,6 +674,23 @@ export const GeneralSection = ({
|
|||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Privacy / Telemetry — only visible when Sentry DSN is baked into the build */}
|
||||
{import.meta.env.VITE_SENTRY_DSN && (
|
||||
<>
|
||||
<SettingsSectionHeader title="Privacy" />
|
||||
<SettingRow
|
||||
label="Send crash reports"
|
||||
description="Help improve the app by sending anonymous crash and performance data"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.general.telemetryEnabled ?? true}
|
||||
onChange={(v) => onGeneralToggle('telemetryEnabled', v)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import { App } from './App';
|
||||
import { initSentryRenderer } from './sentry';
|
||||
import { initializeNotificationListeners } from './store';
|
||||
|
||||
declare global {
|
||||
|
|
@ -14,6 +15,9 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
// Sentry must be initialised before React renders.
|
||||
initSentryRenderer();
|
||||
|
||||
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev,
|
||||
// which can start duplicate IPC init chains. Make initialization a one-time
|
||||
// module-level side effect guarded by a global flag.
|
||||
|
|
|
|||
110
src/renderer/sentry.ts
Normal file
110
src/renderer/sentry.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Sentry initialisation for the **renderer** process.
|
||||
*
|
||||
* Must be called before `ReactDOM.createRoot()` in `main.tsx`.
|
||||
* Supports both Electron (preload bridge) and standalone browser mode.
|
||||
*
|
||||
* When `VITE_SENTRY_DSN` is not set (dev / self-builds), everything is a no-op.
|
||||
*/
|
||||
|
||||
import * as SentryElectron from '@sentry/electron/renderer';
|
||||
import { browserTracingIntegration as reactBrowserTracing, init as reactInit } from '@sentry/react';
|
||||
import {
|
||||
isValidDsn,
|
||||
SENTRY_ENVIRONMENT,
|
||||
SENTRY_RELEASE,
|
||||
TRACES_SAMPLE_RATE,
|
||||
} from '@shared/utils/sentryConfig';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry gate (mirrors src/main/sentry.ts pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Defaults to `true` so early renderer crashes are captured.
|
||||
// Synced to user's telemetryEnabled preference via syncRendererTelemetry().
|
||||
let telemetryAllowed = true;
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Sync the opt-in flag from config. Call after config is loaded
|
||||
* and whenever the user toggles telemetry in Settings.
|
||||
*/
|
||||
export function syncRendererTelemetry(enabled: boolean): void {
|
||||
telemetryAllowed = enabled;
|
||||
}
|
||||
|
||||
export function initSentryRenderer(): void {
|
||||
if (initialized) return;
|
||||
|
||||
const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined;
|
||||
if (!isValidDsn(dsn)) return;
|
||||
|
||||
const baseOptions = {
|
||||
dsn,
|
||||
release: SENTRY_RELEASE,
|
||||
environment: SENTRY_ENVIRONMENT,
|
||||
tracesSampleRate: TRACES_SAMPLE_RATE,
|
||||
sendDefaultPii: false,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cross-version @sentry/core type mismatch
|
||||
const beforeSend = (event: any) => (telemetryAllowed ? event : null);
|
||||
|
||||
if (window.electronAPI) {
|
||||
// Electron renderer — uses IPC transport to main process.
|
||||
// browserTracingIntegration from @sentry/electron/renderer to avoid
|
||||
// @sentry/core version mismatch with @sentry/react.
|
||||
SentryElectron.init({
|
||||
...baseOptions,
|
||||
beforeSend,
|
||||
integrations: [SentryElectron.browserTracingIntegration()],
|
||||
});
|
||||
} else {
|
||||
// Standalone browser mode — direct HTTP transport
|
||||
reactInit({
|
||||
...baseOptions,
|
||||
beforeSend,
|
||||
integrations: [reactBrowserTracing()],
|
||||
});
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/** Whether the renderer SDK was successfully initialised. */
|
||||
export function isSentryRendererActive(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers (no-op when Sentry is not configured)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Record a navigation breadcrumb (tab switches). */
|
||||
export function addNavigationBreadcrumb(from: string, to: string): void {
|
||||
if (!initialized) return;
|
||||
SentryElectron.addBreadcrumb({
|
||||
category: 'navigation',
|
||||
message: `Tab: ${from} → ${to}`,
|
||||
level: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a generic breadcrumb from the renderer. */
|
||||
export function addRendererBreadcrumb(
|
||||
category: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
): void {
|
||||
if (!initialized) return;
|
||||
SentryElectron.addBreadcrumb({ category, message, data, level: 'info' });
|
||||
}
|
||||
|
||||
/** Capture an exception with optional extra context. */
|
||||
export function captureRendererException(error: Error, context?: Record<string, unknown>): void {
|
||||
if (!initialized) return;
|
||||
SentryElectron.withScope((scope) => {
|
||||
if (context) scope.setContext('react', context);
|
||||
SentryElectron.captureException(error);
|
||||
});
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { syncRendererTelemetry } from '@renderer/sentry';
|
||||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||
import { create } from 'zustand';
|
||||
|
||||
|
|
@ -96,6 +97,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
// Config: fast (in-memory read) — needed for theme before first paint.
|
||||
await useStore.getState().fetchConfig();
|
||||
|
||||
// Sync Sentry renderer telemetry gate from loaded config
|
||||
const loadedConfig = useStore.getState().appConfig;
|
||||
syncRendererTelemetry(loadedConfig?.general?.telemetryEnabled ?? true);
|
||||
|
||||
// Remaining fetches have no data dependency on each other — run in parallel
|
||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||
await Promise.all([
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { syncRendererTelemetry } from '@renderer/sentry';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
|
|
@ -66,6 +67,8 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
|||
// Refresh config after update
|
||||
const config = await api.config.get();
|
||||
set({ appConfig: config });
|
||||
// Sync Sentry telemetry gate when config changes
|
||||
syncRendererTelemetry(config.general?.telemetryEnabled ?? true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update config:', error);
|
||||
set({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
truncateLabel,
|
||||
} from '@renderer/types/tabs';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { addNavigationBreadcrumb } from '@renderer/sentry';
|
||||
|
||||
import {
|
||||
findPane,
|
||||
|
|
@ -254,6 +255,14 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
|||
const state = get();
|
||||
const { paneLayout } = state;
|
||||
|
||||
// Sentry breadcrumb for tab navigation
|
||||
const prevTab = state.getActiveTab();
|
||||
const targetPane = findPaneByTabId(paneLayout, tabId);
|
||||
const targetTab = targetPane?.tabs.find((t) => t.id === tabId);
|
||||
if (prevTab?.id !== tabId) {
|
||||
addNavigationBreadcrumb(prevTab?.label ?? 'none', targetTab?.label ?? tabId);
|
||||
}
|
||||
|
||||
// Find which pane contains this tab
|
||||
const pane = findPaneByTabId(paneLayout, tabId);
|
||||
if (!pane) return;
|
||||
|
|
|
|||
|
|
@ -316,6 +316,8 @@ export interface AppConfig {
|
|||
autoExpandAIGroups: boolean;
|
||||
/** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */
|
||||
useNativeTitleBar: boolean;
|
||||
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
|
||||
telemetryEnabled: boolean;
|
||||
};
|
||||
/** Display and UI settings */
|
||||
display: {
|
||||
|
|
|
|||
25
src/shared/utils/sentryConfig.ts
Normal file
25
src/shared/utils/sentryConfig.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Shared Sentry configuration constants.
|
||||
*
|
||||
* Used by both main and renderer process init modules.
|
||||
* Does NOT resolve DSN — each process does that with its own env access
|
||||
* (main: process.env, renderer: import.meta.env).
|
||||
*/
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
/** Release identifier injected at build time via Vite `define`. */
|
||||
export const SENTRY_RELEASE =
|
||||
typeof __APP_VERSION__ === 'string' ? `claude-agent-teams-ui@${__APP_VERSION__}` : undefined;
|
||||
|
||||
/** Environment derived from Node/Vite mode. */
|
||||
export const SENTRY_ENVIRONMENT =
|
||||
process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
||||
|
||||
/** Performance trace sample rate (production: 10%, dev: 100%). */
|
||||
export const TRACES_SAMPLE_RATE = process.env.NODE_ENV === 'production' ? 0.1 : 1.0;
|
||||
|
||||
/** Validate that a string looks like a Sentry DSN. */
|
||||
export function isValidDsn(dsn: string | undefined): dsn is string {
|
||||
return typeof dsn === 'string' && dsn.length > 0 && dsn.startsWith('https://');
|
||||
}
|
||||
|
|
@ -5,6 +5,20 @@
|
|||
|
||||
import { afterEach, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
// Mock Sentry Electron SDK — it requires the real `electron` package at import
|
||||
// time which is unavailable in the vitest/happy-dom environment.
|
||||
const sentryNoOp = {
|
||||
init: vi.fn(),
|
||||
addBreadcrumb: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
|
||||
withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.fn() })),
|
||||
browserTracingIntegration: vi.fn(() => ({ name: 'BrowserTracing', setup: vi.fn(), afterAllSetup: vi.fn() })),
|
||||
};
|
||||
vi.mock('@sentry/electron/main', () => sentryNoOp);
|
||||
vi.mock('@sentry/electron/renderer', () => sentryNoOp);
|
||||
vi.mock('@sentry/react', () => sentryNoOp);
|
||||
|
||||
// Mock HOME for tests that need a predictable home path. Use stubEnv so we never
|
||||
// touch process itself — stubbing process breaks vitest (process.listeners etc).
|
||||
vi.stubEnv('HOME', '/home/testuser');
|
||||
|
|
|
|||
Loading…
Reference in a new issue