shin-monitor源码分析

  在经过两年多的线上沉淀后,将监控代码重新用 TypeScript 编写,删除冗余逻辑,正式开源。

  根据 shin-monitor 的目录结构可知,源码集中在 src 目录中。关于监控系统的迭代过程,可以参考专栏

一、入口

  入口文件是 index.ts,旁边的 utils.ts 是一个工具库。

  在 index.ts 中,将会引入 lib 目录中的 error、action 和 performance 三个文件。

1)defaults

  声明 defaults 变量,配置了各个参数的默认属性,各个参数的使用指南可以查看注释、readme 或 demo 目录中的文件。

const defaults: TypeShinParams = {   src: '//127.0.0.1:3000/ma.gif',       // 采集监控数据的后台接收地址   psrc: '//127.0.0.1:3000/pe.gif',      // 采集性能参数的后台接收地址   pkey: '',                             // 性能监控的项目key   subdir: '',                           // 一个项目下的子目录   rate: 5,                              // 随机采样率,用于性能搜集,范围是 1~10,10 表示百分百发送   version: '',                          // 版本,便于追查出错源   record: {     isOpen: true,                       // 是否开启录像     src: '//cdn.jsdelivr.net/npm/rrweb@latest/dist/rrweb.min.js'   // 录像地址   },   error: {     isFilterErrorFunc: null,            // 需要过滤的代码错误     isFilterPromiseFunc: null,          // 需要过滤的Promise错误   },   console: {     isOpen: true,               // 默认是开启,在本地调试时,可以将其关闭     isFilterLogFunc: null,      // 过滤要打印的内容   },   crash: {     isOpen: true,               // 是否监控页面奔溃,默认开启     validateFunc: null,         // 自定义页面白屏的判断条件,返回值包括 {success: true, prompt:'提示'}   },   event: {     isFilterClickFunc: null,    // 在点击事件中需要过滤的元素   },   ajax: {     isFilterSendFunc: null      // 在发送监控日志时需要过滤的通信   },   identity: {     value: '',                  // 自定义的身份信息字段     getFunc: null,              // 自定义的身份信息获取函数   },  };

2)setParams()

  在 setParams() 函数中,会初始化引入的 3 个类,然后开始监控页面错误、计算性能参数、监控用户行为。

function setParams(params: TypeShinParams): TypeShinParams {   if (!params) {     return null;   }   const combination = defaults;   // 只重置 params 中的参数   for(const key in params) {     combination[key] = params[key];   }   // 埋入自定义的身份信息   const { getFunc } = combination.identity;   getFunc && getFunc(combination);      // 监控页面错误   const error = new ErrorMonitor(combination);   error.registerErrorEvent();                   // 注册 error 事件   error.registerUnhandledrejectionEvent();      // 注册 unhandledrejection 事件   error.registerLoadEvent();                    // 注册 load 事件   error.recordPage();   shin.reactError = error.reactError.bind(error);   // 对外提供 React 的错误处理   shin.vueError = error.vueError.bind(error);       // 对外提供 Vue 的错误处理    // 启动性能监控   const pe = new PerformanceMonitor(combination);   pe.observerLCP();      // 监控 LCP   pe.observerFID();      // 监控 FID   pe.registerLoadAndHideEvent();    // 注册 load 和页面隐藏事件    // 为原生对象注入自定义行为   const action = new ActionMonitor(combination);   action.injectConsole();   // 监控打印   action.injectRouter();    // 监听路由   action.injectEvent();     // 监听事件   action.injectAjax();      // 监听Ajax      return combination; }

  函数中做了大量初始化工作,若不需要某些监控行为,可自行删除。

二、lib 目录

  在 lib 目录中,存放着整个监控系统的核心逻辑。

1)Http

  Http 的主要工作是通信,也就是将搜集起来的监控日志或性能参数,统一发送到后台。

  并且在 Http 中,还会根据算法生成身份标识字符串,以及做最后的参数组装工作。

  监控日志原先采用的发送方式是 Image,目的是跨域,但是发送的数据量有限,像 Ajax 通信,如果需要记录响应,那么长度就会不够。

  因此后期就改成了 fetch() 函数,默认只会上传 8000 长度的数据。

