前端监控

前端性能监控

利用 performance API let timing = performance.getEntriesByType('navigation')[0]; 或者 let timing = performance.timing。封装一个函数,在页面加载完毕后执行,做了一些各个阶段性能指标的计算,然后通过接口发送到服务器,用于统计判断。

在 Console 中输入 performance.getEntriesByType('navigation')[0]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
connectEnd: 9.225000045262277
connectStart: 9.225000045262277
decodedBodySize: 113880
domComplete: 1361.1650000093505
domContentLoadedEventEnd: 522.205000044778
domContentLoadedEventStart: 522.1950000268407
domInteractive: 223.31999999005347
domainLookupEnd: 9.225000045262277
domainLookupStart: 9.225000045262277
duration: 1361.4700000034645
encodedBodySize: 23636
entryType: "navigation"
fetchStart: 9.225000045262277
initiatorType: "navigation"
loadEventEnd: 1361.4700000034645
loadEventStart: 1361.3600000389852
name: "https://juejin.cn/post/6844903540385644557"
nextHopProtocol: "h2"
redirectCount: 0
redirectEnd: 0
redirectStart: 0
requestStart: 12.350000033620745
responseEnd: 204.4350000214763
responseStart: 155.64000001177192
secureConnectionStart: 9.225000045262277
serverTiming: (3) [PerformanceServerTiming, PerformanceServerTiming, PerformanceServerTiming]
startTime: 0
transferSize: 24222
type: "navigate"
unloadEventEnd: 0
unloadEventStart: 0
workerStart: 0

输入 performance.timing 可以得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
connectEnd: 1608797959509
connectStart: 1608797959509
domComplete: 1608797960861
domContentLoadedEventEnd: 1608797960022
domContentLoadedEventStart: 1608797960022
domInteractive: 1608797959723
domLoading: 1608797959663
domainLookupEnd: 1608797959509
domainLookupStart: 1608797959509
fetchStart: 1608797959509
loadEventEnd: 1608797960862
loadEventStart: 1608797960861
navigationStart: 1608797959501
redirectEnd: 0
redirectStart: 0
requestStart: 1608797959512
responseEnd: 1608797959705
responseStart: 1608797959656
secureConnectionStart: 0
unloadEventEnd: 0
unloadEventStart: 0

计算一些关键的性能指标

1
2
3
4
5
6
7
8
9
window.addEventListener('load', (event) => {
// Time to Interactive
let timing = performance.getEntriesByType('navigation')[0];
// let timing = performance.timing
console.log(timing.domInteractive);
console.log(timing.fetchStart);
let diff = timing.domInteractive - timing.fetchStart;
console.log("TTI: " + diff);
})

以上代码只测量了”首次可交互时间”,其它常用的性能指标还有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DNS 解析耗时: domainLookupEnd - domainLookupStart
TCP 连接耗时: connectEnd - connectStart
SSL 安全连接耗时: connectEnd - secureConnectionStart
网络请求耗时 (TTFB): responseStart - requestStart
数据传输耗时: responseEnd - responseStart
DOM 解析耗时: domInteractive - responseEnd
资源加载耗时: loadEventStart - domContentLoadedEventEnd
First Byte时间: responseStart - domainLookupStart
白屏时间: responseEnd - fetchStart
首次可交互时间: domInteractive - fetchStart
DOM Ready 时间: domContentLoadEventEnd - fetchStart
页面完全加载时间: loadEventStart - fetchStart
http 头部大小: transferSize - encodedBodySize
重定向次数:performance.navigation.redirectCount
重定向耗时: redirectEnd - redirectStart

利用 let timing = performance.getEntriesByType('navigation')[0]; 或者 let timing = performance.timing 计算结果是基本一致的。

前端错误监控

前端错误的分类

  • 即时运行错误(代码错误)

  • 资源加载错误

即时运行错误捕获方式

全局捕获

对于代码运行错误,通常的办法是使用 window.onerror 或者 addEventListener 拦截报错:

1
2
3
4
5
6
7
8
9
10
window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) {
console.log('errorMessage: ' + errorMessage); // 异常信息
console.log('scriptURI: ' + scriptURI); // 异常文件路径
console.log('lineNo: ' + lineNo); // 异常行号
console.log('columnNo: ' + columnNo); // 异常列号
console.log('error: ' + error); // 异常堆栈信息
// ...
// 异常上报
};
throw new Error('这是一个错误');
1
2
3
4
5
6
window.addEventListener('error', function() {
console.log(error);
// ...
// 异常上报
});
throw new Error('这是一个错误');

