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:
iliya 2026-03-22 17:03:15 +02:00
parent 115e1d2d0c
commit e005671123
32 changed files with 1677 additions and 200 deletions

View file

@ -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)

View file

@ -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>&nbsp;
<a href="docs/screenshots/7.png"><img src="docs/screenshots/7.png" width="95" alt="Code Review" /></a>&nbsp;
<a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="95" alt="Team View" /></a>&nbsp;
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="100" />&nbsp;
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.jpg" width="95" alt="Agent Comments" /></a>&nbsp;
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="95" alt="Create Team" /></a>&nbsp;
<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>&nbsp;
<a href="docs/screenshots/7.png"><img src="docs/screenshots/7.png" width="75" alt="Code Review" /></a>&nbsp;
<a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="75" alt="Team View" /></a>&nbsp;
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>&nbsp;
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="80" />&nbsp;
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>&nbsp;
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.jpg" width="75" alt="Agent Comments" /></a>&nbsp;
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>&nbsp;
<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

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

BIN
docs/screenshots/8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

BIN
docs/screenshots/9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

View file

@ -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')

View file

@ -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": [

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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;

View file

@ -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}` };
}

View file

@ -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 };

View file

@ -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
View 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);
}

View file

@ -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,81 +81,83 @@ export class ChunkBuilder {
subagents: Process[] = [],
options?: { includeSidechain?: boolean }
): EnhancedChunk[] {
const chunks: EnhancedChunk[] = [];
return startMainSpan('chunks.build', 'build', () => {
const chunks: EnhancedChunk[] = [];
// Filter to main thread messages (non-sidechain)
const mainMessages = options?.includeSidechain
? messages
: messages.filter((m) => !m.isSidechain);
logger.debug(`Total messages: ${messages.length}, Main thread: ${mainMessages.length}`);
// Filter to main thread messages (non-sidechain)
const mainMessages = options?.includeSidechain
? messages
: messages.filter((m) => !m.isSidechain);
logger.debug(`Total messages: ${messages.length}, Main thread: ${mainMessages.length}`);
// Classify each message into categories using MessageClassifier
const classified = classifyMessages(mainMessages);
// Classify each message into categories using MessageClassifier
const classified = classifyMessages(mainMessages);
// Log classification summary
const categoryCounts = new Map<MessageCategory, number>();
for (const { category } of classified) {
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
}
logger.debug('Message classification:', Object.fromEntries(categoryCounts));
// Build chunks from classification - AI chunks are INDEPENDENT
let aiBuffer: ParsedMessage[] = [];
for (const { message, category } of classified) {
switch (category) {
case 'hardNoise':
// Skip - filtered out
break;
case 'compact':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildCompactChunk(message));
break;
case 'user':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildUserChunk(message));
break;
case 'system':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildSystemChunk(message));
break;
case 'ai':
aiBuffer.push(message);
break;
// Log classification summary
const categoryCounts = new Map<MessageCategory, number>();
for (const { category } of classified) {
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
}
}
logger.debug('Message classification:', Object.fromEntries(categoryCounts));
// Flush remaining AI buffer
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
}
// Build chunks from classification - AI chunks are INDEPENDENT
let aiBuffer: ParsedMessage[] = [];
// Log final chunk summary
const userChunkCount = chunks.filter(isUserChunk).length;
const aiChunkCount = chunks.filter(isAIChunk).length;
const systemChunkCount = chunks.filter(isSystemChunk).length;
const compactChunkCount = chunks.filter(isCompactChunk).length;
logger.debug(
`Created ${chunks.length} chunks: ${userChunkCount} user, ${aiChunkCount} AI, ${systemChunkCount} system, ${compactChunkCount} compact`
);
for (const { message, category } of classified) {
switch (category) {
case 'hardNoise':
// Skip - filtered out
break;
return chunks;
case 'compact':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildCompactChunk(message));
break;
case 'user':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildUserChunk(message));
break;
case 'system':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildSystemChunk(message));
break;
case 'ai':
aiBuffer.push(message);
break;
}
}
// Flush remaining AI buffer
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
}
// Log final chunk summary
const userChunkCount = chunks.filter(isUserChunk).length;
const aiChunkCount = chunks.filter(isAIChunk).length;
const systemChunkCount = chunks.filter(isSystemChunk).length;
const compactChunkCount = chunks.filter(isCompactChunk).length;
logger.debug(
`Created ${chunks.length} chunks: ${userChunkCount} user, ${aiChunkCount} AI, ${systemChunkCount} system, ${compactChunkCount} compact`
);
return chunks;
}); // startMainSpan
}
// ===========================================================================

View file

@ -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,135 +64,140 @@ export class SessionSearcher {
query: string,
maxResults: number = 50
): Promise<SearchSessionsResult> {
const startedAt = Date.now();
const results: SearchResult[] = [];
let sessionsSearched = 0;
const fastMode = this.fsProvider.type === 'ssh';
let isPartial = false;
return startMainSpan('session.search', 'search', async () => {
const startedAt = Date.now();
const results: SearchResult[] = [];
let sessionsSearched = 0;
const fastMode = this.fsProvider.type === 'ssh';
let isPartial = false;
if (!query || query.trim().length === 0) {
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
const normalizedQuery = query.toLowerCase().trim();
try {
const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
if (!(await this.fsProvider.exists(projectPath))) {
if (!query || query.trim().length === 0) {
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
// Get all session files
const entries = await this.fsProvider.readdir(projectPath);
const sessionEntries = entries.filter((entry) => {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) return false;
// Filter to only sessions belonging to this subproject
if (sessionFilter) {
const sessionId = extractSessionId(entry.name);
return sessionFilter.has(sessionId);
}
return true;
});
const sessionFiles = await this.collectFulfilledInBatches(
sessionEntries,
this.fsProvider.type === 'ssh' ? 24 : 128,
async (entry) => {
const filePath = path.join(projectPath, entry.name);
const mtimeMs =
typeof entry.mtimeMs === 'number'
? entry.mtimeMs
: (await this.fsProvider.stat(filePath)).mtimeMs;
return { name: entry.name, filePath, mtimeMs };
}
);
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
const normalizedQuery = query.toLowerCase().trim();
// Search session files with bounded concurrency and staged breadth in SSH mode.
const searchBatchSize = fastMode ? 3 : 8;
const stageBoundaries = fastMode
? this.buildFastSearchStageBoundaries(sessionFiles.length)
: [sessionFiles.length];
let searchedUntil = 0;
let shouldStop = false;
try {
const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
for (const stageBoundary of stageBoundaries) {
for (
let i = searchedUntil;
i < stageBoundary && results.length < maxResults;
i += searchBatchSize
) {
if (fastMode && Date.now() - startedAt >= SSH_FAST_SEARCH_TIME_BUDGET_MS) {
isPartial = true;
shouldStop = true;
if (!(await this.fsProvider.exists(projectPath))) {
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
// Get all session files
const entries = await this.fsProvider.readdir(projectPath);
const sessionEntries = entries.filter((entry) => {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) return false;
// Filter to only sessions belonging to this subproject
if (sessionFilter) {
const sessionId = extractSessionId(entry.name);
return sessionFilter.has(sessionId);
}
return true;
});
const sessionFiles = await this.collectFulfilledInBatches(
sessionEntries,
this.fsProvider.type === 'ssh' ? 24 : 128,
async (entry) => {
const filePath = path.join(projectPath, entry.name);
const mtimeMs =
typeof entry.mtimeMs === 'number'
? entry.mtimeMs
: (await this.fsProvider.stat(filePath)).mtimeMs;
return { name: entry.name, filePath, mtimeMs };
}
);
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
// Search session files with bounded concurrency and staged breadth in SSH mode.
const searchBatchSize = fastMode ? 3 : 8;
const stageBoundaries = fastMode
? this.buildFastSearchStageBoundaries(sessionFiles.length)
: [sessionFiles.length];
let searchedUntil = 0;
let shouldStop = false;
for (const stageBoundary of stageBoundaries) {
for (
let i = searchedUntil;
i < stageBoundary && results.length < maxResults;
i += searchBatchSize
) {
if (fastMode && Date.now() - startedAt >= SSH_FAST_SEARCH_TIME_BUDGET_MS) {
isPartial = true;
shouldStop = true;
break;
}
const batch = sessionFiles.slice(i, i + searchBatchSize);
sessionsSearched += batch.length;
const settled = await Promise.allSettled(
batch.map(async (file) => {
const sessionId = extractSessionId(file.name);
return this.searchSessionFile(
projectId,
sessionId,
file.filePath,
normalizedQuery,
maxResults,
file.mtimeMs
);
})
);
for (const result of settled) {
if (results.length >= maxResults) {
break;
}
if (result.status !== 'fulfilled' || result.value.length === 0) {
continue;
}
const remaining = maxResults - results.length;
results.push(...result.value.slice(0, remaining));
}
}
searchedUntil = stageBoundary;
if (shouldStop || !fastMode || results.length >= maxResults) {
break;
}
const batch = sessionFiles.slice(i, i + searchBatchSize);
sessionsSearched += batch.length;
const settled = await Promise.allSettled(
batch.map(async (file) => {
const sessionId = extractSessionId(file.name);
return this.searchSessionFile(
projectId,
sessionId,
file.filePath,
normalizedQuery,
maxResults,
file.mtimeMs
);
})
);
for (const result of settled) {
if (results.length >= maxResults) {
break;
}
if (result.status !== 'fulfilled' || result.value.length === 0) {
continue;
}
const remaining = maxResults - results.length;
results.push(...result.value.slice(0, remaining));
if (
stageBoundary < sessionFiles.length &&
results.length >= SSH_FAST_SEARCH_MIN_RESULTS
) {
isPartial = true;
break;
}
}
searchedUntil = stageBoundary;
if (shouldStop || !fastMode || results.length >= maxResults) {
break;
}
if (stageBoundary < sessionFiles.length && results.length >= SSH_FAST_SEARCH_MIN_RESULTS) {
if (fastMode && results.length < maxResults && sessionsSearched < sessionFiles.length) {
isPartial = true;
break;
}
}
if (fastMode && results.length < maxResults && sessionsSearched < sessionFiles.length) {
isPartial = true;
}
if (fastMode) {
logger.debug(
`SSH fast search scanned ${sessionsSearched}/${sessionFiles.length} sessions in ${Date.now() - startedAt}ms (results=${results.length}, partial=${isPartial})`
);
}
if (fastMode) {
logger.debug(
`SSH fast search scanned ${sessionsSearched}/${sessionFiles.length} sessions in ${Date.now() - startedAt}ms (results=${results.length}, partial=${isPartial})`
);
return {
results,
totalMatches: results.length,
sessionsSearched,
query,
isPartial: fastMode ? isPartial : undefined,
};
} catch (error) {
logger.error(`Error searching sessions for project ${projectId}:`, error);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
return {
results,
totalMatches: results.length,
sessionsSearched,
query,
isPartial: fastMode ? isPartial : undefined,
};
} catch (error) {
logger.error(`Error searching sessions for project ${projectId}:`, error);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
}); // startMainSpan
}
/**

View file

@ -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,

View file

@ -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> {
const messages = await parseJsonlFile(filePath, this.projectScanner.getFileSystemProvider());
return this.processMessages(messages);
return startMainSpan('session.parse', 'parse', async () => {
const messages = await parseJsonlFile(filePath, this.projectScanner.getFileSystemProvider());
return this.processMessages(messages);
});
}
/**

View file

@ -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';

View file

@ -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 => {

View file

@ -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,

View file

@ -318,6 +318,7 @@ export function useSettingsHandlers({
agentLanguage: 'system',
autoExpandAIGroups: false,
useNativeTitleBar: false,
telemetryEnabled: true,
},
display: {
showTimestamps: true,

View file

@ -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>
);
};

View file

@ -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
View 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);
});
}

View file

@ -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([

View file

@ -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({

View file

@ -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;

View file

@ -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: {

View 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://');
}

View file

@ -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');