前端监控SDK:从基础到实践 (1. 性能监控)

1,906 阅读12分钟

前言

在现代前端开发中,随着应用复杂度的提高和用户体验的精细化要求,前端监控已经成为开发者不可或缺的一项技能。从性能优化到错误跟踪,再到用户行为分析,构建一个完整的前端监控平台不仅能帮助团队快速发现问题,还能为业务决策提供可靠的数据支撑。

image.png

作为一名前端开发者,我是在实习过程中接触到的监控平台,一是感觉这相比业务开发来说更有技术含量一点,二是觉得在学习过程中能查漏补缺很多以前没注意到知识点,所以打算写一个前端监控的SDK学习一下。

在开发自己的开源项目 monitor-sdk 的过程中,深刻感受到前端监控的重要性与挑战。在这个系列文章中,我将通过理论结合实践的方式,带大家了解前端监控平台的核心知识点,包括:

  • 性能监控:如何获取页面加载性能数据;
  • 错误监控:如何捕获和上报 JavaScript 错误、资源加载错误、Promise 未捕获异常、怎么集成到vue、react框架上;
  • 用户行为分析:如何记录用户交互数据,为优化提供依据;
  • 异常监控:如何捕获和上报页面异常情况
  • 数据上报:如何合理的对数据进行上报。

image.png 在文章中,我会结合 monitor-sdk 的实现细节,和给大家学习搭建的demo来逐步拆解前端监控的各个模块,带领大家从 0 到 1 搭建属于自己的监控平台。

系列文章

性能监控

首先声明,在性能监控中我们要监控的对象有:

  • 网页资源加载的内容(img、video、js...)
  • 网页FCP、LCP、FP等
  • 网络接口相关的数据

网页性能数据统计方法调研

提到页面性能监控我相信很多人都见过这张图

image.png 那么要怎么统计这些数据呢?方法有二

方法一:PerformanceObserver (性能监测对象):

PerformanceObserver 用于监测性能度量事件,在浏览器的性能时间轴记录新的 performance entry 的时候将会被通知。

其中有个方法PerformanceObserver.observe()

指定监测的 entry types 的集合。当 performance entry 被记录并且是指定的 entryTypes 之一的时候,性能观察者对象的回调函数会被调用。

当监测到的时候可以调用PerformanceObserver.disconnect()停止接收 性能条目

可以通过这个Api获取FP、FCP、LCP、CLS

方法二:PerformanceTiming

PerformanceTiming 接口是为保持向后兼容性而保留的传统接口,并且提供了在加载和使用当前页面期间发生的各种事件的性能计时信息。

可以通过只读属性window.performance.timing 获得实现该接口的一个对象。

这个api统计了浏览器从网址开始导航到 window.onload事件触发的时间点,比如请求开始的时间点——requestStart,响应结束的时间点——responseEnd,通过这些时间点我们可以计算出一些对页面加载质量有指导意见的时长,比如以下几个:

  • TTFB : ResponseStart - RequestStart (首包时间,关注网络链路耗时)
  • FPT : ResponseEnd - FetchStart (首次渲染时间 / 白屏时间)
  • TTI : DomInteractive - FetchStart (首次可交付时间)
  • Ready : DomContentLoadEventEnd - FetchStart (加载完成时间)
  • Load : LoadEventStart - FetchStart (页面完全加载时间)

由于PerformanceTiming已经显示废弃,故放弃方法二选择方法一 image.png

在此之前需要先展示一下全局配置的数据结构

export const config = {
  url: 'http://127.0.0.1:3000/api/data', // 上报地址
  projectName: 'monitor', // 项目名称
  appId: '123456', // 项目id
  userId: '123456', // 用户id
  isAjax: false, // 是否开启ajax上报
  batchSize: 5, // 批量上报大小
  containerElements: ['html', 'body', '#app', '#root'], // 容器元素
  skeletonElements: [], // 骨架屏元素
  reportBefore: () => {}, // 上报前回调
  reportAfter: () => {}, // 上报后回调
  reportSuccess: () => {}, // 上报成功回调
  reportFail: () => {}, // 上报失败回调
}

统计资源加载

我们上报资源加载的数据结构

type commonType = {
    type: string // 类型
    subType: string // 一级类型
    timestamp: number
}