通过window.onerror事件,可以得到具体的异常信息、异常文件的URL、异常的行号与列号及异常的堆栈信息,再捕获异常后,统一上报至我们的日志服务器。

该方法能拦截到大部分的详细报错信息,但是也有例外:

  • 对于跨域的代码运行错误会显示 Script error. 对于这种情况我们需要给 script 标签添加 crossorigin=”anonymous” 属性,或后端配置 Access-Control-Allow-Origin
  • 对于某些浏览器可能不会显示调用栈信息,这种情况可以通过 arguments.callee.caller 来做栈递归

try catch

使用 try… catch 虽然能够较好地进行异常捕获,不至于使得页面由于一处错误挂掉,但 try … catch 捕获方式显得过于臃肿,大多代码使用 try … catch 包裹,影响代码可读性。

不过,对于异步代码来说,可以使用 catch 的方式捕获错误。比如 Promise 可以直接使用 catch 函数,async await 可以使用 try catch。

资源加载错误的捕获方式

object.onerror

img 标签、script 标签等节点都可以添加 onerror 事件,用来捕获资源加载的错误。

performance.getEntries

performance.getEntries 可以获取所有已加载资源的加载时长,通过这种方式,可以间接的拿到没有加载的资源错误。

举例:

浏览器打开一个网站,在Console控制台下,输入:

1
2
3
4
performance.getEntries().forEach(function(item){console.log(item.name)})

// 或者输入:
performance.getEntries().forEach(item=>{console.log(item.name)})

上面这个api,返回的是数组,既然是数组,就可以用forEach遍历。打印出来的资源就是已经成功加载的资源。

再入document.getElementsByTagName(‘img’),就会显示出所有需要加载的的img集合。

于是,document.getElementsByTagName('img') 获取的资源数组减去通过 performance.getEntries() 获取的资源数组,剩下的就是没有成功加载的,这种方式可以间接捕获到资源加载错误。

错误上报的两种方式

ajax

采用 Ajax 通信的方式上报(此方式虽然可以上报错误,但是如果是简单的监控我们并不采用这种方式)

利用Image对象上报

利用 Image 对象上报(推荐,网站的监控体系都是采用的这种方式)

1
2
//通过Image对象进行错误上报
(new Image()).src = 'http://smyhvae.com/myPath?badjs=msg'; // myPath表示上报的路径(我要上报到哪里去)。后面的内容是自己加的参数。

这种方式,不需要借助第三方的库,一行代码即可搞定。

遇到的问题

通常在生产环境下的代码是经过 webpack 打包后压缩混淆的代码,所以我们可能会遇到所有的报错的代码行数都在第一行了,这是因为在生产环境下,我们的代码被压缩成了一行。

解决办法是开启 webpack 的 source-map,我们利用 webpack 打包后的生成的一份 .map 的脚本文件就可以让浏览器对错误位置进行追踪了。

但是问题又来了:

  • 直接在生产环境使用 source-map 文件,会暴露源码,怎么办?
  • source-map 在部分浏览器不兼容,怎么办?

针对第一个问题,我们可能可以把完整的 source-map 包换成 cheap-module-source-map,这样只会暴露错误所在的文件和代码行号,就不会暴露源码了。但是这样还是无法解决不兼容的问题。

针对第二个问题,可以使用引入 npm 库来支持 source-map,可以参考 mozilla/source-map。这个 npm 库既可以运行在客户端也可以运行在服务端,不过更为推荐的是在服务端使用 Node.js 对接收到的日志信息时使用 source-map 解析,以避免源代码的泄露造成风险,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const express = require('express');
const fs = require('fs');
const router = express.Router();
const sourceMap = require('source-map');
const path = require('path');
const resolve = file => path.resolve(__dirname, file);
// 定义post接口
router.get('/error/', async function(req, res) {
// 获取前端传过来的报错对象
let error = JSON.parse(req.query.error);
let url = error.scriptURI; // 压缩文件路径
if (url) {
let fileUrl = url.slice(url.indexOf('client/')) + '.map'; // map文件路径
// 解析sourceMap
let consumer = await new sourceMap.SourceMapConsumer(fs.readFileSync(resolve('../' + fileUrl), 'utf8')); // 返回一个promise对象
// 解析原始报错数据
let result = consumer.originalPositionFor({
line: error.lineNo, // 压缩后的行号
column: error.columnNo // 压缩后的列号
});
console.log(result);
}
});
module.exports = router;

针对第二个问题找到的其它解决方案:

