blog icon indicating copy to clipboard operation
blog copied to clipboard

前端异常监控

Open funnycoderstar opened this issue 3 years ago • 0 comments

前端异常包含很多种情况:1. js 编译时异常(开发阶段就能排)2. js 运行时异常;3. 加载静态资源异常(路径写错、资源服务器异常、CDN 异常、跨域)4. 接口请求异常等

异常监控具体指标

  • JS 语法错误、代码运行异常 ✅
  • Http 请求异常 ✅
  • 静态资源加载异常 ✅
  • Promise 异常 ✅
  • Iframe 异常
  • 跨域 Script error ✅
  • 崩溃和卡顿

各种概率计算

  • JS 错误率 = 错误样本量 / 总样本量
  • API 成功率 = 接口调用成功的样本量 / 总样本量

try catch

try catch 只能捕获到同步的运行时错误,不能捕获语法和异步错误

  1. 同步运行时错误 ✅
try {
    console.log(a);
} catch (e) {
    console.log('错误捕获', e);
}

错误捕获 ReferenceError: a is not defined 2. 语法错误 ❌

try {
    let a = '3.15;
} catch(e) {
    console.log('错误捕获', e);
}

Uncaught SyntaxError: Invalid or unexpected token。 注意这并不是 try catch 捕获到的错误,而是浏览器控制台默认打印出来的

  1. 异步错误 ❌
try {
    setTimeout(() => {
        a.map((v) => v);
    }, 1000);
} catch (e) {
    console.log('错误捕获', e);
}

Uncaught ReferenceError: a is not defined

window.onerror

JS 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror()

/**
 * @param {String}  message    错误信息
 * @param {String}  source    出错文件
 * @param {Number}  lineno    行号
 * @param {Number}  colno    列号
 * @param {Object}  error  Error对象(对象)
 */
window.onerror = (message, source, lineno, colno, error) => {
    console.log('error message:', message);
    console.log('position', lineno, colno);
    console.error('错误捕获:', error);
    return true; // 异常不继续冒泡,浏览器默认打印机制就会取消
};
  1. 同步运行时错误 ✅
console.log(a);

输出如下信息

error message: Uncaught ReferenceError: a is not defined
1.html:20 position 25 17
1.html:21 错误捕获: ReferenceError: a is not defined
    at 1.html:25
  1. 异步运行时错误 ✅
setTimeout(() => {
    arr.map((v) => v);
}, 1000);

输出如下信息

error message: Uncaught ReferenceError: arr is not defined
1.html:20 position 26 9
1.html:21 错误捕获: ReferenceError: arr is not defined
    at 1.html:26
  1. 网络请求异常 ❌
<img src="http://a.com/a.png" />

GET http://a.com/a.png net::ERR_NAME_NOT_RESOLVED 不论是静态资源异常,或者接口异常,错误都无法捕获到。

tips: window.onerror 函数只有在返回 true 的时候,异常才不会向上抛出,否则即使是知道异常的发生控制台还是会显示 Uncaught Error: xxxxx

补充 DOM0 级事件和 DOM2 级事件的区别

  1. DOM0 级事件如下
  • 在标签内写 onclick 事件
<input type="button" onclick="alert(0);" />
  • 在 JS 写 onlicke=function(){}函数
var btn = document.getElementsByClassName('button');
btn.onclick = function () {
    alert(0);
};

存在的问题:

  • 重复添加会被覆盖掉,所以如果使用 window.onerror 的话,如果有其他的这个时间,就会被覆盖掉
  1. DOM2 级事件,addEventListener()和 removeEventListener() addEvenetListener()、removeEventListener() 有三个参数: 第一个参数是事件名(如 click, IE 是 onclick); 第二个参数是事件处理程序函数; 第三个参数如果是 true 则表示在捕获阶段调用,为 false 表示在冒泡阶段调用。

可以为元素添加多个事件处理程序,触发时会按照添加顺序依次调用。

为什么没有 1 级:因为 1 级 DOM 标准中并没有定义事件相关的内容,所以没有所谓的 1 级 DOM 事件模型。

script error

跨域的脚本会给出 "Script Error." 提示,拿不到具体的错误信息和堆栈信息。 复现过程

<input type="button" value="script error" onclick="a()" />
<script src="http://127.0.0.1:3200/a.js"></script>