export type PerformanceResourceType = commonType & {
    /** 资源的名称或 URL */
    name: string
    /** DNS 查询所花费的时间,单位为毫秒 */
    dns: number
    /** 请求的总持续时间,从开始到结束,单位为毫秒 */
    duration: number
    /** 请求使用的协议,如 HTTP 或 HTTPS */
    protocol: string
    /** 重定向所花费的时间,单位为毫秒 */
    redirect: number
    /** 资源的大小,单位为字节 */
    resourceSize: number
    /** 响应体的大小,单位为字节 */
    responseBodySize: number
    /** 响应头的大小,单位为字节 */
    responseHeaderSize: number
    /** 资源类型,如 "script", "css" 等 */
    sourceType: string
    /** 请求开始的时间,通常是一个高精度的时间戳 */
    startTime: number
    /** 资源的子类型,用于进一步描述资源 */
    subType: string
    /** TCP 握手时间,单位为毫秒 */
    tcp: number
    /** 传输过程中实际传输的字节大小,单位为字节 */
    transferSize: number
    /** 首字节时间 (Time to First Byte),从请求开始到接收到第一个字节的时间,单位为毫秒 */

    ttfb: number
    /** 类型,通常用于描述性能记录的类型,如 "performance" */
    type: string
    /** 页面路径" */
    pageUrl: string
}

然后我们就可以用PerformanceObserver来捕获资源加载数据了

主要流程

  1. 通过 PerformanceObserver 捕获资源加载数据。
  2. 对监控数据进行过滤,避免上报 SDK 自己的请求。因为数据上报是sdk自己的行为它可能是ajax请求,那么它也会被PerformanceObserver捕获到,所以需要过滤掉。
  3. 加工数据,提取资源的性能指标。
  4. 批量上报处理后的数据。 在初始页面加载的时候会有很多资源数据被捕获,所以采用批量上传是很有必要的,后续再讲解lazyReportBatch
import { getConfig } from '../common/config'
import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { PerformanceResourceType, resourceType } from '../types'

export function observerEvent() {
  const config = getConfig()
  const url = config.url
  const parsedUrl = new URL(url)
  const host = parsedUrl.host
  const entryHandler = (list: PerformanceObserverEntryList) => {
    const dataList: PerformanceResourceType[] = []
    const entries = list.getEntries()
    for (let i = 0; i < entries.length; i++) {
      const resourceEntry = entries[i] as PerformanceResourceTiming

      // 避免sdk自己发的请求又被上报无限循环
      if (resourceEntry.name.includes(host)) {
        continue
      }
      const data: PerformanceResourceType = {
        type: TraceTypeEnum.performance,
        subType: resourceEntry.entryType, // 类型
        name: resourceEntry.name, // 资源的名字
        sourceType: resourceEntry.initiatorType, // 资源类型
        duration: resourceEntry.duration, // 加载时间
        dns: resourceEntry.domainLookupEnd - resourceEntry.domainLookupStart, // dns解析时间
        tcp: resourceEntry.connectEnd - resourceEntry.connectStart, // tcp连接时间
        redirect: resourceEntry.redirectEnd - resourceEntry.redirectStart, // 重定向时间
        ttfb: resourceEntry.responseStart, // 首字节时间
        protocol: resourceEntry.nextHopProtocol, // 请求协议
        responseBodySize: resourceEntry.encodedBodySize, // 响应内容大小
        responseHeaderSize:
          resourceEntry.transferSize - resourceEntry.encodedBodySize, // 响应头部大小
        transferSize: resourceEntry.transferSize, // 请求内容大小
        resourceSize: resourceEntry.decodedBodySize, // 资源解压后的大小
        startTime: resourceEntry.startTime, // 资源开始加载的时间
        pageUrl: window.location.href, // 页面地址
        timestamp: new Date().getTime()
      }
      dataList.push(data)
      if (i === entries.length - 1) {
        const reportData: resourceType = {
          type: TraceTypeEnum.performance, // 类型
          subType: TraceSubTypeEnum.resource, // 类型
          resourceList: dataList,
          timestamp: new Date().getTime()
        }
        lazyReportBatch(reportData)
      }
    }
  }

  const observer = new PerformanceObserver(entryHandler)
  observer.observe({ type: 'resource', buffered: true })
}

然后是开始收集的时机

export default function observerEntries() {
  if (document.readyState === 'complete') {
    observerEvent()
  } else {
    const onLoad = () => {
      observerEvent()
      window.removeEventListener('load', onLoad, true)
    }
    window.addEventListener('load', onLoad, true)
  }
}

大家可以上demo项目自己启动一下,这就是一次批量上报的请求,箭头所指的地方就是本次上报的资源加载的数据

image.png

image.png

统计FCP、LOAD等数据

这也是同样使用PerformanceObserver

FCP(首次内容绘制)

简介:是指浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间。

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { PaintType } from '../types'

