Web页面,特别是SPA,是在用户的浏览器中运行的,所以页面上的错误、性能数据等信息开发者是无从得知的,而这些数据对于增强页面稳定性和优化页面性能至关重要。
前端监控就是要将这些信息收集起来并发送到服务器上,通过ELK(Elasticsearch、Logstash、 Kibana)聚合和可视化日志,让开发者可以观察页面的错误和行为,统计性能数据,进而修复和改善页面代码。
举个栗子:用户投诉说页面上的某个按钮点击没有反应同时页面没有任何提示,这种情况可能是由于页面触发了在开发和测试阶段都没有发现的Bug。那么我们就可以根据用户报告的页面地址和用户信息找到对应的报错日志,分析错误原因进而修改。
开发一个前端监控系统不是很难,主要是对浏览器原生方法的改写和日志的发送。从应用角度出发,一个合格的前端监控系统应该能够做到:
- 定位日志记录触发的位置和用户。
- 收集未处理的JS错误,追溯用户行为。
- 监听AJAX和响应。
- 计算页面加载时间等性能指标。
- 在不影响页面主要功能的情况下上报日志,且尽可能不遗漏日志。
本篇文章总结了开发一个前端监控系统的关键功能实现方式和注意点。
定位
基础信息
为了确定日志触发的页面,监控系统需要记录一些信息,通常有:
- 应用名(为了区分不同的项目)
- 环境名(为了区分开、测试和生产环境)
- 网页URL
- 来源URL(document.referrer)
- 时间
用户标识
为了在收到用户报告或投诉时搜索日志,监控系统可以记录一些用户的信息(比如用户id)。
设备指纹
有时候碰到了奇异的问题,不一定是页面的错误,有可能是某个用户特殊的系统或浏览器环境导致的。如果能确定这些错误都来自同一台设备,那可以以此来考虑是否要忽略这个错误。
所谓设备指纹,就是从取设备的某一个数据,可以稳定且唯一标识这台设备的代码。设备指纹有多种实现方式,比如Canvas指纹、AudioContext指纹等。
具体的实现和组件可以参考:FingerprintJS。
let fingerPrints = await FingerprintJS.load()
let result = await fingerPrints.get()
let fpid = result.visitorId // 设备指纹
复制代码
错误
所有的JS错误(包括逻辑错误和语法错误),如果没有被捕获(try catch或promise catch),就会被抛出到全局。这种全局错误,可以使用window
的两个事件error
和unhandledrejection
来捕获:
window.addEventListener('error', ev => {
// ev: ErrorEvent | Event
}, true)
window.addEventListener('unhandledrejection', ev => {
// ev: PromiseRejectionEvent
}, true)
复制代码
错误可以提供的信息大致有:
- message:错误消息
- stack:调用栈
- filename:文件名
- lineNo:行号
- colNo:列号
根据不同的事件类型,可以获得不同的信息:
let error
if (event instanceof PromiseRejectionEvent) {
error = {
type: 'promise',
message: event.reason.message,
stack: event.reason.stack,
lineNo: event.reason.lineNo,
colNo: event.reason.colNo,
name: event.reason.name
}
} else if (event instanceof ErrorEvent) {
error = {
type: 'error',
message: event.message,
stack: event.error.stack,
filename: event.filename,
lineNo: event.error.lineNo,
colNo: event.error.colNo,
name: event.error.name
}
} else if (event instanceof Event) {
let target = event.target as Element
error = {
type: 'event',
message: event.type,
stack: target.outerHTML
}
} else {
error = {
type: 'customer',
message: event.message,
stack: event.stack,
name: event.name
}
}
复制代码
当event
的类型为Event
时,有可能是资源(比如img、script、link)加载错误,对象中没有message
和stack
,为了标记发生错误的元素,可以记录下元素的outerHTML
。
追溯用户行为
除了知道错误的代码位置外,我们还希望知道用户的什么操作引发了错误。通常来说,页面上最常见的操作上是鼠标点击,那我们可以捕获监听document
的点击事件,记录触发元素的id
和className
,保存到一个最大长度为10的队列中,在发送错误日志的时候一同将其发送。
AJAX
虽然后台服务器上会有前端调用AJAX的记录,但有时有网络问题或服务器没有响应的问题而导致的超时错误,这些在后台服务器上是没有日志的。更多的时候,前端需要一份自己的日志,便于前端部门自己整合和处理。
为了监听AJAX,我们需要改造XMLHttpRequest
类,主要是改写open
和send
方法。
日志死循环
但再次之前,因为日志本身也需要使用AJAX发送,所以我们需要保留原本的open
和send
方法(不会发送日志的方法):
const xhrOpen = XMLHttpRequest.prototype.open
const xhrSend = XMLHttpRequest.prototype.send
复制代码
以免发生:
发送请求 --> AJAX日志 --> 发送AJAX日志请求 -->AJAX日志 --> 发送AJAX日志请求 ......
复制代码
的死循环。
open
对于open
的改造,主要是在XMLHttpRequest
实例中注入一个保存信息的成员,以便在请求结束后再使用。
const open = function ( method, url, async, username, password) {
this._requestParams = {
url
}
return xhrOpen.call(this, method, url, async, username, password)
}
XMLHttpRequest.prototype.open
复制代码
在_requestParams
成员对象中,存储了请求地址。
send
在send
方法中,需要记录请求的参数和开始时间,并在请求超时(timeout事件)或完成(readystatechange事件)时记录相应的时间和响应。
const send = function (data) {
try {
if (this._requestParams) {
let startTime = Date.now()
this._requestParams.body = data || ''
this.addEventListener('timeout', () => {
let endTime = Date.now()
this._requestParams.time = endTime - startTime
handler(this._requestParams)
})
this.addEventListener('readystatechange', () => {
if (this.readyState === (XMLHttpRequest.DONE || 4)) {
let endTime = Date.now()
this._requestParams.time = endTime - startTime
this._requestParams.code = this.status
handler(this._requestParams)
}
})
}
} catch (er) {
console.error(er) // eslint-disable-line
}
return xhrSend.call(this, data)
}
XMLHttpRequest.prototype.send = send
复制代码
其中handler
函数用于将日志传递到发送器统一发送。为了不让日志系统影响正常的功能,所以将日志记录操作包裹在try catch中。
性能
性能,特别是加载性能,是网页的重要指标。页面加载自然是越快越好,但是在开发和测试环境中的加载速度会因为生产环境中用户自身的网络和设备条件而降低。因此我们需要收集这些信息,分析用户打开页面慢的原因,考虑改良方式,比如是否要增加CDN等。
关键时间点
为了测量加载时间,我们需要得知页面加载时的一些关键时间点,比如请求开始的时间、资源加载完成的时间等。幸好浏览器的performance.timing
中保存了这些信息,其中包含了从上一个页面卸载开始到当前页面加载完成的时间点,我们关心的主要有:
- fetchStart:开始请求或从缓存获取HTML
- domainLookupStart:开始域名解析
- domainLookupEnd:完成域名解析
- connectStart:开始TCP连接
- connectEnd:完成TCP连接
- requestStart:开始发送HTTP请求
- responseEnd:从服务器或缓存获取到HTML的第一个字节
- responseEnd:从服务器或缓存获取到HTML的最后一个字节
- domInteractive:DOM解析结束,开始加载内嵌资源
- domContentLoadedEventEnd:所有立即执行的脚本都执行完毕
- loadEventStart:资源加载完成,load事件触发
通常我们认为fetchStart
是一次页面浏览的开始。根据这些时间点,我们可以计算出一些重要指标:
- PFT(First Paint Time,首次渲染时间):responseEnd – fetchStart
- TTI(Time to Interactive,首次可交互时间):domInteractive – fetchStart
- Ready(HTML加载完成时间)::domContentLoadedEventEnd – fetchStart
- Load(页面完全加载完成时间):loadEventStart – fetchStart
- DNS(DNS查询时间):domainLookupEnd – domainLookupStart
- TCP(TCP连接时间):connectEnd – connectStart
- TTFB(Time to First Byte,请求到响应时间):responseStart – requestStart
- Trans(内容传输时间):responseEnd – responseStart
- DOM(DOM解析时间):domInteractive – responseEnd
- Res(资源加载时间):loadEventStart – domContentLoadedEventEnd
由这些指标,我们可以分析页面加载慢的原因,针对各个环节做特定的优化。
资源加载时间
JS和CSS是页面的关键资源,如果没有它们页面的功能和样式可能不完整或无法使用。同样,它们的加载时间也会影响首屏时间。所以我们需要收集资源的加载时间。
幸好performance.getEntries
函数提供了这样一个接口,这个函数返回一个数组,包含了所有资源的名字和加载解析时间。我们抽取其中的JS和CSS,记录个数和总时间,计算平均时间。
let cssTime = 0
let cssCount = 0
let jsTime = 0
let jsCount = 0
let entries = window.performance.getEntries()
for (let a of entries) {
if (/\.css$/i.test(a.name)) {
cssTime += a.duration
cssCount++
} else if (/\.js$/i.test(a.name)) {
jsTime += a.duration
jsCount++
}
}
let css = Math.round(cssTime / cssCount)
let js = Math.round(jsTime / jsCount)
复制代码
web vitals
除了上述W3C定义的标准时间点外,Google倡导一套描述页面体验的性能指标——web vitals,其中最常用的3种指标(Chrome lighthouse工具也使用)有:
- LCP(Largest Contentful Paint):衡量加载性能,表示页面中“最大块”内容出现的耗时。应该在2.5秒内。
- CLS(Cumulative Layout Shift):衡量视觉稳定性,表示在加载时页面内容变化的幅度和频率。应该小于0.1。
- FID(First Input Delay):衡量可交互性,表示从用户第一次操作到页面响应的延迟。应该在100毫秒内。
没有直接获取这3个指标的方法,因为它们需要比较复杂的计算。有现成的NPM工具可以帮助我们计算:web-vitals。
详细信息可见web-vitals。
需要注意的是,这3个指标不是同步可获取的。LCP和CLS在服务端渲染的页面中不可用,FID需要在用户第一次进行某项操作(比如点击输入框)时才能获取到。
记录时机
performance.timing
中的信息在对应的时间点未到来之前是0,因此为了获取准确地信息,我们需要等待页面加载完成(window load事件)后计算这些指标并上传日志。
但是在一些糟糕的情况下,用户的设备在请求资源时花了太多的时间,load事件迟迟不触发,页面也处于白屏状态。那么用户很可能直接关掉页面,这一次的性能日志无法被发送。可惜的是,反而是这种加载时间过长的情况的日志更有价值,因为它可以暴露更多网页或服务器的问题。
所以我们需要想办法尽可能的发送即使不完整的日志,大致有两种:
- 在
beforeunload
或load
事件中使用sendBeacon
发送日志。(下面小节会介绍) - 将日志保存到localStorage中,下次页面启动时发送。(针对不支持
sendBeacon
的浏览器)
发送
日志的发送可以采取向服务器请求一个虚拟的资源(比如一张图片),或用AJAX的方式。需要注意的是,因为我们监听并改造了AJAX的open
和send
方法,如果用改造后的方法发送日志,会造成死循环,所以需要使用改造前的方法。
为了尽量避免发送日志对网页功能的影响,我们可以利用requestIdleCallback
API在空闲的时候发送。它的作用方式是:如果一帧(一般情况下约16ms)刷新完成后还有剩余的时间,就会执行传入的回调函数。那么也就是说日志不是立即发送的,而是需要保存到一个队列中,等待发送,并在发送后清空队列。
所以发送日志分为3个步骤:
- 添加日志
- 等待时机
- 发送日志
编写为一个类,大致是这样的:
const timeout = 5000
const xhrOpen = XMLHttpRequest.prototype.open
const xhrSend = XMLHttpRequest.prototype.send
/**
* @name 发送器
*/
class Sender {
/**
* @name 构造方法
* @param url 地址
*/
constructor(url) {
this.url = url
this.queue = []
this.callbackId = 0
window.addEventListener('unload', this.clear.bind(this)) // 在unload中处理是因为需要在beforeunload中进行前置操作
}
/**
* @name 等待空闲
*/
wait() {
if (this.callbackId) {
window.cancelIdleCallback?.(this.callbackId)
this.callbackId = 0
}
if (this.queue.length) {
if ('requestIdleCallback' in window) {
this.callbackId = window.requestIdleCallback(
() => {
this.send()
},
{ timeout } // 超时强制发送
)
} else {
this.send() // 不支持requestIdleCallback则立即发送
}
}
}
/**
* @name 发送
*/
send() {
try {
let xhr = new XMLHttpRequest()
xhr.timeout = 5000
this.xhrOpen.call(xhr, 'POST', this.context.url, true)
this.xhrSend.call(xhr, JSON.stringify(this.queue))
} catch (er) {
console.error(er) // eslint-disable-line
} finally {
this.queue = []
}
}
/**
* @name 发送剩余
*/
clear() {
if (this.queue.length) {
let data = JSON.stringify(this.queue)
if ('sendBeacon' in window.navigator) {
window.navigator.sendBeacon(this.context.url, data)
} else {
this.send()
}
}
}
/**
* @name 添加消息
* @param message 消息
*/
add(message) {
this.queue.push(message)
this.wait()
}
}
复制代码
在使用时,我们只需要调用sender.add
添加日志,发送器会在合适的时机发送。
剩余日志
注意到Sender
类有一个clear
方法,其作用是在用户关闭页面但还有剩余日志没有发送时尽量发送剩余的日志。它使用了sendBeacon
发送日志,与XMLHttpRequest
不同的是,在异步发送时,XMLHttpRequest
在页面卸载后会取消发送;而sendBeacon
也是异步发送,但是它将任务设置为浏览器的任务,即使页面卸载,这个发送仍然会被完成,从而提升日志的发送成功率。