a.js 的内容为

function a() {
    throw new Error('Fail a.js');
}

使用 koa-static

const Koa = require('koa');
const app = new Koa();
const KoaStatic = require('koa-static');

// 静态资源目录对于相对入口文件index.js的路径
// 使用  koa-static  使得前后端都在同一个服务下
app.use(KoaStatic(__dirname));

app.listen(3200, () => {
    console.log('启动成功');
});

此时用 window.addEventListener('error') 获取到的信息为 script error

一般情况,如果出现 Script error 这样的错误,基本上可以确定是出现了跨域问题。这时候,是不会有其他太多辅助信息的,但是解决思路无非如下: 跨源资源共享机制( CORS ):我们为 script 标签添加 crossOrigin 属性。

// <script src="http://127.0.0.1:3200/a.js" crossorigin></script>

// 或者动态去添加 `js` 脚本:

const script = document.createElement('script');
script.crossOrigin = 'anonymous';
script.src = url;
document.body.appendChild(script);

这个时候捕获到错误:Uncaught ReferenceError: a is not defined,通过 Network 可以看到 请求 http://127.0.0.1:3200/a.js 出错,CORS error

所以,还需要在服务器端设置:Access-Control-Allow-Origin

const Koa = require('koa');
const app = new Koa();
const KoaStatic = require('koa-static');
const cors = require('@koa/cors');
app.use(cors());
// 静态资源目录对于相对入口文件index.js的路径
// 使用  koa-static  使得前后端都在同一个服务下
app.use(KoaStatic(__dirname));

app.listen(3200, () => {
    console.log('启动成功');
});

此时再次执行捕获到异常:Uncaught Error: Fail a.js

或者加载下面一段 JS,可以让我们在没有跨域头的情况下,拿到 4000 按钮事件处理器的执行异常信息。

const originAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, options) {
    const wrappedListener = function (...args) {
        try {
            return listener.apply(this, args);
        } catch (err) {
            throw err;
        }
    };
    return originAddEventListener.call(this, type, wrappedListener, options);
};
  • 改写 EventTarget 的 addEventListener 方法
  • 对传入的 listener 进行包装,返回包装过的 listener,对其执行进行 try-catch
  • 浏览器不会对 try-catch 起来的异常进行跨域拦截,所以 catch 到的时候,是有堆栈信息的;
  • 重新 throw 出来异常的时候,执行的是同域代码,所以 window.onerror 捕获的时候不会丢失堆栈信息;

我们不仅知道异常堆栈,而且还知道导致该异常的事件处理器,是在何处添加进去的。实现这个效果,也很简单:

(() => {
   const originAddEventListener = EventTarget.prototype.addEventListener;
   EventTarget.prototype.addEventListener = function (type, listener, options) {
+    // 捕获添加事件时的堆栈
+    const addStack = new Error(`Event (${type})`).stack;
     const wrappedListener = function (...args) {
       try {
         return listener.apply(this, args);
       }
       catch (err) {
+        // 异常发生时,扩展堆栈
+        err.stack += '\n' + addStack;
         throw err;
       }
     }
     return originAddEventListener.call(this, type, wrappedListener, options);
   }
 })();

同样的道理,我们也可以对 setTimeout、setInterval、requestAnimationFrame 甚至 XMLHttpRequest 做这样的拦截,得到一些我们本来得不到的信息。

上面这种写法只对 DOM2 级的事件起作用,因为我们拦截的是 addEventListener 事件

参考

静态资源加载

定义:正常情况下,html 页面中主要包含的静态资源有:js 文件、css 文件、图片文件,这些文件加载失败将直接对页面造成影响甚至瘫痪,所有我们需要把他们统计出来。不太确定是否需要把所有静态资源文件的加载信息都统计下来,既然加载成功了,页面正常了,应该就没有统计的必要了,所以我们只统计加载出错的情况。 收集方法:

  1. 回调方法 onerror(Object.onerror):该方法在静态资源跨域加载时是无法获取报错信息的。