export default function observerFCP() {
  const entryHandler = (list: PerformanceObserverEntryList) => {
    for (const entry of list.getEntries()) {
      if (entry.name === 'first-contentful-paint') {
        observer.disconnect()
        const json = entry.toJSON()
        const reportData: PaintType = {
          ...json,
          type: TraceTypeEnum.performance,
          subType: TraceSubTypeEnum.fcp,
          pageUrl: window.location.href,
          timestamp: new Date().getTime()
        }
        // 发送数据 todo;
        lazyReportBatch(reportData)
      }
    }
  }
  // 统计和计算fcp的时间
  const observer = new PerformanceObserver(entryHandler)
  // buffered: true 确保观察到所有paint事件
  observer.observe({ type: 'paint', buffered: true })
}

像其他的FP、LOAD、LCP都是类似的写法了

LCP(最大内容渲染时间)

简介:用于记录视窗内最大的元素绘制的时间,该时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变,另外该指标会在用户第一次交互后停止记录。

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { PaintType } from '../types'

export default function observerLCP() {
  const entryHandler = (list: PerformanceObserverEntryList) => {
    if (observer) {
      observer.disconnect()
    }
    for (const entry of list.getEntries()) {
      const json = entry.toJSON()
      const reportData: PaintType = {
        ...json,
        type: TraceTypeEnum.performance,
        subType: TraceSubTypeEnum.lcp,
        pageUrl: window.location.href,
        timestamp: new Date().getTime()
      }
      // 发送数据 todo;
      lazyReportBatch(reportData)
    }
  }
  // 统计和计算lcp的时间
  const observer = new PerformanceObserver(entryHandler)
  // buffered: true 确保观察到所有paint事件
  observer.observe({ type: 'largest-contentful-paint', buffered: true })
}

FP(首次绘制)

简介:是指浏览器首次将像素绘制到屏幕上的时间点,具体来说,FP表示浏览器首次绘制了至少一个像素,并将其显示在用户的屏幕上。

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { PaintType } from '../types'

export default function observerPaint() {
  const entryHandler = (list: PerformanceObserverEntryList) => {
    for (const entry of list.getEntries()) {
      if (entry.name === 'first-paint') {
        observer.disconnect()
        const json = entry.toJSON() as PerformanceEntry
        // 定义 reportData 的类型
        const reportData: PaintType = {
          ...json,
          type: TraceTypeEnum.performance,
          subType: TraceSubTypeEnum.fp,
          pageUrl: window.location.href,
          timestamp: new Date().getTime()
        }

        // 发送数据 todo;
        lazyReportBatch(reportData)
      }
    }
  }

  // 统计和计算fp的时间
  const observer = new PerformanceObserver(entryHandler)

  // buffered: true 确保观察到所有 paint 事件
  observer.observe({ type: 'paint', buffered: true })
}

LOAD 执行事件load的时机

简介:当所有需要立即加载的资源(如图片和样式表)已加载完成时的时间点

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { PaintType } from '../types'

export default function observePageLoadTime() {
  // 记录页面加载开始的时间
  const startTimestamp = performance.now()

  // 监听 load 事件
  window.addEventListener('load', () => {
    // 记录 load 事件触发的时间
    const loadTimestamp = performance.now()

    // 计算从页面开始加载到 load 事件触发的时间差
    const loadTime = loadTimestamp - startTimestamp

    // 构建性能数据对象
    const reportData: PaintType = {
      name: '',
      entryType: 'load',
      type: TraceTypeEnum.performance,
      subType: TraceSubTypeEnum.load,
      pageUrl: window.location.href,
      startTime: startTimestamp,
      duration: loadTime,
      timestamp: new Date().getTime()
    }
    // 发送数据
    lazyReportBatch(reportData)
  })
}

上报的数据 image.png 然后还有其他类似的性能指标例如:

FMP(首次有意义绘制)

简介:是指在网页加载过程中,用户可以在屏幕上看到有意义内容的时间点。

fmp的统计还没有目前没有一个正统一点的计算方法,我自己也没有实现统计它

调研方案:

认定页面在加载和渲染过程中最大布局变动之后的那个绘制时间即为当前页面的 FMP 」。由于在页面渲染过程中,「 DOM 结构变化的时间点」和与之对应的「渲染的时间点」近似相同,所以一般计算 FMP 的方式是:计算出 DOM 结构变化最剧烈的时间点,即为 FMP。 我查了下资料有前端监控实践——FMP的智能获取算法 - 斑驳光影 - SegmentFault 思否 - 掘金

在load事件触发后,遍历dom树,通过对一些标签设计一套权重系统,例如svg,img的权重为2,canvas,object,embed,video的权重为4,其他的元素为1,然后计算dom元素大小占比大小权重得到分数,通过上面的步骤我们获取到了一个集合,这个集合是"可视区域内得分最高的元素的集合",我们会对这个集合的得分取均值,然后过滤出在平均分之上的元素集合,然后通过performance.getEntries去获取对应资源的加载时间,获取元素的加载速度,最后取所有元素最大的加载时间值,作为页面加载的FMP时间

CLS(累积布局偏移)

