发布于 6 个月前 ,更新于 6 个月前 node,webpack

生产环境js错误收集及定位源码位置

现在前端生产环境的代码基本上是压缩的,如果需要知道压缩代码报错对应的源码位置,我们可以通过sourcemap文件去实现。但对于前端而言,如果把sourcemap放出来,差不多相当于把源码暴露出来了。为了解决这个问题,我把sourcemap文件放到服务端,然后将浏览器的报错发送到服务端由服务端根据错误信息去定位源码所在位置。以前用过Sentry,好像是支持的,但我还有一些其他额外的需求,所以我就造了个简易的轮子。

背景

其实我最初的的想法是只需要监控 js 报错并通过企业微信机器人将错误信息推送到企业微信。

后来企业微信收到的错误消息都是压缩的代码报错信息,无法快速定位具体报错位置(也有可能找不到具体位置),所以使用 sourcemap 在服务端解析错误信息。

查看错误具体位置时又要对照企业微信接收的信息去找对应的文件,嗯,嫌麻烦,又将报错的位置直接定位到 GitLab 源码的链接,点击消息中的链接就可以跳转到源码位置。

再后来企业微信收到的消息太多,不利于管理,所以又将报错信息自动创建到 GitLab 的 issue 中。

企业微信推送和自动创建 GitLab issue 的实现请查看官方文档。本文就介绍一下下面几点的实现,具体如下:

  • 浏览器端收集错误发送给服务端
  • 构建完成时上传 sourcemap到服务端,上传完成后删除本地的sourcemap文件,且打包后的 js 文件末尾不需要 sourcemap URL
  • 服务端接收上传文件
  • 服务端接收错误信息,并通过 source-map 工具将错误信息定位到源码具体位置
浏览器端收集错误发送给服务端

由于使用的是 vuejs,所以可以直接在Vue.config.errorHandler中收集错误(其他地方的错误这里略过),使用时发现在 errorHandler 返回的 error 中无法获取到代码报错所处的行和列,只能从error.stack中获取到,所以只能将整个 stack 传到服务端进行处理。

static/src/main.js 查看源码

Vue.config.errorHandler = function(err, vm, info) {
  const data = {
    message: err.message,
    stack: err.stack,
    info,
    href: location.href
  }
  fetch('http://localhost:3000/log/loading.gif', {
    method: 'POST',
    headers: {
      'content-type': 'application/json'
    },
    body: JSON.stringify(data)
  })
}
构建完成时上传 sourcemap

这里需要做两件事情:

  1. 构建打包需要生成 sourcemap 文件,且打包后的 js 文件末尾不需要 sourcemap URL

此处可以使用 webpack 的 SourceMapDevToolPlugin 插件实现

new webpack.SourceMapDevToolPlugin({
  filename: 'sourcemap/[file].map', // 修改生成 sourcemap 文件的路径(对应 dist/sourcemap)
  append: false // 不在文件末尾添加 sourcemapUrl
})
  1. 构建完成时将所有的 sourcemap 文件上传到服务端

此处我自己写了一个 webpack 插件。由于项目中有使用axios,所以插件中上传文件我也直接使用了 axios。FormData 对象是存在于浏览器端的,在原生 nodejs 中并不存在,所以引入了form-data依赖包来替代FormData

实现的思路:在 webpack compiler 的 afterEmit 钩子中读取构建输出目录下所有的 .js.map 文件,然后一个一个的上传(也可以多个并发上传),上传完成后删除所有的 sourcemap 文件(上面中 SourceMapDevToolPlugin 插件已设置将sourcemap文件全部存放在sourcemap目录,所以最后只需要删除该目录即可)。

static/UploadSourceMapPlugin.js 查看源文件

const Path = require('path')
const Fs = require('fs')
const Axios = require('axios')
const FormData = require('form-data')
const PLUGIN_NAME = 'UploadSourceMapPlugin'

class UploadSourceMapPlugin {
  // 读取目录下所有的 .js.map 文件
  async getAssets(distDir) {
    const files = await Fs.promises.readdir(distDir)
    return files.filter(el => /\.js\.map$/i.test(el)).map(el => Path.join(distDir, el))
  }

  // 上传文件到服务端
  async upload(filepath) {
    const stream = Fs.createReadStream(filepath)
    const formData = new FormData()
    formData.append('file', stream)
    return Axios.default({
      url: 'http://localhost:3000/upload',
      method: 'put',
      headers: formData.getHeaders(),
      timeout: 10000,
      data: formData
    }).then().catch((err) => {
      console.error(Path.basename(filepath), err.message)
    })
  }

  apply(compiler) {
    // 路径需要与 SourceMapDevToolPlugin 插件存放 sourcemap 文件的地址一致
    const sourcemapDir = Path.join(compiler.options.output.path, 'sourcemap')
    compiler.hooks.afterEmit.tapPromise(PLUGIN_NAME, async() => {
      console.log('Uploading sourcemap files...')
      const files = await this.getAssets(Path.join(sourcemapDir, 'js')) // 只上传 js 的 sourcemap 文件
      for (const file of files) {
        await this.upload(file)
      }
      // 注意:node < 14.14.0 可以使用 Fs.promises.rmdir 替代
      await Fs.promises.rm(sourcemapDir, { recursive: true })
    })
  }
}

项目的构建使用的是 vue cli,所以最终 vue.config.js 文件配置如下:

static/vue.config.js 查看源文件

