feat: sentry integration (#51)
* feat: integrate sentry error reporting * feat: report logger errors to sentry with 30s log context
This commit is contained in:
22 files changed
+2143
-227
No files matched your search
@@ -39,4 +39,8 @@ jobs:
|
||||
- name: Publish Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: npm run publish
|
||||
@@ -102,3 +102,6 @@ out/
|
||||
|
||||
# Tanstack
|
||||
.tanstack/
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
Generated
+1648
-148
File diff suppressed because it is too large.
Load diff
@@ -41,6 +41,7 @@
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.2",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@tanstack/router-plugin": "^1.135.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -108,6 +109,8 @@
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.7.0",
|
||||
"@sentry/integrations": "^7.114.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-router": "^1.139.3",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { AppConfig } from '@/types/config';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
export function useAppConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -20,28 +25,105 @@ export function useAppConfig() {
|
||||
const updateConfig = useMutation({
|
||||
mutationFn: async (newConfig: AppConfig) => {
|
||||
await ipc.client.config.save(newConfig);
|
||||
return newConfig;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||
});
|
||||
|
||||
const latestConfigRef = useRef<AppConfig | null>(null);
|
||||
const lastStableConfigRef = useRef<AppConfig | null>(null);
|
||||
const pendingResolversRef = useRef<
|
||||
Array<{ resolve: () => void; reject: (error: Error) => void }>
|
||||
>([]);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
lastStableConfigRef.current = config;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const flushPendingSave = useCallback(async () => {
|
||||
const pendingBatch = pendingResolversRef.current.splice(0);
|
||||
const nextConfig = latestConfigRef.current;
|
||||
if (!nextConfig) {
|
||||
for (const item of pendingBatch) {
|
||||
item.resolve();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedConfig = await updateConfig.mutateAsync(nextConfig);
|
||||
queryClient.setQueryData(['appConfig'], savedConfig);
|
||||
lastStableConfigRef.current = savedConfig;
|
||||
toast({
|
||||
title: 'Settings saved',
|
||||
description: 'Your configuration has been updated.',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
for (const item of pendingBatch) {
|
||||
item.resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Failed to save settings');
|
||||
const fallback = lastStableConfigRef.current;
|
||||
if (fallback) {
|
||||
queryClient.setQueryData(['appConfig'], fallback);
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||
}
|
||||
toast({
|
||||
title: 'Error saving settings',
|
||||
description: err.message,
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
for (const item of pendingBatch) {
|
||||
item.reject(error);
|
||||
}
|
||||
}
|
||||
}, [queryClient, updateConfig]);
|
||||
|
||||
const saveConfig = useCallback(
|
||||
(newConfig: AppConfig) => {
|
||||
latestConfigRef.current = newConfig;
|
||||
queryClient.setQueryData(['appConfig'], newConfig);
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pendingResolversRef.current.push({ resolve, reject });
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
flushPendingSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
});
|
||||
},
|
||||
});
|
||||
[flushPendingSave, queryClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (!debounceTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
void flushPendingSave();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
handleBeforeUnload();
|
||||
};
|
||||
}, [flushPendingSave]);
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoading,
|
||||
error,
|
||||
saveConfig: updateConfig.mutateAsync,
|
||||
saveConfig,
|
||||
isSaving: updateConfig.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { app } from 'electron';
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { getAppDataDir } from './utils/paths';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
function getQuickConfig() {
|
||||
try {
|
||||
const configPath = path.join(getAppDataDir(), 'gui_config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
// Default to false (privacy by default)
|
||||
return config.error_reporting_enabled;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to read config for Sentry init:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getQuickConfig()) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
release: `antigravity-manager@${app.getVersion()}`,
|
||||
beforeSend(event) {
|
||||
if (event.exception?.values?.[0]?.value) {
|
||||
event.exception.values[0].value = event.exception.values[0].value.replace(
|
||||
/Users\\\\[^\\\\]+/g,
|
||||
'Users\\\\***',
|
||||
);
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
logger.setErrorReportingEnabled(true);
|
||||
logger.setSentryReporter((payload) => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('log_level', payload.level);
|
||||
scope.setContext('recent_logs', {
|
||||
entries: payload.logs.map((entry) => ({
|
||||
timestamp: new Date(entry.timestamp).toISOString(),
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
formatted: entry.formatted,
|
||||
})),
|
||||
});
|
||||
scope.setExtra('log_message', payload.message);
|
||||
if (payload.error) {
|
||||
Sentry.captureException(payload.error);
|
||||
return;
|
||||
}
|
||||
Sentry.captureMessage(payload.message, 'error');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.setErrorReportingEnabled(false);
|
||||
logger.setSentryReporter(null);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
import { syncAutoStart } from '../../utils/autoStart';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
// Logic to notify proxy server if configuration changes (hot update)
|
||||
// Logic to update Tray if language changes
|
||||
// For now just save
|
||||
ConfigManager.saveConfig(config);
|
||||
syncAutoStart(config);
|
||||
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||
await ConfigManager.saveConfig(config);
|
||||
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||
if (previous.auto_startup !== config.auto_startup) {
|
||||
syncAutoStart(config);
|
||||
}
|
||||
}
|
||||
+29
-12
@@ -7,6 +7,9 @@ import { logger } from '../../utils/logger';
|
||||
const CONFIG_FILENAME = 'gui_config.json';
|
||||
|
||||
export class ConfigManager {
|
||||
private static cachedConfig: AppConfig | null = null;
|
||||
private static saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private static getConfigPath(): string {
|
||||
const appDataDir = getAppDataDir();
|
||||
if (!fs.existsSync(appDataDir)) {
|
||||
@@ -20,6 +23,7 @@ export class ConfigManager {
|
||||
const configPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
@@ -28,7 +32,7 @@ export class ConfigManager {
|
||||
|
||||
// Merge with default to ensure new fields are present
|
||||
// Zod parse helps validate
|
||||
const merged = {
|
||||
const merged: AppConfig = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...raw,
|
||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||
@@ -45,22 +49,35 @@ export class ConfigManager {
|
||||
// Handle Anthropic Mapping Map vs Object
|
||||
// In JSON it's object
|
||||
|
||||
return merged as AppConfig;
|
||||
this.cachedConfig = merged;
|
||||
return merged;
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to load config', e);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
static saveConfig(config: AppConfig): void {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
}
|
||||
static getCachedConfig(): AppConfig | null {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
static async saveConfig(config: AppConfig): Promise<void> {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
|
||||
this.saveQueue = this.saveQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.promises.writeFile(configPath, content, 'utf-8');
|
||||
this.cachedConfig = config;
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
return this.saveQueue;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,6 @@ export const configRouter = os.router({
|
||||
.input(AppConfigSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
saveConfig(input);
|
||||
await saveConfig(input);
|
||||
}),
|
||||
});
|
||||
@@ -53,7 +53,7 @@ export const generateApiKey = async (): Promise<string> => {
|
||||
// Save to config
|
||||
const config = ConfigManager.loadConfig();
|
||||
config.proxy.api_key = newKey;
|
||||
ConfigManager.saveConfig(config);
|
||||
await ConfigManager.saveConfig(config);
|
||||
|
||||
return newKey;
|
||||
};
|
||||
@@ -57,8 +57,6 @@ export const MainLayout: React.FC = () => {
|
||||
isCollapsed ? 'w-[70px]' : 'w-64',
|
||||
)}
|
||||
>
|
||||
{/* Toggle Button - Visible on hover or always if preferred.
|
||||
Positioned absolute on the border line */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -108,6 +108,15 @@ i18n
|
||||
macos_hint:
|
||||
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Privacy',
|
||||
description: 'Control how your data is used to improve the application.',
|
||||
error_reporting: 'Error Reporting',
|
||||
error_reporting_desc:
|
||||
'Send anonymous error reports to help us improve the app. No personal data is collected.',
|
||||
restart_note:
|
||||
'Changes to error reporting will take effect after restarting the application.',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Upstream Proxy',
|
||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||
@@ -409,6 +418,14 @@ i18n
|
||||
macos_hint:
|
||||
'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。',
|
||||
},
|
||||
privacy: {
|
||||
title: '隐私',
|
||||
description: '控制你的数据如何用于改进应用。',
|
||||
error_reporting: '错误上报',
|
||||
error_reporting_desc:
|
||||
'发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||
restart_note: '错误上报设置会在重启应用后生效。',
|
||||
},
|
||||
proxy: {
|
||||
title: '上游代理',
|
||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import './instrument'; // MUST be the first import to ensure Sentry initializes before app ready
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// import { installExtension, REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
||||
import { ipcMain } from 'electron/main';
|
||||
import { ipcContext } from '@/ipc/context';
|
||||
import { IPC_CHANNELS } from './constants';
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import * as Sentry from '@sentry/electron/renderer';
|
||||
import { IPC_CHANNELS } from './constants';
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
// Config check logic (duplicate of instrument.ts logic but adapted for preload)
|
||||
let sentryEnabled = false;
|
||||
try {
|
||||
const home = os.homedir();
|
||||
let appDataPath = '';
|
||||
if (process.platform === 'win32') {
|
||||
appDataPath = path.join(
|
||||
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
|
||||
'Antigravity',
|
||||
);
|
||||
} else if (process.platform === 'darwin') {
|
||||
appDataPath = path.join(home, 'Library', 'Application Support', 'Antigravity');
|
||||
} else {
|
||||
appDataPath = path.join(home, '.config', 'Antigravity');
|
||||
}
|
||||
|
||||
const configPath = path.join(appDataPath, 'gui_config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
sentryEnabled = config.error_reporting_enabled === true;
|
||||
}
|
||||
} catch (e) {
|
||||
// console.error('Preload: Failed to read config', e);
|
||||
}
|
||||
|
||||
if (sentryEnabled) {
|
||||
// Defer Sentry init to avoid blocking main thread during startup (white screen fix)
|
||||
setTimeout(() => {
|
||||
// console.log('[Preload] Initializing Sentry (Deferred)');
|
||||
Sentry.init({});
|
||||
}, 2000);
|
||||
}
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data === IPC_CHANNELS.START_ORPC_SERVER) {
|
||||
const [serverPort] = event.ports;
|
||||
@@ -10,6 +48,7 @@ window.addEventListener('message', (event) => {
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
SENTRY_ENABLED: sentryEnabled,
|
||||
onGoogleAuthCode: (callback: (code: string) => void) => {
|
||||
const handler = (_event: any, code: string) => callback(code);
|
||||
ipcRenderer.on('GOOGLE_AUTH_CODE', handler);
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
import * as Sentry from '@sentry/electron/renderer';
|
||||
|
||||
if (window.electron.SENTRY_ENABLED) {
|
||||
// Defer initialization to prioritize initial render
|
||||
setTimeout(() => {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
import '@/App';
|
||||
+41
-1
@@ -216,7 +216,7 @@ function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
{isMac && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
'settings.startup.macos_hint',
|
||||
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
||||
@@ -254,6 +254,46 @@ function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Privacy & Error Reporting Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.privacy.title', 'Privacy')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'settings.privacy.description',
|
||||
'Control how your data is used to improve the application.',
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.privacy.error_reporting', 'Error Reporting')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t(
|
||||
'settings.privacy.error_reporting_desc',
|
||||
'Send anonymous error reports to help us improve the app. No personal data is collected.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.error_reporting_enabled || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, error_reporting_enabled: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
'settings.privacy.restart_note',
|
||||
'Changes to error reporting will take effect after restarting the application.',
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||
|
||||
Vendored
+1
@@ -7,6 +7,7 @@ declare global {
|
||||
|
||||
interface Window {
|
||||
electron: {
|
||||
SENTRY_ENABLED: boolean;
|
||||
onGoogleAuthCode: (callback: (code: string) => void) => () => void;
|
||||
changeLanguage: (lang: string) => void;
|
||||
};
|
||||
|
||||
+6
-2
@@ -20,9 +20,11 @@ export const AppConfigSchema = z.object({
|
||||
theme: z.string(),
|
||||
auto_refresh: z.boolean(),
|
||||
refresh_interval: z.number(), // minutes
|
||||
auto_sync: z.boolean(), // 是否自动同步
|
||||
auto_sync: z.boolean(),
|
||||
sync_interval: z.number(), // minutes
|
||||
auto_startup: z.boolean(), // 是否随系统启动
|
||||
auto_startup: z.boolean(),
|
||||
error_reporting_enabled: z.boolean(),
|
||||
privacy_consent_asked: z.boolean().optional().default(false), // Optional for backward compatibility
|
||||
default_export_path: z.string().nullable().optional(), // 导出路径
|
||||
proxy: ProxyConfigSchema,
|
||||
});
|
||||
@@ -39,6 +41,8 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
auto_sync: false,
|
||||
sync_interval: 5,
|
||||
auto_startup: false,
|
||||
error_reporting_enabled: true, // Default to disabled for privacy
|
||||
privacy_consent_asked: false, // Whether the user has been asked for consent
|
||||
default_export_path: null,
|
||||
proxy: {
|
||||
enabled: false,
|
||||
|
||||
@@ -4,6 +4,23 @@ import { getAgentDir } from './paths';
|
||||
|
||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
||||
|
||||
const LOG_WINDOW_MS = 30_000;
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: number;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
type SentryReporter = (payload: {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
error?: Error;
|
||||
logs: LogEntry[];
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* Safely stringify an object, handling circular references
|
||||
* This prevents "Converting circular structure to JSON" errors
|
||||
@@ -33,6 +50,9 @@ function safeStringify(obj: unknown): string {
|
||||
|
||||
class Logger {
|
||||
private logFilePath: string;
|
||||
private recentLogs: LogEntry[] = [];
|
||||
private sentryReporter: SentryReporter | null = null;
|
||||
private sentryEnabled = false;
|
||||
|
||||
constructor() {
|
||||
const agentDir = getAgentDir();
|
||||
@@ -75,10 +95,55 @@ class Logger {
|
||||
console.log(`${color}[${level.toUpperCase()}]${reset} ${message}`, ...args);
|
||||
}
|
||||
|
||||
private pruneLogs(now: number) {
|
||||
while (this.recentLogs.length > 0 && now - this.recentLogs[0].timestamp > LOG_WINDOW_MS) {
|
||||
this.recentLogs.shift();
|
||||
}
|
||||
|
||||
if (this.recentLogs.length > MAX_LOG_ENTRIES) {
|
||||
this.recentLogs = this.recentLogs.slice(-MAX_LOG_ENTRIES);
|
||||
}
|
||||
}
|
||||
|
||||
private extractError(args: unknown[]): Error | undefined {
|
||||
for (const arg of args) {
|
||||
if (arg instanceof Error) {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setSentryReporter(reporter: SentryReporter | null) {
|
||||
this.sentryReporter = reporter;
|
||||
}
|
||||
|
||||
setErrorReportingEnabled(enabled: boolean) {
|
||||
this.sentryEnabled = enabled;
|
||||
}
|
||||
|
||||
log(level: LogLevel, message: string, ...args: unknown[]) {
|
||||
const formattedMessage = this.formatMessage(level, message, ...args);
|
||||
const now = Date.now();
|
||||
this.recentLogs.push({
|
||||
timestamp: now,
|
||||
level,
|
||||
message,
|
||||
formatted: formattedMessage,
|
||||
});
|
||||
this.pruneLogs(now);
|
||||
|
||||
this.writeToConsole(level, message, ...args);
|
||||
this.writeToFile(formattedMessage);
|
||||
|
||||
if (level === 'error' && this.sentryEnabled && this.sentryReporter) {
|
||||
this.sentryReporter({
|
||||
level,
|
||||
message,
|
||||
error: this.extractError(args),
|
||||
logs: [...this.recentLogs],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]) {
|
||||
|
||||
+39
-24
@@ -1,30 +1,45 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import path from 'path';
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src'),
|
||||
kafkajs: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
mqtt: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
amqplib: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
'amqp-connection-manager': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
nats: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
'@fastify/static': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
'@fastify/view': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
'@nestjs/microservices': path.resolve(process.cwd(), './src/mocks/nestjs-microservices'),
|
||||
'@nestjs/websockets': path.resolve(process.cwd(), './src/mocks/nestjs-websockets'),
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'keytar',
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src'),
|
||||
kafkajs: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
mqtt: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
amqplib: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
'amqp-connection-manager': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
nats: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
'@fastify/static': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
'@fastify/view': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||
'@nestjs/microservices': path.resolve(process.cwd(), './src/mocks/nestjs-microservices'),
|
||||
'@nestjs/websockets': path.resolve(process.cwd(), './src/mocks/nestjs-websockets'),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: ['better-sqlite3', 'keytar'],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
+30
-3
@@ -1,4 +1,31 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({});
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: ['better-sqlite3', 'keytar'],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
+45
-20
@@ -1,28 +1,53 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
||||
import path from 'path';
|
||||
import { codeInspectorPlugin } from 'code-inspector-plugin';
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
target: 'react',
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
},
|
||||
}),
|
||||
codeInspectorPlugin({ bundler: 'vite' }),
|
||||
],
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src'),
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
// Electron loads files from app://, so we need to normalize paths for Source Map matching
|
||||
sourcemaps: {
|
||||
// Rewrite the source paths to strip the Electron app:// protocol
|
||||
rewriteSources: (source) => {
|
||||
// Transform: app:///.vite/renderer/main_window/assets/index.js
|
||||
// Into: ~/.vite/renderer/main_window/assets/index.js
|
||||
return source.replace(/^app:\/\/\//, '~/');
|
||||
},
|
||||
},
|
||||
}),
|
||||
tanstackRouter({
|
||||
target: 'react',
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
},
|
||||
}),
|
||||
codeInspectorPlugin({ bundler: 'vite' }),
|
||||
],
|
||||
define: {
|
||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src'),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user