
前端的错误监控、性能数据往往对业务的稳定性有很重要的影响,即使我们在开发阶段十分小心,也难免线上会出现异常,并且线上环境的异常我们往往后知后觉。而页面的性能数据则关系到用户体验,因此采集页面的性能数据也十分的重要。
现在第三方完整解决方案国外有 sentry,国内有 fundebug 、frontjs,他们提供前端接入的 SDK 和数据服务,然后有一定的免费额度,超出就需要使用付费方案。前端的 SDK 用户监控用户端异常和性能,后端服务用户可以创建应用,每个应用分配一个 APPKEY,然后 SDK 完成自动上报。
本文不考虑数据服务,只对前端监控进行分析,讲下 web 如何进行监控和采集这些数据,并且通过 TS 集成这些功能做出一套前端监控 SDK 。
既然需要采集数据,我们要明确下可能需要哪些数据,目前来看有如下一些数据:
下面分析一下这些数据如何获取:
window.onerror AOP 捕获异常能力无论是异步还是非异步错误,onerror 都能捕获到运行时错误。window.onerror不能捕获页面资源的加载错误,但资源加载错误能被window.addEventListener在捕获阶段捕获。由于addEventListener也能够捕获 js 错误,因此需要过滤避免重复触发事件钩子window.onerror无法捕获 Promise 任务中未被处理的异常,通过unhandledrejection可以捕获window.addEventListener( "error", function (event) { const target: any = event.target || event.srcElement; const isElementTarget = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement; if (!isElementTarget) return false; const url = target.src || target.href; onResourceError?.call(this, url); }, true ); const oldOnError= window.onerror; const oldUnHandleRejection = window.onunhandledrejection; window.Onerror= function (...args) { if (oldOnError) { oldOnError(...args); } const [msg, url, line, column, error] = args; onError?.call(this, { msg, url, line, column, error }); }; window.Onunhandledrejection= function (e: PromiseRejectionEvent) { if (oldUnHandleRejection) { oldUnHandleRejection.call(window, e); } onUnHandleRejection && onUnHandleRejection(e); }; 在 Vue 中,我们应该通过Vue.config.errorHandler = function(err, vm, info) {};进行异常捕获,这样可以获取到更多的上下文信息。
对于 React,React 16 提供了一个内置函数 componentDidCatch,使用它可以非常简单的获取到 react 下的错误信息
componentDidCatch(error, info) { console.log(error, info); } 通常我们会关注以下性能指标:
白屏时间节点指的是从用户进入网站(输入 url 、刷新、跳转等方式)的时刻开始计算,一直到页面有内容展示出来的时间节点。 这个过程包括 dns 查询、建立 tcp 连接、发送首个 http 请求(如果使用 https 还要介入 TLS 的验证时间)、返回 html 文档、html 文档 head 解析完毕。
首屏时间的统计比较复杂,因为涉及图片等多种元素及异步渲染等方式。观察加载视图可发现,影响首屏的主要因素的图片的加载。通过统计首屏内图片的加载时间便可以获取首屏渲染完成的时间。
DOM 解析完毕时间,可统计 DomReady 时间,因为通常会在这个时间点绑定事件
对于 web 端获取性能数据方法很简单,只需要使用浏览器自带的 Performance 接口
Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API 、Navigation Timing API 、User Timing API 和 Resource Timing API 。
从图中可以看到很多指标都是成对出现,这里我们直接求差值,就可以求出对应页面加载过程中关键节点的耗时,这里我们介绍几个比较常用的,比如:
const timingInfo = window.performance.timing; // DNS 解析,DNS 查询耗时 timingInfo.domainLookupEnd - timingInfo.domainLookupStart; // TCP 连接耗时 timingInfo.connectEnd - timingInfo.connectStart; // 获得首字节耗费时间,也叫 TTFB timingInfo.responseStart - timingInfo.navigationStart; // *: domReady 时间(与 DomContentLoad 事件对应) timingInfo.domContentLoadedEventStart - timingInfo.navigationStart; // DOM 资源下载 timingInfo.responseEnd - timingInfo.responseStart; // 准备新页面时间耗时 timingInfo.fetchStart - timingInfo.navigationStart; // 重定向耗时 timingInfo.redirectEnd - timingInfo.redirectStart; // Appcache 耗时 timingInfo.domainLookupStart - timingInfo.fetchStart; // unload 前文档耗时 timingInfo.unloadEventEnd - timingInfo.unloadEventStart; // request 请求耗时 timingInfo.responseEnd - timingInfo.requestStart; // 请求完毕至 DOM 加载 timingInfo.domInteractive - timingInfo.responseEnd; // 解释 dom 树耗时 timingInfo.domComplete - timingInfo.domInteractive; // *:从开始至 load 总耗时 timingInfo.loadEventEnd - timingInfo.navigationStart; // *: 白屏时间 timingInfo.responseStart - timingInfo.fetchStart; // *: 首屏时间 timingInfo.domComplete - timingInfo.fetchStart; 接口数据主要包括接口耗时、接口请求异常,耗时可以通过对 XmlHttpRequest 和 fetch 请求的拦截过程中进行时间统计,异常通过 xhr 的 readyState 和 status 属性判断。
XmlHttpRequest 拦截:修改 XMLHttpRequest 的原型,在发送请求时开启事件监听,注入 SDK 钩子 XMLHttpRequest.readyState 的五种就绪状态:
XMLHttpRequest.prototype.open = function (method: string, url: string) { // ...省略 return open.call(this, method, url, true); }; XMLHttpRequest.prototype.send = function (...rest: any[]) { // ...省略 const body = rest[0]; this.addEventListener("readystatechange", function () { if (this.readyState === 4) { if (this.status >= 200 && this.status < 300) { // ...省略 } else { // ...省略 } } }); return send.call(this, body); }; Fetch 拦截:Object.defineProperty
Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, get() { return (url: string, options: any = {}) => { return originFetch(url, options) .then((res) => { // ... }) }; } }); 通过 navigatorAPI 获取在进行解析,使用第三方包mobile-detect帮助我们获取解析
全局数据增加 url 、页面标题、用户标识,SDK 可以自动为网页 session 分配一个随机用户 label 作为标识,以此标识单个用户
主要包含用户点击页面元素、控制台信息、用户鼠标移动轨迹。
下面是针对这些数据进行统一的监控 SDK 设计
为更好的解耦模块,我决定使用基于事件订阅的方式,整个 SDK 分成几个核心的模块,由于使用 ts 开发并且代码会保持良好的命名规范和语义化,只有在关键的地方才会有注释,完整的代码实现见文末 Github 仓库。
对外暴露事件,_开头为框架内部事件
export enum TrackerEvents { // 对外暴露事件 performanceInfoReady = "performanceInfoReady", // 页面性能数据获取完毕 reqStart = "reqStart", // 接口请求开始 reqEnd = "reqEnd", // 接口请求完成 reqError = "reqError", // 请求错误 jsError = "jsError", // 页面逻辑异常 vuejsError = "vuejsError", // vue 错误监控事件 unHandleRejection = "unHandleRejecton", // 未处理 promise 异常 resourceError = "resourceError", // 资源加载错误 batchErrors = "batchErrors", // 错误合并上报事件,用户合并上报请求节省请求数量 mouseTrack = "mouseTrack", // 用户鼠标行为追踪 } import { WebMonitor } from "femonitor-web"; const mOnitor= Monitor.init(); /* Listen single event */ monitor.on([event], (emitData) => {}); /* Or Listen all event */ monitor.on("event", (eventName, emitData) => {}) WebMonitor 、errorObserver 、ajaxInterceptor 、fetchInterceptor 、performance
集成了框架的其他类,对传入配置和默认配置进行 deepmerge,根据配置进行初始化
this.initOptions(options); this.getDeviceInfo(); this.getNetworkType(); this.getUserAgent(); this.initGlobalData(); // 设置一些全局的数据,在所有事件中 globalData 中都会带上 this.initInstances(); this.initEventListeners(); 支持链式操作
监听 window.onerror 和 window.onunhandledrejection,并且对 err.message 进行解析,获取想要 emit 的错误数据。
window.Onerror= function (...args) { // 调用原始方法 if (oldOnError) { oldOnError(...args); } const [msg, url, line, column, error] = args; const stackTrace = error ? ErrorStackParser.parse(error) : []; const msgText = typeof msg === "string" ? msg : msg.type; const errorObj: IError = {}; myEmitter.customEmit(TrackerEvents.jsError, errorObj); }; window.Onunhandledrejection= function (error: PromiseRejectionEvent) { if (oldUnHandleRejection) { oldUnHandleRejection.call(window, error); } const errorObj: IUnHandleRejectiOnError= {}; myEmitter.customEmit(TrackerEvents.unHandleRejection, errorObj); }; window.addEventListener( "error", function (event) { const target: any = event.target || event.srcElement; const isElementTarget = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement; if (!isElementTarget) return false; const url = target.src || target.href; const errorObj: BaseError = {}; myEmitter.customEmit(TrackerEvents.resourceError, errorObj); }, true ); 拦截 ajax 请求,并触发自定义的事件。对 XMLHttpRequest 的 open 和 send 方法进行重写
XMLHttpRequest.prototype.open = function (method: string, url: string) { const reqStartRes: IAjaxReqStartRes = { }; myEmitter.customEmit(TrackerEvents.reqStart, reqStartRes); return open.call(this, method, url, true); }; XMLHttpRequest.prototype.send = function (...rest: any[]) { const body = rest[0]; const requestData: string = body; const startTime = Date.now(); this.addEventListener("readystatechange", function () { if (this.readyState === 4) { if (this.status >= 200 && this.status < 300) { const reqEndRes: IReqEndRes = {}; myEmitter.customEmit(TrackerEvents.reqEnd, reqEndRes); } else { const reqErrorObj: IHttpReqErrorRes = {}; myEmitter.customEmit(TrackerEvents.reqError, reqErrorObj); } } }); return send.call(this, body); }; 对 fetch 进行拦截,并且触发自定义的事件。
Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, get() { return (url: string, options: any = {}) => { const reqStartRes: IFetchReqStartRes = {}; myEmitter.customEmit(TrackerEvents.reqStart, reqStartRes); return originFetch(url, options) .then((res) => { const status = res.status; const reqEndRes: IReqEndRes = {}; const reqErrorRes: IHttpReqErrorRes = {}; if (status >= 200 && status < 300) { myEmitter.customEmit(TrackerEvents.reqEnd, reqEndRes); } else { if (this._url !== self._options.reportUrl) { myEmitter.customEmit(TrackerEvents.reqError, reqErrorRes); } } return Promise.resolve(res); }) .catch((e: Error) => { const reqErrorRes: IHttpReqErrorRes = {}; myEmitter.customEmit(TrackerEvents.reqError, reqErrorRes); }); }; } }); 通过 Performance 获取页面性能,在性能数据完备后 emit 事件
const { domainLookupEnd, domainLookupStart, connectEnd, connectStart, responseEnd, requestStart, domComplete, domInteractive, domContentLoadedEventEnd, loadEventEnd, navigationStart, responseStart, fetchStart } = this.timingInfo; const dnsLkTime = domainLookupEnd - domainLookupStart; const tcpCOnTime= connectEnd - connectStart; const reqTime = responseEnd - requestStart; const domParseTime = domComplete - domInteractive; const domReadyTime = domContentLoadedEventEnd - fetchStart; const loadTime = loadEventEnd - navigationStart; const fpTime = responseStart - fetchStart; const fcpTime = domComplete - fetchStart; const performanceInfo: IPerformanceInfo<number> = { dnsLkTime, tcpConTime, reqTime, domParseTime, domReadyTime, loadTime, fpTime, fcpTime }; myEmitter.emit(TrackerEvents.performanceInfoReady, performanceInfo); 完整 SDK 实现见下方 Github 仓库地址,欢迎 star 及 fork 。
1 weimo383 2021-05-05 17:13:23 +08:00 为什么不用 proxy |
3 SansXie 2021-05-06 07:47:49 +08:00 via Android 学习了 |
4 alex1504 OP @MichealXie 欢迎 issue |