错误监控
# 错误监控
虽然在我们开发完成之后,会经历多轮的单元测试
、集成测试
、人工测试
,但是难免漏掉一些边缘的测试场景,甚至还有一些奇奇怪怪的玄学故障出现
;而出现报错后,轻则某些数据页面无法访问
,重则导致客户数据出错
;
因此,我们的前端监控,需要对前端页面的错误进行监控,一个强大完整的错误监控系统,可以帮我们做以下的事情:
- 应用报错时,及时知晓线上应用出现了错误,及时安排修复止损;
- 应用报错后,根据上报的用户行为追踪记录数据,迅速进行bug复现;
- 应用报错后,通过上报的错误行列以及错误信息,找到报错源码并快速修正;
- 数据采集后,进行分析提供宏观的 错误数、错误率、影响用户数等关键指标;
# JS运行异常
当 JavaScript运行时产生的错误 就属于 JS运行异常
,比如我们常见的:
TypeError: Cannot read properties of null
TypeError: xxx is not a function
ReferenceError: xxx is not defined
像这种运行时异常,我们很少手动去捕获它,当它发生异常之后,js有两种情况都会触发它
这里有一个点需要特别注意,
SyntaxError 语法错误
,除了用eval()
执行的脚本以外,一般是不可以被捕获到的。其实原因很简单, 语法错误,在
编译解析阶段
就已经报错了,而拥有语法错误的脚本不会放入任务队列
进行执行,自然也就不会有错误冒泡到我们的捕获代码。当然,现在代码检查这么好用,早在编写代码时这种语法错误就被避免掉了,一般我们碰不上语法错误的~
# 1、window.onerror
window.onerror
是一个全局变量,默认值为null。当有js运行时错误触发时,window会触发error事件,并执行 window.onerror()
,借助这个特性,我们对 window.onerror
进行重写就可以捕获到代码中的异常
const rawOnError = window.onerror;
// 监听 js 错误
window.onerror = (msg, url, line, column, error) => {
//处理原有的onerror
if (rawOnError) {
rawOnError.call(window, msg, url, line, column, error);
}
console.log("监控中......");
console.log(msg, url, line, column, error);
}
# 2、window.addEventListener('error')
window.addEventListener('error')
来捕获 JS运行异常
;它会比 window.onerror
先触发;
window.addEventListener('error', e => {
console.log(e);
}, true)
# 两者的区别和选用
- 它们两者均可以捕获到
JS运行异常
,但是 方法二除了可以监听JS运行异常
之外,还可以同时捕获到静态资源加载异常
onerror
可以接受多个参数。而addEventListener('error')
只有一个保存所有错误信息的参数
更加建议使用第二种 addEventListener('error')
的方式;原因很简单:不像方法一可以被 window.onerror 重新覆盖
;而且可以同时处理静态资源错误
# 静态资源加载异常
界面上的link的css
、script的js资源
、img图片
、CDN资源
打不开了,其实都会触发window.addEventListener('error')
事件
使用
addEventListener
捕获资源错误时,一定要将 第三个选项设为 true,因为资源错误没有冒泡,所以只能在捕获阶段捕获。
我们只需要再事件中加入简单的判断,就可以区分是资源加载错误,还是js错误
window.addEventListener('error', e => {
const target = e.target;
//资源加载错误
if (target && (target.src || target.href)) {
}
else { //js错误
}
}, true)
# Promise异常
什么叫 Promise异常
呢?其实就是我们使用 Promise
的过程中,当 Promise
被 reject
且没有被 catch
处理的时候,就会抛出 Promise异常
;同样的,如果我们在使用 Promise
的过程中,报了JS的错误,同样也被以 Promise异常
的形式抛出:
Promise.resolve().then(() => console.log(c));
Promise.reject(Error('promise'))
而当抛出 Promise异常
时,会触发 unhandledrejection
事件,所以我们只需要去监听它就可以进行 Promise 异常
的捕获了,不过值得注意的一点是:相比与上面所述的直接获取报错的行号、列号等信息,Promise异常
我们只能捕获到一个 报错原因
而已
window.addEventListener('unhandledrejection', e => {
console.log("---promiseErr监控中---");
console.error(e)
})
# Vue2、Vue3 错误捕获
Vue2
如果在组件渲染时出现运行错误,错误将会被传递至全局Vue.config.errorHandler
配置函数;Vue3
同Vue2
,如果在组件渲染时出现运行错误,错误将会被传递至全局的app.config.errorHandler
配置函数;
我们可以利用这两个钩子函数来进行错误捕获,由于是依赖于 Vue配置函数
的错误捕获,所以我们在初始化
时,需要用户将 Vue实例
传进来;
if (config.vue?.Vue) {
config.vue.Vue.config.errorHandler = (e, vm, info) => {
console.log("---vue---")
console.log(e);
}
}
注意:由于内容实在太多,下面的内容在视频中不做演示,感兴趣的同学可以自己继续深入研究
# HTTP请求异常
所谓 Http请求异常
也就是异步请求 HTTP 接口时的异常,比如我调用了一个登录接口,但是我的传参不对,登录接口给我返回了 500 错误码
,其实这个时候就已经产生了异常了.
看到这里,其实有的同学可能会疑惑
,我们现在的调用 HTTP 接口,一般也就是通过 async/await
这种基于Promise的解决异步的最终方案;那么,假如说请求了一个接口地址报了500,因为是基于 Promise
调用的接口,我们能够在上文的 Promise异常
捕获中,获取到一个错误信息(如下图);
但是有一个问题别忘记了,Promise异常捕获没办法获取报错的行列
,我们只知道 Promise 报错了,报错的信息是 接口请求500
;但是我们根本不知道是哪个接口报错了;
所以说,我们对于 Http请求异常
的捕获需求就是:全局统一监控
、报错的具体接口
、请求状态码
、请求耗时
以及请求参数
等等;
而为了实现上述的监控需求,我们需要了解到:现在异步请求的底层原理都是调用的 XMLHttpRequest
或者 Fetch
,我们只需要对这两个方法都进行 劫持
,就可以往接口请求的过程中加入我们所需要的一些参数捕获;
# 跨域脚本错误
还有一种错误,平常我们较难遇到,那就是 跨域脚本错误
,简单来说,就是你跨域调用的内容出现的错误。
当跨域加载的脚本中发生语法错误时,浏览器出于安全考虑,不会报告错误的细节,而只报告简单的
Script error
。浏览器只允许同域下的脚本捕获具体错误信息,而其他脚本只知道发生了一个错误,但无法获知错误的具体内容(控制台仍然可以看到,JS脚本无法捕获)
其实对于三方脚本的错误,我们是否捕获都可以,不过我们需要一点处理,如果不需要捕获的话,就不进行上报,如果需要捕获的话,只上报类型;
# React 错误捕获
React
一样也有官方提供的错误捕获,见文档:zh-hans.reactjs.org/docs/react-…
和 Vue
不同的是,我们需要自己定义一个类组件暴露给项目使用,我这里就不具体详写了,感兴趣的同学可以自己进行补全:
import React from 'react';
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
componentDidCatch(error, info) {
this.setState({ hasError: true });
// 将component中的报错发送到后台
// monitor为监控sdk导出的对象
monitor && monitor.reactError(error, info);
}
render() {
if (this.state.hasError) {
return null
// 也可以在出错的component处展示出错信息
// return <h1>出错了!</h1>;
}
return this.props.children;
}
}
其中 reactError() 方法在组装错误信息。:
monitor.reactError = function (err, info) {
report({
type: ERROR_REACT,
desc: err.toString(),
stack: info.componentStack
});
};
# 项目代码实战
# 1、创建全局配置
// config/index.js
const config = {
appId: 'monitor-demo',
userId: 'ys',
reportUrl: 'http://127.0.0.1:3001/report/actions',
vue: {
Vue: null,
router: null,
},
ua:navigator.userAgent,
}
export default config
export function setConfig(options) {
for (const key in config) {
if (options[key]) {
config[key] = options[key]
}
}
}
# 2、error
// error/index.js
import config from '../config'
import lastCaptureEvent from '../utils/captureEvent'
import {getPaths} from "../utils/"
/**
* 这个正则表达式用于匹配 JavaScript 错误栈中的堆栈跟踪信息中的单个条目,其中包含文件名、行号和列号等信息。
* 具体来说,它匹配以下格式的文本:
* at functionName (filename:lineNumber:columnNumber)
* at filename:lineNumber:columnNumber
* at http://example.com/filename:lineNumber:columnNumber
* at https://example.com/filename:lineNumber:columnNumber
*/
const FULL_MATCH =
/^\s*at (?:(.*?) ?\()?((?:file|https?|blob|chrome-extension|address|native|eval|webpack|<anonymous>|[-a-z]+:|.*bundle|\/).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i;
// 限制只追溯10个
const STACKTRACE_LIMIT = 10;
export default function error() {
/*
const rawOnError = window.onerror;
// 监听 js 错误
window.onerror = (msg, url, line, column, error) => {
//处理原有的onerror
if (rawOnError) {
rawOnError.call(window, msg, url, line, column, error);
}
console.log("监控中......");
console.log(msg, url, line, column);
console.log(error);
}
*/
// 监听 promise 错误 缺点是获取不到列数据
window.addEventListener('unhandledrejection', e => {
console.log("---promiseErr监控中---");
console.error(e)
const lastEvent = lastCaptureEvent();
let data = {};
const reason = e.reason;
if (typeof reason === 'string') {
data.message = reason
}
else if (typeof reason === 'object') {
const paths = getPaths(lastEvent);
data.message = reason.message
if (reason.stack) {
const errs = parseStackFrames(reason);
const currentError = errs[0];
data.filename = currentError.filename;
data.functionName = currentError.functionName;
data.lineno = currentError.lineno;
data.colno = currentError.colno;
data.stack = reason.stack;
data.paths = paths;
data.type = 'error';
data.errorType = "promiseError";
}
}
console.log(data);
})
// 捕获资源加载失败错误 js css img...
//window.addEventListener('error',fn) 能捕获js错误,也能捕获资源加载失败错误
//使用 addEventListener 捕获资源错误时,一定要将 第三个选项设为 true,
//因为资源错误没有冒泡,所以只能在捕获阶段捕获。
//同理,由于 window.onerror 是通过在冒泡阶段捕获错误,所以无法捕获资源错误。
window.addEventListener('error', e => {
const target = e.target;
//注意当前并不是事件对象本身,而是error事件,因此获取不了当前点击的对象
//我们可以利用事件传递的机制,获取最后一个捕获的对象
const lastEvent = lastCaptureEvent();
//资源加载错误
if (target && (target.src || target.href)) {
const paths = getPaths(target);
const data = {
type:'error',
errorType: "resourceError",
filename: target.src || target.href,
tagName: target.tagName,
message:`加载${target.tagName}资源失败`,
paths:paths ? paths : 'Window',
}
console.log(data);
}
else { //js错误
const errs = parseStackFrames(e.error);
const currentError = errs[0];
const paths = getPaths(lastEvent);
const data = {
type:'error',
errorType: "jsError",
filename: currentError.filename,
functionName: currentError.functionName,
lineno: currentError.lineno,
colno: currentError.colno,
message: e.message,
stack: e.error.stack,
paths:paths ? paths : 'Window'
}
console.log(data);
}
}, true)
if (config.vue?.Vue) {
config.vue.Vue.config.errorHandler = (e, vm, info) => {
console.log("---vue---")
const lastEvent = lastCaptureEvent();
const paths = getPaths(lastEvent);
const errs = parseStackFrames(e);
const {
filename,
functionName,
lineno,
colno
} = errs[0];
const data = {
type: 'error',
errorType: "vueError",
filename,
functionName,
lineno,
colno,
message: e.message,
stack: e.stack,
paths: paths ? paths : 'Window'
}
console.log(data);
}
}
}
function parseStackLine(line) {
const lineMatch = line.match(FULL_MATCH);
if (!lineMatch) return {};
const filename = lineMatch[2];
const functionName = lineMatch[1] || '';
const lineno = parseInt(lineMatch[3], 10) || undefined;
const colno = parseInt(lineMatch[4], 10) || undefined;
return {
filename,
functionName,
lineno,
colno,
};
}
// 解析错误堆栈
function parseStackFrames(error) {
const { stack } = error;
// 无 stack 时直接返回
if (!stack) return [];
const frames = [];
for (const line of stack.split('\n').slice(1)) {
const frame = parseStackLine(line);
if (frame) {
frames.push(frame);
}
}
return frames.slice(0, STACKTRACE_LIMIT);
}
/**
* 手动捕获错误
* @param {*} error
* @param {*} msg
*/
export function errorCaptured(error, msg){
console.log(error);
console.log(msg);
}
# 3、事件处理
// utils/captureEvent.js
let lastCaptureEvent;
['click', 'mousedown', 'keydown', 'scroll', 'mousewheel', 'mouseover'].forEach(eventType => {
document.addEventListener(
eventType,
event => {
lastCaptureEvent = event;
},
{ capture: true, passive: true }
)
});
export default () => {
return lastCaptureEvent;
}
# 4、路径处理
export const getComposePathEle = (e) => {
//如果存在path属性,直接返回path属性
//e.composedPath()也能返回事件路径,但是还是有兼容性问题
//https://developer.mozilla.org/zh-CN/docs/Web/API/Event/composedPath
if(!e) return [];
let pathArr = e.path || (e.composedPath && e.composedPath());
if ((pathArr||[]).length) {
return pathArr;
}
//如果不存在,就向上遍历节点
let target = e.target;
const composedPath = [];
while(target && target.parentNode) {
composedPath.push(target);
target = target.parentNode;
}
//最后push进去document和window
composedPath.push(document, window);
return composedPath;
}
export const getComposePath = (e) => {
if (!e) return [];
const composedPathEle = getComposePathEle(e);
const composedPath = composedPathEle.reverse().slice(2).map(ele => {
let selector = ele.tagName.toLowerCase();
if(ele.id) {
selector += `#${ele.id}`;
}
if(ele.className) {
selector += `.${ele.className}`;
}
return selector;
})
return composedPath;
}
export const getPaths = (e) => {
if (!e) return '';
const composedPath = getComposePath(e);
const selector = composedPath.join(' > ');
return selector;
}
# 5、index.js
import { setConfig } from "./config"
import error, { errorCaptured } from "./error"
const monitor = {
init(options = {}) {
setConfig(options);
error();
},
errorCaptured
}
export default monitor