背景
- 前端监控是指通过一系列手段对Web页面或应用程序进行实时监控和数据采集,以了解页面或应用程序的性能状况、用户行为等等,并及时发现和解决潜在的问题。
- 一个完整的前端监控平台可以包括:数据收集与上报、数据整理与存储、数据展示
- 这里仅介绍第一个环节——数据收集与上报,该环节可以分为收集阶段和上报阶段,大致情况如下:
1、错误数据收集
1.1、js 错误
- js 错误类型有语法错误、同步错误、异步错误。语法错误在开发阶段就可被发现,不做考虑;同步错误可以被
try catch
给捕获到的,一般在 catch 语句中手动上报错误;
// 手动捕获错误函数
export function errorCaptcher(error, msg) {
// 上报错误
lazyReport('error', {
message: msg,
error: error,
errorType: 'catchError'
});
}
-
异步错误无法被
try catch
捕获到的,可以使用window.onerror
监听// 防止多次使用 onerror,覆盖的情况 const originOnError = window.onerror; window.onerror = function (msg, url, row, col, error) { if (originOnError) { originOnError.call(window, msg, url, row, col, error); } // 错误上报 lazyReport('error', { message: msg, file: url, row, col, error, errorType: 'jsError' }); }
1.2、promise 错误
window.onerror
对于promise错误是无能为力的,一般使用addEventListener()
监听unhandledrejection
事件,可以捕获到未处理的 promise 错误
window.addEventListener('unhandledrejection', (error) => {
lazyReport('error', {
message: error.reason,
error,
errorType: 'promiseError'
});
});
1.3、资源加载错误
- 使用
addEventListener()
监听error
事件,可以捕获到资源加载失败错误
// resource error 捕获
window.addEventListener('error', (error) => {
let target = error.target;
let isElementTarget = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement;
// js error不再处理,避免重复上报
if (!isElementTarget) {
return;
}
lazyReport('error', {
message: "加载 " + target.tagName + " 资源错误",
file: target.src,
errorType: 'resourceError'
});
}, true)
2、行为数据收集
2.1、用户埋点统计
- 埋点是监控用户在我们应用上的一些动作表现,埋点又分为手动埋点和无痕埋点
- 手动埋点就是手动的在代码里面添加相关的埋点代码,比如用户点击某个按钮,就在这个按钮的点击事件中加入相关的埋点代码
<button
onClick={() => {
// 业务代码
tracker('click', '用户去支付');
}}
>手动埋点</button>
// 向外暴露的手动上报函数
export function tracker(actionType, data) {
lazyReport('action', {
actionType,
data
});
}
- 无痕埋点是为了解决手动埋点的缺点,实现一种不用侵入业务代码就能在应用中添加埋点监控的埋点方式
// 自动埋点实现
function autoTracker () {
// 添加全局click监听
document.body.addEventListener('click', function (e) {
const clickedDom = e.target;
// 获取data-target属性值
let target = clickedDom?.getAttribute('data-target');
if (target) {
// 如果设置data-target属性就上报对应的值--手动埋点
tracker('click', target);
} else {
// 如果没有设置data-target属性就上报被点击元素的html路径
const path = getPathTo(clickedDom);
tracker('click', path);
}
}, false);
};
2.2、PV统计
- PV即页面浏览量,用来表示该页面的访问数量
- 在SPA应用之前只需要监听
onload
事件即可统计页面的PV,在SPA应用中,页面路由的切换完全由前端实现,主流的react和vue框架都有自己的路由管理库,而单页路由又区分为hash
路由和history
路由,两种路由的原理又不一样,所以统计起来会有点复杂。这里将分别针对两种路由来实现不同的采集数据的方式
2.2.1、history 路由
- history路由依赖全局对象
history
实现,常用有 go、back、forward、pushState 和 replaceState 五种方法 - history路由的实现主要依赖的就是
pushState
和replaceState
来实现的,但是这两种方法不能被popstate
监听到,所以需要对这两种方法进行重写来实现数据的采集 - 下面的代码中提供了方法的重写,以及监听页面的跳转和停留时间
export function historyPageTrackerReport() {
let beforeTime = Date.now(); // 进入页面的时间
let beforePage = ''; // 上一个页面
// 获取在某个页面的停留时间
function getStayTime() {
let curTime = Date.now();
let stayTime = curTime - beforeTime;
beforeTime = curTime;
return stayTime;
}
// 重写方法
const createHistoryEvent = function (name) {
// 拿到原来的处理方法
const origin = window.history[name];
return function(event) {
let res = origin.apply(this, arguments);
let e = new Event(name);
e.arguments = arguments;
window.dispatchEvent(e);
return res;
};
};
window.history.pushState = createHistoryEvent('pushState');
window.history.replaceState = createHistoryEvent('replaceState');
// history.pushState
window.addEventListener('pushState', function () {
listener()
});
// history.replaceState
window.addEventListener('replaceState', function () {
listener()
});
// 页面load监听
window.addEventListener('load', function () {
listener()
});
// unload监听
window.addEventListener('unload', function () {
listener()
});
// history.go()、history.back()、history.forward() 监听
window.addEventListener('popstate', function () {
listener()
});
function listener() {
const stayTime = getStayTime(); // 停留时间
const currentPage = window.location.href; // 页面路径
lazyReport('visit', {
stayTime,
page: beforePage,
})
beforePage = currentPage;
}
}
2.2.2、hash 路由
- url 上 hash 的改变会出发
hashchange
的监听,所以只需要在全局加上一个监听函数,在监听函数中实现采集并上报。但是在react和vue中,对于 hash 路由的跳转并不是通过hashchange
的监听实现的,而是通过pushState
实现,所以,还需要加上对pushState
的监听
export function hashPageTrackerReport() {
let beforeTime = Date.now(); // 进入页面的时间
let beforePage = ''; // 上一个页面
function getStayTime() {
let curTime = Date.now();
let stayTime = curTime - beforeTime;
beforeTime = curTime;
return stayTime;
}
function listener() {
const stayTime = getStayTime();
const currentPage = window.location.href;
lazyReport('visit', {
stayTime,
page: beforePage,
})
beforePage = currentPage;
}
const createHistoryEvent = function (name) {
const origin = window.history[name];
return function(event) {
let res = origin.apply(this, arguments);
let e = new Event(name);
e.arguments = arguments;
window.dispatchEvent(e);
return res;
};
};
window.history.pushState = createHistoryEvent('pushState');
// history.pushState
window.addEventListener('pushState', function () {
listener()
});
// hash路由监听
window.addEventListener('hashchange', function () {
listener()
});
// 页面load监听
window.addEventListener('load', function () {
listener()
});
}
2.3、UV统计
- UV统计的是一天内访问该网站的用户数
- uv统计比较简单,就只需要在SDK初始化的时候上报一条消息就可以了
function init(options) {
// 拿到配置信息 注入监控代码
loadConfig(options);
// uv统计
lazyReport('user', '加载应用');
}
3、性能数据采集
3.1、FCP 统计
- FCP(first-contentful-paint),从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间
- 性能指标都需要通过
PerformanceObserver
来获取,它是一个性能监测对象,用于监测性能度量事件
export function observePaint() {
if (!window.PerformanceObserver) return
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
observer.disconnect()
}
const json = entry.toJSON()
delete json.duration
const reportData = {
...json,
subType: entry.name,
pageURL: window.location.href,
}
lazyReport('performance-fcp', reportData)
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint', buffered: true })
}
3.2、load DOMContentLoaded 监听
- 当纯 HTML 被完全加载以及解析时,
DOMContentLoaded
事件会被触发,不用等待 css、img、iframe 加载完 - 当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发
load
事件
export function observerLoad() {
['load', 'DOMContentLoaded'].forEach(type => onEvent(type))
}
function onEvent(type) {
function callback() {
lazyReport('performance', {
subType: type.toLocaleLowerCase(),
pageURL: getPageURL(),
startTime: performance.now(),
});
window.removeEventListener(type, callback, true)
}
window.addEventListener(type, callback, true)
}
3.3、xhr 请求耗时监听
// xhr 请求耗时
export function overwriteOpenAndSend() {
originalProto.open = function newOpen(...args) {
this.url = args[1]
this.method = args[0]
originalOpen.apply(this, args)
}
originalProto.send = function newSend(...args) {
this.startTime = Date.now()
const onLoadend = () => {
this.endTime = Date.now()
this.duration = this.endTime - this.startTime
const { status, duration, startTime, endTime, url, method } = this
const reportData = {
status,
duration,
startTime,
endTime,
url,
method: (method || 'GET').toUpperCase(),
success: status >= 200 && status < 300,
subType: 'xhr',
type: 'performance',
}
lazyReport('performance-xhr', reportData)
this.removeEventListener('loadend', onLoadend, true)
}
this.addEventListener('loadend', onLoadend, true)
originalSend.apply(this, args)
}
}
4、数据上报方法
4.1、xhr 上报
- 通过xhr上报,如果设置成异步的时候,当用户跳转新页面或者关闭页面时就会丢失当前这个请求,如果设置成同步,又会让页面造成卡顿的现象
4.2、Image的形式来发送请求
- img标签的方式是通过将埋点数据伪装成图片URL的请求方式,这样就避免了跨域的问题,但是因为浏览器对url的长度会有限制,所以通过这种方式上报不适合大数据量上报的场景
4.3、Navigator.sendBeacon
- sendBeacon可以说是为埋点量身定做的,这种方式不会有跨域的限制,也不会存在因为刷新页面等情况造成数据丢失的情况,唯一的缺点就是在某些浏览器上存在兼容性的问题
4.4、采用 sendBeacon 上报和 img 标签上报结合的方式
export function report(type, params) {
const appId = window['_monitor_app_id_'];
const userId = window['_monitor_user_id_'];
const url = window['_monitor_report_url_'];
const logParams = {
appId, // 项目的appId
userId,
type, // 上报信息类型
data: params, // 上报的数据
currentTime: new Date().getTime(), // 时间戳
currentPage: window.location.href, // 当前页面
ua: navigator.userAgent, // ua信息
};
let logParamsString = JSON.stringify(logParams);
if (navigator.sendBeacon) {
// 支持sendBeacon的浏览器
navigator.sendBeacon(url, logParamsString);
} else {
let oImage = new Image();
oImage.src = `${url}?logs=${logParamsString}`;
}
}
5、上报时机
- 采用
requestIdleCallback/setTimeout
延时上报 - 在
beforeunload
回调函数里上报 - 缓存上报数据,达到一定数量后再上报
- 一般情况下是三种方式一起使用
// 防止卸载时还有剩余的埋点数据没发送
window.addEventListener('unload', () => {
const data = getCache();
report(data);
});
// 延时、合并上报
const cache = [];
export function getCache() {
return cache;
}
export function addCache(data) {
cache.push(data);
}
// lazyReport.js
export function lazyReport(type, params) {
// ....
const data = getCache();
if (delay === 0) { // delay=0相当于不做延迟上报
report(data);
return;
}
if (data.length > 10) { // 数据达到10条上报
report(data);
clearTimeout(timer);
return;
}
clearTimeout(timer);
timer = setTimeout(() => { // 合并上报
report(data);
}, delay);
}
5、sdk 初始化信息配置
function init(options) {
const {
appId, // 系统id
userId, // 用户id
reportUrl, // 后端url
autoTracker, // 自动埋点
delay, // 延迟和合并上报的功能
hashPage, // 是否hash录有
errorReport // 是否开启错误监控
} = options;
if (appId) {
window['_monitor_app_id_'] = appId;
}
if (userId) {
window['_monitor_user_id_'] = userId;
}
if (reportUrl) {
window['_monitor_report_url_'] = reportUrl;
}
if (delay) {
window['_monitor_delay_'] = delay;
}
// 是否开启错误监控
if (errorReport) {
errorTrackerReport();
}
// 是否开启无痕埋点
if (autoTracker) {
autoTrackerReport();
}
// 路由监听
if (hashPage) {
hashPageTrackerReport(); // hash路由上报
} else {
historyPageTrackerReport(); // history路由上报
}
// DOMContentLoaded、load 监听
observerLoad()
// FCP 监听
observePaint()
}