Electron 中的 crash 上报和 dump 分析

3,917 阅读10分钟

用户电脑上的 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 数据,包含以下字段:

字段名字段含义示例
verElectron 版本5.13.0
platform系统环境win32、darwin、linux
process_type崩溃进程browser、renderer、worker、utility
guid唯一标识符5e1286fc-da97-479e-918b-6bfb0c3d1c72
_versionpacakge.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

本文正在参加「金石计划」