前端监控SDK:从基础到实践 (2. 错误监控)

698 阅读6分钟

前言

上篇文章我们分享了性能监控相关的内容,这次我们来讲讲错误监控的内容

系列文章

错误监控(js、resource、promise)

我将代码错误、资源加载错误、异步错误放在一起讲。

代码错误

我们先来看看js有哪些常见错误

1、SyntaxError(语法错误)

const 11a; // Uncaught SyntaxError: Invalid or unexpected token

2、ReferenceError(引用错误)

console.log(a); // ReferenceError: a is not defined

3、RangeError(范围错误)

const arr = new Array(-10) // RangeError: Invalid array length

4、TypeError(类型错误)

const arr = new '123';  // TypeError: "123" is not a constructor

const obj = {}
obj.fn() // TypeError: obj.fn is not a function

5、跨域脚本错误

当一个页面加载了跨域脚本并发生运行时错误时,如果跨域资源的服务器没有设置适当的 CORS(跨域资源共享)头,浏览器会屏蔽具体的错误信息(出于安全考虑),然后它的message会是Script error.

解决办法:

// 1.在跨域脚本资源的响应头中添加:
Access-Control-Allow-Origin: *

// 2.为 <script> 标签设置 crossorigin 属性:
<script src="https://example.com/script.js" crossorigin="anonymous"></script>

调研错误捕获的方法

1. 监听error事件

现代化的全局错误监听机制,用于捕获页面中发生的 JavaScript 错误和静态资源加载错误。当页面中的 JavaScript 执行出现未捕获的异常,或者资源(如图片、脚本、样式表等)加载失败时,该事件会被触发,并提供详细的错误信息

window.addEventListener('error', (event: ErrorEvent | Event) => {
  console.error('Error captured by event listener:', event);
  // 上报错误信息到后台
}, true);
2. window.onerror

window.onerror 是一个全局的错误捕获事件,用于捕获页面中未被捕获的 JavaScript 错误。当页面中的 JavaScript 发生错误时,如果没有被 try-catch 捕获,这个错误会被 window.onerror 捕获并处理。

window.onerror = function (message, source, lineno, colno, error) {
  console.error('Error captured:', { message, source, lineno, colno, error });
  // 上报错误信息到后台
};

让我们对比一下

特性window.addEventListener('error')window.onerror
运行时 JavaScript 错误支持支持
资源加载错误支持(如图片、CSS 文件、脚本加载失败)不支持
错误对象的访问支持可能为null

window.onerror还有一个问题在于它只能设置一个,多次设置会覆盖之前的,而window.addEventListener('error')就不会有这样的问题

基于以上我们采用window.addEventListener('error')来做代码错误监控

js错误捕获

我在这分了三种错误类型,一种是资源加载错误、js执行错误、跨域错误

window.addEventListener(
    'error',
    (e: ErrorEvent | Event) => {
      const errorType = getErrorType(e)
      switch (errorType) {
        case TraceSubTypeEnum.resource:
          initResourceError(e)
          break
        case TraceSubTypeEnum.js:
          initJsError(e as ErrorEvent)
          break
        case TraceSubTypeEnum.cors:
          initCorsError(e as ErrorEvent)
          break
        default:
          break
      }
    },
    true
  )

先来看看getErrorType的实现

const getErrorType = (event: ErrorEvent | Event) => {
  const isJsError = event instanceof ErrorEvent
  if (!isJsError) {
    return TraceSubTypeEnum.resource
  }
  return event.message === 'Script error.'
    ? TraceSubTypeEnum.cors
    : TraceSubTypeEnum.js
}

其实就是先根据入参event判断它的类型,就可以知道是js错误还是资源错误。然后如果是js错误再根据message判断是跨域错误还是js代码错误

js错误上报数据类型

export type ErrorCommonType = {
  errId: string
  state: any[]
  timestamp: number
}

export type JsErrorType = commonType &
  ErrorCommonType & {
    message: string | Event // 错误信息
    src?: string // 资源路径,打包后到路径
    lineNo?: number // 错误行号
    columnNo?: number // 错误列号
    stack: any[] // 错误堆栈
    pageUrl: string // 页面路径
    eventData: string // 录屏数据
  }

js错误上报具体代码

const initJsError = (e: ErrorEvent) => {
  const {
    colno: columnNo,
    lineno: lineNo,
    type,
    message,
    filename: src,
    error
  } = e
  const subType = TraceSubTypeEnum.js
  const stack = parseStackFrames(error)
  const behavior = getBehaviour()
  const state = behavior?.breadcrumbs?.state || []
  const eventData = getRecordScreenData()
  const reportData: JsErrorType = {
    columnNo,
    lineNo,
    type,
    message,
    src,
    subType,
    pageUrl: window.location.href,
    stack,
    errId: getErrorUid(`${subType}-${message}-${src}`),
    state,
    timestamp: new Date().getTime(),
    eventData
  }
  lazyReportBatch(reportData)
}

我们需要根据堆栈信息提取出报错行列数、报错方法等信息

// 解析错误堆栈
export function parseStackFrames(error: Error) {
  const { stack } = error
  // 正则表达式,用以解析堆栈split后得到的字符串
  const FULL_MATCH =
    /^\s*at (?:(.*?) ?()?((?:file|https?|blob|chrome-extension|address|native|eval|webpack|<anonymous>|[-a-z]+:|.*bundle|/).*?)(?::(\d+))?(?::(\d+))?)?\s*$/i

  // 限制只追溯5个
  const STACKTRACE_LIMIT = 5

  // 解析每一行
  function parseStackLine(line: string) {
    const lineMatch = line.match(FULL_MATCH)
    if (!lineMatch) {
      return {}
    }
    const filename = lineMatch[2]
    const functionName = lineMatch[1] || ''
    const lineno = parseInt(lineMatch[3], 10) || undefined
    const colno = parseInt(lineMatch[4], 10) || undefined
    return {
      filename,
      functionName,
      lineno,
      colno
    }
  }
  // 无 stack 时直接返回
  if (!stack) {
    return []
  }
  const frames = []
  for (const line of stack.split('\n').slice(1)) {
    const frame = parseStackLine(line)
    if (frame) {
      frames.push(frame)
    }
  }
  return frames.slice(0, STACKTRACE_LIMIT)
}

可自行去demo网站测试一下哦 image.png

image.png 然后是跨域上报的具体实现代码

const initCorsError = (e: ErrorEvent) => {
  const { message } = e
  const type = TraceTypeEnum.error
  const subType = TraceSubTypeEnum.cors
  const reportData = {
    type,
    subType,
    message
  }
  lazyReportBatch(reportData)
}

资源加载错误捕获

收集 JavaScript、CSS 和图片等加载错误

const initResourceError = (e: Event) => {
  // 通过 e.target 确定错误是发生在哪个资源上
  const target = e.target as ResourceErrorTarget
  // img是src,link就是href
  const src = target.src || target.href
  const type = e.type
  const subType = TraceSubTypeEnum.resource
  const tagName = target.tagName
  const message = ''
  const html = target.outerHTML
  // 获取dom加载位置
  const path = getPathToElement(target)
  const behavior = getBehaviour()
  const state = behavior?.breadcrumbs?.state || []
  const reportData: ResourceErrorType = {
    type,
    subType,
    tagName,
    message,
    html,
    src,
    pageUrl: window.location.href,
    path,
    errId: getErrorUid(`${subType}-${message}-${src}`),
    state,
    timestamp: new Date().getTime()
  }
  lazyReportBatch(reportData)
}

获取dom的路径方法

