前端监控SDK:从基础到实践 (4. 异常监控)

687 阅读9分钟

前言

上篇文章我们分享了行为监控相关的内容,这次我们来讲讲页面异常的内容

系列文章

异常监控

白屏监控

白屏的可能原因有哪些

  1. JavaScript错误:如果在页面加载或执行过程中发生了JavaScript错误,可能会导致整个页面无法正常渲染,从而呈现为空白页面。常见的JavaScript错误包括语法错误、未定义的变量、函数调用错误等。
  2. 资源加载问题:当网页或应用程序中的关键资源(如CSS文件、JavaScript文件、图像等)无法加载时,可能会导致页面白屏。这可能是由于网络问题、文件路径错误、资源文件损坏等原因引起的。
  3. HTML结构错误:如果HTML文档的结构出现问题,如缺少必要的标签、标签嵌套错误等,可能会导致页面无法正确渲染,从而呈现为空白页面。
  4. 样式问题:如果CSS文件中存在错误,如语法错误、选择器错误等,可能会导致页面无法正确应用样式,从而呈现为空白页面。
  5. 服务器问题:如果服务器端出现问题,如响应超时、返回错误状态码等,可能会导致页面无法正常加载,从而呈现为空白页面。
  6. 缓存问题:如果浏览器缓存中存在旧的页面缓存,而新的页面更新未能正确加载,可能会导致页面呈现为空白。
  7. 第三方库或插件冲突:如果项目中使用了第三方库或插件,并且存在版本冲突或不兼容的情况,可能会导致页面无法正常渲染,从而呈现为空白页面。

白屏检测方法调研

  • 检测根节点是否渲染——不准确,不通用、骨架屏不支持
  • Mutation Observer 监听DOM——判断大量dom消失或者全部消失。不准确、骨架屏不支持
  • (H5截图)图片对比算法——准确,但是需要截图canvas,性能有影响、骨架屏需要换图片
  • (Native截图)图片对比算法——需要借助容器能力
  • 利用performace.getEntries("paint")获取fp/fcp来感知渲染——兼容性、骨架屏不支持
  • 采样对比——综合性比较强

