• 如何自己开发一个前端监控SDK


    最近在负责团队前端监控系统搭建的任务。因为我们公司有统一的日志存储平台、日志清洗平台和基于 Grafana 搭建的可视化看板,就剩日志的采集和上报需要自己实现了,所以决定封装一个前端监控 SDK 来完成日志的采集和上报。

    架构设计

    因为想着以后有机会可以把自己封装的 SDK 推广给其他团队使用,所以 SDK 在架构设计上就需要有更多的可拓展性。我的想法是把 SDK 根据职责拆解成几个模块,然后有一个核心模块来管理所有的模块,各团队往不同的模块里添加插件由此实现自身定制化的需求。

    我们知道一个前端监控 SDK 它需要完成的任务有:日志采集 =>日志整理 =>日志上报。所以根据这个工作流,我把整个 SDK 分成四个模块:

    在这里插入图片描述

    • Plugin:负责原始数据的采集。Plugin 内部采用插件化的方式去实现,不同的插件采集不同的数据。比如如果我们想采集网络请求相关的数据,那个可以封装一个专门采集网络请求的插件。
    • Builder:负责把原始数据封装成我们想要的数据结构。
    • Reporter:负责把数据上报到日志平台。因为考虑到一份数据可能会上报到不同的日志平台,所以 Reporter 我也是采用插件化的方式去实现,不同的插件上报到不同的日志平台。
    • Manager:负责和各模块之间进行通信,以及封装一些公共的方法。

    综上,整个 SDK 的工作流程如下:

    在这里插入图片描述

    1. Manager 建立和各个模块之间的联系。

    2. Plugin 中的某个插件采集到相应的数据,并把数据发送给 Manager。

    3. Manager 接收到来自 Plugin 的数据,并把数据转发给 Builder。

    4. Builder 接收到数据以后按照预设的数据处理方法对数据进行处理,处理完后再把数据发送给 Manager 。

    5. Manager 接收到来自 Builder 的数据,并把数据转发给 Reporter 。

    6. Reporter 中的每个插件接收到数据以后,会把数据上报到对应的日志平台。

    在整个SDK运作的过程中,每个模块专注于自己的职责,全程只和 Manager 通信,不受其他模块的影响。

    另外,在模块接收或者发送数据的时候都会对外暴露相应的生命周期,这样开发者就可以拿到不同阶段的数据,并对数据进行自定义处理以及决定是否要中断流程。

    模块通信

    模块通信我采用的是发布-订阅模式,并定义了3个事件:assign (注册)、receive (接收数据)、next (发送数据)。

    Manager 会订阅 next 事件,而其他模块会订阅 assign 事件和 receive 事件。最开始的时候,其他模块通过 assign 事件接收到 Manager 的实例,由此其他模块就可以使用 Manager 上定义的发布-订阅相关的方法。当数据在某个模块处理完毕后,这个模块会发布 next 事件把数据传给 Manager ,Manager 接收到数据后再发布 receive 事件把数据传给下一个模块。

    export class Manager<O extends ManagerConfigType> extends EventBus {
      constructor(config?: O) {
        super()
        this.assignPlugins(config.plugins || [])
        this.assignBuilder(config.builder || {})
        this.assignReporter(config.reporters || [])
        this.on('manager:next', this.next.bind(this))
        this.on('manager:next', this.next.bind(this))
      }
      
      private assignPlugins(plugins: any[]) {
        plugins.forEach(plugin => {
          this.on('plugin:assign', plugin.init.bind(plugin))
        })
        this.emit('plugin:assign', this)
      }
    
      private assignBuilder(builder: any) {
        this.on('builder:assign', builder.init.bind(builder))
        this.emit('builder:assign', this)
      }
    
      private assignReporter(reporters: any[]) {
        reporters.forEach(reporter => {
          this.on('reporter:assign', reporter.init.bind(reporter))
        })
        this.emit('reporter:assign', this)
      }
      
      // 从上一级模块接收数据,然后发给下一级模块
      public next(args: { from: 'plugin' | 'builder' | 'reporter', data: any}) {
        const { from, data } = args
        if (from === 'plugin') {
          this.emit('builder:receive', data)
        } else if (from === 'builder') {
          this.emit('reporter:receive', data)
        }
      }
    }
    
    export class PluginA<O extends PluginAConfigType> {
      public init(manager: any) {
        this._manager = manager
      }
      
      private handleError(msg) {
        this._manager.emit('manager:next', { from: 'plugin', data: msg })
      }
    }
    
    export class Builder<O extends BuilderConfigType> {
      public init(manager: any) {
        this._manager = manager
        this._manager.on('builder:receive', this.receive.bind(this))
      }
      
      private receive(data: any) {
        // 处理数据
        const newData = this.process(data)
        this._manager.emit('manager:next', { from: 'builder', data: newData })
      }
    }
    
    export class ReporterA<O extends ReporterAConfigType> {
      public init(manager: any) {
        this._manager = manager
        this._manager.on('reporter:receive', this.receive.bind(this))
      }
      
      public receive(args: any) {
        this.report(args)
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73

    接口请求捕获

    在大多数情况下,前端通过HTTP的方式和服务端进行交互。不管是自己封装请求方法,还是直接使用类似于 axios 的 HTTP 请求库,都是需要基于 XHR 和 Fetch 去实现的。所以我们需要重写 XHR 和 Fetch 暴露出来的 Hook 并进行代理,由此获得请求相关的信息。

    XMLHttpRequest

    XMLHttpRequest.open() 方法用来初始化一个新创建的请求,在这个方法里我们可以拿到请求的 URL 和请求方法。

    XMLHttpRequest.send() 方法用来发送HTTP请求,在这个方法里我们可以拿到请求参数。

    另外,在 XMLHttpRequest.onreadystatechange 事件里,我们可以监听到请求状态的变化。当 xhr.readyState === XMLHttpRequest.DONE 时表示请求操作已经完成,这时候我们就可以记录请求的状态码和请求结束的时间。

    	public overideXHRMethod() {
        const xhrproto = XMLHttpRequest.prototype;
        const originalOpen = xhrproto.open
        const originalSend = xhrproto.send
        
        xhrproto.send = function (this, ...args: any): void {
          const xhr = this;
          msg = {
            ...msg,
            request: args[0],
          }
          return originalSend.apply(xhr, args);
        }
    
        xhrproto.open = function (this, ...args: any): void {
          const xhr = this;
    
          msg = {
            ...msg,
            url: args[1],
            method: (args[0] || '').toUpperCase(),
            statusCode: 0,
            startTimestamp: Date.now() // 请求开始时间
          }
    
          const onreadystatechangeHandler = function (): void {
            if (xhr.readyState === XMLHttpRequest.DONE) {
                try {
                  msg.statusCode = xhr.status
                  msg.endTimestamp = Date.now() // 请求结束时间
                  msg.responseHeaders = xhr.getAllResponseHeaders()
                  if (['', 'json', 'text'].indexOf(xhr.responseType) !== -1) {
                    msg.response = typeof xhr.response === 'object' ? JSON.stringify(xhr.response) : xhr.response
                  }
                } catch (e) {
                  /* do nothing */
                }
            }
          }
    
          if ('onreadystatechange' in xhr && typeof xhr.onreadystatechange === 'function') {
            const original = xhr.onreadystatechange
            xhr.onreadystatechange = function (...readyStateArgs: any): void {
              onreadystatechangeHandler();
              return original.apply(xhr, readyStateArgs);
            }
          } else {
            xhr.addEventListener('readystatechange', onreadystatechangeHandler);
          }
    
          return originalOpen.apply(xhr, args);
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53

    如果想获取响应头可以使用方法 XMLHttpRequest.getAllResponseHeaders()XMLHttpRequest.getResponseHeader() 。不过这两个方法并不能拿到所有的响应头信息,对于跨域的请求只能拿到以下几个字段:

    • Cache-Control
    • Content-Language
    • Content-Length
    • Content-Type
    • Expires
    • Last-Modified
    • Pragma

    如果想拿到其他字段,需要在响应头 Access-Control-Expose-Headers 里指定哪些字段是可以公开的。

    Fetch

    window.fetch() 方法用来发起获取资源的请求,它的第一个参数为请求的 URL,第二个参数为 Request 对象。它返回一个 promise,这个 promise 会在请求响应后被 resolve,并传回 Response 对象。

    	public overideFetchMethod() {
        const originalFetch = window.fetch
        if (!originalFetch) {
          return
        }
        window.fetch = function(...args) {
          const method = String(args[1]?.method || 'get').toUpperCase()
          const url = String(args[0])
          return originalFetch.apply(window, args).catch((err: Error) => {
            let msg: ReportMsgType = {
              url,
              method: method
            }
            throw err
          })
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    因为公司的项目里很少用到由 Fetch 发起的请求,所以这里写的比较简单。

    JS错误捕获

    对于那些可预见的 JS 错误,通常我们通过 try/catch 去捕获。其他的 JS 错误,我们可以通过全局监听 error 事件来捕获。另外值得一提的是,对于 Promise 中的错误,如果我们有用 reject 去处理错误那么会触发 rejectionhandled 事件,否则会触发 unhandledrejection 事件。

    	private handleError(event: ErrorEvent | PromiseRejectionEvent) {
        const error = 'error' in event ? event.error : event.reason
        const msg: ReportMsgType = {
          name: 'browserError',
          catchBy: 'PluginBrowser',
          error
        }
      }
    
      private initMonitor() {
        window.addEventListener('error', this.handleError.bind(this))
        window.addEventListener('unhandledrejection', this.handleError.bind(this))
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    资源加载错误捕获

    常见的前端资源加载包括图片的渲染和外部文件的引用,我们可以通过监听 img、link 和 script 标签的 error 事件来捕获这些资源的加载错误。

    const resourceTagName: string[] = ['img', 'script', 'link']
    
    export class PluginResource<O extends ResourceConfigType> {
    	private handleError(event: Event) {
        const target = event.target as HTMLScriptElement | HTMLLinkElement
        if (!target) {
          return
        }
        const tagName = (target.tagName || '').toLowerCase()
        const isResource = resourceTagName.includes(tagName)
        if (!isResource) {
          return
        }
        const msg: ReportMsgType = {
          name: 'ResourceLoadError',
          catchBy: 'PluginResource',
          url: 'src' in target ? target.src : target.href,
          tagName: tagName
        }
      }
    
      private initMonitor() {
        window.addEventListener('error', this.handleError.bind(this), true)
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    最后

    目前整个 SDK 还处于很初级的阶段,能完成常见错误类型的捕获和上报,后续随着需求的增加 SDK 需要实现更多的功能,希望后续再更新一波~

  • 相关阅读:
    SpringBoot SpringBoot 开发实用篇 4 数据层解决方案 4.9 MongoDB 下载与安装
    199、在RabbitMQ管理控制台中管理 Exchange(充当消息交换机的组件) 和 Queue(消息队列),以及对默认Exchange的讲解
    用DIV+CSS技术设计的水果介绍网站(web前端网页制作课作业)
    交叉编译 openssl
    python图片合成
    OpenHarmony 唤醒花屏问题
    【学习笔记】AGC009/AGC019/AGC029/AGC035
    国内某知名半导体公司:实现虚拟化环境下的文件跨网安全交换
    2023年【制冷与空调设备运行操作】考试资料及制冷与空调设备运行操作考试试卷
    [附源码]计算机毕业设计JAVAJAVA大方汽车租赁管理系统
  • 原文地址:https://blog.csdn.net/m0_37937502/article/details/132797107