前端监控系统
监控什么
背景
项目某核心页面加载较慢、交互卡顿、实际使用中错误过多
性能监测
针对页面加载较慢,选择监测 FCP,FP
针对交互卡顿,监测用户点击按钮后页面的响应时间
FCP(First Contentful Paint):FCP是指页面上首次渲染任何文本、图像、非空白的canvas或SVG的时间点。它表示了用户首次看到页面有实际内容的时间,即页面开始呈现有意义的内容的时间点。 FP(First Paint):FP是指页面上首次渲染任何内容的时间点,包括背景颜色、图片、文本等。它表示了页面开始呈现任何可视化内容的时间,但不一定是有意义的内容。 简而言之,FCP 关注的是页面上首次呈现有意义内容的时间点,而 FP 关注的是页面上首次呈现任何可视化内容的时间点。FCP 更关注用户感知的页面加载时间,因为它表示用户可以开始阅读或与页面进行交互的时间点。而 FP 则更关注页面开始渲染的时间点,无论内容是否有意义
错误监控
主要是 js 报错
如何监控
性能监测
使用 PerformanceObserver 监控 FCP 与 FP
// src/monitor/performHandler.js
import { isSupportPerformanceObserver } from './utils'
import { reportData } from './report'
export default function () {
  if (!isSupportPerformanceObserver()) return
  const entryHandler = (list) => {
    for (const entry of list.getEntries()) {
      // 当收集到 first-contentful-paint 时就可以断开观察了
      if (entry.name === 'first-contentful-paint') {
        observer.disconnect()
      }
      const json = entry.toJSON()
      delete json.duration
      const data = {
        ...json,
        subType: entry.name,
        type: 'performance',
        pageURL: window.location.href,
      }
      reportData(data)
    }
  }
  const observer = new PerformanceObserver(entryHandler)
  
  // type 为 paint 包含两种性能指标:first-contentful-paint 和 first-paint
  // buffered 属性表示是否观察缓存数据,也就是说观察回调添加的时机比事件触发时机晚也没关系
  observer.observe({ type: 'paint', buffered: true })
}
// src/main.jsx
import performHandler from './monitor/performHandler.js'
performHandler()
检测用户操作响应时间,主要是计算从用户点击开始到页面重新渲染的时间。为了减少代码侵入性,将监控操作响应的时间封装为 useClickPerform hook
// src/monitor/useClickPerform.js
import { useState, useEffect } from 'react'
import { reportData } from './report'
export const useClickPerform = () => {
  const [startTime, setStartTime] = useState(null)
  const [renderTime, setRenderTime] = useState(0)
  // startTime 更新后,useEffect 调用时说明页面已经重新渲染结束了
  // 在这里计算 renderTime 即可
  useEffect(() => {
    if (startTime) {
      // 提供高精度时间测量,精确到毫秒的小数点后几位,适合性能监控
      const time = performance.now() - startTime
      setRenderTime(time)
    }
    // 卸载时重置 startTime
    return () => {
      setStartTime(null)
    }
  }, [startTime])
  // renderTime 更新后上报数据
  useEffect(() => {
    if (renderTime > 0) {
      reportData({
        subType: 'click res',
        type: 'performance',
        pageURL: window.location.href,
        renderTime
      })
    }
  }, [renderTime])
  return {
    setStartTime,
    renderTime
  }
}
// App.jsx
import { Button } from 'antd'
import { useClickPerform } from './monitor/useClickPerform';
function App() {
  const { setStartTime, renderTime } =  useClickPerform()
  const handleClick = () => {
    setStartTime(performance.now())
    // 业务逻辑...
  }
  return (
    <>
      <Button onClick={handleClick}>耗时操作 {renderTime}ms</Button>
    </>
  );
};
export default Appx
错误监控
React 提供的 ErrorBoundary 只能用 class 组件,因为内部依赖 componentDidCatch 生命周期方法,而且不能捕获回调与异步错误
因此统一改为 onerror 与 unhandledrejection 来捕获全局 js 错误
// src/monitor/errorHandler.js
import { reportData } from "./report"
export default function () {
  // 监听 promise 错误
  window.addEventListener('unhandledrejection', (e) => {
    reportData({
      reason: e.reason?.stack,
      subType: 'promise',
      type: 'error',
      startTime: e.timeStamp,
      pageURL: window.location.href,
    })
  })
  // 监听 js 错误
  window.onerror = (msg, url, line, column, error) => {
    reportData({
      msg,
      line,
      column,
      error: error.stack,
      subType: 'js',
      pageURL: url,
      type: 'error',
      startTime: performance.now(),
    })
  }
}
// src/main.jsx
import errorHandler from './monitor/errorHandler.js'
errorHandler()
附上监听文件加载错误的代码(本项目用不到)
// 捕获资源加载失败错误 js css img...
window.addEventListener('error', e => {
    const target = e.target
    if (!target) return
    if (target.src || target.href) {
        const url = target.src || target.href
        reportData({
            url,
            type: 'error',
            subType: 'resource',
            startTime: e.timeStamp,
            html: target.outerHTML,
            resourceType: target.tagName,
            paths: e.path.map(item => item.tagName).filter(Boolean),
            pageURL: window.location.href,
        })
    }
}, true)
如何收集
本项目不涉及到错误缓存,下文仅提供思路
- 全局变量
 
