背景:为啥要上这套?#
桌面应用一上路,用户环境五花八门。你不留“黑盒里的手电筒”,真出事儿只能靠猜。日志是“回看现场”,崩溃收集是“捞犯罪工具”。两套一起上,省心省命。
目标就两件事:
- 出问题能第一时间感知,能快速定位。
- 日志别乱飞、别泄密、别越滚越大。
方案一览#
- 应用日志:electron-log
- 主进程/渲染进程分文件,自动写磁盘,支持轮转与级别。
- JS 异常:全局兜底(main/renderer)
uncaughtException
、unhandledRejection
、window.onerror
、window.onunhandledrejection
- 原生崩溃(minidump):Electron
crashReporter
或第三方(Sentry/Bugsnag)- 有公网服务就走 Sentry Electron,全家桶省事;私有化也行,
crashReporter
支持自建上传端点。
- 有公网服务就走 Sentry Electron,全家桶省事;私有化也行,
- 统一上报:把关键日志作为“面包屑”带到崩溃事件里,现场还原更有谱。
日志:主/渲染双管齐下#
安装
pnpm add electron-log
主进程(electron/main.ts
)
import { app } from 'electron'import log from 'electron-log'
app.setAppLogsPath() // 可自定义目录:app.setAppLogsPath('/var/logs/myapp')log.initialize()
// 基础配置log.transports.file.level = 'info' // debug|info|warn|errorlog.transports.file.maxSize = 10 * 1024 * 1024 // 10MB 轮转log.transports.file.archiveLog = true // 归档旧日志(不同版本可能用不同字段,保留为 true 即可)log.transports.console.level = 'info'
// 简单脱敏(token、邮箱等)const secrets = [/bearer\s+[a-z0-9\._-]+/i, /token=([a-z0-9\._-]+)/i, /\b[\w.-]+@[\w.-]+\.\w+\b/]function redact(msg: any) { let s = String(msg) secrets.forEach(r => { s = s.replace(r, (m) => m.replace(/.(?=..)/g, '*')) }) return s}const raw = log.infolog.info = (...args: any[]) => raw(...args.map(redact))
log.info('app start', { version: app.getVersion(), platform: process.platform })export { log }
预加载(electron/preload.ts
)
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('logger', { info: (msg: any, extra?: any) => ipcRenderer.send('log:info', { msg, extra }), error: (msg: any, extra?: any) => ipcRenderer.send('log:error', { msg, extra }),})
主进程收日志(electron/main.ts
)
import { ipcMain } from 'electron'import { log } from './log' // 上面导出的
ipcMain.on('log:info', (_e, p) => log.info('[renderer]', p.msg, p.extra ?? {}))ipcMain.on('log:error', (_e, p) => log.error('[renderer]', p.msg, p.extra ?? {}))
渲染进程用法(Vue/React 任你用)
window.logger.info('home mounted', { route: '/home' })window.logger.error('fetch failed', { code: 500, url: '/api/me' })
在哪里找日志?
- 路径:
app.getPath('logs')
- macOS:
~/Library/Logs/<appName>
- Windows:
%USERPROFILE%\AppData\Roaming\<appName>\logs
- Linux:
~/.config/<appName>/logs
- macOS:
- 常见文件:
main.log
、renderer.log
(electron-log 会区分进程写)
小抄
import { shell, app } from 'electron'shell.showItemInFolder(require('path').join(app.getPath('logs'), 'main.log'))
JS 异常兜底:别让错误悄悄溜走#
主进程
import { log } from './log'
process.on('uncaughtException', (err) => { log.error('uncaughtException', err.stack || err.message)})process.on('unhandledRejection', (reason: any) => { log.error('unhandledRejection', reason?.stack || String(reason))})
渲染进程(挂在入口)
window.addEventListener('error', (e) => { window.logger?.error('window.onerror', { message: e.message, stack: e.error?.stack })})window.addEventListener('unhandledrejection', (e) => { window.logger?.error('unhandledrejection', { reason: String(e.reason) })})
原生崩溃:minidump + 上报#
两条路:
A. 纯 Electron crashReporter(自建端)#
import { app, crashReporter } from 'electron'
app.whenReady().then(() => { crashReporter.start({ productName: 'MyApp', companyName: 'MyCo', submitURL: 'https://crash.example.com/minidump', // 你自建的接收端 uploadToServer: true, compress: true, extra: { version: app.getVersion(), channel: 'stable' }, })})
- 崩溃转储(minidump)路径:
app.getPath('crashDumps')
- 自建接收端可用
sentry
的 minidump 端点、electron-crash-service
、或自己写个接收保存服务(Nginx + 小服务)
B. 用 Sentry(省心省力)#
pnpm add @sentry/electron
主进程
import * as Sentry from '@sentry/electron/main'import { app } from 'electron'Sentry.init({ dsn: 'https://<your_dsn>', release: app.getVersion(), environment: process.env.NODE_ENV, tracesSampleRate: 0.1, // 视情况})
渲染进程
import * as Sentry from '@sentry/electron/renderer'Sentry.init({ dsn: 'https://<your_dsn>' })
把日志“喂”给 Sentry 当面包屑(可选)
import * as Sentry from '@sentry/electron/main'import { log } from './log'
const rawInfo = log.infolog.info = (...args: any[]) => { Sentry.addBreadcrumb({ category: 'log', level: 'info', message: args.map(String).join(' ') }) return rawInfo(...args)}
如何验证崩溃采集?
- JS:随便丢个
throw new Error('boom')
看事件是否进来了 - 原生崩溃:
process.crash?.()
(某些平台可用),或者调用一个非法原生模块 API 触发;之后检查crashDumps
和后台事件
自动更新联动日志(强烈建议)#
autoUpdater 的日志能救命,合并到文件里:
import { autoUpdater } from 'electron-updater'import log from 'electron-log'
autoUpdater.logger = loglog.transports.file.level = 'info'
autoUpdater.on('error', (e) => log.error('autoUpdater error', e))autoUpdater.on('update-available', (i) => log.info('update-available', i?.version))autoUpdater.on('update-downloaded', () => log.info('update-downloaded'))
排查套路:三步走#
- 先看日志文件
- 发生时间点前后 1 分钟的
main.log
/renderer.log
- 搜
error|unhandled|crash|update
- 发生时间点前后 1 分钟的
- 看崩溃事件
- Sentry/自建端,按版本聚合,确认是否在某版本集中爆发
- 复现与回滚
- 有能稳定复现的最小步骤,优先;顶不住就“忍痛割爱”先回滚,别把所有用户拉下水
安全与合规:别踩红线#
- 脱敏:日志里尽量别落用户隐私、Token、Cookie;必要信息用哈希/掩码
- 开关:设置“错误数据上报”开关,可在设置页关闭;企业内网注意代理与鉴权
- 采样:高频异常要限流/采样,别把后台打爆
常见坑(都是坑过的)#
- 日志疯长:
- 开太多
debug
,又不轮转;控制级别 + 上限体积,老日志归档
- 开太多
- 只在 dev 生效:
- 某些同学忘了在生产入口调用初始化(尤其是
crashReporter.start
/Sentry.init)
- 某些同学忘了在生产入口调用初始化(尤其是
- 代理/防火墙挡上传:
- 公司网络拦了域名,或走系统代理失败;给
HTTP_PROXY
/自建上报域名留余地
- 公司网络拦了域名,或走系统代理失败;给
- 多实例抢文件锁:
- 应用多开/崩溃重启时并发写日志;electron-log 支持队列,但你也要避免多进程同时“自己造轮子”
- Snap/沙箱权限:
- Linux Snap 打包后目录权限紧;尽量用默认
app.getPath('logs')
,别写到奇怪的地方
- Linux Snap 打包后目录权限紧;尽量用默认
小结#
别把日志和崩溃当成“上线再说”的锦上添花,这是保命三件套。先用 electron-log 把基础盘子铺好,再按团队情况上 Sentry 或自建崩溃端。级别、轮转、脱敏这些小事做细一点,出了问题就有底气;没问题的时候,它们也安安静静,不抢戏。