# 异常监控系统
# 异常类型
# js 异常
try-catch
缺点:无法捕获异步错误
window.error
可捕获异步,信息全面
返回 true 就不会被上抛了。不然控制台中还会看到错误日志。
缺点:无法捕获网络请求错误
监听 error 事件
window.addEventListener('error',() => {})
Promise 错误
window.addEventListener("unhandledrejection", (e) => { throw e.reason; });
Async/await 错误
本质就是 Promise 监听 unhandledrejection 向上抛出错误即可
总结
我们可以将 unhandledrejection 事件抛出的异常再次抛出就可以统一通过 error 事件进行处理了。
window.addEventListener("unhandledrejection", (e) => { throw e.reason; }); window.addEventListener( "error", (args) => { console.log("error event:", args); return true; }, true );
这是 AOP(面向切面编程)设计模式,当错误发生的时候,我们会在 catch 中重新 throw 一个错误出来,最后在将原生 addEventListener 抛出执行。
# Vue
Vue.config.errorHandler = (err, vm, info) => {
let { message, name, script = "", line = 0, column = 0, stack } = err;
console.log("errorHandler:", err);
};
# React
错误边界仅可以捕获其子组件的错误。错误边界无法捕获其自身的错误。如果一个错误边界无法渲染错误信息,则错误会向上冒泡至最接近的错误边界。
使用 componentDidCatch 进行错误捕获
import React from "react";
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
}
componentDidCatch(error, info) {
// 发生异常时打印错误
console.log("componentDidCatch", error);
}
render() {
return this.props.children;
}
}
# 环境信息收集
# 行为分类
# 用户行为
使用 addEventListener 全局监听点击事件,将用户行为(click、input)和 dom 元素名字收集。当错误发生将错误和行为一起上报。
# 浏览器行为
监听 XMLHttpRequest 对象的 onreadystatechange 回调函数,在回调函数执行时收集数据。
监听 window.onpopstate,页面跳转的时会触发此方法,将信息收集。
# 错误上报
# img 上报
推荐使用 1*1 的 gif
原因是: 1、没有跨域问题 2、发 GET 请求之后不需要获取和处理数据、服务器也不需要发送数据 3、不会携带当前域名 cookie! 4、不会阻塞页面加载,影响用户的体验,只需 new Image 对象 5、相比于 BMP/PNG 体积最小,可以节约 41% / 35% 的网络资源小
new Image().src = "http://localhost:7001/monitor/error";
# ajax 上报
axios.post("http://localhost:7001/monitor/error");
# 上报数据
// 分别是错误信息,错误地址,lineno,colno,error.message,error.stack
// 将info信息序列化后上传
const str = window.btoa(JSON.stringify(info));
const host = "http://localhost:7001/monitor/error";
new Image().src = `${host}?info=${str}`;
# 数据清洗去重
except: [
/^Script error\.?/,
/^Javascript error: Script error\.? on line 0/,
], // 忽略某个错误
repeat:5
// 重复出现的错误,只上报config.repeat次
repeat(error) {
const rowNum = error.rowNum || '';
const colNum = error.colNum || '';
const repeatName = error.msg + rowNum + colNum;
this.repeatList[repeatName] = this.repeatList[repeatName]
? this.repeatList[repeatName] + 1
: 1;
return this.repeatList[repeatName] > this.config.repeat;
}
// 忽略错误
except(error) {
const oExcept = this.config.except;
let result = false;
let v = null;
if (utils.typeDecide(oExcept, 'Array')) {
for (let i = 0, len = oExcept.length; i < len; i++) {
v = oExcept[i];
if ((utils.typeDecide(v, 'RegExp') && v.test(error.msg))) {
result = true;
break;
}
}
}
return result;
}
# sourceMap 上传
let mapKeys = Object.keys(compilation.assets).filter(item => /.map$/.test(item.toLowerCase()))
let promiseList = []
for (let item in mapKeys) {
promiseList.push(
getClient(
appKeys,
appVersion,
mapKeys[item].substr(mapKeys[item].lastIndexOf('/') + 1),
compilation.assets[mapKeys[item]].source().toString()
)
)
}
Promise.all(promiseList)
# 插入 html
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { type } = require('os')
const { resolve } = require('path')
const { compilation } = require('webpack')
class HtmlAddAttrPlugins {
constructor(options = {}) {
this.options = options
}
addAttr (tag, key, val) {
if (!tag || !tag.length) return
tag.forEach((tag, index) => {
let value = val
if (typeof val === 'function') {
value = val(tag, compilation, index)
}
!tag.attributes && (tag.attributes = {})
tag.attributes[key] = value
})
}
apply (compiler) {
let _self = this
compiler.hooks.compilation.tap('htmlPlugin', compilation => {
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroup.tapAysnc('htmlPlugin',
(data, cb) => {
let options = Object.assign({}, null, _self.options.attributes)
Object.keys(options).forEach((key) => {
let val = options[key]
if (typeof value != "string" && typeof value != 'function') return
_self.addAttr(data.headTags, key, value)
_self.addAttr(data.bodyTags, key, value)
})
if (typeof cb === 'function') {
cb(null, data)
} else {
return new Promise(resolve => resolve(data))
}
})
})
}
}
# 异常收集
这里使用 egg 进行异常收集
将错误接收并转码写入到日志中
async index() {
const { ctx } = this;
const { info } = ctx.query
const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
console.log('fronterror:', json)
// 记入错误日志
this.ctx.getLogger('frontendLogger').error(json)
ctx.body = '';
}
# 整理与上报方案
除了异常报错信息本身,我们还需要记录用户操作日志,以实现场景复原。这就涉及到上报的量和频率问题。如果任何日志都立即上报,这无异于自造的 DDOS 攻击。因此,我们需要合理的上报方案。
# 前端存储日志
存储方式 | cookie | localStorage | sessionStorage | IndexedDB | webSQL |
类型 | key-value | key-value | NoSQL | SQL | |
数据格式 | string | string | string | object | |
容量 | 4k | 5M | 5M | 500M | 60M |
进程 | 同步 | 同步 | 同步 | 异步 | 异步 |
检索 | key | key | key, index | field | |
性能 | 读快写慢 | 读慢写快 |
综合之后,IndexedDB 是最好的选择,它具有容量大、异步的优势,异步的特性保证它不会对界面的渲染产生阻塞。缺点,就是 api 非常复杂,不像 localStorage 那么简单直接。针对这一点,我们可以使用hello-indexeddb这个工具
当一个事件、变动、异常被捕获之后,形成一条初始日志,被立即放入暂存区(indexedDB 的一个 store),之后主程序就结束了收集过程,后续的事只在 webworker 中发生。在一个 webworker 中,一个循环任务不断从暂存区中取出日志,对日志进行分类,将分类结果存储到索引区中,并对日志记录的信息进行丰富,将最终将会上报到服务端的日志记录转存到归档区
# 日志分析
日志分析的关键在于 webpack 打包时将打包完成的 sourceMap 进行上传
# webpack Plugins
apply(compiler) {
console.log('UploadSourceMapWebPackPlugin apply')
// 定义在打包后执行
compiler.hooks.done.tap('upload-sourecemap-plugin', async status => {
// 读取sourcemap文件
const list = glob.sync(path.join(status.compilation.outputOptions.path, `./**/*.{js.map,}`))
// console.log('list:', list)
for (let filename of list) {
await this.upload(this.options.uploadUrl, filename)
}
})
}
# 服务端接收并保存
async upload() {
const { ctx } = this
const stream = ctx.req
const filename = ctx.query.name
const dir = path.join(this.config.baseDir, 'uploads')
// 判断upload目录是否存在
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir)
}
const target = path.join(dir, filename)
const writeStream = fs.createWriteStream(target)
stream.pipe(writeStream)
}
# 反序列化 Error
使用 error-stack-parser 将上传的
# 信息上报
# 用户体验层
window.performance.timing
timing: {
// 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面,这个值会和fetchStart相同。
navigationStart: 1543806782096,
// 上一个页面unload事件抛出时的时间戳。如果没有上一个页面,这个值会返回0。
unloadEventStart: 1543806782523,
// 和 unloadEventStart 相对应,unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。
unloadEventEnd: 1543806782523,
// 第一个HTTP重定向开始时的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0。
redirectStart: 0,
// 最后一个HTTP重定向完成时(也就是说是HTTP响应的最后一个比特直接被收到的时间)的时间戳。
// 如果没有重定向,或者重定向中的一个不同源,这个值会返回0.
redirectEnd: 0,
// 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。
fetchStart: 1543806782096,
// DNS 域名查询开始的UNIX时间戳。
//如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和fetchStart一致。
domainLookupStart: 1543806782096,
// DNS 域名查询完成的时间.
//如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
domainLookupEnd: 1543806782096,
// HTTP(TCP) 域名查询结束的时间戳。
//如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart一致。
connectStart: 1543806782099,
// HTTP(TCP) 返回浏览器与服务器之间的连接建立时的时间戳。
// 如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
connectEnd: 1543806782227,
// HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,则返回0。
secureConnectionStart: 1543806782162,
// 返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的时间戳。
requestStart: 1543806782241,
// 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳。
//如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。
responseStart: 1543806782516,
// 返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时
//(如果在此之前HTTP连接已经关闭,则返回关闭时)的时间戳。
responseEnd: 1543806782537,
// 当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时)的时间戳。
domLoading: 1543806782573,
// 当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时)的时间戳。
domInteractive: 1543806783203,
// 当解析器发送DOMContentLoaded 事件,即所有需要被执行的脚本已经被解析时的时间戳。
domContentLoadedEventStart: 1543806783203,
// 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的时间戳。
domContentLoadedEventEnd: 1543806783216,
// 当前文档解析完成,即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳
domComplete: 1543806783796,
// load事件被发送时的时间戳。如果这个事件还未被发送,它的值将会是0。
loadEventStart: 1543806783796,
// 当load事件结束,即加载事件完成时的时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是0.
loadEventEnd: 1543806783802
}
通过以上数据,我们可以得到几个有用的时间
DNS 查询耗时 :domainLookupEnd - domainLookupStart
TCP 链接耗时 :connectEnd - connectStart
request 请求耗时 :responseEnd - responseStart
解析 dom 树耗时 : domComplete - domInteractive
白屏时间 :responseStart - navigationStart
domready 时间 :domContentLoadedEventEnd - navigationStart
onload 时间 :loadEventEnd – navigationStart
# 关键性能指标
//首包时间
firstbyte: timing.responseStart - timing.domainLookupStart
//First Paint Time, 首次渲染时间 / 白屏时间
fpt: timing.responseEnd - timing.fetchStart
//Time to Interact,首次可交互时间
TTI: timing.domInteractive - timing.fetchStart
//HTML 加载完成时间, 即 DOM Ready 时间
ready: timing.domContentLoadedEventEnd - timing.fetchStart
//页面完全加载时间
load:timing.loadEventEnd - timing.fetchStart
# 业务层
- PV(Page View):页面浏览量或点击量
- UV():指访问某个站点的不同 ip 地址的人数
- 页面停留时间:用户在每一个页面的停留时间
# 小程序错误上报
差异化劫持,格式化上报
劫持 APP 方法
// 劫持原小程序App方法
rewriteApp() {
const originApp = App;
const self = this;
App = function (app) {
// 合并方法,插入记录脚本
['onLaunch', 'onShow', 'onHide', 'onError'].forEach((methodName) => {
const userDefinedMethod = app[methodName]; // 暂存用户定义的方法
if (methodName === 'onLaunch') {
self.getNetworkType();
self.config.setLocation && self.getLocation();
self.config.setSystemInfo && self.getSystemInfo();
}
app[methodName] = function (options) {
methodName === 'onError' && self.error({ msg: options }); // 错误上报
return userDefinedMethod && userDefinedMethod.call(this, options);
};
});
return originApp(app);
};
}
劫持 Page 方法
// 劫持原小程序Page方法
function rewritePage() {
const originPage = Page;
Page = (page) => {
Object.keys(page).forEach((methodName) => {
typeof page[methodName] === 'function'
&& this.recordPageFn(page, methodName);
});
// 强制记录两生命周期函数
page.onReady || this.recordPageFn(page, 'onReady'); // 这个函数记录错误的时间,所在页面,方法名等信息
page.onLoad || this.recordPageFn(page, 'onLoad');
// 执行原Page对象
return originPage(page);
};
}
# 环境信息获取
//app.js
globalData:{
referrer:{}
}
onShow(options){
this.globalData.referrer=options
}
// SDK.js
let App= getApp()
let refererObj= App.globalData.referrer
# 参考文章
使用 vue+node 搭建前端异常监控系统 (opens new window)
一文从零彻底搞懂前端的内存监控、异常、泄漏 (opens new window)