likes
comments
collection
share

让我们一起写一个前端监控系统吧(2)

作者站长头像
站长
· 阅读数 13
  • 相关链接

上一期中我们讲了前端系统中前端的基本架构,大家想必对我们的项目有了深入的了解,本篇文章中,我们将详细介绍被监控网站,有啥值得监控,以及由此 npm 包如何书写。

由上一章的介绍我们可以知道,被监控网站分为四个部分,分别是淘宝首页实时聊天表单按钮在线博客.

在开始讲解之前,我先把 npm 包的链接放上来

淘宝首页

  • 此页面主要负责监控
  1. 组件加载时间
  2. 加载白屏时间、
  3. FCP(First Contentful Paint) : 首次绘制任何文本,图像,非空白canvas或SVG的时间点.
  4. LCP(Largest Contentful Paint) : 可视区域“内容”最大的可见元素开始出现在页面上的时间点。

那么话不多说,让我们顺序来看一下这些页面是如何被监控的!

npm 插件mixin,监控组件加载时间。

此组件并非无脑使用,我们设置了一个开关this.$options.computeTime,如果您想要使用,在组件中打开开关即可「即设置为 true 」,实现了组件化的粒度细分

import http from '../utils/request'

let mixin = {

    beforeCreate() {
        // 我们在这里添加了一个属性
        let shouldcompute = this.$options.computeTime
        // 如果用户设置了这个属性,那么就可以启用mixin,获得加载时间。
        if (!shouldcompute) return
        // 获取创建之初的时间
        this.createTime = new Date().getTime()

    },

    mounted() {

        let shouldcompute = this.$options.computeTime

        if (!shouldcompute) return
        // 获取挂载完成的时间
        this.endTime = new Date().getTime()
        // 得到加载时间
        let mountTime = this.endTime - this.createTime
        // 获取所有的节点
        let componentNameArr = this.$vnode.tag.split('-')
        // 从而获取当前节点
        let componentName = componentNameArr[componentNameArr.length - 1]
        // 将得到的数据发送给后台
        http.post('plugin/mount', {

            kind: 'experience',

            type: 'ComponentMountTime',
            // 组件名称
            componentName,
            // 组件加载时间
            mountTime,
            // 发送当前时间
            timeStamp: Date.now(),

        })

    },
}
// 将mixin封装起来
export default {

    install(Vue, options) {

        const oldRevue = Vue.prototype.$revue

        Vue.prototype.$revue = Object.assign({}, oldRevue, {

        compnentMount: mixin

    })

    // Vue.mixin(mixin)

    },
    immediate: {

        install(Vue, options) {

            Vue.mixin(mixin)

        },

    },

    m: mixin

}

我们特意设置了 immediate这个属性,你可以通过如下方法在main.js中进行调用。

Vue.use(revue.immediate)

npm插件 判断是否白屏

import onload from '../utils/onload'
import http from '../utils/request'

let blankScreen = () => {
  let wrapperElements = ['html', 'body', '#app']
  let emptyPoints = 0
  // 
  function getSelector(element) {
    if (element.id) {
      return '#' + element.id
    } else if (element.className) {
      // a b c => .a.b.c
      return (
        '.' +
        element.className
          .split(' ')
          .filter((item) => !!item)
          .join('.')
      )
    } else {
      return element.nodeName.toLowerCase()
    }
  }
  function isWrapper(element) {
    let selector = getSelector(element)
    if (wrapperElements.indexOf(selector) !== -1) {
      emptyPoints++
    }
  }
  // 使用 elementsFromPoint 与 isWrapper 来判断是否白屏
  onload(function () {
    for (let i = 1; i <= 9; i++) {
      let xElements = document.elementsFromPoint(
        (window.innerWidth * i) / 10,
        window.innerHeight / 2
      )
      let yElements = document.elementsFromPoint(
        window.innerWidth / 2,
        (window.innerHeight * i) / 10
      )
      isWrapper(xElements[0])
      isWrapper(yElements[0])
    }

    if (emptyPoints >= 18) {
      let centerElements = document.elementsFromPoint(
        window.innerWidth / 2,
        window.innerHeight / 2
      )

      http.post('/plugin/blank', {
        kind: 'stability',
        type: 'blank',
        emptyPoints,
        screen: window.screen.width + 'X' + window.screen.height,
        viewPoint: window.innerWidth + 'X' + window.innerHeight,
        timeStamp: Date.now(),
        selector: getSelector(centerElements[0]),
      })
    }
  })
}