简介:从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数。

调研方案:

布局偏移分数 = 影响分数 * 距离分数

影响分数测量不稳定元素对两帧之间的可视区域产生的影响。

距离分数指的是任何不稳定元素在一帧中位移的最大距离(水平或垂直)除以可视区域的最大尺寸维度(宽度或高度,以较大者为准)。

CLS 就是把所有布局偏移分数加起来的总和。 CLS 一共有三种计算方式:

  1. 累加
  2. 取所有会话窗口的平均数
  3. 取所有会话窗口中的最大值

FID (首次可交互时间)

简介:用户首次与页面交互(如点击、触摸、键盘输入)到浏览器实际响应事件的时间间隔。

TTI(首次可交互时间)

简介:它用于衡量网页完全加载完成后,用户可以与页面进行交互的时间。它是页面加载过程中的一个关键度量标准,更准确地反映了用户实际体验的时间点。

捕获http网络请求(fetch、xhr)

在网页中网络请求大致分fetch和xhr,axios发的请求它的底层是xhr 那么要捕获网络请求的办法就是我们要重写一下fetch和xhr

fetch

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { urlToJson } from '../common/utils'
import { AjaxType } from '../types'

const originalFetch: typeof window.fetch = window.fetch

function overwriteFetch(): void {
  window.fetch = function newFetch(
    url: any,
    config?: RequestInit
  ): Promise<Response> {
    const params = (
      config?.body ? config.body : urlToJson(url as string)
    ) as string
    const startTime = Date.now()
    const urlString =
      typeof url === 'string' ? url : url instanceof URL ? url.href : url.url
    const reportData: AjaxType = {
      type: TraceTypeEnum.performance,
      subType: TraceSubTypeEnum.fetch,
      url: urlString,
      startTime,
      endTime: 0,
      duration: 0,
      status: 0,
      success: false,
      method: config?.method || 'GET',
      pageUrl: window.location.href,
      params,
      timestamp: new Date().getTime()
    }
    return originalFetch(url, config)
      .then(res => {
        reportData.status = res.status
        return res
      })
      .catch(err => {
        reportData.status = err.status
        throw err
      })
      .finally(() => {
        const endTime = Date.now()
        reportData.endTime = endTime
        reportData.duration = endTime - startTime
        reportData.success = false
        // todo 上报数据
        lazyReportBatch(reportData)
      })
  }
}

export default function fetch(): void {
  overwriteFetch()
}

xhr

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { urlToJson } from '../common/utils'
import { AjaxType } from '../types'

export const originalProto = XMLHttpRequest.prototype
export const originalSend = originalProto.send
export const originalOpen = originalProto.open

// 扩展 XMLHttpRequest 类型,允许自定义属性
declare global {
  interface XMLHttpRequest {
    startTime?: number
    endTime?: number
    duration?: number
    method?: string
    url?: string
  }
}

function overwriteOpenAndSend() {
  originalProto.open = function newOpen(
    method: string,
    url: string | URL,
    async: boolean = true,
    username?: string,
    password?: string
  ) {
    // 这将保留原始的 open 方法签名,并确保 async、username 和 password 可选
    this.url = url.toString() // 可能需要转为 string 类型
    this.method = method
    originalOpen.apply(this, [method, url, async, username, password])
  }

  originalProto.send = function newSend(
    ...args: [Document | XMLHttpRequestBodyInit | null | undefined]
  ) {
    this.addEventListener('loadstart', () => {
      this.startTime = Date.now()
    })

    const onLoaded = () => {
      this.endTime = Date.now()
      this.duration = (this.endTime ?? 0) - (this.startTime ?? 0)
      const { url, method, startTime, endTime, duration, status } = this
      const params = (args[0] ? args[0] : urlToJson(url as string)) as string

      const reportData: AjaxType = {
        status,
        duration,
        startTime,
        endTime,
        url,
        method: method?.toUpperCase(),
        type: TraceTypeEnum.performance,
        success: status >= 200 && status < 300,
        subType: TraceSubTypeEnum.xhr,
        pageUrl: window.location.href,
        params,
        timestamp: new Date().getTime()
      }
      // todo: 发送数据
      lazyReportBatch(reportData)
      this.removeEventListener('loadend', onLoaded, true)
    }

    this.addEventListener('loadend', onLoaded, true)
    originalSend.apply(this, args)
  }
}

export default function xhr() {
  overwriteOpenAndSend()
}

上报的请求例子

image.png

image.png

尾言

以上就是性能监控的全部内容了,笔者不一定正确,如果大家有发现有错误或有优化的地方也可以在评论区指出。下一篇会写错误监控的内容。

最后如果文章对你有帮助也希望能给我点个赞,给我的开源项目点个star,monitor-sdk 你的支持是我学习的最大动力。