// cache.js
import { deepCopy } from './util'
const cache = []
export function getCache() {
    return deepCopy(cache)
}
export function addCache(data) {
    cache.push(data)
}
export function clearCache() {
    cache.length = 0
}
// 缓存并延时上报
let timer = null
export function lazyReportCache(data, timeout = 3000) {
    addCache(data)
    clearTimeout(timer)
    timer = setTimeout(() => {
        const data = getCache()
        if (data.length) {
            report(data)
            clearCache()
        }
    }, timeout)
}
- 浏览器缓存(SS/LS)也可以
 
如何上报
图片打点上报,利用 img 元素的 src 属性发起 GET 请求,将数据拼接到 url 中
(new Image).src = url + '?reportData=' + encodeURIComponent(JSON.stringify(data));
无需考虑跨域问题,不需要等待服务器返回数据。但缺点就是 url 长度受浏览器的限制
所以使用 navigation.sendBeacon,用于发送少量(统计)数据到服务器,具有异步非阻塞、离开页面、低优先级等优点
// src/monitor/report.js
const sendBeacon = (function () {
  if (isSupportSendBeacon()) {
    return window.navigator.sendBeacon.bind(window.navigator)
  }
  const reportImageBeacon = function (data) {
    reportImage(data)
  }
  return reportImageBeacon
})()
function reportImage(data) {
  const img = new Image();
  img.src = url + '?reportData=' + encodeURIComponent(JSON.stringify(data));
}
何时上报
本项目并没有集成缓存的方案,因此采用立即上报的方式,利用 requestIdleCallback 或 setTimeout 在浏览器空闲时上报数据即可
// src/monitor/report.js
export function reportData(data, isImmediate = false) {
  const reportData = JSON.stringify({
    id: getId(),
    data,
  })
  if (isImmediate) {
    sendBeacon(url, reportData)
    return
  }
  if (window.requestIdleCallback) {
    window.requestIdleCallback(() => {
      sendBeacon(url, reportData)
    }, { timeout: 3000 })
  } else {
    setTimeout(() => {
      sendBeacon(url, reportData)
    })
  }
}
如果使用了缓存,可以当缓存到达一定空间时上报,也可以在 visibilitychange === hidden  时统一上报(考虑到移动端,不建议使用 beforeunload 事件)
附 Utils
import { v4 as uuidv4 } from 'uuid';
export function isSupportPerformanceObserver() {
  return !!window.PerformanceObserver
}
export function isSupportSendBeacon() {
  return !!window.navigator?.sendBeacon
}
export function getId() {
  return uuidv4()
}
附单例模式管理数据
export class Cache {
  static instance = null;
  static getInstance() {
    if (ErrorHandler.instance == null) {
      ErrorHandler.instance = new ErrorHandler();
    }
    return ErrorHandler.instance;
  }
}
export default Cache.getInstance()