var img = document.getElementById('#img');
img.onerror = function (e) {
    // 捕获错误
    console.log(e);
};
  1. 利用 performance.getEntries()方法:获取到加载成功的资源列表。onload 事件中遍历出所有页面资源集合,利用排除法,到所有集合中过滤掉成功的资源列表,即为加载失败的资源。 此方法看似合理,也确实能够排查出加载失败的静态资源,但是检查的时机很难掌握,另外,如果遇到异步加载的 js 也就歇菜了。 performance.getEntries() 用来统计静态资源相关的时间信息,返回一个数组,数组的每个元素代表对应的静态资源的信息。 属性介绍
  • initiatorType 资源属性,有 img、css 等
  • duration 请求花费的时间
  • 其他的与上面的 window.performance.timing 的属性一样
// 浏览器获取网页时,会对网页中每一个对象(脚本文件、样式表、图片文件等等)发出一个HTTP请求。而通过window.performance.getEntries方法,则可以以数组形式,返回这些请求的时间统计信息,每个数组成员均是一个PerformanceResourceTiming对象!

function performanceGetEntries() {
    // 判断浏览器是否支持
    if (!window.performance && !window.performance.getEntries) {
        return false;
    }
    var result = [];
    // 获取当前页面所有请求对应的PerformanceResourceTiming对象进行分析
    window.performance.getEntries().forEach((item) => {
        result.push({
            url: item.name,
            entryType: item.entryType,
            type: item.initiatorType,
            'duration(ms)': item.duration,
        });
    });
    // 控制台输出统计结果
    console.table(result); // 表示已经加载的资源

    //  然后把整个资源的数量减去已经加载好的资源,剩下的就是没有加载出来的资源的数量。
}
  1. 添加一个 Listener(error)来捕获前端的异常,也是我正在使用的方法,比较靠谱。但是这个方法会监控到很多的 error, 所以我们要从中筛选出静态资源加载报错的 error,判断 e.target.localName 是否有值,有值就是资源错误, 代码如下:
/**
 * 监控页面静态资源加载报错
 */
function loadResourceError() {
    window.addEventListener(
        'error',
        function (e) {
            console.log(e, '错误捕获===');
            if (e) {
                let target = e.target || e.srcElement;
                let isElementTarget = target instanceof HTMLElement;
                if (!isElementTarget) {
                    // js错误
                    console.log('js错误===');
                    // js error处理
                    let { filename, message, lineno, colno, error } = e;
                    let { message: ErrorMsg, stack } = error;
                } else {
                    // 页面静态资源加载错误处理
                    console.log('资源加载错误===');
                    let { type, timeStamp, target } = e;
                    let { localName, outerHTML, tagName, src } = target;
                    let typeName = target.localName;
                    let sourceUrl = '';
                    if (typeName === 'link') {
                        sourceUrl = target.href;
                    } else if (typeName === 'script') {
                        sourceUrl = target.src;
                    }
                    alert('资源加载失败,请刷新页面或切换网络重试。(' + sourceUrl + ')');
                }
            }
            // 设为true表示捕获阶段调用,会在元素的onerror前调用,在window.addEventListener('error')后调用
        },
        true,
    );
}
// 我们根据e.target的属性来判断它是link标签,还是script标签。目前只关注只监控了css,js文件加载错误的情况。
/**
 * 监控页面静态资源加载报错
 */
function recordResourceError() {
    // 当浏览器不支持 window.performance.getEntries 的时候,用下边这种方式
    window.addEventListener(
        'error',
        function (e) {
            var typeName = e.target.localName;
            var sourceUrl = '';
            if (typeName === 'link') {
                sourceUrl = e.target.href;
            } else if (typeName === 'script') {
                sourceUrl = e.target.src;
            }
            var resourceLoadInfo = new ResourceLoadInfo(RESOURCE_LOAD, sourceUrl, typeName, '0');
            resourceLoadInfo.handleLogInfo(RESOURCE_LOAD, resourceLoadInfo);
        },
        true,
    );
}

我们根据报错是的 e.target 的属性来判断它是 link 标签,还是 script 标签。由于目前我关注对前端造成崩溃的错误,所以目前只监控了 css,js 文件加载错误的情况。

要做实时监控和预警,还需要知道更多详细的信息 解决方案

  • 统计出每天的量,列出每天加载报错的变化,点击图表的 bar, 可以看到每天的数据变化,以作对比。
  • 分析出静态资源加载出错主要发生在哪些页面上,缩小排查的范围。
  • 分析出影响用户的人数,也许很多错误就发生在一个人身上,减少盲目排查。

window.addEventListener(‘error’)与 window.onerror 的异同点在于:

  • 前者能够捕获到资源加载错误,后者不能。
  • 都能捕获 js 运行时错误,捕获到的错误参数不同。前者参数为一个 event 对象;后者为 msg, url, lineNo, columnNo, error 一系列参数。event 对象中都含有后者参数的信息。 注意:
  • window 上 error 事件代理,过滤 window 本身的 error;根据标签类型判断资源类型,src 或 href 为资源地址;为了捕获跨域 js 的错误,需要在相应资源标签上添加 crossorigin 属性。
  • addEventListener 的第三个参数 一定要是 true,表示在捕获阶段触发,如果改成 false 就是冒泡阶段,那是获取不到错误事件的。

JS 错误监控

如:一段时间内,应用 JS 报错的走势(chart 图表)、JS 错误发生率、JS 错误在 PC 端发生的概率、JS 错误在 IOS 端发生的概率、JS 错误在 Android 端发生的概率,以及 JS 错误的归类。然后,我们再去其中的 Js 错误进行详细的分析,辅助我们排查出错的位置和发生错误的原因。

如:JS 错误类型、 JS 错误信息、JS 错误堆栈、JS 错误发生的位置以及相关位置的代码;JS 错误发生的几率、浏览器的类型,版本号,设备机型等等辅助信息 为了得到这些数据,我们需要在上传的时候将其分析出来。在众多日志分析中,很多字段及功能是重复通用的,所以应该将其封装起来。

// 设置日志对象类的通用属性
function setCommonProperty() {
    this.happenTime = new Date().getTime(); // 日志发生时间
    this.webMonitorId = WEB_MONITOR_ID; // 用于区分应用的唯一标识(一个项目对应一个)
    this.simpleUrl = window.location.href.split('?')[0].replace('#', ''); // 页面的url
    this.customerKey = utils.getCustomerKey(); // 用于区分用户,所对应唯一的标识,清理本地数据后失效
    this.pageKey = utils.getPageKey(); // 用于区分页面,所对应唯一的标识,每个新页面对应一个值
    this.deviceName = DEVICE_INFO.deviceName;
    this.os = DEVICE_INFO.os + (DEVICE_INFO.osVersion ? ' ' + DEVICE_INFO.osVersion : '');
    this.browserName = DEVICE_INFO.browserName;
    this.browserVersion = DEVICE_INFO.browserVersion;
    // TODO 位置信息, 待处理
    this.monitorIp = ''; // 用户的IP地址
    this.country = 'china'; // 用户所在国家
    this.province = ''; // 用户所在省份
    this.city = ''; // 用户所在城市
    // 用户自定义信息, 由开发者主动传入, 便于对线上进行准确定位
    this.userId = USER_INFO.userId;
    this.firstUserParam = USER_INFO.firstUserParam;
    this.secondUserParam = USER_INFO.secondUserParam;
}

// JS错误日志,继承于日志基类MonitorBaseInfo
function JavaScriptErrorInfo(uploadType, errorMsg, errorStack) {
    setCommonProperty.apply(this);
    this.uploadType = uploadType;
    this.errorMessage = encodeURIComponent(errorMsg);
    this.errorStack = errorStack;
    this.browserInfo = BROWSER_INFO;
}
JavaScriptErrorInfo.prototype = new MonitorBaseInfo();

JS 错误发生率 = JS 错误个数(一次访问页面中,所有的 js 错误都算一次)/PV (PC,IOS,Android 平台同理)

前端接口监控

ajax

如果拦截 ajax 请求

fetch

如何拦截 fetch 请求,如何处理 http code 为 200 时,接口返回的结构体中的错误

如何监控前端接口请求? 万变不离其宗,他们都是对浏览器的这个对象 window.XMLHttpRequest 或者 fetch 进行了封装,所以我们只要能够监听到这个对象的一些事件,就能够把请求的信息分离出来。

/**
 * 页面接口请求监控
 */
function recordHttpLog() {
    // 监听ajax的状态
    function ajaxEventTrigger(event) {
        var ajaxEvent = new CustomEvent(event, {
            detail: this,
        });
        window.dispatchEvent(ajaxEvent);
    }
    var oldXHR = window.XMLHttpRequest;
    function newXHR() {
        var realXHR = new oldXHR();
        realXHR.addEventListener(
            'loadstart',
            function () {
                ajaxEventTrigger.call(this, 'ajaxLoadStart');
            },
            false,
        );
        realXHR.addEventListener(
            'loadend',
            function () {
                ajaxEventTrigger.call(this, 'ajaxLoadEnd');
            },
            false,
        );
        // 此处的捕获的异常会连日志接口也一起捕获,如果日志上报接口异常了,就会导致死循环了。
        // realXHR.onerror = function () {
        //   siftAndMakeUpMessage("Uncaught FetchError: Failed to ajax", WEB_LOCATION, 0, 0, {});
        // }
        return realXHR;
    }
    var timeRecordArray = [];
    window.XMLHttpRequest = newXHR;
    window.addEventListener('ajaxLoadStart', function (e) {
        var tempObj = {
            timeStamp: new Date().getTime(),
            event: e,
        };
        timeRecordArray.push(tempObj);
    });
    window.addEventListener('ajaxLoadEnd', function () {
        for (var i = 0; i < timeRecordArray.length; i++) {
            if (timeRecordArray[i].event.detail.status > 0) {
                var currentTime = new Date().getTime();
                var url = timeRecordArray[i].event.detail.responseURL;
                var status = timeRecordArray[i].event.detail.status;
                var statusText = timeRecordArray[i].event.detail.statusText;
                var loadTime = currentTime - timeRecordArray[i].timeStamp;
                if (!url || url.indexOf(HTTP_UPLOAD_LOG_API) != -1) return;
                var httpLogInfoStart = new HttpLogInfo(
                    HTTP_LOG,
                    url,
                    status,
                    statusText,
                    '发起请求',
                    timeRecordArray[i].timeStamp,
                    0,
                );
                httpLogInfoStart.handleLogInfo(HTTP_LOG, httpLogInfoStart);
                var httpLogInfoEnd = new HttpLogInfo(
                    HTTP_LOG,
                    url,
                    status,
                    statusText,
                    '请求返回',
                    currentTime,
                    loadTime,
                );
                httpLogInfoEnd.handleLogInfo(HTTP_LOG, httpLogInfoEnd);
                // 当前请求成功后就在数组中移除掉
                timeRecordArray.splice(i, 1);
            }
        }
    });
}
// 设置日志对象类的通用属性
function setCommonProperty() {
    this.happenTime = new Date().getTime(); // 日志发生时间
    this.webMonitorId = WEB_MONITOR_ID; // 用于区分应用的唯一标识(一个项目对应一个)
    this.simpleUrl = window.location.href.split('?')[0].replace('#', ''); // 页面的url
    this.completeUrl = utils.b64EncodeUnicode(encodeURIComponent(window.location.href)); // 页面的完整url
    this.customerKey = utils.getCustomerKey(); // 用于区分用户,所对应唯一的标识,清理本地数据后失效,
    // 用户自定义信息, 由开发者主动传入, 便于对线上问题进行准确定位
    var wmUserInfo = localStorage.wmUserInfo ? JSON.parse(localStorage.wmUserInfo) : '';
    this.userId = utils.b64EncodeUnicode(wmUserInfo.userId || '');
    this.firstUserParam = utils.b64EncodeUnicode(wmUserInfo.firstUserParam || '');
    this.secondUserParam = utils.b64EncodeUnicode(wmUserInfo.secondUserParam || '');
}
// 接口请求日志,继承于日志基类MonitorBaseInfo
function HttpLogInfo(uploadType, url, status, statusText, statusResult, currentTime, loadTime) {
    setCommonProperty.apply(this);
    this.uploadType = uploadType; // 上传类型
    this.httpUrl = utils.b64EncodeUnicode(encodeURIComponent(url)); // 请求地址
    this.status = status; // 接口状态
    this.statusText = statusText; // 状态描述
    this.statusResult = statusResult; // 区分发起和返回状态
    this.happenTime = currentTime; // 客户端发送时间
    this.loadTime = loadTime; // 接口请求耗时
}

funnycoderstar avatar May 17 '22 12:05 funnycoderstar