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"
|
pnpm pkg set version="$VERSION"
|
||||||
|
|
||||||
- name: Build app
|
- 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
|
run: pnpm build
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
|
|
@ -106,6 +111,11 @@ jobs:
|
||||||
pnpm pkg set version="$VERSION"
|
pnpm pkg set version="$VERSION"
|
||||||
|
|
||||||
- name: Build app (macOS ${{ matrix.arch }})
|
- 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
|
run: pnpm build
|
||||||
|
|
||||||
- name: Verify packaged inputs (macOS ${{ matrix.arch }})
|
- name: Verify packaged inputs (macOS ${{ matrix.arch }})
|
||||||
|
|
@ -173,6 +183,11 @@ jobs:
|
||||||
pnpm pkg set version="$VERSION"
|
pnpm pkg set version="$VERSION"
|
||||||
|
|
||||||
- name: Build app (Windows)
|
- 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
|
run: pnpm build
|
||||||
|
|
||||||
- name: Verify packaged inputs (Windows)
|
- name: Verify packaged inputs (Windows)
|
||||||
|
|
@ -241,6 +256,11 @@ jobs:
|
||||||
pnpm pkg set version="$VERSION"
|
pnpm pkg set version="$VERSION"
|
||||||
|
|
||||||
- name: Build app (Linux)
|
- 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
|
run: pnpm build
|
||||||
|
|
||||||
- name: Verify packaged inputs (Linux)
|
- name: Verify packaged inputs (Linux)
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -1,11 +1,13 @@
|
||||||
<p align="center">
|
<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/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="95" alt="Code Review" /></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="95" alt="Team View" /></a>
|
<a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="75" alt="Team View" /></a>
|
||||||
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="100" />
|
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>
|
||||||
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.jpg" width="95" alt="Agent Comments" /></a>
|
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="80" />
|
||||||
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="95" alt="Create Team" /></a>
|
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>
|
||||||
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="80" alt="Settings" /></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>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Claude Agent Teams UI</h1>
|
<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 { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import { sentryVitePlugin } from '@sentry/vite-plugin'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { resolve } from 'path'
|
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({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [
|
plugins: [
|
||||||
externalizeDepsPlugin({
|
externalizeDepsPlugin({
|
||||||
exclude: bundledDeps
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@main': resolve(__dirname, 'src/main'),
|
||||||
|
|
@ -48,6 +71,7 @@ export default defineConfig({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
sourcemap: 'hidden',
|
||||||
outDir: 'dist-electron/main',
|
outDir: 'dist-electron/main',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
|
@ -94,6 +118,11 @@ export default defineConfig({
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['@codemirror/language-data']
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||||
|
|
@ -101,8 +130,9 @@ export default defineConfig({
|
||||||
'@main': resolve(__dirname, 'src/main')
|
'@main': resolve(__dirname, 'src/main')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react(), ...sentryPlugins],
|
||||||
build: {
|
build: {
|
||||||
|
sourcemap: 'hidden',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: resolve(__dirname, 'src/renderer/index.html')
|
index: resolve(__dirname, 'src/renderer/index.html')
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@sentry/electron": "^7.10.0",
|
||||||
|
"@sentry/react": "^10.45.0",
|
||||||
"@tanstack/react-virtual": "^3.10.8",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"@tiptap/extension-placeholder": "^3.20.1",
|
"@tiptap/extension-placeholder": "^3.20.1",
|
||||||
"@tiptap/markdown": "^3.20.1",
|
"@tiptap/markdown": "^3.20.1",
|
||||||
|
|
@ -162,6 +164,7 @@
|
||||||
"@electron/rebuild": "^4.0.3",
|
"@electron/rebuild": "^4.0.3",
|
||||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
|
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@sentry/vite-plugin": "^5.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/mdast": "^4.0.4",
|
"@types/mdast": "^4.0.4",
|
||||||
|
|
@ -212,7 +215,8 @@
|
||||||
"files": [
|
"files": [
|
||||||
"out/renderer/**",
|
"out/renderer/**",
|
||||||
"dist-electron/**",
|
"dist-electron/**",
|
||||||
"package.json"
|
"package.json",
|
||||||
|
"!**/*.map"
|
||||||
],
|
],
|
||||||
"asar": true,
|
"asar": true,
|
||||||
"asarUnpack": [
|
"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.
|
// On Windows this saturates all threads, blocking the event loop.
|
||||||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
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 { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||||
|
|
@ -1320,6 +1324,9 @@ void app.whenReady().then(() => {
|
||||||
// Apply configuration settings
|
// Apply configuration settings
|
||||||
const config = configManager.getConfig();
|
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.
|
// Apply launch-at-login setting only in packaged builds.
|
||||||
// In dev, macOS may deny this (and Electron logs a noisy error to stderr).
|
// 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.
|
// Also guard by platform: Electron only supports this on macOS/Windows.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import { syncTelemetryFlag } from '@main/sentry';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
|
|
@ -171,6 +172,14 @@ async function handleUpdateConfig(
|
||||||
|
|
||||||
configManager.updateConfig(validation.section, validation.data);
|
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) {
|
if (isClaudeRootUpdate && onClaudeRootPathUpdated) {
|
||||||
const nextClaudeRootPath = (validation.data as { claudeRootPath?: string | null })
|
const nextClaudeRootPath = (validation.data as { claudeRootPath?: string | null })
|
||||||
.claudeRootPath;
|
.claudeRootPath;
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
||||||
'agentLanguage',
|
'agentLanguage',
|
||||||
'autoExpandAIGroups',
|
'autoExpandAIGroups',
|
||||||
'useNativeTitleBar',
|
'useNativeTitleBar',
|
||||||
|
'telemetryEnabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
const result: Partial<GeneralConfig> = {};
|
const result: Partial<GeneralConfig> = {};
|
||||||
|
|
@ -372,6 +373,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
||||||
}
|
}
|
||||||
result.useNativeTitleBar = value;
|
result.useNativeTitleBar = value;
|
||||||
break;
|
break;
|
||||||
|
case 'telemetryEnabled':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `general.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.telemetryEnabled = value;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return { valid: false, error: `Unsupported general key: ${key}` };
|
return { valid: false, error: `Unsupported general key: ${key}` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* and returns IpcResult<T> for consistent renderer-side handling.
|
* and returns IpcResult<T> for consistent renderer-side handling.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { addMainBreadcrumb } from '@main/sentry';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { IpcResult } from '@shared/types/ipc';
|
import type { IpcResult } from '@shared/types/ipc';
|
||||||
|
|
@ -13,6 +14,7 @@ export function createIpcWrapper(logPrefix: string) {
|
||||||
const log = createLogger(logPrefix);
|
const log = createLogger(logPrefix);
|
||||||
|
|
||||||
return async function wrap<T>(operation: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
|
return async function wrap<T>(operation: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
|
||||||
|
addMainBreadcrumb('ipc', `${logPrefix}:${operation}`);
|
||||||
try {
|
try {
|
||||||
const data = await fn();
|
const data = await fn();
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||||
|
import { addMainBreadcrumb } from '@main/sentry';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||||
|
|
@ -876,16 +877,17 @@ async function handleCreateTeam(
|
||||||
return { success: false, error: validation.error };
|
return { success: false, error: validation.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('create', () =>
|
return wrapTeamHandler('create', () => {
|
||||||
getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
||||||
|
return getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||||
try {
|
try {
|
||||||
event.sender.send(TEAM_PROVISIONING_PROGRESS, progress);
|
event.sender.send(TEAM_PROVISIONING_PROGRESS, progress);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
logger.warn(`Failed to emit provisioning progress: ${message}`);
|
logger.warn(`Failed to emit provisioning progress: ${message}`);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLaunchTeam(
|
async function handleLaunchTeam(
|
||||||
|
|
@ -979,8 +981,9 @@ async function handleLaunchTeam(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('launch', () =>
|
return wrapTeamHandler('launch', () => {
|
||||||
getTeamProvisioningService().launchTeam(
|
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||||
|
return getTeamProvisioningService().launchTeam(
|
||||||
{
|
{
|
||||||
teamName: validatedTeamName.value!,
|
teamName: validatedTeamName.value!,
|
||||||
cwd,
|
cwd,
|
||||||
|
|
@ -1005,8 +1008,8 @@ async function handleLaunchTeam(
|
||||||
logger.warn(`Failed to emit launch provisioning progress: ${message}`);
|
logger.warn(`Failed to emit launch provisioning progress: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleValidateCliArgs(
|
async function handleValidateCliArgs(
|
||||||
|
|
@ -2055,6 +2058,7 @@ async function handleStopTeam(
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('stop', async () => {
|
return wrapTeamHandler('stop', async () => {
|
||||||
|
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
|
||||||
getTeamProvisioningService().stopTeam(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 { calculateMetrics } from '@main/utils/jsonl';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import { startMainSpan } from '../../sentry';
|
||||||
import type { WaterfallData, WaterfallItem } from '@shared/types';
|
import type { WaterfallData, WaterfallItem } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:ChunkBuilder');
|
const logger = createLogger('Service:ChunkBuilder');
|
||||||
|
|
@ -80,81 +81,83 @@ export class ChunkBuilder {
|
||||||
subagents: Process[] = [],
|
subagents: Process[] = [],
|
||||||
options?: { includeSidechain?: boolean }
|
options?: { includeSidechain?: boolean }
|
||||||
): EnhancedChunk[] {
|
): EnhancedChunk[] {
|
||||||
const chunks: EnhancedChunk[] = [];
|
return startMainSpan('chunks.build', 'build', () => {
|
||||||
|
const chunks: EnhancedChunk[] = [];
|
||||||
|
|
||||||
// Filter to main thread messages (non-sidechain)
|
// Filter to main thread messages (non-sidechain)
|
||||||
const mainMessages = options?.includeSidechain
|
const mainMessages = options?.includeSidechain
|
||||||
? messages
|
? messages
|
||||||
: messages.filter((m) => !m.isSidechain);
|
: messages.filter((m) => !m.isSidechain);
|
||||||
logger.debug(`Total messages: ${messages.length}, Main thread: ${mainMessages.length}`);
|
logger.debug(`Total messages: ${messages.length}, Main thread: ${mainMessages.length}`);
|
||||||
|
|
||||||
// Classify each message into categories using MessageClassifier
|
// Classify each message into categories using MessageClassifier
|
||||||
const classified = classifyMessages(mainMessages);
|
const classified = classifyMessages(mainMessages);
|
||||||
|
|
||||||
// Log classification summary
|
// Log classification summary
|
||||||
const categoryCounts = new Map<MessageCategory, number>();
|
const categoryCounts = new Map<MessageCategory, number>();
|
||||||
for (const { category } of classified) {
|
for (const { category } of classified) {
|
||||||
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
logger.debug('Message classification:', Object.fromEntries(categoryCounts));
|
||||||
|
|
||||||
// Flush remaining AI buffer
|
// Build chunks from classification - AI chunks are INDEPENDENT
|
||||||
if (aiBuffer.length > 0) {
|
let aiBuffer: ParsedMessage[] = [];
|
||||||
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log final chunk summary
|
for (const { message, category } of classified) {
|
||||||
const userChunkCount = chunks.filter(isUserChunk).length;
|
switch (category) {
|
||||||
const aiChunkCount = chunks.filter(isAIChunk).length;
|
case 'hardNoise':
|
||||||
const systemChunkCount = chunks.filter(isSystemChunk).length;
|
// Skip - filtered out
|
||||||
const compactChunkCount = chunks.filter(isCompactChunk).length;
|
break;
|
||||||
logger.debug(
|
|
||||||
`Created ${chunks.length} chunks: ${userChunkCount} user, ${aiChunkCount} AI, ${systemChunkCount} system, ${compactChunkCount} compact`
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile
|
||||||
import { parseJsonlFile } from '@main/utils/jsonl';
|
import { parseJsonlFile } from '@main/utils/jsonl';
|
||||||
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
|
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import { startMainSpan } from '../../sentry';
|
||||||
import {
|
import {
|
||||||
extractMarkdownPlainText,
|
extractMarkdownPlainText,
|
||||||
findMarkdownSearchMatches,
|
findMarkdownSearchMatches,
|
||||||
|
|
@ -62,135 +64,140 @@ export class SessionSearcher {
|
||||||
query: string,
|
query: string,
|
||||||
maxResults: number = 50
|
maxResults: number = 50
|
||||||
): Promise<SearchSessionsResult> {
|
): Promise<SearchSessionsResult> {
|
||||||
const startedAt = Date.now();
|
return startMainSpan('session.search', 'search', async () => {
|
||||||
const results: SearchResult[] = [];
|
const startedAt = Date.now();
|
||||||
let sessionsSearched = 0;
|
const results: SearchResult[] = [];
|
||||||
const fastMode = this.fsProvider.type === 'ssh';
|
let sessionsSearched = 0;
|
||||||
let isPartial = false;
|
const fastMode = this.fsProvider.type === 'ssh';
|
||||||
|
let isPartial = false;
|
||||||
|
|
||||||
if (!query || query.trim().length === 0) {
|
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))) {
|
|
||||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all session files
|
const normalizedQuery = query.toLowerCase().trim();
|
||||||
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.
|
try {
|
||||||
const searchBatchSize = fastMode ? 3 : 8;
|
const baseDir = extractBaseDir(projectId);
|
||||||
const stageBoundaries = fastMode
|
const projectPath = path.join(this.projectsDir, baseDir);
|
||||||
? this.buildFastSearchStageBoundaries(sessionFiles.length)
|
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
|
||||||
: [sessionFiles.length];
|
|
||||||
let searchedUntil = 0;
|
|
||||||
let shouldStop = false;
|
|
||||||
|
|
||||||
for (const stageBoundary of stageBoundaries) {
|
if (!(await this.fsProvider.exists(projectPath))) {
|
||||||
for (
|
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||||
let i = searchedUntil;
|
}
|
||||||
i < stageBoundary && results.length < maxResults;
|
|
||||||
i += searchBatchSize
|
// Get all session files
|
||||||
) {
|
const entries = await this.fsProvider.readdir(projectPath);
|
||||||
if (fastMode && Date.now() - startedAt >= SSH_FAST_SEARCH_TIME_BUDGET_MS) {
|
const sessionEntries = entries.filter((entry) => {
|
||||||
isPartial = true;
|
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) return false;
|
||||||
shouldStop = true;
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const batch = sessionFiles.slice(i, i + searchBatchSize);
|
if (
|
||||||
sessionsSearched += batch.length;
|
stageBoundary < sessionFiles.length &&
|
||||||
|
results.length >= SSH_FAST_SEARCH_MIN_RESULTS
|
||||||
const settled = await Promise.allSettled(
|
) {
|
||||||
batch.map(async (file) => {
|
isPartial = true;
|
||||||
const sessionId = extractSessionId(file.name);
|
break;
|
||||||
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 (fastMode && results.length < maxResults && sessionsSearched < sessionFiles.length) {
|
||||||
|
|
||||||
if (shouldStop || !fastMode || results.length >= maxResults) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stageBoundary < sessionFiles.length && results.length >= SSH_FAST_SEARCH_MIN_RESULTS) {
|
|
||||||
isPartial = true;
|
isPartial = true;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (fastMode && results.length < maxResults && sessionsSearched < sessionFiles.length) {
|
if (fastMode) {
|
||||||
isPartial = true;
|
logger.debug(
|
||||||
}
|
`SSH fast search scanned ${sessionsSearched}/${sessionFiles.length} sessions in ${Date.now() - startedAt}ms (results=${results.length}, partial=${isPartial})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (fastMode) {
|
return {
|
||||||
logger.debug(
|
results,
|
||||||
`SSH fast search scanned ${sessionsSearched}/${sessionFiles.length} sessions in ${Date.now() - startedAt}ms (results=${results.length}, partial=${isPartial})`
|
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
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,8 @@ export interface GeneralConfig {
|
||||||
useNativeTitleBar: boolean;
|
useNativeTitleBar: boolean;
|
||||||
/** Paths manually added via "Select Folder" that persist across app restarts */
|
/** Paths manually added via "Select Folder" that persist across app restarts */
|
||||||
customProjectPaths: string[];
|
customProjectPaths: string[];
|
||||||
|
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
|
||||||
|
telemetryEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisplayConfig {
|
export interface DisplayConfig {
|
||||||
|
|
@ -293,6 +295,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
autoExpandAIGroups: false,
|
autoExpandAIGroups: false,
|
||||||
useNativeTitleBar: false,
|
useNativeTitleBar: false,
|
||||||
customProjectPaths: [],
|
customProjectPaths: [],
|
||||||
|
telemetryEnabled: true,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
} from '@main/utils/jsonl';
|
} from '@main/utils/jsonl';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { startMainSpan } from '../../sentry';
|
||||||
import { type ProjectScanner } from '../discovery/ProjectScanner';
|
import { type ProjectScanner } from '../discovery/ProjectScanner';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,8 +75,10 @@ export class SessionParser {
|
||||||
* Parse a JSONL file at the given path.
|
* Parse a JSONL file at the given path.
|
||||||
*/
|
*/
|
||||||
async parseSessionFile(filePath: string): Promise<ParsedSession> {
|
async parseSessionFile(filePath: string): Promise<ParsedSession> {
|
||||||
const messages = await parseJsonlFile(filePath, this.projectScanner.getFileSystemProvider());
|
return startMainSpan('session.parse', 'parse', async () => {
|
||||||
return this.processMessages(messages);
|
const messages = await parseJsonlFile(filePath, this.projectScanner.getFileSystemProvider());
|
||||||
|
return this.processMessages(messages);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
* - CORS_ORIGIN: CORS origin policy (default '*')
|
* - 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 { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { captureRendererException, isSentryRendererActive } from '@renderer/sentry';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
type BugReportContext,
|
type BugReportContext,
|
||||||
|
|
@ -43,6 +44,14 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||||
logger.error('ErrorBoundary caught an error:', error, errorInfo);
|
logger.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
this.setState({ errorInfo });
|
this.setState({ errorInfo });
|
||||||
|
|
||||||
|
// Report to Sentry when telemetry is active
|
||||||
|
if (isSentryRendererActive()) {
|
||||||
|
captureRendererException(error, {
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
...this.getBugReportContext(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReload = (): void => {
|
handleReload = (): void => {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export interface SafeConfig {
|
||||||
agentLanguage: string;
|
agentLanguage: string;
|
||||||
autoExpandAIGroups: boolean;
|
autoExpandAIGroups: boolean;
|
||||||
useNativeTitleBar: boolean;
|
useNativeTitleBar: boolean;
|
||||||
|
telemetryEnabled: boolean;
|
||||||
};
|
};
|
||||||
notifications: {
|
notifications: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -172,6 +173,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
|
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
|
||||||
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
|
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
|
||||||
useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false,
|
useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false,
|
||||||
|
telemetryEnabled: displayConfig?.general?.telemetryEnabled ?? true,
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
enabled: displayConfig?.notifications?.enabled ?? true,
|
enabled: displayConfig?.notifications?.enabled ?? true,
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,7 @@ export function useSettingsHandlers({
|
||||||
agentLanguage: 'system',
|
agentLanguage: 'system',
|
||||||
autoExpandAIGroups: false,
|
autoExpandAIGroups: false,
|
||||||
useNativeTitleBar: false,
|
useNativeTitleBar: false,
|
||||||
|
telemetryEnabled: true,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
|
|
|
||||||
|
|
@ -674,6 +674,23 @@ export const GeneralSection = ({
|
||||||
</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { initSentryRenderer } from './sentry';
|
||||||
import { initializeNotificationListeners } from './store';
|
import { initializeNotificationListeners } from './store';
|
||||||
|
|
||||||
declare global {
|
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,
|
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev,
|
||||||
// which can start duplicate IPC init chains. Make initialization a one-time
|
// which can start duplicate IPC init chains. Make initialization a one-time
|
||||||
// module-level side effect guarded by a global flag.
|
// 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 { api } from '@renderer/api';
|
||||||
|
import { syncRendererTelemetry } from '@renderer/sentry';
|
||||||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
|
@ -96,6 +97,10 @@ export function initializeNotificationListeners(): () => void {
|
||||||
// Config: fast (in-memory read) — needed for theme before first paint.
|
// Config: fast (in-memory read) — needed for theme before first paint.
|
||||||
await useStore.getState().fetchConfig();
|
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
|
// Remaining fetches have no data dependency on each other — run in parallel
|
||||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { syncRendererTelemetry } from '@renderer/sentry';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -66,6 +67,8 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
||||||
// Refresh config after update
|
// Refresh config after update
|
||||||
const config = await api.config.get();
|
const config = await api.config.get();
|
||||||
set({ appConfig: config });
|
set({ appConfig: config });
|
||||||
|
// Sync Sentry telemetry gate when config changes
|
||||||
|
syncRendererTelemetry(config.general?.telemetryEnabled ?? true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to update config:', error);
|
logger.error('Failed to update config:', error);
|
||||||
set({
|
set({
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
truncateLabel,
|
truncateLabel,
|
||||||
} from '@renderer/types/tabs';
|
} from '@renderer/types/tabs';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
import { addNavigationBreadcrumb } from '@renderer/sentry';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findPane,
|
findPane,
|
||||||
|
|
@ -254,6 +255,14 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
||||||
const state = get();
|
const state = get();
|
||||||
const { paneLayout } = state;
|
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
|
// Find which pane contains this tab
|
||||||
const pane = findPaneByTabId(paneLayout, tabId);
|
const pane = findPaneByTabId(paneLayout, tabId);
|
||||||
if (!pane) return;
|
if (!pane) return;
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,8 @@ export interface AppConfig {
|
||||||
autoExpandAIGroups: boolean;
|
autoExpandAIGroups: boolean;
|
||||||
/** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */
|
/** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */
|
||||||
useNativeTitleBar: boolean;
|
useNativeTitleBar: boolean;
|
||||||
|
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
|
||||||
|
telemetryEnabled: boolean;
|
||||||
};
|
};
|
||||||
/** Display and UI settings */
|
/** Display and UI settings */
|
||||||
display: {
|
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';
|
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
|
// 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).
|
// touch process itself — stubbing process breaks vitest (process.listeners etc).
|
||||||
vi.stubEnv('HOME', '/home/testuser');
|
vi.stubEnv('HOME', '/home/testuser');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue