hugh 的个人博客

Everyday is a new day

收集错误信息及堆栈-前端监控之数据收集篇

js 错误是第一指标,任何一个 js 错误都有可能导致阻塞,影响我们页面的正常运转。

本篇主要对 js 错误收集的分享

1. 了解异常发生的情况和影响

注: 了解异常发生的情况及影响, 有助于我们选择合适方式进行异常捕获处理

任何一个 js 异常的发生都会导致当前代码块不能正常执行

那么那些情况会导致 js 异常阻塞呢

我们都知道, js 是单线程的、基于事件循环机制的的语言

情况一: 同步代码出现异常

同一个线程中运行的代码,异常阻塞

var a = 0
console.log("--step1")
a = a+b
console.log("--step2")

--step1
ReferenceError: b is not defined

情况二:多个代码片段,其中之一出现异常

当我们在代码中使用多块 script

<body>
进入part1
<script>
console.log("====step1")
var a= 1
a = a + b
console.log("====step2")
</script>

进入part2


<script>
console.log("====step3")
</script>
</body>

====step1

ReferenceError: b is not defined

====step3

情况三:外链代码,一个出现异常

多个外联 script

<script src="./js/s1.js"></script>
<script src="./js/s2.js"></script>

结果同上

====step1

ReferenceError: b is not defined

====step3

情况四:同步异步代码混合

var a = 0
console.log("--step1")
setTimeout(() => {
	console.log("--step3")
	a = a+b
	console.log("--step4")
},1000)


console.log("--step2")

结果如下

--step1
--step2
--step3

ReferenceError: b is not defined

情况五:异步代码外 try...catch

对异步代码进行异常捕获

window.addEventListener("error", function() {
console.log("全局错误捕获",arguments)
})


try{
    var a = 1;
    setTimeout(function() {
        a = a+b
    }, 100)
}catch(e) {
    console.log("===未在try..catch捕获")
}

情况六:加异常捕获

对可能出现异常的代码加异常捕获

var a = 0
console.log("--step1")
try{
	a = a+b
}catch(e){
	console.log(e.message)
}


console.log("--step2")

结果

--step1
b is not defined
--step2

情况七: await

包含 promise 的操作

async function a () {
	await Promise.reject("===step1")
}


//1
a()
console.log("===step2")


//2
async function b() => {
await a()
	console.log("===step3")
}
b()


// 3
async function c() {
	try{
		await a()
	}catch(e) {
		console.log(e)
	}
	console.log("===step4")
}
c()

结果

分支 1:

===step2
UnhandledPromiseRejectionWarning: ===step1

分支 2:

UnhandledPromiseRejectionWarning:===step1

分支 3:

===step1

===step4

情况八: promise 代码块异常

window.addEventListener("error", function() {
	console.log("全局错误捕获",arguments)
})
window.addEventListener("unhandledrejection", event => {
	console.warn("promise Error",event.reason.message, event.reason.stack)
});


function a() {
    return new Promise(function() {
        var a = 1
        a = a +b

    })
}
a()

测试结果

promise Error "b is not defined" "ReferenceError: b is not defined

at http://localhost:63342/jserror/test/thead9.html:20:20
at new Promise ()

以上测试,可以得出以下结论

  • a. 同步代码块异常会阻塞后续代码
  • b. 不同的 script 标签之间互不影响
  • c. 异步代码只会影响当前异步代码块的后续代码
  • d.promise 如果返回 reject,需要使用 catch 捕获处理
  • f. 如果使用 async、await, 可以转换成 try..catch 捕获

2. 了解 js 中异常抛出的内容

注: 异常抛出的内容,是我们定位问题的关键

按 1 中异常出现的情况,我们知道,异常信息主要分两类

一类是抛出 Error 相关错误

一类是 promise reject 未处理时异常


上述图中,只描述了同域下错误及标准 API 提供的错误信息

3. 收集跨域异常信息

非同域下错误是什么样子呢?

  
<script>
window.onerror=function(e) {
	console.log("全局错误:", arguments)
}
</script>
<script src="http://192.168.31.200:8080/js/s1.js"></script>

Chrome 下得到的结果是

Script error.

这是浏览器同源策略导致的, 会隐藏不同源资源的错误详情

想处理以上情况,有两种方案,

方案一:crossorigin

对引入的资源加crossorigin="anonymous"

<script>
window.onerror=function(e) {
	console.log("全局错误:", arguments)
}
</script>
<script src="http://10.10.47.38:8080/js/s1.js" crossorigin="anonymous"></script>

Chrome 下得到结果:

Uncaught ReferenceError: b is not defined

注: 该方法局限性, 一是浏览器兼容性, 二是请求的资源需要添加 CORS 相关响应头

方案二(只作为不支持 crossorigin 的补充)

使用 try..catch 包装需要捕获错误的方法或代码块

如何确定,哪些方法是我们需要单独封装的

我们已经知道,异常是出现在外部 js 中,有可能是 cdn 的资源或者引用网络上其他的资源,他们有个特点就是跨域, 一旦这些文件发生错误, 我们无法获取到具体的错误信息,

那么除了 crossorigin,我们还有那些方法可以取到跨域 js 的异常呢?

a. 在同域 js 中调用跨域 js 的函数, 手动包装 try..catch

 try{
	m1()
}catch(e){
	throw e
}

这时候抛出的 error 等同于同域下的错误信息

b. 跨域 js 中有一些异步的代码, 如 setTimeout、eventListener 等

对于这一类,我们可以对原生的方法进行封装, 对参数包裹 try...catch, 可以达到手动包装的效果

如 setTimeout, 我们对函数入参进行封装即可

// test.html
window.onerror=function(e) {
	console.log("全局错误:", arguments[0])
}
var originTo = window.setTimeout
function wrap (originTo) {
	return function(fun, arg) {
		var fun2 = function() {
			try{
				fun.call(this, arguments)
			}catch(e){
				throw e
			}
		}
		originTo(fun2, arg)
	}
}
window.setTimeout = wrap(originTo)


m1()


// s5.js
function m1 () {
	setTimeout(function() {
		console.log("====step1")
		var a = 1
		a = a+b
		console.log("====step2")
	},100)
}


输出结果为:

全局错误: Uncaught ReferenceError: b is not defined

我们使用自定义方法可以对常用对象进行包裹, 但是并不能做到全部拦截, 如大家常用的 sentry, 如果出现不在特定方法内的跨域错误, 会直接被 sentry 吞掉

基于以上思路, 我们提供一个通用的封装方法,可参考 sentry 或者 badjs, sentry 代码如下

context: function(options, func, args) {
	if (isFunction(options)) {
		args = func || [];
		func = options;
		options = undefined;
	}
	return this.wrap(options, func).apply(this, args);
},

 
/*
* Wrap code within a context and returns back a new function to be executed
*
* @param {object} options A specific set of options for this context [optional]
* @param {function} func The function to be wrapped in a new context
* @param {function} func A function to call before the try/catch wrapper [optional, private]
* @return {function} The newly wrapped functions with a context
*/
wrap: function(options, func, _before) {
	var self = this;
	// 1 argument has been passed, and it's not a function
	// so just return it
	if (isUndefined(func) && !isFunction(options)) {
	return options;
}

 
// options is optional
if (isFunction(options)) {
  func = options;
  options = undefined;
}

// At this point, we've passed along 2 arguments, and the second one
// is not a function either, so we'll just return the second argument.
if (!isFunction(func)) {
  return func;
}

// We don't wanna wrap it twice!
try {
  if (func.__raven__) {
    return func;
  }

  // If this has already been wrapped in the past, return that
  if (func.__raven_wrapper__) {
    return func.__raven_wrapper__;
  }
} catch (e) {
  // Just accessing custom props in some Selenium environments
  // can cause a "Permission denied" exception (see raven-js#495).
  // Bail on wrapping and return the function as-is (defers to window.onerror).
  return func;
}

function wrapped() {
  var args = [],
    i = arguments.length,
    deep = !options || (options &amp;&amp; options.deep !== false);

  if (_before &amp;&amp; isFunction(_before)) {
    _before.apply(this, arguments);
  }

  // Recursively wrap all of a function's arguments that are
  // functions themselves.
  while (i--) args[i] = deep ? self.wrap(options, arguments[i]) : arguments[i];

  try {
    // Attempt to invoke user-land function
    // NOTE: If you are a Sentry user, and you are seeing this stack frame, it
    //       means Raven caught an error invoking your application code. This is
    //       expected behavior and NOT indicative of a bug with Raven.js.
    return func.apply(this, args);
  } catch (e) {
    self._ignoreNextOnError();
    self.captureException(e, options);
    throw e;
  }
}

// copy over properties of the old function
for (var property in func) {
  if (hasKey(func, property)) {
    wrapped[property] = func[property];
  }
}
wrapped.prototype = func.prototype;

func.__raven_wrapper__ = wrapped;
// Signal that this function has been wrapped already
// for both debugging and to prevent it to being wrapped twice
wrapped.__raven__ = true;
wrapped.__inner__ = func;

return wrapped;
}

