前端工程化4:从0到1构建完整的前端监控平台
前言
一套完整的前端监控系统的主要部分:
- 数据上报方式
- 数据上送时机
- 性能数据采集
- 错误数据采集
- 用户行为采集
- 定制化指标
- 监控sdk
监控的目的:
一、数据上报方式
本文的方案是,优先navigator.sendBeacon,降级使用1x1像素gif图片,根据实际情况需要采用xhr/fetch。
1、图片打点
图片打点上报的优势:
1)支持跨域,一般而言,上报域名都不是当前域名,上报的接口请求会构成跨域
2)体积小且不需要插入dom中(相比之下,script、link要挂载到页面上才可以请求)
3)不需要等待服务器返回数据
图片打点缺点是:
1)url受浏览器长度限制
2)只能发送GET请求,无法获取相应结果
通过创建一个Image对象,将要上报的数据作为URL参数拼接到一个1x1像素的透明图片URL中,发送一个GET请求来触发上报。
const data = { event: 'click', element: 'button' };
const url = ` https://example.com/track?data= ${encodeURIComponent(JSON.stringify(data))}`;
const img = new Image();
img.src = url;
2、fetch请求上报
这类方法用于业务数据确实,模块未展示,需要紧急上报的情况
优点:可以灵活地设置请求头属性,post请求可以发送大体量数据,满足特定场景的埋点需求。
缺点:数据量大的请求占用带宽资源多,增加服务器压力。页面销毁时的监控埋点大概率上报失败。
3、sendBeacon
navigator.sendBeacon是一个用于发送少量数据到服务器的浏览器API。它有以下几个优点
-
异步和非阻塞
navigator.sendBeacon 是异步的,它不会阻塞浏览器的其他操作。这对于性能监控来说非常重要,因为都不希望监控的过程影响到页面的性能。
-
在页面卸载时仍然可以发送数据
当用户离开页面(例如关闭页面或者导航到其他页面)时,navigator.sendBeacon仍然可以发送数据。这对于捕获和上报页面卸载前的最后一些性能数据来说非常有用。
-
低优先级
navigator.sendBeacon 发送的请求是低优先级的,它不会影响到页面的其他网络请求。
-
简单易用
navigator.sendBeacon 的API非常简单,只需要提供上报的URL和数据,就可以发送请求。
与此同时,navigator.sendBeacon 也有一些限制。例如,它只能发送POST请求,不能发送GET请求。而且,它发送的请求没有返回值,不能接收服务器的响应。
最后,一些旧的浏览器可能不支持 navigator.sendBeacon。因此,在使用 navigator.sendBeacon 时,需要根据实际情况进行兼容性处理。
navigator.sendBeacon()
可以在用户离开页面(包括关闭浏览器窗口、标签页,或者在移动设备上切换应用等情况)时进行数据上报。
navigator.sendBeacon()
的设计目的是在页面卸载时异步发送数据,尽可能确保数据能够被发送出去而不影响页面的卸载流程。然而,当应用被强制终止时,操作系统可能会立即终止应用的所有进程,不给应用任何机会执行数据上报操作。
以下是一个使用navigator.sendBeacon()
的示例代码:
function onPageUnload() {
const data = { /* 要上报的数据 */ };
const url = '/data-collection-endpoint';
navigator.sendBeacon(url, JSON.stringify(data));
}
window.addEventListener('unload', onPageUnload);
由于兼容问题,一些旧的浏览器可能不支持 navigator.sendBeacon。因此,在使用 navigator.sendBeacon 时,需要根据实际情况进行兼容性处理:
import {isSupportSendBeacon} from './util'
// 如果浏览器不支持 sendBeacon,就使用图片打点
const sendBeacon = (function(){
if(isSupportSendBeacon()){
return window.navigator.sendBeacon.bind(window.navigator)
}
const reportImageBeacon = function(url, data){
reportImage(url, data)
}
return reportImageBeacon
})()
export function reportImage(url, data) {
const img = new Image();
img.src = url + '?reportData=' + encodeURIComponent(JSON.stringify(data));
}
三、数据上报时机
1、上报时机有三种:
- 采用 requestIdleCallback/setTimeout 延时上报。
- 在 beforeunload 回调函数里上报。
- 缓存上报数据,达到一定数量后再上报。
将三种方式结合一起上报:
先缓存上报数据,缓存到一定数量后,利用 requestIdleCallback/setTimeout 延时上报。在页面离开时统一将未上报的数据进行上报。
2、还有一种情况:
用户杀掉app进程时
将数据暂存在localStorage
中,在用户下次进入应用时检查并上报是一种可行的方法,业内也有这样做的情况。
以下是一个示例代码实现:
// 上报数据的函数
function reportData(data) {
// 发送数据到服务器的逻辑,这里只是示例
fetch('/reporting-endpoint', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(result => {
console.log('数据上报成功:', result);
// 上报成功后可以从 localStorage 中删除数据
localStorage.removeItem('pendingData');
})
.catch(error => {
console.error('数据上报失败:', error);
// 上报失败可以考虑再次尝试或者在下次进入时继续尝试
});
}
// 在合适的时机尝试上报 localStorage 中的数据
function checkAndReportPendingData() {
const pendingData = localStorage.getItem('pendingData');
if (pendingData) {
reportData(JSON.parse(pendingData));
}
}
// 在某个事件触发时(比如页面加载)调用检查函数
window.addEventListener('load', checkAndReportPendingData);
// 在需要上报数据时,先将数据存入 localStorage
function saveDataForLaterReporting(data) {
localStorage.setItem('pendingData', JSON.stringify(data));
}
这种方法的优点是可以在一定程度上提高数据上报的成功率,尤其是对于一些关键数据,即使在用户意外退出应用的情况下也有机会在下次进入时上报。
然而,这种方法也有一些局限性:
- 如果用户长时间不使用应用或者清除了浏览器缓存(包括
localStorage
数据),那么数据可能无法上报。 - 如果数据量较大,存储在
localStorage
中可能会占用较多的存储空间。 - 需要考虑数据的安全性和隐私性,确保存储在
localStorage
中的数据不会被恶意获取。
四、性能数据收集上报
以Spa页面来说,页面的加载过程大致是这样的:
包括dns查询、建立tcp连接、发送http请求、返回html文档、html文档解析等阶段
最初,可以通过 window.performance.timing 来获取加载过程模型中各个阶段的耗时数据,后来 window.performance.timing 被废弃
,通过 PerformanceObserver
来获取。旧的 api,返回的是一个 UNIX 类型的绝对时间,和用户的系统时间相关,分析的时候需要再次计算。而新的 api,返回的是一个相对时间,可以直接用来分析。
根据最初的规划,性能监控需要收集的数据指标需要有FP、FCP、LCP、DOMContentLoaded、onload、资源加载时间、接口请求时间。
收集FP、FCP、LCP、资源加载时间具体是利用浏览器Performance API。关于Performance API:
// window.performance.timing 各字段说明
{
navigationStart, // 同一个浏览器上下文中,上一个文档结束时的时间戳。如果没有上一个文档,这个值会和 fetchStart 相同。
unloadEventStart, // 上一个文档 unload 事件触发时的时间戳。如果没有上一个文档,为 0。
unloadEventEnd, // 上一个文档 unload 事件结束时的时间戳。如果没有上一个文档,为 0。
redirectStart, // 表示第一个 http 重定向开始时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
redirectEnd, // 表示最后一个 http 重定向结束时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
fetchStart, // 表示浏览器准备好使用 http 请求来获取文档的时间戳。这个时间点会在检查任何缓存之前。
domainLookupStart, // 域名查询开始的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
domainLookupEnd, // 域名查询结束的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
connectStart, // http 请求向服务器发送连接请求时的时间戳。如果使用了持久连接,这个值会和 fetchStart 相同。
connectEnd, // 浏览器和服务器之前建立连接的时间戳,所有握手和认证过程全部结束。如果使用了持久连接,这个值会和 fetchStart 相同。
secureConnectionStart, // 浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,返回 0。
requestStart, // 浏览器向服务器发起 http 请求(或者读取本地缓存)时的时间戳,即获取 html 文档。
responseStart, // 浏览器从服务器接收到第一个字节时的时间戳。
responseEnd, // 浏览器从服务器接受到最后一个字节时的时间戳。
domLoading, // dom 结构开始解析的时间戳,document.readyState 的值为 loading。
domInteractive, // dom 结构解析结束,开始加载内嵌资源的时间戳,document.readyState 的状态为 interactive。
domContentLoadedEventStart, // DOMContentLoaded 事件触发时的时间戳,所有需要执行的脚本执行完毕。
domContentLoadedEventEnd, // DOMContentLoaded 事件结束时的时间戳
domComplete, // dom 文档完成解析的时间戳, document.readyState 的值为 complete。
loadEventStart, // load 事件触发的时间。
loadEventEnd // load 时间结束时的时间。
}
1、收集上报FP;
FP(First Paint)首次绘制,即浏览器开始绘制页面的时间点。这包括了任何用户自定义的绘制,它是渲染任何文本、图像、SVG等的开始时间
import { getPageURL, isSupportPerformanceObserver } from '../utils/util'
import { lazyReportCache } from '../utils/report'
export default function observePaint() {
if (!isSupportPerformanceObserver()) return
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-paint') {
observer.disconnect()
}
const json = entry.toJSON()
delete json.duration
const reportData = {
...json,
subType: entry.name,
type: 'performance',
pageURL: getPageURL(),
}
lazyReportCache(reportData)
}
}
const observer = new PerformanceObserver(entryHandler)
// buffered 属性表示是否观察缓存数据,也就是说观察代码添加时机比事情触发时机晚也没关系。
observer.observe({ type: 'paint', buffered: true })
}
代码中observer.disconnect()是PerformanceObserver对象的一个方法,用于停止观察性能指标并断开与回调函数的连接。
事实上
observer.observe({ type: 'paint', buffered: true })
包含两种性能指标:first-contentful-paint
和first-paint
。
当调用observer.disconnect()方法时,PerformanceObserver对象将停止观察性能指标,并且不再接收任何性能指标的更新。与此同时,与回调函数的连接也会被断开,即使有新的性能指标数据产生,也不会再触发回调函数。
这个方法通常在不再需要观察性能指标时调用,以避免不必要的资源消耗。
2、收集上报FCP
FCP(First Contentful Paint)
:首次内容绘制,即浏览器首次绘制DOM内容的时间点,如文本、图像、SVG等。
看起来FCP和FP一致,其实还是有区别的
FCP(First Contentful Paint):FCP是指页面上首次渲染任何文本、图像、非空白的canvas或SVG的时间点。它表示了用户首次看到页面有实际内容的时间,即页面开始呈现有意义的内容的时间点。
FP(First Paint):FP是指页面上首次渲染任何内容的时间点,包括背景颜色、图片、文本等。它表示了页面开始呈现任何可视化内容的时间,但不一定是有意义的内容。
简而言之,FCP关注的是页面上首次呈现有意义内容的时间点,而FP关注的是页面上首次呈现任何可视化内容的时间点。FCP更关注用户感知的页面加载时间,因为它表示用户可以开始阅读或与页面进行交互的时间点。而FP则更关注页面开始渲染的时间点,无论内容是否有意义
import { getPageURL, isSupportPerformanceObserver } from '../utils/util'
import { lazyReportCache } from '../utils/report'
export default function observePaint() {
if (!isSupportPerformanceObserver()) 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,
type: 'performance',
pageURL: getPageURL(),
}
lazyReportCache(reportData)
}
}
const observer = new PerformanceObserver(entryHandler)
// buffered 属性表示是否观察缓存数据,也就是说观察代码添加时机比事情触发时机晚也没关系。
observer.observe({ type: 'paint', buffered: true })
}
3、收集上报LCP
LCP(Largest Contentful Paint)
:最大内容绘制,即视口中最大的图像或文本块的渲染完成的时间点
import { getPageURL, isSupportPerformanceObserver } from '../utils/util'
import { lazyReportCache } from '../utils/report'
export default function observeLCP() {
if (!isSupportPerformanceObserver()) {
return
}
const entryHandler = (list) => {
if (observer) {
observer.disconnect()
}
for (const entry of list.getEntries()) {
const json = entry.toJSON()
delete json.duration
const reportData = {
...json,
target: entry.element?.tagName,
name: entry.entryType,
subType: entry.entryType,
type: 'performance',
pageURL: getPageURL(),
}
lazyReportCache(reportData)
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'largest-contentful-paint', buffered: true })
}
4、收集上报DOMContentLoaded
DOMContentLoaded
:当HTML文档被完全加载和解析完成后,DOMContentLoaded事件被触发,无需等待样式表、图像和子框架的完成加载
import { lazyReportCache } from '../utils/report'
export default function observerLoad() {
['DOMContentLoaded'].forEach(type => onEvent(type))
}
function onEvent(type) {
function callback() {
lazyReportCache({
type: 'performance',
subType: type.toLocaleLowerCase(),
startTime: performance.now(),
})
window.removeEventListener(type, callback, true)
}
window.addEventListener(type, callback, true)
}
5、收集上报onload数据
onload
:当所有需要立即加载的资源(如图片和样式表)已加载完成时的时间点
import { lazyReportCache } from '../utils/report'
export default function observerLoad() {
\['load'].forEach(type => onEvent(type))
}
function onEvent(type) {
function callback() {
lazyReportCache({
type: 'performance',
subType: type.toLocaleLowerCase(),
startTime: performance.now(),
})
window.removeEventListener(type, callback, true)
}
window.addEventListener(type, callback, true)
}
6、收集上报资源加载时间
收集资源加载时间
observer.observe({ type: 'resource', buffered: true })
我在想什么是资源加载时间?应该就是下面的entry.duration的。我觉得写监控SDK很有意义,可以更加深入的学习浏览器模型。了解浏览器是怎么看待各种html文件资源的
import { executeAfterLoad, isSupportPerformanceObserver} from '../utils/util'
import { lazyReportCache } from '../utils/report'
export default function observeEntries() {
executeAfterLoad(() => {
observeEvent('resource')
})
}
export function observeEvent(entryType) {
function entryHandler(list) {
const data = list.getEntries()
for (const entry of data) {
if (observer) {
observer.disconnect()
}
lazyReportCache({
name: entry.name, // 资源名称
subType: entryType,
type: 'performance',
sourceType: entry.initiatorType, // 资源类型
duration: entry.duration, // 资源加载耗时
dns: entry.domainLookupEnd - entry.domainLookupStart, // DNS 耗时
tcp: entry.connectEnd - entry.connectStart, // 建立 tcp 连接耗时
redirect: entry.redirectEnd - entry.redirectStart, // 重定向耗时
ttfb: entry.responseStart, // 首字节时间
protocol: entry.nextHopProtocol, // 请求协议
responseBodySize: entry.encodedBodySize, // 响应内容大小
responseHeaderSize: entry.transferSize - entry.encodedBodySize, // 响应头部大小
resourceSize: entry.decodedBodySize, // 资源解压后的大小
startTime: performance.now(),
})
}
}
let observer
if (isSupportPerformanceObserver()) {
observer = new PerformanceObserver(entryHandler)
observer.observe({ type: entryType, buffered: true })
}
}
7、收集上报接口请求时间
这里通过覆写原生xhr对象方法,对方法做拦截实现接口时间收集以及上报
import { originalOpen, originalSend, originalProto } from '../utils/xhr'
import { lazyReportCache } from '../utils/report'
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',
}
lazyReportCache(reportData)
this.removeEventListener('loadend', onLoadend, true)
}
this.addEventListener('loadend', onLoadend, true)
originalSend.apply(this, args)
}
}
export default function xhr() {
overwriteOpenAndSend()
}
五、错误数据收集上报
根据最初的规划需要收集资源加载错误
、js错误
和promise错误
。
1、收集上报资源加载错误
收集 JavaScript、CSS 和图片的加载错误,使用window.addEventListener监听错误
import { lazyReportCache } from '../utils/report'
import { getPageURL } from '../utils/util'
export default function error() {
// 捕获资源加载失败错误 js css img...
window.addEventListener('error', e => {
const target = e.target
if (!target) return
if (target.src || target.href) {
const url = target.src || target.href
lazyReportCache({
url,
type: 'error',
subType: 'resource',
startTime: e.timeStamp,
html: target.outerHTML,
resourceType: target.tagName,
paths: e.path.map(item => item.tagName).filter(Boolean),
pageURL: getPageURL(),
})
}
}, true)
}
2、收集上报js错误
收集 JavaScript 错误,可以使用 window.onerror
或者 window.addEventListener('error', callback)
import { lazyReportCache } from '../utils/report'
import { getPageURL } from '../utils/util'
export default function error() {
// 监听 js 错误
window.onerror = (msg, url, line, column, error) => {
lazyReportCache({
msg,
line,
column,
error: error.stack,
subType: 'js',
pageURL: url,
type: 'error',
startTime: performance.now(),
})
}
}
说明一下window.onerror无法捕获资源加载错误,所以这里可以单独拿来监听js错误。
3、收集上报promise错误
收集 Promise 错误,可以使用 window.addEventListener(‘unhandledrejection
’, callback)
import { lazyReportCache } from '../utils/report'
import { getPageURL } from '../utils/util'
export default function error() {
// 监听 promise 错误 缺点是获取不到列数据
window.addEventListener('unhandledrejection', e => {
lazyReportCache({
reason: e.reason?.stack,
subType: 'promise',
type: 'error',
startTime: e.timeStamp,
pageURL: getPageURL(),
})
})
}
为了减少对html文件代码的干扰,错误收集可以添加一个缓存代理。
六、行为数据收集上报
根据最初的规划,行为数据收集pv、uv,页面停留时长,用户点击。
1、收集上报pv、uv
收集 pv(Page View,页面浏览量)和 uv(Unique Visitor,独立访客)数据,需要在每次页面加载时发送一个请求到服务器,然后在服务器端进行统计
import { lazyReportCache } from '../utils/report'
import getUUID from './getUUID'
import { getPageURL } from '../utils/util'
export default function pv() {
lazyReportCache({
type: 'behavior',
subType: 'pv',
startTime: performance.now(),
pageURL: getPageURL(),
referrer: document.referrer,
uuid: getUUID(),
})
}
这里只能收集了pv数据,uv数据统计需要在服务端进行。
2、页面上报停留时长
收集页面停留时长,可以在页面加载时记录一个开始时间,然后在页面卸载时记录一个结束时间,两者的差就是页面的停留时长。这个计算逻辑可以放在beforeunload
事件里做
import { report } from '../utils/report'
import { onBeforeunload, getPageURL } from '../utils/util'
import getUUID from './getUUID'
export default function pageAccessDuration() {
onBeforeunload(() => {
report({
type: 'behavior',
subType: 'page-access-duration',
startTime: performance.now(),
pageURL: getPageURL(),
uuid: getUUID(),
}, true)
})
}
3、用户点击上报
收集用户点击事件,可以使用 addEventListener
来监听 click
事件,这里借助了冒泡
import { lazyReportCache } from '../utils/report'
import { getPageURL } from '../utils/util'
import getUUID from './getUUID'
export default function onClick() {
['mousedown', 'touchstart'].forEach(eventType => {
let timer
window.addEventListener(eventType, event => {
clearTimeout(timer)
timer = setTimeout(() => {
const target = event.target
const { top, left } = target.getBoundingClientRect()
lazyReportCache({
top,
left,
eventType,
pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
type: 'behavior',
subType: 'click',
target: target.tagName,
paths: event.path?.map(item => item.tagName).filter(Boolean),
startTime: event.timeStamp,
pageURL: getPageURL(),
outerHTML: target.outerHTML,
innerHTML: target.innerHTML,
width: target.offsetWidth,
height: target.offsetHeight,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
uuid: getUUID(),
})
}, 500)
})
})
}
七、改造完善四维监控类
将性能数据、错误数据、行为数据
入口文件的收集方法在监控类四维init方法内初始化
import performance from './performance/index'
import behavior from './behavior/index'
import error from './error/index'
class FourDimension {
constructor() {
this.init()
}
// 初始化
init() {
performance()
error()
behavior()
}
}
new FourDimension().init()
在具体使用过程中,采用异步加载的方式引入。
八、自定义指标
1、long task
执行时间超过50ms的任务,被称为 long task 长任务
获取页面的长任务列表:
const entryHandler = list => {
for (const long of list.getEntries()) {
// 获取长任务详情
console.log(long);
}
};
let observer = new PerformanceObserver(entryHandler);
observer.observe({ entryTypes: ["longtask"] });
2、memory页面内存
performance.memory
可以显示此刻内存占用情况,它是一个动态值,其中:
-
jsHeapSizeLimit 该属性代表的含义是:内存大小的限制。
-
totalJSHeapSize 表示总内存的大小。
-
usedJSHeapSize 表示可使用的内存的大小。
通常,usedJSHeapSize 不能大于 totalJSHeapSize,如果大于,有可能出现了内存泄漏
// load事件中获取此时页面的内存大小
window.addEventListener("load", () => {
console.log("memory", performance.memory);
});
3、首屏加载时间
首屏加载时间和首页加载时间不一样,首屏指的是屏幕内的dom渲染完成的时间
比如首页很长需要好几屏展示,这种情况下屏幕以外的元素不考虑在内
计算首屏加载时间流程
1)利用MutationObserver
监听document
对象,每当dom变化时触发该事件
2)判断监听的dom是否在首屏内,如果在首屏内,将该dom放到指定的数组中,记录下当前dom变化的时间点
3)在MutationObserver的callback函数中,通过防抖函数,监听document.readyState
状态的变化
4)当document.readyState === 'complete'
,停止
定时器和 取消对document的监听
5)遍历存放dom的数组,找出最后变化节点的时间,用该时间点减去performance.timing.navigationStart
得出首屏的加载时间