正文
我们在平时的工作中,开发功能的同时不可能把场景考虑的面面俱到,而生产环境往往情况是非常复杂的,用户录入进去的数据总是千奇百怪,那如果遇到问题的话,我们又要如何进行排查呢?总不可能让用户录个屏吧哈哈~所以我们就出现了前端埋点的操作,不过埋点的方向以及文章都挺多的,也都挺复杂的,这篇文章我们就讲一个比较有趣的错误捕获思路。
我们平时在使用框架开发遇到bug时,比如Vue,如果是在本地环境,我们在控制台可以很容易的找到出现问题的文件,甚至点击进入即可直接定位到我们的文件中对应报错的位置,这样排查问题就比较方便。而在生产环境,我们可以配置sourcemap,就也能比较方便的定位到问题出现的地方。但这样的话就会出现一个问题,首先上传到服务器的包体积就会因为生成了很多map文件而变得很大,其次我们的网站代码会非常容易暴露甚至是直接被调试,而且这样子也仅仅是我们自测的时候去发现问题,无法监测到用户端到底是做了什么操作才出现的问题。
那么,有没有一个方法是可以监控到客户端用户操作时,出现问题的代码位置呢?
思考:
综上,我们这次要做的这个工具的目的就比较明确了:
- 错误捕获
- 错误分析/错误定位
- 错误收集/日志输出
前置
在错误捕获之前,我们先提前了解一个服务端的库——source-map
使用source-map库,我们可以通过向该库暴露出的方法中传入bug出现的文件对应的map文件,以及错误的行数和列数,通过对应的方法解析后,我们可以得到该错误出现的源文件以及具体在源文件中的定位。
至此,我们明确了错误捕获中,我们主要就是想拿四个信息:
- 错误的message信息
- 错误出现的文件名
- 错误行数
- 错误列数
那么,我们可不可以设计这样一个流程呢?
- 1.在配置文件中将sourcemap的配置打开,从而使得项目打包后会生成map文件。
- 2.通过编写webpack插件,监听webpack打包完成钩子,在打包完成后触发,将生成的map文件自动上传到我们的服务器上。
- 3.然后在前端,通过错误捕获,将报错信息传给我们的服务器,由服务器根据报错信息再结合map文件,最终解析出我们的报错行数,同时形成日志输出出来并记录下来。
这样的话,我们就可以非常方便的捕获错误,监控生产问题,同时也实现了一个简单的webpack插件(又可以拿去和面试官吹水了~)。
错误捕获
onerror
前端的错误捕获我们最常见的当然是window.onerror
了,我们可以通过定义window.onerror
函数来对全局错误进行捕获。
1window.onerror = function(message, source, lineno, colno) {
2 console.log(message)
3 console.log(source)
4 console.log(lineno)
5 console.log(colno)
6}
通过window.onerror
我们很容易可以拿到我们想要的具体信息。
errorHandler
但window.onerror
并不能捕获到框架组件生命周期的错误,所以我们可以再补充一个框架的错误捕获,以Vue为例:
1...
2const app = createApp(App)
3app.use(store).use(router).mount('#app')
4
5app.config.errorHandler = function (err, vm, info) {
6 console.log(err)
7 console.log(vm)
8 console.log(info)
9};
我们在errorHandler事件中,可以拿到错误对象err,vue实例,错误信息。这里我们并不能像上面onerror错误捕获一样很方便的取出出错的行数和列数,但我们能够拿到一个完整的错误堆栈对象,那么我们就可以对错误对象的堆栈信息进行处理,提取出我们想要的行数和列数。
这里用到了一个堆栈解析工具——StackTrace-Parser
1npm install stacktrace-parser
1app.config.errorHandler = function (err, vm, info) {
2 const errInfo = stackTraceParser.parse(err.stack)[0]
3 const message = err.message
4 const lineno = errInfo.lineNumber
5 const colno = errInfo.column
6 const source = errInfo.file
7 ...
8 };
补充
错误捕获还有一个onunhandledrejection的事件,用于捕获Promise类型的错误,但是经过尝试发现不是很好去拿到错误的定位信息,同时,考虑到一般Promise我们会使用catch
去处理异常的操作,所以这里就暂时不处理这个类型的错误事件了。
至此,我们的捕获相关的逻辑已经完成,剩下的就是如何设计服务端,如何将这些信息传递给服务端并完成解析了。
错误分析/错误定位
服务端,我们设计两个接口,一个用于上传map文件(upload),一个用于接收错误信息(sendErrorLog)。
上传接口就不多说了,主要就是在前端打包完成之后,服务端接收传过来的map文件。我们主要看一下接收错误信息的接口逻辑。
1const handleErrorMessage = require("./utils/index");
2...
3app.post("/sendErrorLog", (req, res) => {
4 handleErrorMessage(req.body);
5 res.send("hello");
6});
1const fs = require("fs");
2const { SourceMapConsumer } = require("source-map");
3const path = require("path");
4
5
6const arr = fs.readdirSync(path.resolve(__dirname, "../uploads"));
7const sourceMap = {};
8for (let i = 0; i < arr.length; i++) {
9 fs.readFile(
10 path.resolve(__dirname, "../uploads", arr[i]),
11 "utf-8",
12 function (err, data) {
13 if (err) {
14 return err;
15 }
16 sourceMap[arr[i]] = data;
17 }
18 );
19}
20
21module.exports = function handleErrorMessage(message) {
22 const errorLine = message.lineno;
23 const errorCol = message.colno;
24 const jsName = message.source.split("/").pop();
25 const sourceName = jsName + ".map";
26
27 if (!sourceMap[sourceName]) {
28 sourceMap[sourceName] = fs.readFileSync(
29 path.resolve(__dirname, "../uploads", sourceName),
30 "utf-8"
31 );
32 }
33 SourceMapConsumer.with(sourceMap[sourceName], null, (consumer) => {
34
35 const originalPosition = consumer.originalPositionFor({
36 line: errorLine,
37 column: errorCol,
38 });
39
40 console.log("Error occurred at:");
41 console.log("file:" + originalPosition.source);
42 console.log("line:" + originalPosition.line);
43 console.log("column:" + originalPosition.column);
44 console.log("message:" + message.message);
45 });
46};
整体的思路就是:
- 服务器启动时读取upload文件夹下的所有map文件,将对应文件的内容读取出来
- 在sendErrorLog接口被调用后,通过source-map库去解析错误信息
- 输出错误日志
这里考虑到一般服务器我们都是一直启动的状态,所以在调用解析逻辑之前,先判断souceMap数据是否已经读取出来,如果没有读取出来,再同步去读取,之后再去解析错误信息。
完善前端逻辑
接口已经有了,这里我们再回过头完善一下前端的逻辑。
首先,我们根据前面对错误捕获的了解,完成一下错误上传的逻辑,:
1import axios from 'axios'
2import * as stackTraceParser from 'stacktrace-parser';
3...
4
5
6if (process.env.NODE_ENV == "production") {
7
8 app.config.errorHandler = function (err, vm, info) {
9 const errInfo = stackTraceParser.parse(err.stack)[0]
10 const message = err.message
11 const lineno = errInfo.lineNumber
12 const colno = errInfo.column
13 const source = errInfo.file
14 axios
15 .post("http://127.0.0.1:3000/sendErrorLog", {
16 message,
17 lineno,
18 colno,
19 source,
20 })
21 .then((data) => {
22 console.log(data);
23 });
24 };
25
26
27 window.onerror = function(message, source, lineno, colno) {
28 axios
29 .post("http://127.0.0.1:3000/sendErrorLog", {
30 message,
31 lineno,
32 colno,
33 source,
34 })
35 .then((data) => {
36 console.log(data);
37 });
38 }
39}
然后,我们开始实现map文件上传的逻辑。
我们先去找一个webpack打包完成输出文件后的钩子——afterEmit。
在这个钩子触发时,说明打包文件已经被输出出来了,我们可以去读取打包文件的js文件夹,从中过滤出map文件,上传至服务器,同时在打包文件中将map文件进行删除操作。
1const pluginName = "SendMapWebpackPlugin";
2const fs = require("fs");
3const axios = require("axios");
4const path = require('path')
5
6class SendMapWebpackPlugin {
7 apply(compiler) {
8 const outputPath = compiler.options.output.path;
9 compiler.hooks.afterEmit.tap(pluginName, (compilation) => {
10 console.log("webpack 构建");
11 console.log(process.env.NODE_ENV);
12 if (process.env.NODE_ENV == "production") {
13 fs.readdir(outputPath + "/js", function (err, data) {
14 if (data) {
15 data.forEach((v) => {
16
17 if (v.endsWith(".map")) {
18 const file = fs.readFileSync(
19 path.resolve(__dirname, "../dist/js", v),
20 "utf-8"
21 );
22 axios({
23 url: "http://127.0.0.1:3000/upload",
24 method: "post",
25 data: { file, fileName: v },
26 headers: {
27 "Content-Type": "application/octet-stream",
28 },
29 })
30 .then((res) => {
31 console.log("success");
32 fs.rm(path.resolve(__dirname, "../dist/js", v), (err) => {
33 if(err) {
34 console.log(err)
35 return
36 }
37 console.log('delete success')
38 })
39 })
40 .catch((err) => {
41 console.log(err);
42 });
43 }
44 });
45 }
46 });
47 }
48 });
49 }
50}
51...
测试效果
逻辑写完了,我们在前端代码中留下一些bug来测试一下效果。
然后,我们执行npm run build
打包操作。
可以看到我们打包完成后的dist文件夹中,已经没有了map文件:
而在服务端,我们接收到了这些map文件。
上传map文件逻辑没有问题,接下来,我们看一下错误解析逻辑。
我们可以在本地安装一个serve包,便于我们快捷的以dist文件夹为基础起一个小型服务器。
将dist文件夹在终端中打开,执行执行serve -p 8080
。
点击按钮触发bug,我们可以看到错误已被成功捕获,并将对应的信息通过接口传递给服务端。
在服务端的输出中,我们可以看到已对错误进行了解析,错误发生的定位信息已经输出出来了,对照前端文件中错误发生的位置也是没有问题的~
最后附上这个玩具的Demo地址,有兴趣的掘友可以玩一下~