export default {
  install(Vue, options) {
    const oldRevue = Vue.prototype.$revue
    Vue.prototype.$revue = Object.assign({}, oldRevue, {
      blankScreen
    })
  },
  immediate: {
    install(Vue, options) {
      blankScreen()
      const oldRevue = Vue.prototype.$revue
      Vue.prototype.$revue = Object.assign({}, oldRevue, {
        blankScreen
      })
    },
  },
  b: blankScreen
}

判断是否是白屏的思路是:我们对页面上的9个点分横纵进行判断是否有元素,如果18次判断全部都没有检索到元素,我们就需要向后端发送数据,让它知道这个页面发生白屏了!

npm插件,获取性能数据

import onload from '../utils/onload'
import http from '../utils/request'

let timing = () => {
  let FMP, LCP
  // 加一个 if 是因为有时候这玩意是 undefined
  if (PerformanceObserver) {
    // 增加一个性能条目的观察者
    new PerformanceObserver((entryList, observer) => {
      let perfEntries = entryList.getEntries()
      FMP = perfEntries[0] //startTime 2000以后
      observer.disconnect() //不再观察了
    }).observe({ entryTypes: ['element'] }) //观察页面中的意义的元素

    new PerformanceObserver((entryList, observer) => {
      let perfEntries = entryList.getEntries()
      LCP = perfEntries[0]
      observer.disconnect() //不再观察了
    }).observe({ entryTypes: ['largest-contentful-paint'] }) //观察页面中的意义的元素
  }

  //用户的第一次交互 点击页面
  onload(function () {
    setTimeout(() => {
      const { fetchStart, loadEventStart } = performance.timing
      // 此处直接使用 API 了
      let FP = performance.getEntriesByName('first-paint')[0]
      let FCP = performance.getEntriesByName('first-contentful-paint')[0]
      let loadTime = loadEventStart - fetchStart
      //开始发送性能指标
      //console.log('FP', FP)
      //console.log('FCP', FCP)
      //console.log('FMP', FMP)
      //console.log('LCP', LCP)
      http.post('/plugin/paint', {
        kind: 'experience', //用户体验指标
        type: 'paint', //统计每个阶段的时间
        firstPaint: FP.startTime,
        firstContentfulPaint: FCP.startTime,
        firstMeaningfulPaint: FMP?.startTime || -1,
        largestContentfulPaint: LCP?.startTime || -1,
        timeStamp: Date.now(),
      })
      http.post('/plugin/load', {
        kind: 'experience', //用户体验指标
        type: 'load', //统计每个阶段的时间
        loadTime,
        timeStamp: Date.now(),
      })
    }, 3000)
  })
}

export default {
  install(Vue, options) {
    const oldRevue = Vue.prototype.$revue
    Vue.prototype.$revue = Object.assign({}, oldRevue, {
      timing
    })
  },
  immediate: {
    install(Vue, options) {
      timing(Vue, options)
      const oldRevue = Vue.prototype.$revue
      Vue.prototype.$revue = Object.assign({}, oldRevue, {
        timing
      })
    },
  },
  t: timing
}

表单页面

表单按钮 => 『监控错误内容』

  • 表单按钮这里的逻辑主要是对常见的JS错误进行汇总,然后收集起来,发送到后端。

这里有一张图,涵盖了JS的主要错误。 让我们一起写一个前端监控系统吧(2)

  1. EvalError错误
// html
<button @click="EvalError">
// js代码
/*
 * 如果此处非法使用 eval(),则抛出 EvalError 异常
 * 根据 ES9
 * 此异常不再会被JavaScript抛出,但是EvalError对象仍然保持兼容性
**/
EvalError() {
  return eval( '(' + obj + ')' )
}
复制代码
  1. InternalError错误
