轻量级前端监控SDK的实现

1,330 阅读5分钟

摘要

本文所实现的轻量级SDK主要包括错误监听和性能监控两部分,目前支持的性能指标为FCP、FP、LCP和TTFB。实现的大致思路为捕获相应的错误,监听相应的性能,然后进行数据组装处理,最后上报到指定的url。

错误监听

监听业务接口响应错误

所有的接口请求本质上都是XHR请求和fetch请求,所以当需要监听业务接口响应的错误时,可以重写xhr,fetch方法,对想要的数据进行捕获处理。

重写XHR,包括open和send方法

  const XMLHttpRequest = window.XMLHttpRequest
  // 缓存原始的open方法
  const originOpen = XMLHttpRequest.prototype.open
  // 重写open方法
  XMLHttpRequest.prototype.open = function (
    this: XMLHttpRequestFormat,
    method: string,
    url: string
  ) {
    this.xhrData = {
      method,
      url,
      sTime: Date.now(),
      type: HTTPTYPE.XHR
    }
    return originOpen.apply(this, [method, url, true])
  }
  
  // 缓存原始的send方法
  const originSend = XMLHttpRequest.prototype.send
  // 重写send方法
  XMLHttpRequest.prototype.send = function (
    this: XMLHttpRequestFormat,
    ...args
  ) {
    const { sTime, method, url } = this.xhrData
    this.addEventListener('loadend', function (this: XMLHttpRequestFormat) {
      // todo 判断返回状态是否发生错误,进行错误数据上报
    })
    return originSend.apply(this, args)
  }

重写fetch方法

const originFetch = window.fetch
window.fetch = function (url, config) {
  // todo 记录SDK上报所需数据
  return originFetch.apply(window, [url, config]).then(
    res => {
      // todo 当返回的状态发生错误时进行数据上报
      return res
    },
    (err: Error) => {
      // todo 进行错误的数据上报
      throw err
    }
  )
}

监听资源加载错误及js错误

资源加载的错误及js错误的监听通过window.addEventListener实现。

 window.addEventListener(
    'error',
    function (event: ErrorEvent) {
      // 资源加载错误
      const errorTarget = event.target as ResourceErrorTarget
      if (errorTarget.localName) {
        // todo 进行资源加载错误数据组装与上报
      } else {
        // 捕获普通js错误
        // todo 进行js错误数据组装与上报
      }
    },
    true
  )

监听开发中浏览器捕获到的未处理的Promise错误

一些浏览器能够捕获未处理的Promise错误,监听unhandledrejection事件,即可捕获到未处理的Promise错误。

window.addEventListener(
  'unhandledrejection',
  function (event: PromiseRejectionEvent) {
    // todo 进行未处理的Promise错误数据组装与上报
  },
  true
)

监听vue错误

vue官方提供了一个API: app.config.errorHandler, 用于为应用内抛出的未捕获错误指定一个全局处理函数。

app.config.errorHandler = (err, instance, info) => {
  // todo 进行vue错误数据组装与上报
  console.error(err)
}

上报数据组装

捕获到相应的错误后,可针对错误种类编写相应的上报数据组装函数,此处偏定制化,一般根据个人或公司需求进行针对性的数据上报。这里比较复杂的处理为stack解析及sourcemap映射。

stack信息处理

在promise错误,js错误及vue错误的错误对象中都可以得到stack对象,stack的信息处理一般通过error-stack-parser包实现,从而消除各浏览器的差异,提取给定错误的原始文件名、行和列信息。

const stackFrames = ErrorStackParser.parse(error)

使用error-stack-parser库的时候,根据js的调用栈原理,只取stackFrames数组中的第一个元素即可定位到错误发生的文件。

sourcemap映射处理

现在的前端开发都是模块化、组件化的方式,在上线前对js和css文件进行合并压缩容易造成混淆,无法找到确切的报错文件与位置,sourcemap的作用就是将生成后的代码映射到源码文件中。 sourcemap的映射通过source-map-js包实现。

source-map-js包来源于source-map包,此处选择它的原因是source-map包中有一些前端无法支持的node语法,而source-map-js中对js进行了更好的支持。

sourcemap的还原流程
  1. 开启打包配置中的sourcemap选项(一般在打包配置文件中设置sourcemap为true),从而在打包后生成.map文件,将.map文件放到安全可靠的服务器位置,使用fetch请求获取.map文件内容。
  2. new 一个 sourceMapConsumer 的实例,表示一个已解析的源映射。
    const consumer = new sourceMap.SourceMapConsumer()
  3. 输入报错发生的行和列,可以得到源码对应的原始文件名、行和列信息。consumer.originalPositionFor()
  4. 从源文件的sourcesContent字段中,获取报错发生的源代码信息。
