修复问题的前提条件肯定是重现问题,但是想要重现问题最好就是能够定位问题。客户端一般都是给客户安装在自己设备上进行使用的,客户的设备很难具备远程调试的条件,所以在客户端中收集日志用于分析和定位问题是很有必要的。

虽然前端日志收集工具已经很多了,但是对接和注重日志收集的项目还是比较少。当然对于个人项目我倾向还是自己弄一个简单的日志收集工具(也是因为我其实没找到比较好的 Electron 日志收集依赖库,其大部分是主进程的日志收集),我现在主要是想收集渲染进程相关错误(当然其实渲染进程就是一个浏览器进程,在 UI 中使用大部分前端日志收集工具也没问题)

由于目前自己也不需要太复杂的日志收集功能,且想尝试了解一下日志收集相关的技术点,于是准备自己尝试实现一个简单的错误日志收集工具,其次我实现的第一个版本是在渲染进程 UI 层代码中去注册监听 onerror 相关函数获取错误,随后通过 electron IPC 发送至主进程写入文件进行保存。

首先就是渲染进程中 UI 部分的代码,我这里目前主要关注代码运行时错误日志,那么可以通过 error 和 unhandledrejection 事件监听大部分的异常事件。

window.addEventListener('error', function (e) {})

window.addEventListener('unhandledrejection', function (event) {})

在 error 事件中的回调函数中就能拿到部分关键的错误信息,但是在 unhandledrejection 的话拿相关的错误信息可能要麻烦一些,于是我找到 https://github.com/csnover/TraceKit 用于解析错误信息,统一错误信息后就将错误信息构造为一个 json 数据通过 IPC 接口发送给主进程,具体代码如下

import TraceKit from 'tracekit'

function handlingErrors(params: any) {
    console.error('handlingErrors', params)
    const ipc = (<any>window).ClientIPCAPI
    if (ipc && ipc.logHelper) {
        ipc.logHelper(params)
    }
}

window.addEventListener(
    'error',
    function (event) {
        // console.log('error', `${event.error.stack}`)
        handlingErrors({
            msg: event.message,
            url: event.filename,
            line: event.lineno,
            col: event.colno,
            error: {
                message: event.error.message,
                stack: event.error.stack,
            },
            // source: 'window.addEventListener error',
            time: new Date()
                .toLocaleString('zh-CN', {timeZone: 'Asia/Shanghai'})
                .toString(),
        })
    },
    true
)

window.addEventListener('unhandledrejection', function (event) {
    TraceKit.report(event.reason)
})

紧接着,需要在主进程实现 logHelper 这个 IPC 函数用于将错误信息输出到客户端的日志文件中。

import {app, ipcMain} from 'electron'

// 注册渲染进程日志收集帮助函数
const getTime = () => {
    const date = new Date()
    return `${date.getFullYear()}_${date.getMonth() + 1}_${date.getDate()}`
}
const logsDirOutPath = `${app.getPath('home')}/client/logs`
const logsFileOutPath = `${logsDirOutPath}/renderer_${getTime()}.log`
fs.access(logsDirOutPath, fs.constants.F_OK, err => {
    if (err) {
        fs.mkdir(logsDirOutPath, {recursive: true}, err => {
            if (err) throw err
        })
    }
})
ipcMain.on('log-helper', async function (event, argument) {
    // console.log(argument)
    fs.appendFile(logsFileOutPath, argument + '\n', e => {
        //
    })
    return {}
})

当然目前我们渲染进程其实还是调用不了 IPC 接口的,还是需要通过 preload 脚本对 IPC 接口暴露到渲染进程

import {contextBridge, ipcRenderer} from 'electron'

// 通过 contextBridge 注入到渲染进程 window 对象上
contextBridge.exposeInMainWorld('ClientIPCAPI', {
    logHelper: (...args: any) =>
        ipcRenderer.send('log-helper', JSON.stringify(args)),
})

虽然是实现了日志的收集,这里有个小问题就是这需要侵入 UI 层的代码,我最初的想法是利用 preload 脚本主动执行和注入错误日志收集脚本(因为我发现 preload 在渲染进程创建时就会在渲染进程的上下文中执行还能拿到 window 对象,那么按道理我只需要将应该写在 UI 层的这两个错误事件监听函数放到 preload 脚本中不就不需要 UI 层去主动引入和注册监听事件了),事实证明我还是太天真了,preload 脚本实际上也是在沙盒隔离环境中执行的,所以这里 preload 拿到的 window 对象和渲染进程上下文中的 window 对象并不是同一个对象。相关文档: https://www.electronjs.org/docs/latest/tutorial/tutorial-preload#augmenting-the-renderer-with-a-preload-script

既然 preload 脚本无法直接这么玩,那么还有没有手段能够让页面加载前我能够先注册异常事件监听器呢?那必须有,这不就是向渲染进程注入 JavaScript 脚本吗,直接把相关的逻辑单独编译后在渲染进程加载前将脚本注入进去这不就实现 UI 层无感异常事件监听了。在 Electron.WebContents 下面有 executeJavaScript 函数就是用于注入 JavaScript 脚本到页面上下文中的,主进程创建窗口时调用 executeJavaScript 函数就能实现,参考代码如下

…

const mainWindow = new BrowserWindow({
    width: 1620,
    height: 1080,
    show: false,
    webPreferences: {
        preload: path.join(__dirname, 'src/preload.js'),
    },
})

// 执行注入脚本
const injectionScriptPath = path.join(__dirname, 'src/injection.js')
if (fs.existsSync(injectionScriptPath)) {
    const injectionScriptContent = String(fs.readFileSync(injectionScriptPath))
    mainWindow.webContents.executeJavaScript(injectionScriptContent)
}

…

这样一来就实现了 UI 层无感知的异常日志收集,当然关于日志收集还有很多坑没有填,比如提供一个日志打印函数主动输出日志,以及主进程日志收集等等,然后就是日志服务器建设,客户端上传日志和日志分析处理等等…