需要服务器配置 .js.map 后缀的文件不可访问。 如果这样的话,服务器解析的时候不能直接去下载静态资源 .map 文件,而是需要去找到服务器本地对应的 map 文件,这样要单独配置路径和写逻辑很麻烦,而且文件夹结构有变动的话也不灵活。 所以我们的方案是做token权限校验,map 文件必须加正确的 token 参数,服务器才会返回资源(xxx.js.map?token=xxxx),否则 nginx 会屏蔽没有 token 或者 token 错误的请求。

这种方式就不需要单独在通过服务端接口解析了

Vue 和 React 捕获异常

Vue

正常我们捕获不到 Vue 组件的异常,查阅资料得知,在 Vue 中,异常可能被 Vue 自身给 try ... catch 了,不会传到 window.onerror 事件触发,那么我们如何把 Vue 组件中的异常作统一捕获呢?

使用 Vue.config.errorHandler 这样的 Vue 全局配置,可以在 Vue 指定组件的渲染和观察期间未捕获错误的处理函数。这个处理函数被调用时,可获取错误信息和 Vue 实例。

1
2
3
4
5
Vue.config.errorHandler = function (err, vm, info) {
// handle error
// `info` 是 Vue 特定的错误信息,比如错误所在的生命周期钩子
// 只在 2.2.0+ 可用
}

React

在 React 中,可以使用 ErrorBoundary 组件包括业务组件的方式进行异常捕获,配合 React 16.0+ 新出的 componentDidCatch API,可以实现统一的异常捕获和日志上报。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

componentDidCatch(error, info) {
// Display fallback UI
this.setState({ hasError: true });
// You can also log the error to an error reporting service
logErrorToMyService(error, info);
}

render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}

使用方式如下:

1
2
3
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>

实际使用后的优化

  • 我们发现不同的浏览器报错的变量可能不一样,同一个报错在chrome浏览器和firefox上 columnNo 参数一点偏差。 用两种报错解析了一下,如下图,报错的代码都是18行,是没问题的,Firefox报错是下图第一个:console 18 0 true,chrome是testBase 18 0 true,行数没问题,偏差不影响我们最终查错,我的18行源代码是:console.log(testBase)。 testBase是故意没有申明,testBase是undefined,出问题的应该是testBase这个变量,过从报错情况上看,确实是谷歌浏览器更精准一点。 虽然不在意IE,不过IE11报错列数和firefox一致。

  • 页面触发事件报错,用户一直触发按钮,这时就会不停上报错误信息。解决:存储上一个报错信息和时间,进行比对,同一个报错,短时间内避免一直重复发送。

  • 引入监控的项目,由于业务原因可能需要上传一些业务信息方便分析,所以预留一个配置字段,上传错误的时候请求会带上业务相关信息。

页面埋点

页面埋点应该是大家最常写的监控了,一般起码会监控以下几个数据:

  • PV / UV
  • 停留时长
  • 流量来源
  • 用户交互

对于这几类统计,一般的实现思路大致可以分为两种,分别为手写埋点和无埋点的方式。

相信第一种方式也是大家最常用的方式,可以自主选择需要监控的数据然后在相应的地方写入代码。这种方式的灵活性很大,但是唯一的缺点就是工作量较大,每个需要监控的地方都得插入代码。

另一种无埋点的方式基本不需要开发者手写埋点了,而是统计所有的事件并且定时上报。这种方式虽然没有前一种方式繁琐了,但是因为统计的是所有事件,所以还需要后期过滤出需要的数据。

开源的解决方案

一些开源的解决方案,比如腾讯的 badjs,淘宝的 JSTracker,阿里巴巴的 FdSafe,支付宝的 saijs,国外的 sentry 和对应的前端 sdk ravenjs,包括对应的 TraceKit。

目前主流的前端监控系统,包括一些收费服务,国内比如 fundebug,产品功能和 sentry 还真像,只不过他只关注前端,sentry 关注所有的 Error 收集场景,他们解决的问题其实都是本文要说到的一些通用问题,但是针对到具体项目适合不适合,就要看业务方自己选择了。

其中个人觉得 sentry 是比较好的选择,有以下几点:

  • 开源
  • 对各种前端框架的友好支持 (Vue、React、Angular)
  • 支持 SourceMap

Sentry 官方提供的免费服务有次数限制,达到一定限制后继续使用就需要收费了,但是我们可以利用 Sentry 的开源库在自己的服务器上搭建服务,官方已经提供了完善的操作文档。

有兴趣的童鞋可以参考 Sentry 搭建错误监控系统:
超详细!搭建一个前端错误监控系统

参考链接:

前端性能与异常上报

从无到有<前端异常监控系统>落地

前端面试之道