public send(data: TypeSendParams, callback?: ParamsCallback): void {   // var ts = new Date().getTime().toString();   // var img = new Image(0, 0);   // img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts;   const m = this.paramify(data);   // 大于8000的长度,就不在上报,废弃掉   if (m.length >= 8000) {     return;   }   const body: TypeSendBody = { m };   callback && callback(data, body); // 自定义的参数处理回调   // 如果修改headers,就会多一次OPTIONS预检请求   fetch(this.params.src, {     method: "POST",     // headers: {     //   'Content-Type': 'application/json',     // },     body: JSON.stringify(body)   }); }

  而性能参数的发送采用了 sendBeacon() 方法,在页面关闭时也能上报,这是普通的请求所不具备的特性。

  它能将少量数据异步 POST 到后台,并且支持跨域,而少量是指多少并没有特别指明,由浏览器控制,网上查到的资料说一般在 64KB 左右。

public sendPerformance(data: TypeCaculateTiming): void {   // 如果传了数据就使用该数据,否则读取性能参数,并格式化为字符串   var str = this.paramifyPerformance(data);   var rate = randomNum(10, 1); // 选取1~10之间的整数   if (this.params.rate >= rate && this.params.pkey) {     navigator.sendBeacon(this.params.psrc, str);   } }

2)Error

  在 Error 中,会注册 window 的 error 事件,用于监控脚本或资源错误,在脚本错误中,会提示行号和列号。

  不过资源错误是看不到具体的错误原因的,只会给个结果,出现了错误,连错误状态码也没有。

    window.addEventListener('error', (event: ErrorEvent): void => {       const errorTarget = event.target as (Window | TypeEventTarget);       // 过滤掉与业务无关或无意义的错误       if (isFilterErrorFunc && isFilterErrorFunc(event)) {         return;       }       // 过滤 target 为 window 的异常       if (         errorTarget !== window           && (errorTarget as TypeEventTarget).nodeName           && CONSTANT.LOAD_ERROR_TYPE[(errorTarget as TypeEventTarget).nodeName.toUpperCase()]       ) {         this.handleError(this.formatLoadError(errorTarget as TypeEventTarget));       } else {         // 过滤无效错误         event.message && this.handleError(           this.formatRuntimerError(             event.message,             event.filename,             event.lineno,             event.colno,             // event.error,           ),         );       }     }, true); // 捕获

  还会注册 window 的 unhandledrejection 事件,用于监控未处理的 Promise 错误,当 Promise 被 reject 且没有 reject 处理器时触发。

  在 unhandledrejection 事件中,对于响应信息,其实是做了些扩展的,参考《SDK中的 unhandledrejection 事件》。

    window.addEventListener('unhandledrejection',(event: PromiseRejectionEvent): void => {       // 处理响应数据,只抽取重要信息       const { response } = event.reason;       // 若无响应,则不监控       if (!response || !response.request) {         return;       }       const desc: TypeAjaxDesc = response.request.ajax;       desc.status = event.reason.status || response.status;       // 过滤掉与业务无关或无意义的错误       if(isFilterPromiseFunc && isFilterPromiseFunc(desc)) {         return;       }       this.handleError({         type: CONSTANT.ERROR_PROMISE,         desc,         // stack: event.reason && (event.reason.stack || "no stack")       });     }, true);

  这 2 个错误的使用,都在 demo/error.html 中有所记录,另一个重要的错误是白屏。

  在白屏时,还会上报录像内容,白屏的迭代过程可以参考此处

  对 body 的子元素做深度优先搜索,若已找到一个有高度的元素、或若元素隐藏、或元素有高度并且不是 body 元素,则结束搜索。

  为了便于定位白屏原因,在白屏时,还会记录些元素信息,例如元素类型、样式、高度等。

  private isWhiteScreen(): TypeWhiteScreen {     const visibles = [];     const nodes = [];       //遍历到的节点的关键信息,用于查明白屏原因     // 深度优先遍历子元素     const dfs = (node: HTMLElement): void => {       const tagName = node.tagName.toLowerCase();       const rect = node.getBoundingClientRect();       // 选取节点的属性作记录       const attrs: TypeWhiteHTMLNode = {         id: node.id,         tag: tagName,         className: node.className,         display: node.style.display,         height: rect.height       };       const src = (node as HTMLImageElement).src;       if(src) {         attrs.src = src;    // 记录图像的地址       }       const href =(node as HTMLAnchorElement).href;       if(href) {         attrs.href = href; // 记录链接的地址       }       nodes.push(attrs);       // 若已找到一个有高度的元素,则结束搜索       if(visibles.length > 0) return;       // 若元素隐藏,则结束搜索       if (node.style.display === 'none') return;       // 若元素有高度并且不是 body 元素,则结束搜索       if(rect.height > 0 && tagName !== 'body') {         visibles.push(node);         return;       }       node.children && [].slice.call(node.children).forEach((child: HTMLElement): void => {         const tagName = child.tagName.toLowerCase();         // 过滤脚本和样式元素         if(tagName === 'script' || tagName === 'link') return;         dfs(child);       });     };     dfs(document.body);     return {       visibles: visibles,       nodes: nodes     };   }

  监控白屏的时机,是在 load 事件中,延迟 1 秒触发。

  原先是在 DOMContentLoaded 事件内触发,经测试发现,当因为脚本错误出现白屏时,两个事件的触发时机会很接近。

  在线上监控时发现会有一些误报,HTML是有内容的,那很可能是 DOMContentLoaded 触发时,页面内容还没渲染好。

  对于热门的 React 和 Vue 库,声明了两个方法:reactError() 和 vueError(),将这两个方法分别应用于项目中,就能监控框架错误了。

  React 需要在项目中创建一个 ErrorBoundary 类,在类中调用 reactError() 方法。

  如果 Vue 是被模块化引入的,那么就得在模块的某个位置调用该方法,因为此时 Vue 不会绑定到 window 中,即不是全局变量。

3)Action

  在 Action 中会监控打印、路由、点击事件和 Ajax 通信。这 4 种行为都会对原生对象进行注入,它们的使用也都可以在 demo 目录中找到。

  以路由为例,不仅要监听 popstate 事件,还要重写 pushState 和 replaceState。

  public injectRouter(): void {     /**      * 全局监听跳转      * 点击后退、前进按钮或者调用 history.back()、history.forward()、history.go() 方法才会触发 popstate 事件      * 点击 <a href=/xx/yy#anchor>hash</a> 按钮也会触发 popstate 事件      */     const _onPopState = window.onpopstate;     window.onpopstate = (args: PopStateEvent): void => {       this.sendRouterInfo();       _onPopState && _onPopState.apply(this, args);     };     /**      * 监听 pushState() 和 replaceState() 两个方法      */     const bindEventListener = (type: string): TypeStateEvent => {       const historyEvent: TypeStateEvent = history[type];       return (...args): void => {         // 触发 history 的原始事件,apply 的第一个参数若不是 history,就会报错         const newEvent = historyEvent.apply(history, args);         this.sendRouterInfo();         return newEvent;       };     };     history.pushState = bindEventListener('pushState');     history.replaceState = bindEventListener('replaceState');   }

4)Performance

  Performance 主要是对性能参数的搜集,大部分的性能参数是通过 performance.getEntriesByType('navigation')[0] 或 performance.timing 获取的。

  performance.timing 已被废弃,尽量不要使用,此处只是为了兼容。Performance 的迭代过程可以参考此处

  参数的发送时机有两者,第一种是 window.load 事件中,第二种是页面隐藏的事件中。

  LCP、FID、FP 等参数可通过浏览器提供的对象获取。

  public observerLCP(): void {     const lcpType = 'largest-contentful-paint';     const isSupport = this.checkSupportPerformanceObserver(lcpType);     // 浏览器兼容判断     if(!isSupport) {       return;     }     const po = new PerformanceObserver((entryList): void=> {       const entries = entryList.getEntries();       const lastEntry = (entries as any)[entries.length - 1] as TypePerformanceEntry;       this.lcp = {         time: rounded(lastEntry.renderTime || lastEntry.loadTime),                  // 时间取整         url: lastEntry.url,                                                         // 资源地址         element: lastEntry.element ? removeQuote(lastEntry.element.outerHTML) : ''  // 参照的元素       };     });     // buffered 为 true 表示调用 observe() 之前的也算进来     po.observe({ type: lcpType, buffered: true } as any);     // po.observe({ entryTypes: [lcpType] });     /**      * 当有按键或点击(包括滚动)时,就停止 LCP 的采样      * once 参数是指事件被调用一次后就会被移除      */     ['keydown', 'click'].forEach((type): void => {       window.addEventListener(type, (): void => {         // 断开此观察者的连接         po.disconnect();       }, { once: true, capture: true });     });   }

  FMP 需要自行计算,才能得到,我采用了一套比较简单的规则。

  • 首先,通过 MutationObserver 监听每一次页面整体的 DOM 变化,触发 MutationObserver 的回调。
  • 然后在回调中,为每个 HTML 元素(不包括忽略的元素)打上标记,记录元素是在哪一次回调中增加的,并且用数组记录每一次的回调时间。
  • 接着在触发 load 事件时,先过滤掉首屏外和没有高度的元素,以及元素列表之间有包括关系的祖先元素,再计算各次变化时剩余元素的总分。
  • 最后在得到分数最大值后,从这些元素中挑选出最长的耗时,作为 FMP。

  为了能更好的描述出首屏的时间,将 LCP 和 FMP 两个时间做比较,取最长的那个时间。

 

发表评论

相关文章