轻量级前端监控SDK的实现(二)

1,116 阅读12分钟

最近,完成了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”面板下。

image.png Lighthouse会对每一项指标打分,并根据权重计算总得分。使用评分计算器可探索评分。

image.png Lighthouse收集完性能指标(主要以毫秒为单位报告),通过查看指标值在其Lighthouse评分分布中的位置,将每个原始指标值转换为从0到100的指标分数。评分分布是从HTTP Archive上真实网站性能数据的性能指标得出的对数正态分布。

Lighthouse 评分曲线模型使用 HTTPArchive 数据来确定两个控制点,然后设置对数正态曲线的形状。HTTPArchive 数据的第 25 个百分位数变为 50 分(中值控制点),第 8 个百分位数变为 90 分(良好/绿色控制点)。在探索下面的评分曲线图时,请注意在 0.50 和 0.92 之间,度量值和分数之间存在近乎线性的关系。0.96 左右的分数是上面的“收益递减点”,曲线拉开,需要越来越多的指标改进来提高已经很高的分数。

HTTP Archive 是一个开源的、用来记录互联网上站点的性能情况和趋势的数据库,存储有国内外很多网站性能指标的“历史”数据。因此可用于网站横向性能比较等。

image.png

Lighthouse可直接计算出指标得分,若不引用Lighthouse独立计算,可使用以下的数学公式,该公式即上图的对数正态曲线,x为性能指标值,C(x)为最后得分。

C(x)=12(1Erf(lnxμ2σ))C\left(x\right)=\frac{1}{2}(1-E_{rf}(\frac{\ln x-\mu}{\sqrt{2}\sigma}))

其中

μ=ln m\mu=\ln\ m
σ=ln p10μ20.9061938024368232\sigma=\frac{\left|\ln\ p_{10}-\mu\right|}{\sqrt{2}0.9061938024368232}
Erf(x)=sign(x)(1(((((1.061405429tAS(x)+1.453152027)tAS(x))+1.421413741)tAS(x)+0.284496736)tAS(x)+0.254829592)tAS(x)exp((xx)))E_{rf}\left(x\right)=\operatorname{sign}\left(x\right)\left(1-\left(\left(\left(\left(\left(1.061405429\cdot t_{AS}\left(x\right)+-1.453152027\right)\cdot t_{AS}\left(x\right)\right)+1.421413741\right)\cdot t_{AS}\left(x\right)+-0.284496736\right)\cdot t_{AS}\left(x\right)+0.254829592\right)\cdot t_{AS}\left(x\right)\cdot\exp\left(-\left(x\cdot x\right)\right)\right)
tAS(x)=11+0.3275911abs(x)t_{AS}\left(x\right)=\frac{1}{1+0.3275911\operatorname{abs}\left(x\right)}

公式中变量为m与p10,其根据不同的性能指标取不同的值,标准同样来源于Goolgle。m为 needs improvement 与 poor的分界值,p10为 good 与 needs improvement 的分界值。

image.png

image.png

image.png

image.png

image.png

image.png

计算系统的整体得分,一般会在参考Lighthouse的得分计算规则的基础上,去除一些推荐在实验室环境测量的指标的权重。

下方是目前字节使用的线上站点性能满意度(系统得分)的权重计算公式,去除了SI和TBT这两个不推荐在线上环境测量的指标。

image.png 我们在计算站点性能满意度时可以综合参考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中新的三大指标。

image.png 所以,我们可使用以下公式计算页面性能得分:

FCP0.16+LCP0.42+INP0.17+CLS0.25FCP*0.16 + LCP*0.42 + INP*0.17 + CLS*0.25

优化内容

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基本开发完成。