143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
/**
|
||
* 统一日志基座(主进程):
|
||
* - 结构化行:`<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') })
|
||
}
|