// html
<button @click="InternalError">
// js代码
/**
 * 该错误在JS引擎内部发生,特别是当它有太多数据要处理并且堆栈增长超过其关键限制时。
*/
InternalError() {
    function foo() {
        foo()
    }
    foo()
}
复制代码
  1. RangeError错误
// html
<button @click="RangeError">
// js
// 当数字超出允许的值范围时,将抛出此错误
RangeError() {
    const arr = [99, 88]
    arr.length = 99 ** 99
}
复制代码
  1. ReferenceError错误
// html
<button @click="ReferenceError">
// js
// 当出现非法引用的时候报错
ReferenceError() {
    foo.substring(1);
}
复制代码
  1. URIError错误
// html
<button @click="URIError">
// js
/**
* 用 encodeURI 等编码含有不合法字符的字符串,导致编解码失败
* 编码操作会将每一个字符实例替换为一到四个相对应的UTF-8编码形式的转义序列。
* 如果试图一个非高-低位完整的代理自负,将会抛出一个URIError错误
*/
URIError() {
    let a = encodeURI('\uD800%')
    console.log(a)
}
复制代码
  1. TypeError错误
  • 此处访问到了undefined
// html
<button @click="TypeError">
// js代码
TypeError() {
    window.someVar.error = 'error'
}
复制代码
  1. AsyncError错误 | Promise错误
// html
<button @click="Async">
// js代码
AsyncError() {
    Promise.reject('this is an error message');
}
复制代码

npm 封装 「我们如何获取错误信息?」

概述JSError有同步错误也有异步错误,window.onerror既可以捕获同步错误也可以捕获异步错误,在Vue中有一个API叫Vue.config.errorHandler,会截取同步错误,window.onerror自然就接收被筛选出来的异步错误了,但是这个时候我们发现Promise错误并没有被window.onerror捕获到,所以我们还需要unhandledrejection来捕获这个错误,至此,所有的错误就捕获完毕了。

我们在写入我们自己的方法之前,不能直接覆盖,需要确认用户是否使用过我们使用的方法,如果没有使用过,那么我们就可以直接使用,如果使用过,那我们就调用一下call方法

const oldErrorHandler = Vue.config.errorHandler
Vue.config.errorHandler = (error, vm, info) => {
  if(oldErrorHandler) oldErrorHandler.call(this, error, vm, info)
}

我们需要使用一个包「StackTracey」把错误处理一下,处理成我们好处理的样子

import StackTracey from 'stacktracey'
const stack = new StackTracey(error)

使用Vue.config.errorHandler来捕获同步错误

    Vue.config.errorHandler = (error, vm, info) => {
      if (oldErrorHandler) oldErrorHandler.call(this, err, vm, info)
      const stack = new StackTracey(error)
      const log = {
        kind: "stability",
        errorType: "jsError",   //jsError
        simpleUrl: window.location.href.split('?')[0].replace('#', ''),   // 页面的url
        timeStamp: new Date().getTime(),   // 日志发生时间
        position: `${stack.items[0].column}:${stack.items[0].line}`,  // 需要处理掉无效的前缀信息
        fileName: stack.items[0].fileName,  //错误文件名
        message: stack.items[0].callee,  //错误信息
        detail: `${error.toString()}`,
        isYibu: 'false',  //是否是异步
      }
      console.error(error)
      axios.post('/plugin/postErrorMessage', log)
    }

使用window.onerror来捕获异步错误

    window.addEventListener("error", function (event) {
      // console.log(event)
      let log = {
        kind: "stability", //稳定性指标
        errorType: "jsError", //jsError
        simpleUrl: window.location.href.split('?')[0].replace('#', ''), // 页面的url
        timeStamp: new Date().getTime(), // 日志发生时间
        position: (event.lineno || 0) + ":" + (event.colno || 0), //行列号
        fileName: event.filename, //报错链接
        message: event.message, //报错信息
        detail: "null",
        isYibu: "ture"
      };
      axios.post('/plugin/postErrorMessage', log)
    },
      true
    ); // true代表在捕获阶段调用,false代表在冒泡阶段捕获,使用true或false都可以