// 载入map文件
const rawSourceMapText = await this.loadSourceMap(fileName)
const rawSourceMap = JSON.parse(rawSourceMapText)
// 获取真实的报错行列数
const consumer = new sourceMap.SourceMapConsumer(rawSourceMap)
const position = consumer.originalPositionFor({
  line: lineNumber,
  column: columnNumber
})
const { source, line, column } = position
const { sources, sourcesContent } = rawSourceMap
// 找到报错信息源文件的源码信息
let index = sources.indexOf(source)
const lines = sourcesContent[index].split('\n')

性能监听

获取首屏加载时间(FCP)

这个指标用于记录浏览器从响应用户输入网址,到首屏内容渲染完成的时间。此时整个网页的内容不一定渲染完成,但当前视窗的内容需要渲染完毕。

function getFCP(): void {
  if (isPerformanceObserverSupported()) {
    const entryHandler = (list: PerformanceObserverEntryList) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
          if (observer) {
            observer.disconnect()
          }
          if (entry.startTime < getFirstHiddenTime().timeStamp) {
            const value = Number(entry.startTime.toFixed(3))
            // todo 组织value进行FCP上报
          }
        }
      }
    }
    const observer = new PerformanceObserver(entryHandler)
    observer.observe({
      type: 'paint',
      buffered: true
    })
  }
}

获取白屏时间(FP)

记录浏览器从响应用户输入网址,到浏览器开始显示内容的时间。白屏时间是首屏时间的一个子集。

function getFP(): void {
  if (isPerformanceObserverSupported()) {
    const entryHandler = (list: PerformanceObserverEntryList) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'first-paint') {
          if (observer) {
            observer.disconnect()
          }
          if (entry.startTime < getFirstHiddenTime().timeStamp) {
            const value = Number(entry.startTime.toFixed(3))
            // todo 组织value进行FP上报
          }
        }
      }
    }
    const observer = new PerformanceObserver(entryHandler)
    observer.observe({
      type: 'paint',
      buffered: true
    })
  }
}

获取最大内容绘制时间(LCP)

在大多数网页上,有一个元素因其大小和突出程度而与众不同。LCP是网站渲染包含最多内容的元素所花费的时间。网页通常是分阶段加载的,因此,页面上的最大元素也可能会发生变化。如果有任意一个新元素大于先前的最大元素内容,则浏览器还将报告一个新的PerformanceEntry
当用户与页面进行交互(通过轻触、滚动或按键)时,浏览器将立刻停止报告新条目,因为用户交互通常会改变用户可见的内容。出于分析目的,应该仅报告最近一次分发的PerformanceEntry

function getLCP(): void {
  if (isPerformanceObserverSupported()) {
    const entryHandler = (list: PerformanceObserverEntryList) => {
      const entries = list.getEntries()
      const lastEntry = entries[entries.length - 1]
      if (lastEntry.startTime < getFirstHiddenTime().timeStamp) {
        const value = Number(lastEntry.startTime.toFixed(3))
        // todo 组织value进行LCP上报
      }
    }
    const observer = new PerformanceObserver(entryHandler)
    observer.observe({
      type: 'largest-contentful-paint',
      buffered: true
    })
    // 若出现交互,中止LCP的计算
    ;['click', 'keydown'].forEach((event: string) => {
      addEventListener(
        event,
        (): void => {
          // 断开此观察者的连接
          observer.disconnect()
        },
        { once: true, capture: true }
      )
    })
  }
}

获取首字节时间(TTFB)

首字节时间测量用户浏览器从服务器接收首个“字节”数据所需的时间,衡量服务器对访问者浏览器请求的响应能力。

function getTTFB(): void {
  if (
    isPerformanceObserverSupported() &&
    PerformanceObserver.supportedEntryTypes?.includes('navigation')
  ) {
    const entryHandler = (list: PerformanceObserverEntryList) => {
      const [entry] = list.getEntriesByType('navigation')
      if (observer) {
        observer.disconnect()
      }
      const { requestStart, responseStart } =
        entry as PerformanceNavigationTiming
      const value = Number((responseStart - requestStart).toFixed(3))
      // todo 组织value进行TTFB上报
    }
    const observer = new PerformanceObserver(entryHandler)
    observer.observe({
      type: 'navigation',
      buffered: true
    })
  }
}

数据上报

目前采用XMLHttpRequest和image方法进行上报,后续可能迭代为sendBeacon方法上报。

imgRequest(data: ReportData, url: string): void {
  let img: HTMLImageElement | null = new Image()
  const spliceStr = url.indexOf('?') === -1 ? '?' : '&'
  img.src = `${url}${spliceStr}data=${encodeURIComponent(
    JSON.stringify(data)
  )}`
  img = null
}

async xhrPost(data: ReportData, url: string) {
  const xhr = new XMLHttpRequest()
  xhr.open('POST', url)
  xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8')
  xhr.withCredentials = true
  xhr.send(JSON.stringify(data))
}

总结

通过这四部分,一个简单的轻量级的SDK基本实现,后续会进行功能升级迭代,可能会再次更新一篇升级版文章,敬请期待。