基于以上故采用采样对比方法(通过动态采样页面关键点来判断页面是否处于白屏)

  1. 采样点矩阵策略

    • 在视窗区域内建立3x3网格(共9个采样点)
    • 横向坐标:(window.innerWidth * i)/10 (i=1~9)
    • 纵向坐标:(window.innerHeight * i)/10 (i=1~9)
    • 中心点复用避免重复计算,共形成17个采样点
  2. 容器元素判定

    • 通过containerElements配置白屏判定容器(如div#app)
    • 通过skeletonElements配置骨架屏元素(如.ant-skeleton)
    • 使用elementsFromPoint获取采样点最顶层元素
import { getBehaviour, getRecordScreenData } from '../behavior'
import { getConfig } from '../common/config'
import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { whiteScreenType } from '../types'

let initTime = 0
const whiteScreenTime = 6000

// 定义外层容器元素的集合
const { containerElements, skeletonElements } = getConfig()
// 页面加载完毕
function onload(callback: any) {
  if (document.readyState === 'complete') {
    callback()
  } else {
    window.addEventListener('load', callback)
  }
}

// 选中dom的名称
function getSelector(element: Element) {
  if (element.id) {
    return '#' + element.id
  } else if (element.className) {
    // div home => div.home
    return (
      '.' +
      element.className
        .split(' ')
        .filter(item => !!item)
        .join('.')
    )
  } else {
    return element.nodeName.toLowerCase()
  }
}
// 监听页面白屏
function whiteScreen() {
  // 容器元素个数
  let emptyPoints = 0

  // 是否为容器节点
  function isContainer(element: Element) {
    const selector = getSelector(element)
    if (
      containerElements.indexOf(selector) != -1 ||
      skeletonElements.some(skeletonSelector =>
        element.matches(skeletonSelector)
      )
    ) {
      emptyPoints++
    }
  }

  function main() {
    // 页面加载完毕初始化
    for (let i = 1; i <= 9; i++) {
      const xElements = document.elementsFromPoint(
        (window.innerWidth * i) / 10,
        window.innerHeight / 2
      )
      const yElements = document.elementsFromPoint(
        window.innerWidth / 2,
        (window.innerHeight * i) / 10
      )
      isContainer(xElements[0])
      // 中心点只计算一次
      if (i != 5) {
        isContainer(yElements[0])
      }
    }
    // 17个点都是容器节点算作白屏
    if (emptyPoints != 17) {
      initTime = new Date().getTime()
    } else {
      const nowTime = new Date().getTime()
      if (nowTime - initTime >= whiteScreenTime) {
        const behavior = getBehaviour()
        const state = behavior?.breadcrumbs?.state || []
        const eventData = getRecordScreenData()
        const reportData: whiteScreenType = {
          type: TraceTypeEnum.exception,
          subType: TraceSubTypeEnum.whiteScreen,
          pageUrl: window.location.href,
          timestamp: nowTime,
          state,
          eventData
        }
        console.error('页面白屏')
        lazyReportBatch(reportData)
        if (window.whiteLoopTimer) {
          clearTimeout(window.whiteLoopTimer)
          window.whiteLoopTimer = null
        }
      }
    }
  }
  onload(main)
}

export default function whiteSceenLoop() {
  initTime = new Date().getTime()
  window.whiteLoopTimer = setInterval(() => {
    whiteScreen()
  }, 2000)
}

image.png

触发白屏上报的信息 image.png

页面卡顿

方法调研

  • 监控 FPS(帧率)

    • 通过 requestAnimationFrame 来监控帧率。我们可以在每帧渲染时计算时间间隔,从而计算 FPS。
  • 监控长任务(Long Tasks)

    • 长时间的主线程阻塞会导致卡顿。可以通过 PerformanceObserver 来捕获长任务。

最后我选用的是监控FPS来判断页面是否卡顿

帧率(FPS)是衡量页面流畅度的重要指标。通常,60 FPS 是流畅的标准,低于 24 FPS 则会出现明显的卡顿。代码通过 requestAnimationFrame 来监控每一帧的渲染时间,并计算帧率。

所以实现思路就是在一秒内,触发了requestAnimationFrame多少次回调函数,根据触发的次数判断此时页面是否在卡顿。

import { getBehaviour, getRecordScreenData } from '../behavior'
import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import { stutterStype } from '../types'

let lastFrameTime = performance.now()
let lastReportTime = 0
let frameCount = 0
const minFPS = 24
const reportInterval = 3000 // 每隔 3 秒最多上报一次

function trackFPS(timestamp: number) {
  // 如果页面不可见,则重置计数器

  // 计算每一帧的时间间隔
  const delta = timestamp - lastFrameTime

  frameCount++

  // 每过一秒输出 FPS
  if (delta >= 1000) {
    if (
      frameCount <= minFPS &&
      performance.now() - lastReportTime > reportInterval
    ) {
      const behavior = getBehaviour()
      const state = behavior?.breadcrumbs?.state || []
      const eventData = getRecordScreenData()
      const reportData: stutterStype = {
        type: TraceTypeEnum.exception,
        subType: TraceSubTypeEnum.stutter,
        pageUrl: window.location.href,
        timestamp: Date.now(),
        state,
        eventData
      }
      lastReportTime = performance.now()
      lazyReportBatch(reportData)
    }
    frameCount = 0
    lastFrameTime = timestamp
  }

  // 继续请求下一帧
  requestAnimationFrame(trackFPS)
}

export default function stutterLoop() {
  requestAnimationFrame(trackFPS)
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      frameCount = 1000
      lastFrameTime = 0
    }
  })
}

使页面卡顿的方法

const triggerStutter = () => {
    for (let i = 0; i < 5; i++) {
      const end = Date.now() + 300; // 阻塞 300 毫秒
      while (Date.now() < end) {
      }
    }
  }

触发卡顿时上报的信息 image.png

页面崩溃

页面崩溃的原因:

  • 内存泄漏
  • 过度的 DOM 操作
  • 过度的 CPU 计算

