基于 typescript 开发前端错误及性能监控 SDK - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
alex1504
V2EX    分享创造

基于 typescript 开发前端错误及性能监控 SDK

  •  
  •   alex1504 2021-05-05 14:46:23 +08:00 2607 次点击
    这是一个创建于 1687 天前的主题,其中的信息可能已经有所发展或是发生改变。

    poster

    前端的错误监控、性能数据往往对业务的稳定性有很重要的影响,即使我们在开发阶段十分小心,也难免线上会出现异常,并且线上环境的异常我们往往后知后觉。而页面的性能数据则关系到用户体验,因此采集页面的性能数据也十分的重要。

    现在第三方完整解决方案国外有 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 ); 

    页面逻辑和未 catch 的 promise 异常

     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); } 

    页面性能数据

    通常我们会关注以下性能指标:

    • 白屏时间:从浏览器输入地址并回车后到页面开始有内容的时间;
    • 首屏时间:从浏览器输入地址并回车后到首屏内容渲染完毕的时间;
    • 用户可操作时间节点:domready 触发节点,点击事件有反应;
    • 总下载时间:window.onload 的触发节点。

    白屏时间

    白屏时间节点指的是从用户进入网站(输入 url 、刷新、跳转等方式)的时刻开始计算,一直到页面有内容展示出来的时间节点。 这个过程包括 dns 查询、建立 tcp 连接、发送首个 http 请求(如果使用 https 还要介入 TLS 的验证时间)、返回 html 文档、html 文档 head 解析完毕。

    首屏时间

    首屏时间的统计比较复杂,因为涉及图片等多种元素及异步渲染等方式。观察加载视图可发现,影响首屏的主要因素的图片的加载。通过统计首屏内图片的加载时间便可以获取首屏渲染完成的时间。

    • 页面存在 iframe 的情况下也需要判断加载时间
    • gif 图片在 IE 上可能重复触发 load 事件需排除
    • 异步渲染的情况下应在异步获取数据插入之后再计算首屏
    • css 重要背景图片可以通过 JS 请求图片 url 来统计(浏览器不会重复加载)
    • 没有图片则以统计 JS 执行时间为首屏,即认为文字出现时间

    用户可操作时间

    DOM 解析完毕时间,可统计 DomReady 时间,因为通常会在这个时间点绑定事件

    对于 web 端获取性能数据方法很简单,只需要使用浏览器自带的 Performance 接口

    页面性能数据采集

    Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API 、Navigation Timing API 、User Timing API 和 Resource Timing API 。

    performance

    从图中可以看到很多指标都是成对出现,这里我们直接求差值,就可以求出对应页面加载过程中关键节点的耗时,这里我们介绍几个比较常用的,比如:

    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 的五种就绪状态:

    • 0:请求未初始化(还没有调用 open())。
    • 1:请求已经建立,但是还没有发送(还没有调用 send())。
    • 2:请求已发送,正在处理中(通常现在可以从响应中获取内容头)。
    • 3:请求在处理中;通常响应中已有部分数据可用了,但是服务器还没有完成响应的生成。
    • 4:响应已完成;您可以获取并使用服务器的响应了。
    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 作为标识,以此标识单个用户

    用户行为数据

    主要包含用户点击页面元素、控制台信息、用户鼠标移动轨迹。

    • 用户点击元素:window 事件代理
    • 控制台信息:重写 console
    • 用户鼠标移动轨迹:第三方库rrweb

    下面是针对这些数据进行统一的监控 SDK 设计

    SDK 开发

    为更好的解耦模块,我决定使用基于事件订阅的方式,整个 SDK 分成几个核心的模块,由于使用 ts 开发并且代码会保持良好的命名规范和语义化,只有在关键的地方才会有注释,完整的代码实现见文末 Github 仓库。

    • class: WebMonitor:核心监控类
    • class:AjaxInterceptor:拦截 ajax 请求
    • class:ErrorObserver:监控全局错误
    • class:FetchInterceptor:拦截 fetch 请求
    • class:Reporter:上报
    • class:Performance:监控性能数据
    • class:RrwebObserver:接入 rrweb 获取用户行为轨迹
    • class:SpaHandler:针对 SPA 应用做处理
    • util: DeviceUtil:设备信息获取辅助函数
    • event: 事件中心

    SDK 提供的事件

    对外暴露事件,_开头为框架内部事件

    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

    WebMonitor

    集成了框架的其他类,对传入配置和默认配置进行 deepmerge,根据配置进行初始化

    this.initOptions(options); this.getDeviceInfo(); this.getNetworkType(); this.getUserAgent(); this.initGlobalData(); // 设置一些全局的数据,在所有事件中 globalData 中都会带上 this.initInstances(); this.initEventListeners(); 

    API

    支持链式操作

    • on:监听事件
    • off:移除事件
    • useVueErrorListener:使用 Vue 错误监控,获取更详细的组件数据
    • changeOptions: 修改配置
    • configData:设置全局数据

    errorObserver

    监听 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 ); 

    ajaxInterceptor

    拦截 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); }; 

    fetchInterceptor

    对 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

    通过 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 。

    https://github.com/alex1504/femonitor-web

    4 条回复    2021-12-28 10:34:14 +08:00
    weimo383
        1
    weimo383  
       2021-05-05 17:13:23 +08:00
    为什么不用 proxy
    alex1504
        2
    alex1504  
    OP
       2021-05-05 18:03:39 +08:00 via iPhone
    @weimo383 也可以 需要 polyfill
    SansXie
        3
    SansXie  
       2021-05-06 07:47:49 +08:00 via Android
    学习了
    alex1504
        4
    alex1504  
    OP
       2021-12-28 10:34:14 +08:00
    @MichealXie 欢迎 issue
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     1101 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 25ms UTC 18:06 PVG 02:06 LAX 10:06 JFK 13:06
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86