export function getPathToElement(element: any) {
  const path = []
  let currentElement = element

  try {
    while (currentElement?.tagName?.toLowerCase() !== 'body') {
      const parentNode = currentElement.parentNode
      const children = Array.from(parentNode?.children)
      const nodeIndex = children.indexOf(currentElement) + 1
      const name = `${currentElement.tagName.toLowerCase()}:nth-child(${nodeIndex})`
      // 将当前元素的标签和其兄弟索引添加到路径数组中
      path.unshift(name)
      // 移动到父元素
      currentElement = parentNode
    }
  } catch (error) {
    console.log(error)
  }
  // 最后添加 body 标签
  path.unshift('body')

  return path.join(' > ')
}

然后我们可以在demo网站上测试一下

image.png 上报的数据

image.png

promise错误捕获

promise的错误是通过监听unhandledrejection事件捕获的,但捕获到的错误是无法获取到错误文件还是错误行列数的

window.addEventListener(
    'unhandledrejection',
    (e: PromiseRejectionEvent) => {
      const stack = parseStackFrames(e.reason)
      const behavior = getBehaviour()
      const state = behavior?.breadcrumbs?.state || []
      const eventData = getRecordScreenData()
      const reportData: PromiseErrorType = {
        type: TraceTypeEnum.error,
        subType: TraceSubTypeEnum.promise,
        message: e.reason.message || e.reason,
        stack,
        pageUrl: window.location.href,
        errId: getErrorUid(`'promise'-${e.reason.message}`),
        state,
        timestamp: new Date().getTime(),
        eventData
      }
      // todo 发送错误信息
      lazyReportBatch(reportData)
    },
    true
  )

随便写一个promise错误

new Promise((resolve, reject) => {
      reject('promise错误')
    })

上报的数据

image.png

react错误捕获

React16开始,官方提供ErrorBoundary错误边界,被该组件包裹的子组件render函数报错时会触发离当前组件最近的父组件ErrorBoundary

这种情况下,可以通过componentDidCatch将捕获的错误上报

import React, { ReactNode } from 'react'
import { lazyReportBatch } from '../common/report'
import {
  getErrorUid,
  getReactComponentInfo,
  parseStackFrames
} from '../common/utils'
import { ReactErrorType } from '../types'
import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { getBehaviour, getRecordScreenData } from '../behavior'

interface ErrorBoundaryProps {
  Fallback: ReactNode // ReactNode 表示任意有效的 React 内容
  children: ReactNode
}

interface ErrorBoundaryState {
  hasError: boolean
}

let err = {}

class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { hasError: false }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    this.setState({ hasError: true })
    const { componentName, url: src } = getReactComponentInfo(errorInfo)
    const type = TraceTypeEnum.error
    const subType = TraceSubTypeEnum.react
    const message = error.message
    const stack = parseStackFrames(error)
    const pageUrl = window.location.href
    const errId = getErrorUid(`${subType}-${message}-${src}`)
    const info = error.message
    const behavior = getBehaviour()
    const state = behavior?.breadcrumbs?.state || []
    const eventData = getRecordScreenData()
    const reportData: ReactErrorType = {
      type,
      subType,
      stack,
      pageUrl,
      message,
      errId,
      componentName,
      info,
      src,
      state,
      timestamp: new Date().getTime(),
      eventData
    }
    err = reportData
    lazyReportBatch(reportData)
  }

  render() {
    const { Fallback } = this.props    
    if (this.state.hasError) {
      // @ts-ignore
      return <Fallback error={err}/>
    }

    return this.props.children
  }
}

export default ErrorBoundary

image.png 上报的数据 image.png 在框架中报错,会有这样的错误栈 image.png

我们需要根据这样的错误栈获取react错误组件的信息

export const getReactComponentInfo = (errorInfo: React.ErrorInfo) => {
  const ANONYMOUS_COMPONENT_NAME = '<Anonymous>'
  const ROOT_COMPONENT_NAME = '<Root>'

  // 获取 React 组件调用栈
  const componentStack = errorInfo.componentStack || ''

  /**
   * 提取组件调用栈中最后一个出错的组件
   * React 的 componentStack 通常包含如下格式:
   *   at ComponentName (filePath:line:column)
   *   at AnotherComponent (filePath:line:column)
   */
  const extractComponentName = (stack: string) => {
    const match = stack.match(/at\s+([^\s]+)\s+(/)
    if (match && match[1]) {
      return `<${match[1]}>`
    }
    return ANONYMOUS_COMPONENT_NAME
  }

  const extractComponentFile = (stack: string) => {
    const match = stack.match(/(([^)]+))/)
    if (match && match[1]) {
      return match[1] // 返回文件路径及位置
    }
    return ''
  }

  // 提取信息
  const componentName =
    extractComponentName(componentStack) || ROOT_COMPONENT_NAME
  const componentFile = extractComponentFile(componentStack)

  return {
    componentName,
    url: componentFile
  }
}

然后在我们项目中这样使用就好

image.png

补充

因为ErrorBoundary是类组件它需要extends React.Component,而我们是做一个sdk,总不能其他项目用我们的库还得还得安装react的依赖吧?所以就需要在package.json配置

{
  "peerDependencies": {
    "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
    "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
  },
}

然后在rollup.config.js配置

module.exports = [
  {
    plugins: [...],
    external: ['react', 'react-dom'], // 声明外部依赖
  }
]

vue错误捕获

errorHandler 是 Vue 提供的全局错误处理方法,用于捕获组件渲染、事件处理、生命周期钩子和子组件中的未捕获错误。开发者可以通过 errorHandler 实现错误的统一处理和上报。

import { getBehaviour, getRecordScreenData } from '../behavior'
import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'
import { lazyReportBatch } from '../common/report'
import {
  getErrorUid,
  getVueComponentInfo,
  parseStackFrames
} from '../common/utils'
import { VueErrorType } from '../types'

// 初始化 Vue异常 的数据获取和上报
export interface Vue {
  config: {
    errorHandler?: any
    warnHandler?: any
  }
}

const initVueError = (app: Vue) => {
  app.config.errorHandler = (err: Error, vm: any, info: string) => {
    console.error(err)
    const { componentName, url: src } = getVueComponentInfo(vm)
    const type = TraceTypeEnum.error
    const subType = TraceSubTypeEnum.vue
    const message = err.message
    const stack = parseStackFrames(err)
    const pageUrl = window.location.href
    const behavior = getBehaviour()
    const state = behavior?.breadcrumbs?.state || []
    const eventData = getRecordScreenData()
    const reportData: VueErrorType = {
      type,
      subType,
      message,
      stack,
      pageUrl,
      info,
      componentName,
      src,
      errId: getErrorUid(`${subType}-${message}-${src}`),
      state,
      timestamp: new Date().getTime(),
      eventData
    }
    lazyReportBatch(reportData)
  }
}

export default initVueError

获取vue错误组件

// 获取vue报错组件信息
export const getVueComponentInfo = (vm: any) => {
  const classifyRE = /(?:^|[-_])(\w)/g
  const classify = (str: string) =>
    str.replace(classifyRE, c => c.toUpperCase()).replace(/[-_]/g, '')
  const ROOT_COMPONENT_NAME = '<Root>'
  const ANONYMOUS_COMPONENT_NAME = '<Anonymous>'
  if (!vm) {
    return {
      componentName: ANONYMOUS_COMPONENT_NAME,
      url: ''
    }
  }
  if (vm.$root === vm) {
    return {
      componentName: ROOT_COMPONENT_NAME,
      url: ''
    }
  }
  const options = vm.$options
  let name = options.name || options._componentTag
  const file = options.__file
  if (!name && file) {
    const match = file.match(/([^/\]+).vue$/)
    if (match) {
      name = match[1]
    }
  }
  return {
    componentName: name ? `<${classify(name)}>` : ANONYMOUS_COMPONENT_NAME,
    url: file
  }
}

vue3使用方法

const app = createApp(App)
initVueError(app)

vue2使用方法

import Vue from 'vue'
initVueError(Vue)

vue错误上报信息 image.png

尾言

参考文档 juejin.cn/post/710084…

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

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