页面崩溃时,我们的主线程就已经卡死这时无法发送请求上报,为了解决这个问题我们可以引入webWorker,通过心跳机制来实现页面崩溃的检测。

思路:主线程和子线程不断通信,当子线程长时间没有收到主线程的信息时那么由子线程进行崩溃上报。 tips:webworker是无法访问dom的

import { getRecordScreenData, getBehaviour } from '../behavior'
import { getConfig } from '../common/config'

export default function crashLoop() {
  if (window.Worker) {
    // 获取配置信息
    const { userId, url } = getConfig()
    // 将 Web Worker 的代码以字符串形式定义
    const workerCode = `
      let pageTime = performance.now(); // 页面主线程响应的时间
      let checkTime = performance.now(); // 当前 Web Worker 的时间

      let intervalId;
      const setTimeoutTime = 2000;
      let nowUrl = '';
      let crash = false;
      let fetchUrl = ''; // 主线程传递的url
      let userId = ''; // 主线程传递的userId
      let crashEventData = []; // 主线程传递的eventData
      let crashState = []; // 主线程传递的state
      // 监听主线程消息
      onmessage = (event) => {
        let { type, pageTime: receivedPageTime, pageUrl, url, id, eventData, state } = event.data;        
        if (url) {
          fetchUrl = url; // 接收主线程传递的配置
        }
        if (id) {
          userId = id; // 接收主线程传递的配置
        }
        nowUrl = pageUrl;
        crashState = state;
        crashEventData = eventData;
        if (type === 'heartbeat-response') {
          pageTime = receivedPageTime;
        } else if (type === 'page-unload') {
          isCrash();
          const nowTime = performance.now();
          if (nowTime - pageTime >= setTimeoutTime * 2 && !crash) {
            reportError();
          }
          // 停止心跳检测并关闭 Worker
          clearInterval(intervalId);
          close();
        }
      };

      function reportError() {
        const data = {
          type: 'exception',
          subType: 'crash',
          pageUrl: nowUrl,
          timestamp: new Date().getTime(),
          eventData: crashEventData,
          state: crashState
        };
        // 检查配置并发送错误报告
        const reportData = {
          userId: userId || 'unknown',
          data: [data],
        };        
        
        fetch(fetchUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(reportData),
        }).catch((error) => console.error('Error sending report:', error));
        
      }

      function isCrash() {
        if (checkTime - pageTime >= setTimeoutTime * 2) {
          if (!crash) {
            reportError();
          }
          console.error('页面可能已经崩溃!');
          crash = true;
          clearInterval(intervalId);
        }
      }

      function sendHeartbeat() {
        checkTime += setTimeoutTime; // 每次发送心跳时增加 2 秒
        postMessage({ type: 'heartbeat' }); // 给主线程发送心跳消息
      }

      // 启动心跳检测,每 2 秒发送一次心跳
      intervalId = setInterval(() => {
        isCrash();
        sendHeartbeat();
      }, setTimeoutTime);
    `

    // 创建一个 Blob 对象,并生成 Worker 的 URL
    const workerBlob = new Blob([workerCode], {
      type: 'application/javascript'
    })
    const workerUrl = URL.createObjectURL(workerBlob)

    // 创建 Worker 实例
    const worker = new Worker(workerUrl)

    // 发送配置到 Worker
    worker.postMessage({ id: userId, url })

    // 监听 Web Worker 的心跳消息
    worker.onmessage = (event: any) => {
      const { type } = event.data
      if (type === 'heartbeat') {
        // 响应心跳消息,发送当前时间戳
        const eventData = getRecordScreenData()
        const behavior = getBehaviour()
        const state = behavior?.breadcrumbs?.state || []
        worker.postMessage({
          type: 'heartbeat-response',
          pageTime: performance.now(),
          pageUrl: window.location.href,
          eventData,
          state
        })
      }
    }

    // 页面卸载时通知 Web Worker
    window.addEventListener('beforeunload', () => {
      worker.postMessage({ type: 'page-unload' })
    })
  } else {
    console.error('当前浏览器不支持 Web Worker')
  }
}

触发页面崩溃代码:

const triggerCrash = () => {
    while (1) { }
  }

崩溃上报的信息 image.png

遇到的难点

最初在使用webWorker也遇到了一些坑,因为sdk是用rollup打包的,但是rollup对webWorker的支持很差。

错误案例一

起初我是这样使用webWorker的

import Worker from '../common/webWorker.ts?worker'
const worker = new Worker()

这样子在本地调试倒没问题,但是当你打包的时候就会有报错 image.png

错误案例二

查询资料rollup如何进行打包webworker,引入rollup-plugin-web-worker-loader

// rollup.config.js
const workerLoader = require("rollup-plugin-web-worker-loader")

plugins: [
      workerLoader({
        inline: false,
        preserveSource: true,
        extensions: ['.ts'],
      }), // 添加 Web Worker 处理插件
    ],

根据开发还是生产环境修改路径

const workerPath =
  import.meta.env.MODE === 'development'
  ? '../common/webWorker.ts?worker' // 开发环境使用 ?worker 后缀
  : 'web-worker:../common/webWorker.ts' // 生产环境使用 web-worker: 前缀
// 动态导入 Web Worker
import(workerPath)
  .then(WorkerModule => {
    if (window.Worker) {
      const worker = new WorkerModule.default()
    }
  })

这样倒是可以打包了不会有报错,但是本地项目引入这个sdk的时候又有问题 了 image.png

解决方案

使用blob的形式

// 将 Web Worker 的代码以字符串形式定义
const workerCode = `
  let pageTime = performance.now(); // 页面主线程响应的时间
  let checkTime = performance.now(); // 当前 Web Worker 的时间

  let intervalId;
  const setTimeoutTime = 2000;
  let nowUrl = '';
  let crash = false;
  let fetchUrl = ''; // 主线程传递的url
  let userId = ''; // 主线程传递的userId
  let crashEventData = []; // 主线程传递的eventData
  let crashState = []; // 主线程传递的state
  // 监听主线程消息
  onmessage = (event) => {
    let { type, pageTime: receivedPageTime, pageUrl, url, id, eventData, state } = event.data;        
    if (url) {
      fetchUrl = url; // 接收主线程传递的配置
    }
    if (id) {
      userId = id; // 接收主线程传递的配置
    }
    nowUrl = pageUrl;
    crashState = state;
    crashEventData = eventData;
    if (type === 'heartbeat-response') {
      pageTime = receivedPageTime;
    } else if (type === 'page-unload') {
      isCrash();
      const nowTime = performance.now();
      if (nowTime - pageTime >= setTimeoutTime * 2 && !crash) {
        reportError();
      }
      // 停止心跳检测并关闭 Worker
      clearInterval(intervalId);
      close();
    }
  };

  function reportError() {
    const data = {
      type: 'exception',
      subType: 'crash',
      pageUrl: nowUrl,
      timestamp: new Date().getTime(),
      eventData: crashEventData,
      state: crashState
    };
    // 检查配置并发送错误报告
    const reportData = {
      userId: userId || 'unknown',
      data: [data],
    };        

    fetch(fetchUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(reportData),
    }).catch((error) => console.error('Error sending report:', error));

  }

  function isCrash() {
    if (checkTime - pageTime >= setTimeoutTime * 2) {
      if (!crash) {
        reportError();
      }
      console.error('页面可能已经崩溃!');
      crash = true;
      clearInterval(intervalId);
    }
  }

  function sendHeartbeat() {
    checkTime += setTimeoutTime; // 每次发送心跳时增加 2 秒
    postMessage({ type: 'heartbeat' }); // 给主线程发送心跳消息
  }

  // 启动心跳检测,每 2 秒发送一次心跳
  intervalId = setInterval(() => {
    isCrash();
    sendHeartbeat();
  }, setTimeoutTime);
`
// 创建一个 Blob 对象,并生成 Worker 的 URL
const workerBlob = new Blob([workerCode], {
  type: 'application/javascript'
})
const workerUrl = URL.createObjectURL(workerBlob)

// 创建 Worker 实例
const worker = new Worker(workerUrl)

尾言

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

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