最近,完成了SDK第二阶段的开发,在第一阶段的基础上,对部分功能进行了优化并增加了一些新的功能。
增加内容
用户行为记录
监控到上报的错误信息时,对其进行分析解决需要了解用户进行了什么操作导致了报错,所以增加用户行为记录。
一般会将用户行为数据称为breadcrumb,意为通过撒面包屑留下记号,进行引路。
/**
* 上报数据breadcrumb属性定义
*/
export interface BreadcrumbData {
/**
* 事件类型
*/
type: EVENTTYPES
/**
* 用户行为类型
*/
category: BREADCRUMBTYPES
/**
* 用户行为相关数据
*/
data: string
/**
* 发生时间
*/
time: number
/**
* 行为数据类别
*/
level: SEVERITY
}
将记录的用户行为事件大致分为以下几类:
/**
* 事件类型
*/
export enum EVENTTYPES {
XHR = 'xhr',
FETCH = 'fetch',
CLICK = 'click',
ROUTE = 'route',
VUE = 'vue',
CODE_ERROR = 'code_error',
UNHANDLEDREJECTION = 'unhandledrejection',
RESOURCE = 'resource',
CUSTOM = 'custom'
}
用户行为类型与行为数据类别定义:
/**
* 用户行为类型
*/
export enum BREADCRUMBTYPES {
USER = 'user',
EXCEPTION = 'exception',
CUSTOM = 'custom'
}
/**
* 行为数据类别
*/
export enum SEVERITY {
ERROR = 'error',
INFO = 'info'
}
用户行为数组长度可配置,默认为10条,超过10条时会以先进先出的形式进行删除。
/**
* 用户行为
*/
export class Breadcrumb {
#maxBreadcrumbs = 10 // 用户行为存放的最大长度
stack: BreadcrumbData[] = [] // 存储用户行为
bindOptions(options: InitOptions): void {
this.#maxBreadcrumbs = options.maxBreadcrumbs || 10
}
/**
* 添加用户行为栈
* @param data 用户行为信息
*/
push(data: BreadcrumbData): void {
data.time || (data.time = Date.now())
// 行为栈长度大于最大长度,删除一条最早的记录
if (this.stack.length >= this.#maxBreadcrumbs) {
this.shift()
}
this.stack.push(data)
// 确保用户行为的顺序正确
this.stack.sort((a, b) => a.time - b.time)
}
/**
* 删除用户行为栈最早的一条记录
* @returns boolean
*/
shift(): boolean {
return this.stack.shift() !== undefined
}
/**
* 清除用户行为栈
*/
clear(): void {
this.stack = []
}
/**
* 获取用户行为栈
* @returns stack数据
*/
getStack(): BreadcrumbData[] {
return [...this.stack]
}
}
const breadcrumb =
_support.breadcrumb || (_support.breadcrumb = new Breadcrumb())
export { breadcrumb }
DOM操作事件监听
通过window.document.addEventListener监听用户点击事件,记录点击的DOM信息,此处记录时使用节流函数防止多次点击。
export function domHandle(): void {
const clickThrottle = throttle(function (data: any) {
const htmlString = htmlElementAsString(
data.document.activeElement as HTMLElement
)
if (htmlString) {
breadcrumb.push({
type: EVENTTYPES.CLICK,
category: breadcrumb.getCategory(EVENTTYPES.CLICK),
data: htmlString,
time: Date.now(),
level: SEVERITY.INFO
})
}
}, delayTime)
window.document.addEventListener(
'click',
function (event) {
clickThrottle(event.view)
},
true
)
}
路由事件监听
有hash模式与history模式两种路由模式,所以要分别进行监听。
hash模式
监听hashchange事件进行路由跳转信息记录。
/**
* 监听路由hash值的变化
*/
export function hashHandle(): void {
if (!Object.hasOwnProperty.call(window, 'onpopstate')) {
window.addEventListener('hashchange', function (data: HashChangeEvent) {
const { oldURL, newURL } = data
const { relative: from } = parseUrlToObj(oldURL)
const { relative: to } = parseUrlToObj(newURL)
breadcrumb.push({
type: EVENTTYPES.ROUTE,
category: breadcrumb.getCategory(EVENTTYPES.ROUTE),
data: JSON.stringify({
from,
to
}),
time: Date.now(),
level: SEVERITY.INFO
})
})
}
}
history模式
history模式需要重写onpopstate事件、pushState事件和replaceState事件。
调用history.pushState()或者history.replaceState()不会触发popstate事件,popstate事件只会在浏览器某些行为下触发,比如点击前进后退按钮,调用history.go(),history.forward(),history.back()。
let lastHref: string = getLocationHref()
export function historyHandle(): void {
// 重写onpopstate事件
const oldOnpopstate = window.onpopstate
window.onpopstate = function (this: any, ...args: any): void {
const to = getLocationHref()
const from = lastHref
lastHref = to
routeHandle(from, to)
oldOnpopstate && oldOnpopstate.apply(this, args)
}
// 重写pushState事件 window.history.pushState(state, title, url)
const oldPushState = window.history.pushState
window.history.pushState = function (this: History, ...args: any): void {
const url = args.length > 2 ? args[2] : undefined
if (url) {
const from = lastHref
const to = String(url)
lastHref = to
routeHandle(from, to)
}
oldPushState && oldPushState.apply(this, args)
}
// 重写replaceState事件 window.history.replaceState(state, title, url)
const oldReplaceState = window.history.replaceState
window.history.replaceState = function (this: History, ...args: any): void {
const url = args.length > 2 ? args[2] : undefined
if (url) {
const from = lastHref
const to = String(url)
lastHref = to
routeHandle(from, to)
}
oldReplaceState && oldReplaceState.apply(this, args)
}
}
function routeHandle(from: string, to: string) {
const { relative: parsedFrom } = parseUrlToObj(from)
const { relative: parsedTo } = parseUrlToObj(to)
if (
parsedFrom !== parsedTo &&
!(
parsedFrom?.endsWith(parsedTo || '/') ||
parsedTo?.endsWith(parsedFrom || '/')
)
) {
breadcrumb.push({
type: EVENTTYPES.ROUTE,
category: breadcrumb.getCategory(EVENTTYPES.ROUTE),
data: JSON.stringify({
from: parsedFrom ? parsedFrom : '/',
to: parsedTo ? parsedTo : '/'
}),
time: Date.now(),
level: SEVERITY.INFO
})
}
}
错误事件监听
在各类错误上报数据组装函数中,增加一条有关错误的用户行为记录,并在需要上报的数据中增加用户行为数据,将行为栈中的全部数据进行上报。
上报对象中的breadcrumb字段即为用户行为记录数据,在监控平台可通过时间线的形式进行展示。
// 增加一条用户行为记录,用于定位错误发生
const breadcrumbType = httpType === 'xhr' ? EVENTTYPES.XHR : EVENTTYPES.FETCH
breadcrumb.push({
type: breadcrumbType,
category: breadcrumb.getCategory(breadcrumbType),
data: name,
level: SEVERITY.ERROR,
time: startTime || Date.now()
})
// 赋值用户行为数据
reportData.breadcrumb = breadcrumb.getStack()
录屏
用户行为记录对错误的发生过程记录有限,搭配录屏回放可以更好的记录错误发生的过程,从而更快更准的定位问题。
录屏实现
通过rrweb实现前端录屏的功能。
import { record } from 'rrweb'
/**
* 录屏信息处理
*/
handleScreen(): void {
let events: any[] = [] // events存储录屏信息
_support.recordScreenId = generateUUID()
record({
emit(event: any, isCheckout: any) {
// isCheckout是一个标识,提示重新制作了快照
if (isCheckout) {
// 此段时间内发生错误,上报录屏信息
if (_support.hasError) {
const recordScreenId = _support.recordScreenId || ''
_support.recordScreenId = generateUUID()
const recordScreenEvents = zip(events)
events = []
_support.hasError = false
const recordScreenData = {
type: OTHERTYPES.RECORD_SCREEN,
name: '录屏信息',
time: Date.now(),
recordScreenId,
recordScreenEvents
}
transportData.sendByXhr(recordScreenData, true)
} else {
// 不上报,清空录屏
events = []
_support.recordScreenId = generateUUID()
}
}
events.push(event)
},
recordCanvas: true,
checkoutEveryNms: 1000 * 10 // 默认每10s重新制作快照
})
}
}
录屏数据压缩
录屏数据一般数据量巨大,需要进行压缩。rrweb提供的压缩方式,是将每个event数据进行单独压缩,压缩比不高,更推荐将多个event批量一次性压缩。
import { Base64 } from 'js-base64'
import pako from 'pako'
/**
* pako.js、js-base64 相结合的压缩方式,压缩比为 85% 以上
* @param data
* @returns
*/
export function zip(data: any): string {
try {
const dataJson = JSON.stringify(data)
// 使用Base64.encode处理字符编码,兼容中文
const str = Base64.encode(dataJson)
// 得到Uint8Array类型,8位无符号整型数组
const binaryString = pako.gzip(str)
const arr = Array.from(binaryString)
let s = ''
arr.forEach((item: any) => {
s += String.fromCharCode(item)
})
return Base64.btoa(s)
} catch {
return ''
}
}
录屏上报时机
一般关注的是,页面报错的时候用户做了哪些操作,所以目前只把报错前10s的录屏上报到服务端。
如何只上报报错时的录屏信息呢 ?
1)window上设置 hasError、recordScreenId 变量,hasError用来判断某段时间代码是否报错,recordScreenId 用来记录此次录屏的id。
2)当页面发生报错需要上报时,判断是否开启了录屏,如果开启了,将 hasError 设为 true,同时将 window 上的 recordScreenId,存储到此次上报信息的 data 中。
3)rrweb 设置10s重新制作快照的频率,每次重置录屏时,判断 hasError 是否为 true(即这段时间内是否发生报错),有的话将这次的录屏信息上报,并重置录屏信息和 recordScreenId,作为下次录屏使用。
4)后台报错列表,从本次报错报的data中取出 recordScreenId 来播放录屏。
/**
* 如果开启了录屏,则赋值录屏id
*/
#hasRecordScreen(reportData: ReportData) {
if (_support.options?.hasRecordScreen) {
_support.hasError = true
reportData.recordScreenId = _support.recordScreenId
}
}
录屏播放demo
播放录屏使用 rrweb-player 包实现。
import rrwebPlayer from 'rrweb-player'
import 'rrweb-player/dist/style.css'
// 播放录屏
function playRecord() {
const events = unzip(recordScreenData.recordScreenEvents)
nextTick(() => {
new rrwebPlayer({
target: document.getElementById('screen') as HTMLElement,
props: {
events: events as any
}
})
})
}
// 解压缩
function unzip(b64Data: string) {
const strData = Base64.atob(b64Data)
const charData = strData.split('').map((x: string) => x.charCodeAt(0))
const binData = new Uint8Array(charData)
const data = pako.ungzip(binData)
// ↓切片处理数据,防止内存溢出报错↓
let str = ''
const chunk = 8 * 1024
let i
for (i = 0; i < data.length / chunk; i++) {
str += String.fromCharCode.apply(null, [
...data.slice(i * chunk, (i + 1) * chunk)
])
}
str += String.fromCharCode.apply(null, [...data.slice(i * chunk)])
// ↑切片处理数据,防止内存溢出报错↑
const unzipStr = Base64.decode(str)
let result = ''
// 对象或数组进行JSON转换
try {
result = JSON.parse(unzipStr)
} catch (error) {
if (/Unexpected token o in JSON at position 0/.test(error as string)) {
// 如果没有转换成功,代表值为基本数据,直接赋值
result = unzipStr
}
}
return result
}
web vitals计算性能指标
Google将性能指标分为核心指标(Core Web Vitals)和其他指标(Other metrics)。Chrome开发者关系指南 详细介绍了这些指标的含义,如何测量及如何优化。
web-vitals 是Google发起的,旨在提供各种质量信号的统一指南。其提供了一些指标的计算,包括核心指标CLS(累积布局偏移)、FID(首次输入延迟)、 LCP(最大内容绘制)和其他指标INP(与下一次绘制的交互)、FCP(首次内容绘制)、TTFB(第一字节时间)。通过监控这些指标可以很好地分析系统的性能。
web-vitals的用法如下:
import {onCLS, onFID, onLCP, onINP, onFCP, onTTFB} from 'web-vitals';
onCLS(console.log);
onFID(console.log);
onLCP(console.log);
onINP(console.log);
onFCP(console.log);
onTTFB(console.log);
Lighthouse评分
Lighthouse 是一个Google开源的自动化工具,主要用于改进网络应用的质量,会对各个测试项的结果打分,并给出优化建议。
Lighthouse已经集成到Chrome DevTools中,位于“Lighthouse”面板下。
Lighthouse会对每一项指标打分,并根据权重计算总得分。使用评分计算器可探索评分。
Lighthouse收集完性能指标(主要以毫秒为单位报告),通过查看指标值在其Lighthouse评分分布中的位置,将每个原始指标值转换为从0到100的指标分数。评分分布是从HTTP Archive上真实网站性能数据的性能指标得出的对数正态分布。
Lighthouse 评分曲线模型使用 HTTPArchive 数据来确定两个控制点,然后设置对数正态曲线的形状。HTTPArchive 数据的第 25 个百分位数变为 50 分(中值控制点),第 8 个百分位数变为 90 分(良好/绿色控制点)。在探索下面的评分曲线图时,请注意在 0.50 和 0.92 之间,度量值和分数之间存在近乎线性的关系。0.96 左右的分数是上面的“收益递减点”,曲线拉开,需要越来越多的指标改进来提高已经很高的分数。
HTTP Archive 是一个开源的、用来记录互联网上站点的性能情况和趋势的数据库,存储有国内外很多网站性能指标的“历史”数据。因此可用于网站横向性能比较等。
Lighthouse可直接计算出指标得分,若不引用Lighthouse独立计算,可使用以下的数学公式,该公式即上图的对数正态曲线,x为性能指标值,C(x)为最后得分。
其中
公式中变量为m与p10,其根据不同的性能指标取不同的值,标准同样来源于Goolgle。m为 needs improvement 与 poor的分界值,p10为 good 与 needs improvement 的分界值。
计算系统的整体得分,一般会在参考Lighthouse的得分计算规则的基础上,去除一些推荐在实验室环境测量的指标的权重。
下方是目前字节使用的线上站点性能满意度(系统得分)的权重计算公式,去除了SI和TBT这两个不推荐在线上环境测量的指标。
我们在计算站点性能满意度时可以综合参考Google与字节。
TTI指标:可交互时间衡量页面何时可以可靠的响应用户的输入。如果主线程上至少5秒都没有长任务,那么可以认为它是“完全可交互的”。
在Google关于TTI的介绍中提到,虽然TTI可以在实际情况下进行测量,但不建议这样做,因为用户交互会影响网页的TTI,从而导致出现大量差异。如需了解页面在实际情况中的交互性,应该测量First Input Delay首次输入延迟(FID)。
INP与FID
INP:当用户采取行动后到下一次浏览器画面的绘制之间经过的时间。
INP记录了用户每次行为到画面发生变化中间经过的时间,而FID仅仅是首次。
FID仅报告用户第一次与页面交互时的响应性,尽管第一印象很重要,但第一次交互并不一定代表页面生命周期中的所有交互。
INP不只是测量第一次交互,而是将所有交互都考虑在内,报告页面整个生命周期中最慢的交互之一。而且,INP不仅测量延迟部分,还测量从交互开始到事件处理程序,直到浏览器能够绘制下一帧的整个持续时间。因此,交互到下一次绘制,这些实现细节使得INP比FID更全面的衡量用户感知的响应性。
2024年3月份,INP将会替换FID成为Core Web Vitals中新的三大指标。
所以,我们可使用以下公式计算页面性能得分:
优化内容
sendBeacon
SDK在上报数据时,用户可能会突然关闭页面,导致已经采集到的数据无法完成上报。为了解决这个问题,提供sendBeacon上报方法。
浏览器引入了Navigator.sendBeacon()方法。这个方法还是异步发出请求,但是请求与当前页面线程脱钩,作为浏览器进程的任务,因此可以保证会把数据发出去,不拖延卸载流程。
Navigator.sendBeacon方法接受两个参数,第一个参数是目标服务器的 URL,第二个参数是所要发送的数据(可选),可以是任意类型(字符串、表单对象、二进制对象等等)。
/**
* 通过sendBeacon方式上报
*/
async sendBySendBeacon(data: ReportData): Promise<void> {
try {
if (this.#dsn) {
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json;charset=UTF-8'
})
navigator.sendBeacon(this.#dsn, blob)
}
}
} catch {
console.error('monitor-sdk:sendBySendBeacon执行异常')
}
}
选择xhr上报方式的时候,在用户关闭页面时,将全部数据上报。
/**
* 监听页面关闭事件,上报全部数据
*/
if (this.#sendType === 'xhr') {
this.bindEvents()
}
bindEvents() {
document.addEventListener('visibilitychange ', () => {
if (document.visibilityState === 'hidden') {
const reportData = this.#queue.getDataStack()
if (reportData.length > 0) {
reportData.forEach(item => {
const blob = new Blob([JSON.stringify(item)], {
type: 'application/json;charset=UTF-8'
})
navigator.sendBeacon(this.#dsn, blob)
})
this.#queue.clearDataStack()
this.#queue.clearStack()
}
}
})
}
数据上报队列
因为SDK主要目的是为了监控系统性能,所以要保证最大程度的不影响系统本身的运行。
将SDK需要上报的数据储存到队列中,在浏览器空闲时进行上报或使用微任务进行上报。
RequestIdleCallback 简单的说,判断一帧有空闲时间,则去执行某个任务。目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况。
/**
* 数据上报队列
*/
export class Queue {
#isFlushing = false
stack: any[] = []
dataStack: any = [] // 上报信息数组
addFn(fn: () => void): void {
if (typeof fn !== 'function') return
if (!('requestIdleCallback' in window || 'Promise' in window)) {
fn()
return
}
this.stack.push(fn)
if (!this.#isFlushing) {
this.#isFlushing = true
// 优先使用requestIdleCallback,在浏览器空闲时执行上报
if ('requestIdleCallback' in window) {
requestIdleCallback(() => this.flushStack(), { timeout: 3000 })
} else {
// 其次使用微任务上报
Promise.resolve().then(() => this.flushStack())
}
}
}
addData(data: ReportData): void {
this.dataStack.push(data)
}
clearDataStack() {
this.dataStack = []
}
clearStack() {
this.stack = []
}
getDataStack() {
return [...this.dataStack]
}
getStack() {
return [...this.stack]
}
flushStack(): void {
const temp = this.stack.slice(0)
this.stack = []
this.dataStack = []
this.#isFlushing = false
for (let i = 0; i < temp.length; i++) {
temp[i]()
}
}
}
至此,一个简单的前端监控SDK基本开发完成。