使用unhandledrejection来捕获Promise错误

    window.addEventListener("unhandledrejection", function (event) {
      // console.log(event)
      let log = {
        kind: "stability", //稳定性指标
        errorType: "jsError", //jsError
        simpleUrl: window.location.href.split('?')[0].replace('#', ''), // 页面的url
        timeStamp: new Date().getTime(), // 日志发生时间
        message: event.reason, //报错信息
        fileName: "null", //报错链接
        position: (event.lineno || 0) + ":" + (event.colno || 0), //行列号
        detail: "null",
        isYibu: "ture"
      };
      axios.post('/plugin/postErrorMessage', log)
    },
      true
    ); // true代表在捕获阶段调用,false代表在冒泡阶段捕获,使用true或false都可以

博客页面「HTTP Error 页面」

主要功能: 上报页面请求报错信息

实现:

通过修改原型链的形式实现对原ajax请求、fetch请求的封装和增强,捕获并向后台发送错误数据。

以下是对监控Ajax错误的实现,我们对浏览器内置的XMLHttpRequest的open和send方法进行保存,并通过修改原型链的形式对以上两个方法进行重写和增强,正是通过这种方法我们既没有影响被监控页面原本请求业务的实现,又完成了我们捕获请求错误的目的。

let XMLHttpRequest = window.XMLHttpRequest;
  let oldOpen = XMLHttpRequest.prototype.open; //缓存老的open方法   XMLHttpRequest.prototype.open = function (method, url, async) {
    //重写open方法     if (!url.match(/plugin/)) {
      //防止死循环       this.logData = { method, url, async }; //增强功能,把初始化数据保存为对象的属性     }
    return oldOpen.apply(this, arguments);
  };

  let oldSend = XMLHttpRequest.prototype.send; //缓存老的send方法   XMLHttpRequest.prototype.send = function (body) {
    //重写sned方法     if (this.logData) {
      //如果有值,说明已经被拦截了       // ......
      let handler = (type) => (e) => {         let data = {
          //...把我们想要的数据给记录下来
        };
        tracker.postHTTP(data);
      };
      this.addEventListener("load", handler("load"), false); //传输完成,所有数据保存在 response 中       this.addEventListener("error", handler("error"), false); //500也算load,只有当请求发送不成功时才是error       this.addEventListener("abort", handler("abort"), false); //放弃     }
    return oldSend.apply(this, arguments);
  };

以下是捕获fetch请求错误的实现,其实是基于类似的思路

if (!window.fetch) return;
  let oldFetch = window.fetch;
  window.fetch = function (url, obj = { method: "GET", body: "" }) {
    //......
    return oldFetch
      .apply(this, arguments)
      .then((response) => {
        if (!response?.ok) {
          console.log("test", response);
          // True if status is HTTP 2xx           if (!url.match(/plugin/)) {
            tracker.postHTTP({
              //这里写入我们想向后台发送的错误数据
            });
          }
        }
      })
      .catch((error) => {
        // 上报错误         console.error("I am error", error);
        tracker.postHTTP({
          //这里写入我们想向后台发送的错误数据
        });
        // throw error;       });
  };

websocketError部分

监听 websocket 错误 通过保留原型链方法再扩展的形式实现对websocket监听,捕获到错误向后台发送错误数据。

const monitor = () => {
/*    */
  WebSocket.prototype.oldsend = WebSocket.prototype.send;
  WebSocket.prototype.send = function (data) {
    // 记得开始时间
    WebSocket.prototype.startTime = new Date();
    // 调用原方法
    WebSocket.prototype.oldsend.call(this, data);
  };
  WebSocket.prototype.oldclose = WebSocket.prototype.close;
  WebSocket.prototype.close = function (err) {
        /* 错误逻辑发送数据 */
      WebSocket.prototype.oldclose.call(this);
    }
  };
  //  WebSocket.prototype...
};

至此,我们详细介绍了 npm 包,并讲述了我们创建 npm 包的思路,希望这篇文章能让你有所收获!

往期文章: