Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端异常监控 #206

Open
funnycoderstar opened this issue May 17, 2022 · 0 comments
Open

前端异常监控 #206

funnycoderstar opened this issue May 17, 2022 · 0 comments

Comments

@funnycoderstar
Copy link
Owner

前端异常包含很多种情况: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; // 接口请求耗时
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant