关于前端:前端监控JS监控SDK手摸手教学实现篇已开源

34次阅读

共计 6991 个字符,预计需要花费 18 分钟才能阅读完成。

本文作者:cjinhuo,未经受权禁止转载。

概要

已开源的前端监控 SDK:mitojs,有趣味的小伙伴能够去瞅瞅~(SDK 在线 Demo)

来到注释,本文分成四个局部

  • 背景
  • 前端监控的原理
  • 结尾

背景

上一篇前端监控: 监控 SDK 手摸手 Teach- 架构篇 (已开源) 讲的是 SDK 的整体架构,这篇讲的是监控代码实现,也就是插件外面代码的实现

前端监控的原理

监控原生事件,如果不反对addEventListener,那么就是重写原生函数拿到入参,再将原函数返回。

replaceOld

咱们须要重写很多原生函数,事后定义一个公共函数便于缩小冗余代码

/**
 * 重写对象下面的某个属性
 *
 * @export
 * @param {IAnyObject} source 须要被重写的对象
 * @param {string} name 须要被重写对象的 key
 * @param {(...args: any[]) => any} replacement 以原有的函数作为参数,执行并重写原有函数
 * @param {boolean} [isForced=false] 是否强制重写(可能原先没有该属性)*/
export function replaceOld(source: IAnyObject, name: string, replacement: (...args: any[]) => any, isForced = false): void {if (source === undefined) return
  if (name in source || isForced) {const original = source[name]
    const wrapped = replacement(original)
    if (typeof wrapped === 'function') {source[name] = wrapped
    }
  }
}

fetch

所有的申请第三方库都是基于 xhrfetch 二次封装的,只须要重写这两个事件就能够拿到所有的接口申请的信息。举个例子,重写 fetch 的代码操作:

replaceOld(_global, BrowserEventTypes.FETCH, (originalFetch: voidFun) => {return function (url: string, config: Partial<Request> = {}): void {const sTime = getTimestamp()
    const method = (config && config.method) || 'GET'
    // 收集 fetch 的根本信息
    const httpCollect: HttpCollectedType = {
      request: {
        httpType: HttpTypes.FETCH,
        url,
        method,
        data: config && config.body
      },
      time: sTime,
      response: {}}
    return originalFetch.apply(_global, [url, config]).then((res: Response) => {
        // 须要克隆一下对象,不然会被标记该对象曾经被应用过
        const resClone = res.clone()
        const eTime = getTimestamp()
        httpCollect.elapsedTime = eTime - sTime
        httpCollect.response.status = resClone.status
        resClone.text().then((data) => {
          // 收集响应体
          httpCollect.response.data = data
          // 收集到须要的数据 notify 函数用来告诉订阅核心
          notify(BrowserEventTypes.FETCH, httpCollect)
        })
        return res
      },
      (err: Error) => {const eTime = getTimestamp()
        httpCollect.elapsedTime = eTime - sTime
        httpCollect.response.status = 0
        // 收集到须要的数据 notify 函数用来告诉订阅核心
        notify(BrowserEventTypes.FETCH, httpCollect)
        throw err
      }
    )
  }
})

对于接口跨域、超时的问题 :这两种状况产生的时候,接口返回的响应体和响应头外面都是空的,status 等于 0,所以很难辨别两者,然而失常状况下,个别我的项目中都的申请都是简单申请,所以在正式申请会先进行 option 进行预申请,如果是跨域的话根本几十毫秒就会返回来,能够以此作为临界值来判断跨域与超时的问题(如果是接口不存在也会被判断成接口跨域)

下面代码就是重写 fetch 的基本操作,拿到收集到数据后就能够做一步数据处理,数据上面再讲。同理可得 以下列表 的重写形式都是如此,重写的过程中拿到入参并收集到你想要的数据,具体代码实现点击上面的链接

  1. console
  2. xhr
  3. onpopstate、pushState、replaceState

onerror

onerror是能够通过 addEventListener 来监听的,当呈现资源谬误或代码谬误时会触发该回调函数

/**
 * 增加事件监听器
 *
 * @export
 * @param {{addEventListener: Function}} target 指标对象
 * @param {TotalEventName} eventName 指标对象上的事件名
 * @param {Function} handler 回调函数
 * @param {(boolean | unknown)} [opitons=false] useCapture 默认为 false
 */
function on(target: { addEventListener: Function},
  eventName: TotalEventName,
  handler: Function,
  opitons: boolean | unknown = false
): void {target.addEventListener(eventName, handler, opitons)
}
on(
  _global,
  'error',
  function (e: ErrorEvent) {
    // 收集到须要的数据 notify 函数用来告诉订阅核心
    notify(BrowserEventTypes.ERROR, e)
  },
  true
)

同理可得 以下列表 的监听形式都是如此:

  1. click
  2. hashchange
  3. unhandlerejecttion

Vue2 和 Vue3 的谬误

Vue提供了一个函数 errorHandler 供开发者来获取框架层面的谬误,所以间接重写该办法并拿到入参即可

const originErrorHandle = Vue.config.errorHandler
Vue.config.errorHandler = function (err: Error, vm: ViewModel, info: string): void {
  const data: ReportDataType = {
    type: ErrorTypes.VUE,
    message: `${err.message}(${info})`,
    level: Severity.Normal,
    url: getUrlWithEnv(),
    name: err.name,
    stack: err.stack || [],
    time: getTimestamp()}
  notify(BaseEventTypes.VUE, { data, vm})
  const hasConsole = typeof console !== 'undefined'
  // vue 源码会判断 Vue.config.silent,为 true 时则不会在控制台打印,false 时则会打印
  if (hasConsole && !Vue.config.silent) {silentConsoleScope(() => {console.error('Error in' + info + ':"' + err.toString() + '"', vm)
      console.error(err)
    })
  }
  return originErrorHandle?.(err, vm, info)
}

当然 Vue2 和 Vue3 拿到的数据格式是不一样的,具体的解决逻辑能够点击这里

react 的 render 谬误捕获

React16.13 中提供了 componentDidCatch 钩子函数来回调错误信息,所以咱们能够新建一个类 ErrorBoundary 来继承 React,而后而后申明 componentDidCatch 钩子函数,能够拿到错误信息

interface ErrorBoundaryProps {
  fallback?: ReactNode
  onError?: (error: Error, componentStack: string) => void
}

interface ErrorBoundaryState {hasError?: boolean}
class ErrorBoundaryWrapped extends PureComponent<ErrorBoundaryProps, ErrorBoundaryState> {
  readonly state: ErrorBoundaryState
  constructor(props: any) {super(props)
    this.state = {hasError: false}
  }
  componentDidCatch(error: Error, { componentStack}: ErrorInfo) {
    // error 和 componentStack 就是咱们须要的错误信息
    const {onError} = this.props
    const reactError = extractErrorStack(error, Severity.Normal)
    reactError.type = ErrorTypes.REACT
    onError?.(error, componentStack)
    this.setState({hasError: true})
  }
  render() {return (this.state.hasError ? this.props.fallback : this.props.children) ?? null
  }
}

而后将组件抛出来,具体的代码实现

留神:如果是在 react 中呈现代码谬误,然而不在 render 函数中,将会被全局的 onerror 捕捉到

插件

实现差不多就这了,具体代码能够去仓库外面看看,上一篇前端监控: 监控 SDK 手摸手 Teach- 架构篇 (已开源)) 中有讲过插件这个概念,插件是用来标准代码分层的一个思维,在指定的区域编写指定性能的代码,可读性和可迭代性会大大提高

export interface BasePluginType<T extends EventTypes = EventTypes, C extends BaseClientType = BaseClientType> {
  // 事件枚举
  name: T
  // 监控事件,并在该事件中用 notify 告诉订阅核心
  monitor: (this: C, notify: (eventName: T, data: any) => void) => void
  // 在 monitor 中触发数据并将数据传入以后函数,拿到数据做数据格式转换(会将 tranform 放入 Subscrib 的 handers)
  transform?: (this: C, collectedData: any) => any
  // 拿到转换后的数据进行 breadcrumb、report 等等操作
  consumer?: (this: C, transformedData: any) => void
}

那么下面的 重写逻辑 就放在 monitor 层,能够看进去有个入参notify,它是用告诉订阅核心的,让咱们看个简略且残缺的例子(具体代码点击这里):

const domPlugin: BasePluginType<BrowserEventTypes, BrowserClient> = {
  name: BrowserEventTypes.DOM,
  // 监听事件
  monitor(notify) {if (!('document' in _global)) return
    // 增加全局 click 事件
    on(
      _global.document,
      'click',
      function () {
        notify(BrowserEventTypes.DOM, {
          category: 'click',
          data: this
        })
      },
      true
    )
  },
  // 转换数据
  transform(collectedData: DomCollectedType) {
    /**
     * 返回蕴含 id、class、innerTextde 字符串的标签
     * @param target html 节点
     */
    function htmlElementAsString(target: HTMLElement): string {const tagName = target.tagName.toLowerCase()
      let classNames = target.classList.value
      classNames = classNames !== ''? ` class="${classNames}"` :''
      const id = target.id ? ` id="${target.id}"` : ''
      const innerText = target.innerText
      return `<${tagName}${id}${classNames !== ''? classNames :''}>${innerText}</${tagName}>`
    }
    // 将拿到的数据 activeElement 转换成相似 <button class="btn-one">click me</button>
    const htmlString = htmlElementAsString(collectedData.data.activeElement as HTMLElement)
    return htmlString
  },
  // 生产已转换的数据
  consumer(transformedData: string) {
    // 转换后的数据增加到用户行为栈 breadcrumb 中
    addBreadcrumbInBrowser.call(this, transformedData, BrowserBreadcrumbTypes.CLICK)
  }
}

应用插件

定义完插件后,须要在 browserClient 初始化的时候应用这些插件(具体代码点击这里):

  const browserClient = new BrowserClient(options)
  const browserPlugins = [
    fetchPlugin,
    xhrPlugin,
    domPlugin,
    errorPlugin,
    hashRoutePlugin,
    historyRoutePlugin,
    consolePlugin,
    unhandlerejectionPlugin
  ]
  browserClient.use([...browserPlugins, ...plugins])

browserClient.use

browserClient是继承与 BaseClientBaseClient 中有个 use 的办法,用来构建插件的 hooks 程序具体代码实现

  /**
   * 援用插件
   *
   * @param {BasePluginType<E>[]} plugins
   * @memberof BaseClient
   */
  use(plugins: BasePluginType<E>[]) {if (this.options.disabled) return
    // 新建公布订阅实例
    const subscrib = new Subscrib<E>()
    plugins.forEach((item) => {if (!this.isPluginEnable(item.name)) return
      // 调用插件中的 monitor 并将公布函数传入
      item.monitor.call(this, subscrib.notify.bind(subscrib))
      const wrapperTranform = (...args: any[]) => {
        // 先执行 transform
        const res = item.transform?.apply(this, args)
        // 拿到 transform 返回的数据并传入
        item.consumer?.call(this, res)
        // 如果须要新增 hook,可在这里增加逻辑
      }
      // 订阅插件中的名字,并传入回调函数
      subscrib.watch(item.name, wrapperTranform)
    })
  }

插件运行流程

那么整体的流程大略如下图所示:

结尾

🤔 小结

监控的原理无非就是重写(将原有的根底上包裹一层)或者增加事件监听器,例如小程序的监控也是如此。

下一篇「监控 SDK 手摸手 Teach- 微信小程序篇」会讲在微信小程序中怎么实现事件埋点、谬误监控,敬请期待~

🧐 开源

监控 SDKmitojs 文档,目前有局部人在用 mitojs 在做本人的监控平台或者埋点相干业务,如果你感兴趣能够,无妨过去瞅瞅 😘

📞 分割 & 内推

字节前端大量招人,内推可帮忙批改简历和实时查问面试进度,欢送砸简历到我的 邮箱:chenjinhuo@bytedance.com

如果你对字节前端、谬误监控、埋点感兴趣、也间接分割我的 微信:cjinhuo

Have A Good Day!!!

正文完
 0