From 2081f583e3bfe4159bcb8dea7f57c63849a3576c Mon Sep 17 00:00:00 2001 From: DEV_DSW <562304744@qq.com> Date: Mon, 27 Apr 2026 17:00:51 +0800 Subject: [PATCH] feat: enhance logging capabilities, implement runtime diagnostics, and update preinstalled skills metadata --- .../.preinstalled-lock.json | 2 +- electron-builder.yml | 11 +- electron/api/routes/logs.ts | 2 + electron/main.ts | 60 +++++-- electron/service/logger.ts | 25 ++- electron/utils/log-helpers.ts | 66 ++++++++ electron/utils/runtime-diagnostics.ts | 160 ++++++++++++++++++ tests/log-helpers.test.ts | 46 +++++ vite.config.ts | 6 + 9 files changed, 354 insertions(+), 24 deletions(-) create mode 100644 electron/utils/log-helpers.ts create mode 100644 electron/utils/runtime-diagnostics.ts create mode 100644 tests/log-helpers.test.ts diff --git a/build/preinstalled-skills/.preinstalled-lock.json b/build/preinstalled-skills/.preinstalled-lock.json index 5a6df94..9e65c2d 100644 --- a/build/preinstalled-skills/.preinstalled-lock.json +++ b/build/preinstalled-skills/.preinstalled-lock.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-04-27T03:36:36.328Z", + "generatedAt": "2026-04-27T07:58:41.747Z", "skills": [ { "slug": "pdf", diff --git a/electron-builder.yml b/electron-builder.yml index 3e95341..c18a6f2 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -40,11 +40,14 @@ asarUnpack: npmRebuild: false afterPack: ./scripts/after-pack.cjs -# Auto-update configuration (to be configured later) +# Auto-update configuration # publish: -# - provider: generic -# url: https://your-update-server.com/latest -# useMultipleRangeRequest: false +# - provider: s3 +# bucket: one-feel-bucket +# region: oss-cn-guangzhou +# endpoint: https://oss-cn-guangzhou.aliyuncs.com +# path: setupPackage +# acl: public-read # macOS Configuration mac: diff --git a/electron/api/routes/logs.ts b/electron/api/routes/logs.ts index ec9d21e..1826414 100644 --- a/electron/api/routes/logs.ts +++ b/electron/api/routes/logs.ts @@ -25,6 +25,8 @@ export async function handleLogRoutes( try { return ok({ content: await logManager.readRecentLogText(parseTailLines(url.searchParams.get('tailLines'))), + directory: logManager.getLogDirectoryPath(), + currentFile: logManager.getCurrentLogFilePath(), }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); diff --git a/electron/main.ts b/electron/main.ts index f2aa6b9..7935a95 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -7,7 +7,7 @@ import configManager from '@electron/service/config-service' import themeManager from '@electron/service/theme-service' import { runTaskOperationService } from '@electron/process/runTaskOperationService' import { initScriptStoreService } from '@electron/service/script-store-service' -import log from 'electron-log'; +import logManager from '@electron/service/logger'; import 'bytenode'; // Ensure bytenode is bundled/externalized correctly import { appUpdater } from '@electron/service/updater'; import axios from 'axios'; @@ -21,9 +21,19 @@ import { applyLaunchAtStartupSetting, syncLaunchAtStartupSettingFromConfig } fro import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '@electron/utils/skill-config'; import { initTelemetry, shutdownTelemetry } from '@electron/utils/telemetry'; import { syncGatewayConfigBeforeLaunch } from '@electron/gateway/config-sync'; +import { installRuntimeDiagnostics, logStartupCheckpoint, startLocalCrashReporter } from '@electron/utils/runtime-diagnostics'; // 初始化 updater,确保在 app ready 之前或者之中注册好 IPC -appUpdater.init(); +startLocalCrashReporter(); +installRuntimeDiagnostics(); +logStartupCheckpoint('main-module-loaded'); + +try { + appUpdater.init(); + logStartupCheckpoint('app-updater-initialized'); +} catch (error) { + logManager.captureException('Failed to initialize app updater', error); +} // 注册 hostapi:fetch IPC 代理 // 模型管理相关接口在本地处理(对齐 ClawX),其余接口代理到远端后端 @@ -48,7 +58,7 @@ function refreshProviderRuntime(): { warnings: string[] } { try { return syncProviderRuntimeSnapshot(); } catch (error) { - log.error('provider runtime sync failed', error); + logManager.captureException('provider runtime sync failed', error); return { warnings: [error instanceof Error ? error.message : String(error)], }; @@ -237,12 +247,12 @@ function bindGatewayLifecycleEvents(): void { } function requestQuitOnSignal(signal: NodeJS.Signals): void { - log.info(`Received ${signal}; requesting app quit`); + logManager.info(`Received ${signal}; requesting app quit`); app.quit(); } function emergencyGatewayCleanup(reason: string, error: unknown): void { - log.error(`${reason}:`, error); + logManager.captureException(reason, error); hostEventBus.closeAll(); void closeHostApiServer().catch(() => { // ignore host API server close failures during emergency cleanup @@ -321,14 +331,14 @@ app.on('before-quit', (event) => { gatewayQuitCleanupInProgress = true; hostEventBus.closeAll(); const closeServerPromise = closeHostApiServer().catch((error) => { - log.warn('Host API server close failed during quit:', error); + logManager.captureException('Host API server close failed during quit', error); }); const stopPromise = Promise.all([ closeServerPromise, gatewayManager.stop(), ]).catch((error) => { - log.warn('gatewayManager.stop() error during quit:', error); + logManager.captureException('gatewayManager.stop() error during quit', error); }); const timeoutPromise = new Promise<'timeout'>((resolve) => { setTimeout(() => resolve('timeout'), GATEWAY_QUIT_TIMEOUT_MS); @@ -339,38 +349,43 @@ app.on('before-quit', (event) => { timeoutPromise, ]).then(async (result) => { if (result === 'timeout') { - log.warn('Gateway shutdown timed out during app quit; proceeding with forced quit'); + logManager.warn('Gateway shutdown timed out during app quit; proceeding with forced quit'); try { const terminated = await gatewayManager.forceTerminateOwnedProcessForQuit(); if (terminated) { - log.warn('Forced gateway process termination completed after quit timeout'); + logManager.warn('Forced gateway process termination completed after quit timeout'); } } catch (error) { - log.warn('Forced gateway termination failed after quit timeout:', error); + logManager.captureException('Forced gateway termination failed after quit timeout', error); } } try { await shutdownTelemetry(); } catch (error) { - log.warn('Telemetry shutdown failed during app quit:', error); + logManager.captureException('Telemetry shutdown failed during app quit', error); } gatewayQuitCleanupCompleted = true; app.quit(); }).catch((error) => { gatewayQuitCleanupInProgress = false; - log.warn('Gateway quit cleanup failed:', error); + logManager.captureException('Gateway quit cleanup failed', error); gatewayQuitCleanupCompleted = true; app.quit(); }); }); app.whenReady().then(async () => { + logStartupCheckpoint('app-ready'); await configManager.init(); + logStartupCheckpoint('config-initialized'); await syncLaunchAtStartupSettingFromConfig(); + logStartupCheckpoint('launch-at-startup-synced'); await themeManager.init(); + logStartupCheckpoint('theme-initialized'); await initTelemetry(); + logStartupCheckpoint('telemetry-initialized'); bindGatewayLifecycleEvents(); try { @@ -386,8 +401,9 @@ app.whenReady().then(async () => { ); }, }); + logStartupCheckpoint('host-api-server-started'); } catch (error) { - log.error('Failed to start Host API server:', error); + logManager.captureException('Failed to start Host API server', error); } let launchAtStartup = Boolean(configManager.get(CONFIG_KEYS.LAUNCH_AT_STARTUP)); @@ -409,23 +425,25 @@ app.whenReady().then(async () => { }); void ensureBuiltinSkillsInstalled().catch((error) => { - log.warn('Failed to install built-in skills:', error); + logManager.captureException('Failed to install built-in skills', error); }); void ensurePreinstalledSkillsInstalled().catch((error) => { - log.warn('Failed to install preinstalled skills:', error); + logManager.captureException('Failed to install preinstalled skills', error); }); try { await syncGatewayConfigBeforeLaunch(); + logStartupCheckpoint('gateway-config-synced'); } catch (error) { - log.warn('Failed to sync OpenClaw config before launch:', error); + logManager.captureException('Failed to sync OpenClaw config before launch', error); } refreshProviderRuntime(); void gatewayManager.init().catch((error) => { - log.warn('Failed to initialize GatewayManager:', error); + logManager.captureException('Failed to initialize GatewayManager', error); }); + logStartupCheckpoint('gateway-init-requested'); onProviderChange(() => { const runtimeSync = refreshProviderRuntime(); @@ -437,14 +455,20 @@ app.whenReady().then(async () => { }); setupMainWindow(); + logStartupCheckpoint('main-window-requested'); // 初始化脚本存储服务 initScriptStoreService() + logStartupCheckpoint('script-store-initialized'); // 开启任务操作子进程 runTaskOperationService() + logStartupCheckpoint('task-operation-service-started'); // 开启subagent子进程 +}).catch((error) => { + logManager.captureException('Fatal error during app bootstrap', error); + emergencyGatewayCleanup('Fatal error during app bootstrap', error); }); // Quit when all windows are closed, except on macOS. There, it's common @@ -452,7 +476,7 @@ app.whenReady().then(async () => { // explicitly with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin' && !configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY)) { - log.info('app closing due to all windows being closed'); + logManager.info('app closing due to all windows being closed'); app.quit(); } }); diff --git a/electron/service/logger.ts b/electron/service/logger.ts index a6c08f3..e23384e 100644 --- a/electron/service/logger.ts +++ b/electron/service/logger.ts @@ -1,10 +1,11 @@ import { IPC_EVENTS } from '@runtime/lib/constants'; import { promisify } from 'util'; -import { ipcMain } from 'electron'; +import { app, ipcMain } from 'electron'; import log from 'electron-log'; import * as path from 'path'; import * as fs from 'fs'; import { getUserDataDir } from '@electron/utils/paths'; +import { serializeUnknownError } from '@electron/utils/log-helpers'; // 转换为Promise形式的fs方法 const readdirAsync = promisify(fs.readdir); @@ -35,6 +36,12 @@ class LogService { this.error('Failed to create log directory:', err); } + try { + app.setAppLogsPath(logPath); + } catch (err) { + this.error('Failed to set app logs path:', err); + } + // 配置electron-log log.transports.file.resolvePathFn = () => { // 使用当前日期作为日志文件名,格式为 YYYY-MM-DD.log @@ -48,6 +55,7 @@ class LogService { // 配置日志文件大小限制,默认10MB log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB + log.transports.file.sync = true; // 配置控制台日志级别,开发环境可以设置为debug,生产环境可以设置为info log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'info'; @@ -160,6 +168,13 @@ class LogService { log.error(message, ...meta); } + public captureException(message: string, error: unknown, extra: Record = {}): void { + this.error(message, { + ...extra, + error: serializeUnknownError(error), + }); + } + public logApiRequest(endpoint: string, data: any = {}, method: string = 'POST'): void { this.info(`API Request: ${endpoint}, Method: ${method}, Request: ${JSON.stringify(data)}`); @@ -219,6 +234,14 @@ class LogService { } } + public getLogDirectoryPath(): string { + return this.logDirPath; + } + + public getCurrentLogFilePath(): string { + return this._getCurrentLogFilePath(); + } + private _getCurrentLogFilePath(): string { const today = new Date(); const formattedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; diff --git a/electron/utils/log-helpers.ts b/electron/utils/log-helpers.ts new file mode 100644 index 0000000..36bd869 --- /dev/null +++ b/electron/utils/log-helpers.ts @@ -0,0 +1,66 @@ +export type SerializedError = { + name?: string; + message: string; + stack?: string; + code?: string | number; + cause?: unknown; +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function serializeError(error: Error): SerializedError { + const code = (error as Error & { code?: string | number }).code; + const cause = (error as Error & { cause?: unknown }).cause; + + return { + name: error.name, + message: error.message, + stack: error.stack, + ...(typeof code !== 'undefined' ? { code } : {}), + ...(typeof cause !== 'undefined' ? { cause: serializeUnknownForLog(cause) } : {}), + }; +} + +export function serializeUnknownError(error: unknown): SerializedError { + if (error instanceof Error) { + return serializeError(error); + } + + if (typeof error === 'string') { + return { message: error }; + } + + if (isPlainObject(error) && typeof error.message === 'string') { + return { + message: error.message, + ...(typeof error.name === 'string' ? { name: error.name } : {}), + ...(typeof error.stack === 'string' ? { stack: error.stack } : {}), + ...(typeof error.code === 'string' || typeof error.code === 'number' ? { code: error.code } : {}), + ...(typeof error.cause !== 'undefined' ? { cause: serializeUnknownForLog(error.cause) } : {}), + }; + } + + return { + message: `Non-Error value thrown: ${String(error)}`, + }; +} + +export function serializeUnknownForLog(value: unknown): unknown { + if (value instanceof Error) { + return serializeError(value); + } + + if (Array.isArray(value)) { + return value.map((item) => serializeUnknownForLog(item)); + } + + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, serializeUnknownForLog(entryValue)]), + ); + } + + return value; +} diff --git a/electron/utils/runtime-diagnostics.ts b/electron/utils/runtime-diagnostics.ts new file mode 100644 index 0000000..a663e60 --- /dev/null +++ b/electron/utils/runtime-diagnostics.ts @@ -0,0 +1,160 @@ +import { app, crashReporter, type BrowserWindow, type WebContents } from 'electron'; +import path from 'node:path'; +import logManager from '@electron/service/logger'; +import { ensureDir, getUserDataDir } from './paths'; +import { serializeUnknownError, serializeUnknownForLog } from './log-helpers'; + +let diagnosticsInstalled = false; +let crashReporterStarted = false; + +function describeWebContents(webContents: WebContents): Record { + return { + webContentsId: webContents.id, + type: webContents.getType(), + url: webContents.getURL(), + loading: webContents.isLoading(), + destroyed: webContents.isDestroyed(), + }; +} + +function describeWindow(window: BrowserWindow): Record { + return { + windowId: window.id, + title: window.getTitle(), + visible: window.isVisible(), + minimized: window.isMinimized(), + maximized: window.isMaximized(), + destroyed: window.isDestroyed(), + ...describeWebContents(window.webContents), + }; +} + +function getCrashDumpsDirectoryPath(): string { + return ensureDir(path.join(getUserDataDir(), 'logs', 'crashDumps')); +} + +function attachWindowDiagnostics(window: BrowserWindow): void { + logManager.info('BrowserWindow created', describeWindow(window)); + + window.on('unresponsive', () => { + logManager.warn('BrowserWindow became unresponsive', describeWindow(window)); + }); + + window.on('responsive', () => { + logManager.info('BrowserWindow became responsive again', describeWindow(window)); + }); + + window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame, frameProcessId, frameRoutingId) => { + logManager.error('WebContents failed to load', { + ...describeWindow(window), + errorCode, + errorDescription, + validatedURL, + isMainFrame, + frameProcessId, + frameRoutingId, + }); + }); + + window.webContents.on('render-process-gone', (_event, details) => { + logManager.error('Window render process exited unexpectedly', { + ...describeWindow(window), + reason: details.reason, + exitCode: details.exitCode, + }); + }); +} + +export function logStartupCheckpoint(stage: string, details: Record = {}): void { + logManager.info(`startup:${stage}`, { + pid: process.pid, + packaged: app.isPackaged, + ...details, + }); +} + +export function startLocalCrashReporter(): void { + if (crashReporterStarted) { + return; + } + + const crashDumpsDir = getCrashDumpsDirectoryPath(); + + try { + app.setPath('crashDumps', crashDumpsDir); + } catch (error) { + logManager.captureException('Failed to set crashDumps path', error, { crashDumpsDir }); + } + + try { + crashReporter.start({ + companyName: 'ZhiNian Team', + productName: app.getName() || 'NIANXX', + submitURL: 'https://127.0.0.1/disabled-crash-upload', + uploadToServer: false, + compress: true, + ignoreSystemCrashHandler: false, + rateLimit: false, + globalExtra: { + appVersion: app.getVersion(), + packaged: String(app.isPackaged), + platform: process.platform, + arch: process.arch, + }, + }); + + crashReporterStarted = true; + logManager.info('Crash reporter started for local dump collection', { + crashDumpsDir, + uploadToServer: false, + }); + } catch (error) { + logManager.captureException('Failed to start crash reporter', error, { crashDumpsDir }); + } +} + +export function installRuntimeDiagnostics(): void { + if (diagnosticsInstalled) { + return; + } + + diagnosticsInstalled = true; + + process.on('warning', (warning) => { + logManager.warn('Node.js process warning', serializeUnknownError(warning)); + }); + + process.on('uncaughtExceptionMonitor', (error) => { + logManager.captureException('uncaughtExceptionMonitor', error); + }); + + process.on('unhandledRejection', (reason) => { + logManager.error('unhandledRejection observed by runtime diagnostics', { + reason: serializeUnknownForLog(reason), + }); + }); + + app.on('browser-window-created', (_event, window) => { + attachWindowDiagnostics(window); + }); + + app.on('render-process-gone', (_event, webContents, details) => { + logManager.error('App render process exited unexpectedly', { + ...describeWebContents(webContents), + reason: details.reason, + exitCode: details.exitCode, + }); + }); + + app.on('child-process-gone', (_event, details) => { + logManager.error('Child process exited unexpectedly', serializeUnknownForLog(details)); + }); + + logManager.info('Runtime diagnostics installed', { + platform: process.platform, + arch: process.arch, + pid: process.pid, + userDataDir: getUserDataDir(), + logsDir: logManager.getLogDirectoryPath(), + }); +} diff --git a/tests/log-helpers.test.ts b/tests/log-helpers.test.ts new file mode 100644 index 0000000..675b229 --- /dev/null +++ b/tests/log-helpers.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { serializeError, serializeUnknownError, serializeUnknownForLog } from '../electron/utils/log-helpers'; + +describe('log helpers', () => { + it('serializes Error instances with code and cause', () => { + const cause = new Error('inner failure'); + const error = new Error('outer failure') as Error & { code?: string; cause?: unknown }; + error.code = 'E_TEST'; + error.cause = cause; + + expect(serializeError(error)).toMatchObject({ + name: 'Error', + message: 'outer failure', + code: 'E_TEST', + cause: { + name: 'Error', + message: 'inner failure', + }, + }); + }); + + it('normalizes string and object rejection values', () => { + expect(serializeUnknownError('boom')).toEqual({ message: 'boom' }); + expect(serializeUnknownError({ message: 'failed', code: 500 })).toEqual({ + message: 'failed', + code: 500, + }); + }); + + it('recursively serializes nested error values for structured logging', () => { + const payload = serializeUnknownForLog({ + task: 'bootstrap', + errors: [new Error('bad dependency')], + }); + + expect(payload).toEqual({ + task: 'bootstrap', + errors: [ + expect.objectContaining({ + name: 'Error', + message: 'bad dependency', + }), + ], + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 1f15620..3f90c7b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,12 @@ const projectAliases = { function isMainProcessExternal(id: string): boolean { if (!id || id.startsWith('\0')) return false; if (id.startsWith('.') || id.startsWith('/') || /^[A-Za-z]:[\\/]/.test(id)) return false; + + // Keep ws bundled into the Electron main chunk. + // The packaged app imports it directly from gateway runtime code, + // and relying on transitive/peer installation causes "Cannot find module 'ws'" + // in production builds. + if (id === 'ws' || id.startsWith('ws/')) return false; // Project-specific aliases that should be bundled (not external) const internalAliases = [