const webpack = require('webpack')
const UploadSourceMapPlugin = require('./uploadSourceMapPlugin')
module.exports = {
  publicPath: '/',
  configureWebpack(config) {
    if (process.env.NODE_ENV === 'production') {
      config.plugins.push(
        new webpack.SourceMapDevToolPlugin({
          filename: 'sourcemap/[file].map', // 修改生成 sourcemap 文件的路径(对应 dist/sourcemap)
          append: false // 不在文件末尾添加 sourcemapUrl
        }),
        new UploadSourceMapPlugin()
      )
    }
  }
}

至此项目构建部分已基本完成。

web服务

服务端直接用@hapi/hapi作为 sever。代码如下:

server/src/app.ts 查看源码

import * as  Hapi from '@hapi/hapi'
import inert from '@hapi/inert'
import * as routes from './router'

async function start() {
  const server = Hapi.server({ host: '127.0.0.1', port: 3001 })
  await server.register(inert)
  server.route(Object.values(routes))
  await server.start()
  console.log(`Server running at: ${server.info.uri}`)
}

start()
上传文件接口

由于sourcemap的文件包含hash值,可以确定每个文件名具有唯一性,所以我直接将所有上传的sourcemap文件全部放在 uploads 目录中。代码较多,就只显示部分代码

server/src/router.ts 查看源码

/** 上传文件接口 */
export const upload = <Hapi.ServerRoute>{
  method: 'put',
  path: '/upload',
  options: {
    payload: {
      multipart: { output: 'stream' },
      allow: ['application/json', 'multipart/form-data'],
    }
  },
  async handler(request, h) {
    const { file } = request.payload as { file: MyFile }
    const dir = Path.join(process.cwd(), 'uploads')
    await Fs.ensureDir(dir)
    return new Promise((resolve) => {
      const ws = Fs.createWriteStream(Path.join(dir, file.hapi.filename))
      file.pipe(ws)

      file.on('end', () => {
        resolve(h.response({ status: true }).code(200))
      })

      file.on('error', () => {
        resolve(h.response({ status: false }).code(500))
      })
    })
  }
}
服务端接收上报错误并通过 source-map 解析

对错误解析进行了简单的封装,主要做了下面几个步骤的操作:

  1. 接收stack信息,然后将信息逐行处理,并从每行中得到对应的sourcemap文件名、错误所在的行和列(对应代码中的stack方法)
  2. 根据sourcemap文件名去uploads目录找相应的文件并读取内容(对应代码中的rawSourceMap方法)
  3. source-map插件通过文件内容、行、列进行解析定位源码位置(对应代码中的parse方法)
  4. 逐行处理时将source-map处理的结果数据拼接返回新的错误信息(对应代码中的stack方法)

server/src/parseError.ts 该文件代码较多做了些简化,完整版请直接 查看源码

import * as Path from 'path'
import * as Fs from 'fs-extra'
import { SourceMapConsumer } from 'source-map'

const uploadDir = Path.join(process.cwd(), 'uploads')
export default class ParseError {
  /** 读取sourcemap文件内容 */
  private async rawSourceMap(filepath: string) {
    return Fs.readJSON(filepath, { throws: false })
  }

  public async stack(stack: string) {
    const lines = stack.split('\n')
    const newLines: string[] = [lines[0]]
    // 逐行处理
    for (const item of lines) {
      if (/ +at.+.js:\d+:\d+\)$/) {
        const arr = item.match(/\((https?:\/\/.+):(\d+):(\d+)\)$/i) || []
        if (arr.length === 4) {
          const url = arr[1]
          const line = Number(arr[2])
          const column = Number(arr[3])
          const filename = (url.match(/[^/]+$/) || [''])[0]

          const res = await this.parse(filename + '.map', line, column)
          if (res && res.source) {
            const content = `    at ${res.name} (${[res.source, res.line, res.column].join(':')})`
            newLines.push(content)
          } else {
            // 未解析成功则使用原错误信息
            newLines.push(item)
          }
        }
      }
    }
    return newLines.join('\n')
  }

  /** 根据行和列,从sourcemap中定位源码的位置 */
  private async parse(filename: string, line: number, column: number) {
    const raw = await this.rawSourceMap(filename)
    const consumer = await SourceMapConsumer.with(raw, null, consumer => consumer)
    return consumer.originalPositionFor({ line, column })
  }
}
接收错误信息并返回解析后的错误信息

上面已经实现了文件上传和错误解析,基本上万事俱备了,只剩接收客户端上传的错误信息并解析,最后可以做你想做的事情。

server/src/router.ts 查看源码

/** 接受错误并解析返回 */
export const jsError = <Hapi.ServerRoute>{
  method: 'post',
  path: '/api/js/error',
  async handler(req) {
    const data = <{ stack: string }>req.payload
    const parser = new ParseError()
    const result = await parser.stack(data.stack)
    parser.destroy() // 解析完销毁cconsumer

    // 这里拿到result后可以做一些你想要的操作,比如推送、存数据等等
    // 这里直接返回解析后的结果

    return result
  }
}

至此,已经完成了从构建上传sourcemap文件,到服务端文件存储,再从浏览器端上传错误信息,到服务端解析定位错误所在源码位置的流程。

源码已放到GitHub https://github.com/satrong/parse-error-demo

原创文章,转载请注明出处 https://www.xiaoboy.com/topic/server-parse-js-error-by-sourcemap.html

© 2016 - 2021 BY 禾惠 粤ICP备20027042号