我们可以调用 wrap 方法对函数进行封装

如项目中使用了 requirejs,那我们可以通过直接对 require 和 define 对象封装,从而达到对跨域文件全内容封装的目的


if (typeof define === 'function' && define.amd) {
	window.define = wrap({deep: false}, define);
	window.require = wrap({deep: false}, require);
}

注: 该方法的局限性在于,需要开发者发现项目中的一些关键入口并手动封装

以上我们讨论了 Error 的类型、出现原因及如何捕获异常,然而上图中标识的错误字段兼容性也是一大问题

好在 前人栽树后人乘凉,有一个库可以帮助我们处理该问题 TraceKit O(∩_∩)O~~

4. 错误捕获上报

结合 2 中内容,再加上万能捕获 window.onerror, 即可对错误信息进行有效的获取

如果你使用了 traceKit 库, 那么你可以直接使用下面代码

tracekit.report.subscribe(function(ex, options) {
            report.captureException(ex, options)       
    })

如果没有,那我们可以直接重新 onerror 方法即可

var oldErrorHandler = window.onerror
window.onerror = function(){
	// 上报错误信息
	if(oldErrorHander){
	oldErrorHandler.apply(this, argument)
	}
}


2 图中 promise 被单独列出分支,因为我们需要使用特定事件处理

if(window.addEventListener) {
	window.addEventListener("unhandledrejection", function(event) {
		report.captureException(event.reason || {message: "unhandlePromiseError"}, {frame: "promise"})
	});
}


5. 框架类异常收集

针对现在流行的框架

vue, 通过errorHandler 钩子处理

function formatComponentName(vm) {
	if (vm.$root === vm) {
		return 'root instance';
	}
	var name = vm._isVue ? vm.$options.name || vm.$options._componentTag : vm.name;
	return (
	(name ? 'component <' + name + '>' : 'anonymous component') +
	(vm._isVue && vm.$options.__file ? ' at ' + vm.$options.__file : '')
	);
}


function vuePlugin(Vue) {
	return {
	doApply: function() {
	Vue = Vue || window.Vue;
	
	
	        // quit if Vue isn't on the page
	        if (!Vue || !Vue.config) return;
	
	        var self = this;
	
	        var _oldOnError = Vue.config.errorHandler;
	        Vue.config.errorHandler = function VueErrorHandler(error, vm, info) {
	            var metaData = {
	                componentName: formatComponentName(vm),
	                propsData: vm.$options.propsData
	            };
	
	            // lifecycleHook is not always available
	            if (typeof info !== 'undefined') {
	                metaData.lifecycleHook = info;
	            }
	
	            self.captureException(error, {
	                frame: "vue",
	                extra: JSON.stringify(metaData)
	            });
	
	            if (typeof _oldOnError === 'function') {
	                _oldOnError.call(this, error, vm, info);
	            }
	        };
	    }
	}
}

react, react16 版本之后,引入错误边界,有些非阻塞异常会通过该钩子抛出

我们可以

class ErrorBoundary extends React.Component {
	constructor(props) {
		super(props);
		this.state = { hasError: false };
	}
	
	
	static getDerivedStateFromError(error) {
		// 更新 state 使下一次渲染能够显示降级后的 UI
		return { hasError: true };
	}
	
	
	componentDidCatch(error, errorInfo) {
		// 你同样可以将错误日志上报给服务器
		report.captureException(error, {
			frame: "react",
			extra: JSON.stringify(errorInfo)
		});
	}


	render() {
		if (this.state.hasError) {
		// 你可以自定义降级后的 UI 并渲染
		return <h1>Something went wrong.</h1>;
		}
		
		
		return this.props.children; 
	}
}

至此,我们就完成了对 Error 的信息获取, 为我们做错误报警及堆栈还原做基础


标题:收集错误信息及堆栈-前端监控之数据收集篇
作者:hugh0524
地址:https://blog.uproject.cn/articles/2019/07/26/1564107400938.html