前端性能监控
1.背景
- 用户量日渐庞大,用户对体验的优化迫在眉睫
- 性能提升信任度,糟糕的性能会严重体验用户体验,大量的研究表明性能和商业收益成正比
- 用户反馈已晚矣,用户发现问题到反馈的流程较长,相当于亡羊补牢,那么,前端监控,呼之欲出。
- 前端监控未雨绸缪,提前告警,精准定位,协助排查,白屏感知,接口异常预警
2.为什么要做前端监控
- 更快的发现问题和解决问题
- 做产品的决策依据
- 为业务扩展提供更多的可能性
- 根据指标优化产品
3.监控三座大山
1.稳定性(报错相关)
- js错误:js执行错误,promise异常
- 资源异常:script,link,img,css等资源的加载异常
- 接口错误:ajax,fetch请求接口异常
- 白屏: 页面空白
2.用户体验(性能相关)
- 加载时间 各个阶段的加载时间
- TTFB(time to first byte 首字节时间) 是指浏览器发起第一个请求到数据返回第一个字节所消耗时间
- FP(First Paint 首次绘制时间) 首次渲染的时间,是第一个像素点绘制到屏幕的时间
- FCP(First Contentful Paint 首次内容绘制时间) 首次有内容绘制渲染的时间,指浏览器将第一个dom渲染到屏幕的时间
- FMP(First Meaningful paint 首次有意义绘制) 首次有意义绘制时间
- FID(First Input Delay 首次输入延迟) 用户首次和页面交互到页面响应交互的时间
3.业务埋点(业务相关)
- PV page view 即页面浏览量和点击量
- UV 指访某个站点的不同ip地址的人数
- 页面的停留时间 用户在每一个页面的停留时间
- 自定义埋点,用户点击某个按钮或者事件的业务统计口径
4.监控流程设计
- 前端埋点 --> 数据采集 --> 数据建模和存储 --> 数据传输(实时/批量) --> 数据统计(分析) --> 数据可视化 --> 报告和报警(短信)
- 代码埋点基本类型
- 代码埋点:通过在代码里插入指定代码来存储保存信息,缺点是,需要单个去埋点,优点是灵活
- 可视化埋点:通过可视化交互的手段代替代码埋点,将业务代码和埋点代码分离,提供一个可视化交互的页面
- 无痕化埋点:绑定所有事件,通过定期上传等手段
5.数据结构设计和工具类编写
1.数据结构设计
{
kind: 'xxx',
type: 'xxx',
errorType: 'xxx',
url: '',
message: event.message,
filename: event.filename,
position: `${event.lineno}:${event.colno}`,
stack: getStack(event.error.stack),
selector: selector
}
2.工具-存储最后操作的dom
- 我们需要改写下click事件和key和keydown事件,将上一次触发的元素存储起来
- 冒泡时间不可控,且容易被阻止冒泡,选用捕获事件,在捕获阶段存储事件
- 利用闭包特性,将值存储起来
const getLastEvent = (function () {
let lastEvent
['click', 'keydown'].forEach(eventType => {
document.addEventListener(eventType, (e) => {
lastEvent = event
}, {
passive: true,
capture: true
})
})
function getLastEvent() {
return lastEvent
}
return getLastEvent
})()
3.工具-提取堆栈信息
- 去除换行符
- 截取1到末尾字符
- map映射,将每一项映射
- 分割成字符串
function getStack(stack) {
return stack
?.split('\n')
?.slice(1)
?.map(item => item.replace(/^\s+at\s+/g, ''))
?.join("^")
}
4.工具-dom中提取css
function getSelector(path) {
if (Array.isArray(path)) {
return path.reverse().filter(ele => {
return ele !== document && ele !== window
}).map(ele => {
let selector = ''
if (ele.id) {
return `${ele.nodeName.toLowerCase()}#${ele.id}`
} else if (ele.className && typeof ele.className === 'string') {
return `${ele.nodeName.toLowerCase()}.${ele.className}`
} else {
selector = ele.nodeName.toLowerCase()
}
return selector
}).join(' ')
}
}
5.埋点或自动上报异常函数封装
import axios from 'axios'
function report(params){
console.log(params,'params')
const instance = axios.create({
baseURL: 'https://some-domain.com/api/',
timeout: 1000,
headers: {'X-Custom-Header': 'foobar'},
body:{params}
});
try{
instance()
}catch{
}
}
6.前端异常上报
1.监听全局js错误和资源异常
- 对于全局的js异常和和资源加载等异常,需要指定参数为true,useCapture参数解析
- useCapture为true时,优先级大于fasle,大于处于他之后声明的true
- useCaptrue为false,优先级小于true,大于在他之前的false
window.addEventListener('error', function (e) {
let lastEvent = getLastEvent()
let selector = lastEvent && getSelector(lastEvent.path)
if (event.target && (event.target.src || event.target.href)) {
const params = {
kind: 'stability',
type: 'error',
errorType: 'resourceError',
filename: event.target.src || event.target.href,
tagName: event.target.tagName,
selector: selector
}
report(params)
return
}
const params = {
kind: 'stability',
type: 'error',
errorType: 'jsError',
url: '',
message: event.message,
filename: event.filename,
position: `${event.lineno}:${event.colno}`,
stack: getStack(event.error.stack),
selector: selector
}
report(params)
},true)
2.监听未捕获的promise异常unhandledrejection
- 由于promise的特殊性,未被catch的promise的都会触发unhandledrejection的异常事件
- unhandledrejection为promise专属的
- 必须忽略axiosError等相关的报错
window.addEventListener('unhandledrejection', function (e) {
let lastEvent = getLastEvent()
let selector = lastEvent && getSelector(lastEvent.path)
let message = ''
let filename = ''
let line = 0
let column = 0
let stack = ''
let reason = event.reason
if(reason?.name === 'AxiosError'){
return
}
if (typeof reason === 'string' || typeof reason === 'number') {
message = reason
} else if (typeof reason === 'object') {
if (reason.stack) {
let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/)
filename = matchResult[1]
line = matchResult[2]
column = matchResult[3]
}
message = reason.message
stack = getStack(reason.stack)
}
const params = {
kind: 'stability',
type: 'error',
errorType: 'promiseError',
message,
filename,
position: `${line}:${column}`,
stack,
selector: selector
}
report(params)
})
3.监听xhr接口异常
- axios和jQuery底层也是基于xhr的垫片魔改
- 我们也基于xhr将监控做在里面
(function () {
let XMLHttpRequest = window.XMLHttpRequest
let oldOpen = XMLHttpRequest.prototype.open
XMLHttpRequest.prototype.open = function (method, url, async) {
if (!url.match(new RegExp(`${reportUrl}`))) {
this.logData = {
method,
url,
async
}
}
return oldOpen.apply(this, arguments)
}
let oldSend = XMLHttpRequest.prototype.send
XMLHttpRequest.prototype.send = function (body) {
if (this.logData) {
let startTime = Date.now()
const handler = (type) => (event) => {
let duration = Date.now() - startTime
let status = this.status
let statusText = this.statusText
const params = {
kind: 'stability',
type: 'xhr',
errorType: type,
pathname: this.logData.url,
status: status + '-' + statusText,
duration,
response: this.response ? JSON.stringify(this.response) : '',
params: body || ''
}
report(params)
}
this.addEventListener('load', handler('load'), false)
this.addEventListener('error', handler('error'), false)
this.addEventListener('abort', handler('abort'), false)
}
return oldSend.apply(this, arguments)
}
})();
4.监听fetch接口异常
- 包裹改写fetch
- 针对特定url过滤,防止无限请求
(function () {
const oldFetch = window.fetch;
let startTime=0
window.fetch = function (url, params) {
const handler = (type,res) => {
let duration = Date.now() - startTime
let status = res.status
let statusText = res.statusText
const log = {
kind: 'stability',
type: 'xhr',
errorType: type,
pathname: url,
status: status + '-' + statusText,
duration,
response: res.response ? JSON.stringify(res.response) : '',
params: params || ''
}
if (url.match(new RegExp(`${reportUrl}`))) {
return
}
report(log)
}
startTime = Date.now()
return oldFetch(url, params).then(res => {
if(!res?.ok || status !== 200){
handler(res?.type || 'error',res)
}
return res
}).catch(res => handler('error',res))
}
})();