feat: 统一日志系统——结构化滚动文件 + api.log 插件通道 + 73 个 IPC 通道追踪 + pi 事件/渲染层错误归档

This commit is contained in:
Rick Ye
2026-09-11 02:12:40 +08:00
parent 2ad1743e10
commit 8e4b5496e8
9 changed files with 332 additions and 98 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* 统一日志基座(主进程):
* - 结构化行:`<ISO时间> <LEVEL> [<scope>] <message> k=v ...`,文件按天滚动
* (userData/logs/main-YYYY-MM-DD.log,保留 14 天)
* - 级别:dev 默认 debug、打包默认 info;ASO_LOG_LEVEL=debug|info|warn|error 可覆盖
* - 子 logger:getLogger(scope) —— 各模块用固定 scope(plugin-host / agent-service / ipc / renderer /
* browser),插件日志经 plugin-host 注入 scope=plugin 且带 pluginId 字段
* - 全局兜底:uncaughtException / unhandledRejection / will-quit 落盘
*
* Agent 自诊断约定:日志文件路径固定,pi 侧可直接读文件排查(见 docs/plugins/06-self-diagnosis.md)。
*/
import { app } from 'electron'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
const RETAIN_DAYS = 14
const MAX_LINE = 8 * 1024
let level: LogLevel = 'info'
let logDir: string | null = null
/** 当天文件名(含日期)缓存;跨天自动切换 */
let fileDay = ''
let fileReady = false
/** 文件不可用时保留最近日志(进程内仍可读) */
const memoryTail: string[] = []
function resolveLevel(): LogLevel {
const env = process.env.ASO_LOG_LEVEL
if (env === 'debug' || env === 'info' || env === 'warn' || env === 'error') return env
return app.isPackaged ? 'info' : 'debug'
}
function ensureFile(now: Date): string | null {
const day = now.toISOString().slice(0, 10)
if (fileReady && fileDay === day && logDir) return join(logDir, `main-${day}.log`)
try {
if (!logDir) logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
pruneOldLogs(logDir, day)
fileDay = day
fileReady = true
return join(logDir, `main-${day}.log`)
} catch {
return null
}
}
function pruneOldLogs(dir: string, today: string): void {
try {
for (const name of readdirSync(dir)) {
const m = /^main-(\d{4}-\d{2}-\d{2})\.log$/.exec(name)
if (!m) continue
const ageDays = (Date.now() - statSync(join(dir, name)).mtimeMs) / 86_400_000
if (m[1] !== today && (ageDays > RETAIN_DAYS || m[1] > today)) unlinkSync(join(dir, name))
}
} catch {
// 清理失败不影响写日志
}
}
function fmtValue(v: unknown): string {
if (v === undefined) return 'undefined'
if (v === null) return 'null'
if (v instanceof Error) return v.stack ? v.stack.replace(/\s+/g, ' ').slice(0, 600) : v.message
if (typeof v === 'object') {
try {
return JSON.stringify(v).slice(0, 400)
} catch {
return '[object]'
}
}
return String(v).slice(0, 400)
}
function write(level_: LogLevel, scope: string, message: string, kv?: Record<string, unknown>): void {
if (LEVEL_ORDER[level_] < LEVEL_ORDER[level]) return
const now = new Date()
const parts = [
now.toISOString(),
level_.toUpperCase().padEnd(5),
`[${scope}]`,
message.slice(0, 500)
]
if (kv) {
for (const [k, v] of Object.entries(kv)) parts.push(`${k}=${fmtValue(v)}`)
}
const line = parts.join(' ').slice(0, MAX_LINE)
// 控制台镜像(dev 终端可读)
// eslint-disable-next-line no-console
;(level_ === 'error' ? console.error : level_ === 'warn' ? console.warn : console.log)(line)
memoryTail.push(line)
if (memoryTail.length > 500) memoryTail.shift()
const file = ensureFile(now)
if (file) {
try {
appendFileSync(file, line + '\n', 'utf8')
} catch {
fileReady = false
}
}
}
export interface Logger {
debug(message: string, kv?: Record<string, unknown>): void
info(message: string, kv?: Record<string, unknown>): void
warn(message: string, kv?: Record<string, unknown>): void
error(message: string, kv?: Record<string, unknown>): void
}
export function getLogger(scope: string): Logger {
return {
debug: (message, kv) => write('debug', scope, message, kv),
info: (message, kv) => write('info', scope, message, kv),
warn: (message, kv) => write('warn', scope, message, kv),
error: (message, kv) => write('error', scope, message, kv)
}
}
/** 最近日志(内存尾部;诊断 IPC 预留) */
export function recentLines(count = 100): string[] {
return memoryTail.slice(-count)
}
export function initLogger(): void {
level = resolveLevel()
const log = getLogger('logger')
process.on('uncaughtException', (err) => {
log.error('uncaughtException', { err })
})
process.on('unhandledRejection', (reason) => {
log.error('unhandledRejection', { err: reason })
})
app.on('will-quit', () => {
log.info('app quit')
})
log.info('logger init', { level, userData: app.getPath('userData') })
}