在长期生产 bug 并修复 bug 的循环中,上线产品不可避免出现异常。如何能快速定位到发生错误的代码位置、第一时间通知开发人员异常发生以及报错的堆栈信息、用户OS与浏览器版本等十分重要。
而错误埋点追踪系统的出现就是为了应对上述问题的解决方案。
错误类型
JS执行错误
1. SyntaxError:解析时发生语法错误,window.onerror捕获不到,一般SyntaxError在构建阶段,甚至本地开发阶段就会被发现。
2. TypeError:值不是所期待的类型。
3. ReferenceError:引用未声明的变量。
4. RangeError:当一个值不在其所允许的范围或者集合中
网络错误
- ResourceError:资源加载错误
- HttpError:Http请求错误
前端异常捕获
js 异常的特点是,出现不会导致 JS 引擎崩溃,最多只会终止当前执行的任务。在Javascript中,我们通常有以下两种异常捕获机制。
1. try…catch 语句能捕捉到的异常,必须是线程执行已经进入 try catch 但 try catch 未执行完的时候抛出来的,优点是能够较好地进行异常捕获,不至于使得页面由于一处错误挂掉,缺点是显得过于臃肿,大多代码使用try ... catch
包裹,影响代码可读性。以下都是无法被捕获到的情形:
- 异步任务抛出的异常(执行时 try catch 已经从执行完了,比如 setTimeout)。
- promise,正常情况下异常被 promise 内部捕获到了,并未往上抛异常,使用 promise.catch() 处理或者 promise 前使用用 await 就可以被 try... catch 捕获了。
- 语法错误(代码运行前,在编译时就检查出来了的错误)。
2. window.onerror 最大的好处就是同步任务、异步任务都可捕获,可以得到具体的异常信息、异常文件的URL、异常的行号与列号及异常的堆栈信息,捕获异常后,统一上报至我们的日志服务器,而且可以全局监听。缺点是无法捕获资源加载错误,同时,跨域脚本无法准确捕获异常,跨域之后 window.onerror
捕获不到正确的异常信息,而是统一返回一个Script error
,可通过在<script>
使用 crossorigin
属性来规避这个问题,或者使用 try ... catch ... 进行捕获。
当一项资源(如图片或脚本)加载失败,加载资源的元素会触发一个 Event 接口的 error 事件,图片、script、css加载错误,都能被捕获。
window.addEventListener('error', function() {
console.log(error);
// ...
// 异常上报
});
或者是劫持原生方法:
const nativeAddEventListener = EventTarget.prototype.addEventListener; // 先将原生方法保存起来。
EventTarget.prototype.addEventListener = function (type, func, options) { // 重写原生方法。
const wrappedFunc = function (...args) { // 将回调函数包裹一层try catch
try {
return func.apply(this, args);
} catch (e) {
const errorObj = {
...
error_name: e.name || '',
error_msg: e.message || '',
error_stack: e.stack || (e.error && e.error.stack),
error_native: e,
...
};
// 接下来可以将errorObj统一进行处理。
}
}
return nativeAddEventListener.call(this, type, wrappedFunc, options); // 调用原生的方法,保证addEventListener正确执行
3. Promise 内部异常,如果遗漏处理,最好是添加一个 Promise 全局异常捕获事件 unhandledrejection
:
window.addEventListener("unhandledrejection", e => {
console.log('unhandledrejection',e)
});
4. vue工程异常 ,window.onerror 并不能捕获.vue文件发生的获取。使用Vue.config.errorHandler
这样的Vue全局配置处理函数,被调用时,可获取错误信息和Vue 实例:
//main.js
import { createApp } from "vue";
import App from "./App.vue";
let app = createApp(App);
app.config.errorHandler = function(e) {
console.log(e);
//错误上报...
};
app.mount("#app");
综合以上所述的前端异常捕获方式:
import { createApp } from "vue";
import App from "./App.vue";
let app = createApp(App);
window.addEventListener(
"error",
(e) => {
console.log(e);
/** TODO:上报逻辑 */
return true;
},
true
);
/** 处理未捕获的异常,主要是promise内部异常,统一抛给 onerror */
window.addEventListener("unhandledrejection", (e) => {
throw e.reason;
});
/** 框架异常统一捕获 */
app.config.errorHandler = function(err, vm, info) {
/** TODO:上报逻辑 */
console.log(err, vm, info);
};
app.mount("#app");
5. React 异常捕获,react 通过componentDidCatch,声明一个错误边界的组件
sourcemap
通常在生产环境下的代码是经过 webpack 打包后压缩混淆的,否则源代码泄漏易造成安全问题。在该环境下,代码被压缩成了一行。而webpack 打包后会生成一份.map的脚本文件,浏览器利用它对错误位置进行追踪,但这种做法并不可取。
更为推荐的是在服务端使用 Node.js 接收到的日志信息时使用 source-map 对其进行解析,以避免源代码的泄露造成风险。
vue.config.js
配置里通过属性 productionSourceMap: true
来控制 webpack 是否生成 map 文件。
编写一个插件让 webpack 在打包完成后触发一个钩子实现 sourcemap 文件上传
首先,在vue.config.js
中进行配置:
import SourceMapUploader from "./source-map-upload";
module.exports = {
configureWebpack: {
resolve: {
alias: {
"@": resolve("src"),
},
},
plugins: [
new SourceMapUploader({url: "http://localhost:3000/upload"})
],
}
// chainWebpack: (config) => {},
}
sourcemap 文件上传实现(source-map-upload.js ):
const fs = require("fs");
const http = require("http");
const path = require("path");
class SourceMapUploader {
constructor(options) {
this.options = options;
}
/**
* 用到了hooks,done表示在打包完成之后
* status.compilation.outputOptions就是打包的dist文件
*/
apply(compiler) {
if (process.env.NODE_ENV == "production") {
compiler.hooks.done.tap("sourcemap-uploader", async (status) => {
/** 读取目录下的 map 后缀的文件 */
let dir = path.join(status.compilation.outputOptions.path, "/js/");
let chunks = fs.readdirSync(dir);
let map_file = chunks.filter((item) => {
return item.match(/\.js\.map$/) !== null;
});
/** 上传 sourcemap */
while (map_file.length > 0) {
let file = map_file.shift();
await this.upload(this.options.url, path.join(dir, file));
}
});
}
}
/** 调用upload接口,上传文件 */
upload(url, file) {
return new Promise((resolve) => {
let req = http.request(`${url}?name=${path.basename(file)}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
Connection: "keep-alive",
},
});
let fileStream = fs.createReadStream(file);
fileStream.pipe(req, { end: false });
fileStream.on("end", function() {
req.end();
resolve();
});
});
}
}
module.exports = SourceMapUploader;
行为搜集
通过搜集用户的操作,可以明显发现错误为什么产生。 用户的操作分类如下:
- UI行为: 点击、滚动、聚焦/失焦、长按
- 浏览器行为:请求、前进/后退、跳转、新开页面、关闭
- 控制台行为:log、warn、error
如何搜集?
- 点击行为:使用addEventListener监听全局上的click事件,将事件和DOM元素名字收集。与错误信息一起上报。
- 发送请求:监听XMLHttpRequest的onreadystatechange回调函数。
- 页面跳转:监听window.onpopstate,页面进行跳转时会触发。
- 控制台行为:重写console对象的info等方法。
错误上报
两种方式:
- 通过动态创建一个img,这种方式无需加载任何库,而且页面是无需刷新的,将需要上报的错误数据放在url中,相当于 get 请求,没有跨域问题。缺点是有url长度限制,一般够用;
- ajax 与正常的接口请求无异,可以用 post。
确定上报的内容,应该包含异常位置(行号,列号),异常信息,在错误堆栈中包含了绝大多数调试有关的信息,请求的时候只能以字符串方式传输,因此需要将对象进行序列化处理。
- 将异常数据从属性中解构出来,存入一个JSON对象
- 将JSON对象转换为字符串
- 将字符串转换为Base64
function uploadErr({ lineno, colno, error: { stack }, message, filename }) {
let str = window.btoa(
JSON.stringify({
lineno,
colno,
error: { stack },
message,
filename,
})
);
let front_ip = "http://localhost:3000/error";
new Image().src = `${front_ip}?info=${str}`;
}
使用 POST 一个 1 x 1 的 gif 对 错误、环境、行为信息 进行上报。原因是:
- 没有跨域问题
- 发 GET 请求之后不需要获取和处理数据、服务器也不需要发送数据
- 不会携带当前域名 cookie!
- 不会阻塞页面加载,影响用户的体验,只需 new Image 对象
- 相比于 BMP/PNG 体积最小,可以节约 41% / 35% 的网络资源小
一句话总结:监听 / 劫持 原始方法,获取需要上报的数据,在错误发生时 触发 函数使用 gif 上报。
后端或者监控平台接收到信息后进行对应的反向操作,就可以在日志中记录。
接收到的错误记录转化为有效的数据入库,核心功能需要对数据进行清洗,生成标识错误的errorKey,进行过滤。
读取到监控平台时,先读取对应的 map文件(按 filename 对应),然后只需传入压缩后的JS报错行号列号即可,就会返回压缩前JS的错误信息。
而且,在上报的时候增加报错时间,用户浏览器信息,对错误类型区分,自定义错误类型统计,引入图表可视化展示,更加直观地追踪。
同时对上报频率做限制。如类似mouseover事件中的报错应该考虑防抖般的处理。