用户电脑上的 Electron 应用,如果发生了 crash,开发者应该如何排查和定位问题呢?正确的做法是:
- 收集 crash 报告
- 分析 dump 文件
接下来就为大家详细讲解。
收集崩溃报告
一旦发生 crash,进程就立即退出了,代码里面的业务逻辑自然就无法执行,因此无法在 JavaScript 代码中捕获 crash 事件。好在 Electron 内置的 crashReporter 模块为开发者提供了配置 crash 上报的能力,其底层使用了 crashpad 来收集和上传崩溃信息,其工作原理如下:
虽然底层的实现比较复杂,但使用起来非常简单:
const { crashReporter } = require('electron')
crashReporter.start({ submitURL: 'https://your-domain.com/url-to-submit' })
当程序 crash 时,会以 POST 的方式向 submitURL 发送 multipart/form-data
数据,包含以下字段:
字段名 | 字段含义 | 示例 |
---|---|---|
ver | Electron 版本 | 5.13.0 |
platform | 系统环境 | win32、darwin、linux |
process_type | 崩溃进程 | browser、renderer、worker、utility |
guid | 唯一标识符 | 5e1286fc-da97-479e-918b-6bfb0c3d1c72 |
_version | pacakge.json 中的版本号 | 5.13.0 |
_productName | 产品名称 | electron-desktop |
prod | 基础产品名称 | Electron |
upload_file_minidump | 崩溃报告文件 | xxx.dmp |
例如下面是主进程的 crash 示例数据:
{
_productName: 'electron-desktop',
_version: '1.0.0',
guid: '7343249b-739e-49e1-a4a9-dafa87a161fb',
io_scheduler_async_stack: '0x115CE0766 0x0',
osarch: 'x86_64',
pid: '91742',
platform: 'darwin',
process_type: 'browser', // 主进程
prod: 'Electron',
ptype: 'browser',
ui_scheduler_async_stack: '0x115CE0766 0x0',
ver: '9.3.2'
}
下面是渲染进程的 crash 示例数据:
{
_productName: 'electron-desktop',
_version: '1.0.0',
blink_scheduler_async_stack: '0x118DCEC03 0x115C28766',
guid: '7343249b-739e-49e1-a4a9-dafa87a161fb',
'num-experiments': '0',
osarch: 'x86_64',
pid: '91639',
platform: 'darwin',
process_type: 'renderer', // 渲染进程
prod: 'Electron',
ptype: 'renderer',
v8_code_space_firstpage_address: '0x3c00040000',
v8_isolate_address: '0x3c00000000',
v8_map_space_firstpage_address: '0x3c08200000',
v8_ro_space_firstpage_address: '0x3c08040000',
ver: '9.3.2'
}
可以看到,渲染进程 crash 数据会更多一点,包含了 v8 相关的字段。另外,大家仔细看的话会发现它们的 guid
是一样的,这个字段表示什么含义呢?其实 guid
是一个与当前应用程序实例相关联的唯一标识符,每个应用程序实例都有自己的 guid
,如果同一个实例发生多次 crash,那么上报的 guid
是一样的,因此可以用于跟踪和分析应用程序的崩溃情况。
小知识:
guid
的全称是全球唯一标识符(Globally Unique Identifier),是一种由数字和字母组成的标识符,用于唯一标识某个实体或对象。
一般来说,这些默认的上报字段是不够用的,好在 Electron 提供了方法让开发者自定义上报字段:
crashReporter.addExtraParameter(key, value);
注意,key 是不能超过 39 个字节的,否则这个字段会被自动忽略:
const notLongEnoughKey = new Array(39).fill('1').join('')
const longKey = new Array(40).fill('1').join('')
crashReporter.addExtraParameter(notLongEnoughKey, 'notLongEnoughKey') // 可以上报
crashReporter.addExtraParameter(longKey, 'longKey') // 无法上报
value 是不能超过 20320 个字节的,否则这个字段会被自动截断:
const notLongEnoughValue = new Array(20320).fill('1').join('')
const longValue = new Array(20321).fill('1').join('')
crashReporter.addExtraParameter('notLongEnoughValue', notLongEnoughValue) // 不截断
crashReporter.addExtraParameter('longValue', longValue) // 截断
但这个方法每次只能设置一个额外参数,如果在 start 的时候,这些参数已经确定的话,可以直接写到 extra 对象里面:
crashReporter.start({
submitURL: 'http://30.197.128.166:3030/crashReport',
extra: {
userName: 'keliq',
userAge: '18',
},
})
注意,这个方法只会为当前进程设置额外参数,比如说你在主进程中调用此方法,那么只有当主进程 crash 了才会带上这个参数,渲染进程 crash 是没有的,反之亦然。如果想给所有进程设置额外参数的话,需要用到 globalExtra
这个属性:
crashReporter.start({
submitURL: 'http://30.197.128.166:3030/crashReport',
globalExtra: {
userName: 'keliq',
userAge: '18',
},
})
这样所有进程 crash 都会带上这个参数了。
有意思的是,虽然官方文档中说
globalExtra
的 key 和 value 长度有限制,和extra
一样,但是经过笔者实际验证(Electron 9.3.2 版本),发现globalExtra
不做限制。
如果用户频繁 crash,不停上报日志会给服务器带来较大压力,crashReporter 也提供了一个 rateLimit
参数来控制上报频率不超过每小时 1 次。
当发生 crash 之后,dump 文件会默认保存到应用的 userData 目录,我们可以用下面的代码模拟:
const { crashReporter } = require('electron')
crashReporter.start({ uploadToServer: false })
setTimeout(() => process.crash(), 5000)
进入到 Library/Application Support/electron-desktop/Crashpad
目录,发现已经生成了 dump 文件放在了 pending
子目录下面:
为了更真实地模拟 crash 上报的场景,可以在本地用 Koa 搭建一个 server 用于接收 crash 数据:
const Koa = require('koa')
const Router = require('@koa/router')
const multer = require('@koa/multer')
const app = new Koa()
const router = new Router()
const upload = multer()
router.post('/crashReport', upload.single('upload_file_minidump'), (ctx) => {
console.log('ctx.request.file', ctx.request.file)
console.log('ctx.request.body', ctx.request.body)
ctx.body = 'done'
})
app.use(router.routes())
app.listen(3030)
然后 submitURL 填写本地 server 地址:
const { crashReporter } = require('electron')
crashReporter.start({ submitURL: 'http://localhost:3030/crashReport' })
setTimeout(() => process.crash(), 5000)
5 秒后发现 crash 信息已经上报上来了:
不过当发生 crash 时,会检查 pending 目录下是否存在未上传的 dump 文件,如果有的话先上传到服务器,然后再把本次 crash 的 dump 文件也上传,所以接口可能会一下子收到很多条记录,上传完成后,dump 文件会从 pending 目录移动到 completed 目录:
你可能好奇,上传之前的 dump 文件时,form-data 信息从哪里获取的呢?其实就在 dump 文件内部:
上传成功后,服务端的返回值会被记录下来,通过下面两个 API 能拿到:
crashReporter.getLastCrashReport() // 获取上次 crash 报告
crashReporter.getUploadedReports() // 获取全部报告
不得不吐槽这里的方法名,很容易被误解,按照官方文档的解释 getLastCrashReport
其实并非获取上一次的 crash 报告,而是上一次成功上传的 crash 报告,所以用 getLastUploadedReport
更合适:
而 getUploadedReports
则返回了所有的 crash 报告,虽然官方文档说返回所有上传的 crash 报告,但是笔者本地测试发现,即使没有上传,也都返回了,所以叫做 getCrashReports
更合适。
下面是这两个 API 打印出来的结构,其中 date 为 1970-01-01,id 为空的那些数据,就是没有上传的本地报告,或者上传失败的报告:
// 上一次成功上传的 crash 报告
getLastCrashReport {
date: 2023-03-22T15:28:00.000Z,
id: 'cfb7d037-77e4-4d7d-9d5d-25fa321cfb72'
}
// 所有的 crash 报告
getUploadedReports [
{ date: 1970-01-01T00:00:00.000Z, id: '' },
{ date: 1970-01-01T00:00:00.000Z, id: '' },
{ date: 1970-01-01T00:00:00.000Z, id: '' },
{
date: 2023-03-22T15:28:00.000Z,
id: 'cfb7d037-77e4-4d7d-9d5d-25fa321cfb72'
},
{
date: 2023-03-22T15:26:23.000Z,
id: '7343249b-739e-49e1-a4a9-dafa87a161fb'
}
]
通过 getLastCrashReport
这个 API,开发者可以在应用启动的时候拿到上次 crash 报告 id,然后向后端请求分析结果,告诉用户上次 crash 是什么原因。
分析 dump 文件
服务端拿到 dump 文件后,可以使用 breakpad 工具进行分析了,安装方式如下:
$ git clone https://chromium.googlesource.com/breakpad/breakpad
$ cd breakpad
$ ./configure
$ make
$ make install
安装完成之后可以文件在 /usr/local/bin
目录当中找到可执行文件:
$ ls -l /usr/local/bin/minidump*
-rwxr-xr-x 1 keliq admin 400K 3 21 17:34 /usr/local/bin/minidump_dump
-rwxr-xr-x 1 keliq admin 1.1M 3 21 17:34 /usr/local/bin/minidump_stackwalk
可以使用 minidump_stackwalk
命令来解析 dump 文件:
$ minidump_stackwalk user.dmp > user.txt
除此之外,也可以使用 Electron 官方提供的 minidump 来解析,
const minidump = require('minidump')
const fs = require('fs')
minidump.walkStack('user.dmp', (error, result) => {
fs.writeFileSync(path.join(__dirname, 'user.txt'), result.toString())
})
其实本质上也还是调用了 breakpad,只不过 Electron 封装了一层而已:
获取到的 crash 信息为:
Operating system: Mac OS X
0.0.0
CPU: amd64
family 6 model 142 stepping 10
8 CPUs
GPU: UNKNOWN
Crash reason: EXC_BREAKPOINT / EXC_I386_BPT
Crash address: 0x111969f78
Process uptime: 17 seconds
虽然 reason 标识了程序崩溃的原因,但信息太少,需要继续向下寻找错误栈:
Thread 0 (crashed)
0 Electron Framework + 0x2823f78
rax = 0x0000000000000000 rdx = 0x0000000000000000
rcx = 0x0000000116516fe0 rbx = 0x00007ffee62af488
rsi = 0x0000000000000000 rdi = 0x00000000ffffff00
rbp = 0x00007ffee62af970 rsp = 0x00007ffee62af440
r8 = 0x0000000000000100 r9 = 0x0000010100000100
r10 = 0x0000000000000100 r11 = 0x0000000000000100
r12 = 0x00007ffee62af488 r13 = 0x00007ffee62af4a0
r14 = 0x00007ffee62afa10 r15 = 0x00007ffee62afa18
rip = 0x0000000111969f78
Found by: given as instruction pointer in context
1 Electron Framework + 0x28241da
rbp = 0x00007ffee62af9b0 rsp = 0x00007ffee62af980
rip = 0x000000011196a1da
Found by: previous frame's frame pointer
2 Electron Framework + 0x28c55d6
rbp = 0x00007ffee62afc30 rsp = 0x00007ffee62af9c0
rip = 0x0000000111a0b5d6
Found by: previous frame's frame pointer
3 Electron Framework + 0x2b9080e
rbp = 0x00007ffee62afd80 rsp = 0x00007ffee62afc40
rip = 0x0000000111cd680e
Found by: previous frame's frame pointer
详细列出来各个寄存器中的值,太难懂了,如果想要定位到报错那一行代码,就需要去 Electron 镜像网站加载 symbol 文件,解压出来可以看到各种符号表:
然后在 walkStack 的时候使用 symbol:
const symbolPaths = path.join(__dirname, 'electron-v20.0.0-darwin-x64-symbols/breakpad_symbols')
// 可以在 walkStack 之前设置好 symbol
minidump.addSymbolPath(symbolPaths)
// 也可以在调用 walkStack 的时候指定 symbol
minidump.walkStack(minidumpFilePath, symbolPaths, callback)
这样得到的信息可读性就比较强了,而且可以直接定位到报错的地方:
Thread 0 (crashed)
0 Electron Framework!logging::LogMessage::~LogMessage() [logging.cc : 953 + 0x0]
rax = 0x0000000000000000 rdx = 0x0000000000000000
rcx = 0x0000000116516fe0 rbx = 0x00007ffee62af488
rsi = 0x0000000000000000 rdi = 0x00000000ffffff00
rbp = 0x00007ffee62af970 rsp = 0x00007ffee62af440
r8 = 0x0000000000000100 r9 = 0x0000010100000100
r10 = 0x0000000000000100 r11 = 0x0000000000000100
r12 = 0x00007ffee62af488 r13 = 0x00007ffee62af4a0
r14 = 0x00007ffee62afa10 r15 = 0x00007ffee62afa18
rip = 0x0000000111969f78
Found by: given as instruction pointer in context
1 Electron Framework!logging::ErrnoLogMessage::~ErrnoLogMessage() [logging.cc : 622 + 0x8]
rbp = 0x00007ffee62af9b0 rsp = 0x00007ffee62af980
rip = 0x000000011196a1da
Found by: previous frame's frame pointer
2 Electron Framework!base::WaitableEvent::WaitMany(base::WaitableEvent**, unsigned long) [waitable_event_mac.cc : 229 + 0x30]
rbp = 0x00007ffee62afc30 rsp = 0x00007ffee62af9c0
rip = 0x0000000111a0b5d6
Found by: previous frame's frame pointer
本文正在参加「金石计划」