一、什么是sentry
Sentry 是一个实时事件日志记录和汇集的平台。其专注于错误监控以及提取一切事后处理所需信息而不依赖于麻烦的用户反馈。它分为客户端和服务端,客户端(目前客户端有Javascript,Python, PHP,C#, Ruby等多种语言)就嵌入在你的应用程序中间,程序出现异常就向服务端发送消息,服务端将消息记录到数据库中并提供一个web页方便查看。Sentry由python编写,源码开放,性能卓越,易于扩展,目前著名的用户有Disqus, Path, mozilla, Pinterest等。
本篇文章只对其前端异常堆栈计算的核心逻辑进行梳理
二、核心处理逻辑
1.入口
使用raven-js导出的类Raven,调用其install方法。
1 2 3 | install: function(opts) { return this.config(opts).init(); } |
其中config方法为扩展自定义配置。在init方法中进行了防止重复初始化处理,并调用TraceKit.report.subscribe方法,重写了window.onerror方法为traceKitWindowOnError。
2.重写window.onerror方法
重写window.onerror方法是为了进行兼容处理,统一不同浏览器环境下错误对象的差异(chrome,firefox,ie),输出统一的错误对象后。在触发onerror回调前调用了_handleOnErrorStackInfo方法,该方法为重新整理数据处理onerror类型的错误报文并进行异常过滤,最终提交给服务端处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // 初始化异常捕获 init: function() { var self = this; if (self.isSetup() && !self._isGotyouInstalled) { TraceKit.report.subscribe(function() { // onerror监听 self._handleOnErrorStackInfo.apply(self, arguments); }); self._instrumentTryCatchTime(); self._catchStaticResError(); //监听静态资源报错 self._isGotyouInstalled = true; } return this; }, |
1 2 3 4 5 6 7 8 9 10 11 12 | function subscribe(handler) { installGlobalHandler(); handlers.push(handler); } function installGlobalHandler() { if (_onErrorHandlerInstalled) { return; } _oldOnerrorHandler = _window.onerror; _window.onerror = traceKitWindowOnError; _onErrorHandlerInstalled = true; } |
3.监听静态资源报错
在初始化异常捕获的init方法中调用了_catchStaticResError方法,在该方法中通过window.addEventListener在捕获阶段捕获静态资源加载的异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | if (_window.addEventListener) { /* 需要特别注意addEventListener的第三个参数,是否在捕获阶段处理 这个参数,大多数时候用的都是false 在这里,chrome、firefox也都可以用false 但是opera用false时就无法处理error 必须设置为true,在捕获阶段处理error,脚本才能正常运行 */ _window.addEventListener('error', catchStaticResErrorHandler, true); } else if (_window.attachEvent) { /** * IE9以上才会捕捉媒体数据加载异常的报错 */ _window.attachEvent('onerror', catchStaticResErrorHandler); } |
在其回调中,对错误报文进行处理并异常过滤,最终提交给服务端处理。
4.对计时器函数进行包装
在上一篇前端js错误监控系列一里我们已经提到,目前前端捕获页面异常的方式主要有两种:try...catch和window.onerror。
虽然使用window.onerror可以获取页面的出错信息、出错文件和行号,但是window. onerror有跨域限制,如果需要获取错误发生的具体描述、堆栈内容、行号、列号和具体的出错文件等详细日志,就必须使用try…catch,但是try…catch又不能在多个作用域中统一处理错误。以下面的代码为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | try{ // 单一作用域try...catch可以捕获错误信息并进行处理 console.log(obj); }catch(e){ console.log(e); //处理异常,ReferenceError: obj is not defined } try{ // 不同作用域不能捕获到错误信息 setTimeout(function() { console.log(obj); // 直接报错,不经过catch处理 }, 200); }catch(e){ console.log(e); } // 同一个作用域下能捕获到错误信息 setTimeout(function() { try{ // 当前作用域try...catch可以捕获错误信息并进行处理 console.log(obj); }catch(e){ console.log(e); //处理异常,ReferenceError: obj is not defined } }, 200); |
从上面的例子中,我们可以看到,try...catch无法获取异步函数或其他作用域中的错误信息。幸运的是,我们可以对前端脚本中常用的异步方法入口函数或模块引用的入口方法统一使用try…catch进行一层封装,这样就可以使用try…catch捕获每个引用模块作用域下的主要错误信息了。
包裹封装的具体思想如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | function wrapFunction(fn) { return function() { try { return fn.apply(this, arguments); } catch (e) { console.log(e); _errorProcess(e); return; } }; } // 之后fn函数里面的代码运行出错时则是可以被捕获到的了 var _setTimeout = setTimeout; setTimeout = function(fn, time){ return _setTimeout(wrapFunction(fn), time); } |
主要的包装逻辑就是,将计时器里的回调函数使用try...catch包裹,对错误进行捕获。我们可以看到 raven.js的源码里,_instrumentTryCatch方法中对计时器函数进行了包装,可以看到源码的wrap方法即是包裹的方法,与我们的封装思想一致。捕获到错误之后,通过captureException处理收集错误,发送给后端服务器.
在raven.js的源码里,如果浏览器支持requestAnimationFrame,对requestAnimationFrame也进行了包装,同样调用的是wrap方法,将回调函数使用try...catch包裹
5.对未处理的promise错误进行处理
实现原理:当promise被reject并且错误信息没有被处理的时候,会抛出一个unhandledrejection。这个错误不会被window.onerror以及window.addEventListener('error')捕获,但是有专门的window.addEventListener('unhandledrejection')方法进行捕获处理。
在raven.js里如果你设置了对未捕获的promise rejection进行处理,那么会通过_attachPromiseRejectionHandler方法监听unhandledrejection事件,进行异常捕获
1 2 3 4 5 6 7 8 9 10 | if (self._globalOptions.captureUnhandledRejections) { self._attachPromiseRejectionHandler(); } _attachPromiseRejectionHandler: function() { this._promiseRejectionHandler = this._promiseRejectionHandler.bind(this); _window.addEventListener && _window.addEventListener('unhandledrejection', this._promiseRejectionHandler); return this; }, |
6.对浏览器中可能存在的基于发布订阅模式进行回调处理的函数进行包装重写
在_instrumentTryCatch方法中会检测全局对象是否有以下属性,并检测以下属性是否有发布订阅接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | var eventTargets = [ 'EventTarget', 'Window', 'Node', 'ApplicationCache', 'AudioTrackList', 'ChannelMergerNode', 'CryptoOperation', 'EventSource', 'FileReader', 'HTMLUnknownElement', 'IDBDatabase', 'IDBRequest', 'IDBTransaction', 'KeyOperation', 'MediaController', 'MessagePort', 'ModalWindow', 'Notification', 'SVGElementInstance', 'Screen', 'TextTrack', 'TextTrackCue', 'TextTrackList', 'WebSocket', 'WebSocketWorker', 'Worker', 'XMLHttpRequest', 'XMLHttpRequestEventTarget', 'XMLHttpRequestUpload' ]; for (var i = 0; i < eventTargets.length; i++) { wrapEventTarget(eventTargets[i]); } |
如果存在发布订阅接口,将重写对应发布订阅接口(通过检测是否有'addEventlistener属性'和'removeEventListener'属性),在对应回调调用时用try...catch包裹,对调用过程中的错误进行监控上报.
7.对捕获到的错误对象进行处理
捕获到的错误通过captureException方法进行处理,在该方法中会对错误类型进行判断,错误对象的判断通过utils内部的方法进行判断,原理是调用Object.property.toString.call方法,将各错误对象转化为字符串,来确定错误类型
对于'[object Object]'非错误对象,先进行兼容
1 2 3 4 5 6 7 | else if (isPlainObject(ex)) { // If it is plain Object, serialize it manually and extract options // This will allow us to group events based on top-level keys // which is much better than creating new group when any key/value change options = this._getCaptureExceptionOptionsFromPlainObject(options, ex); ex = new Error(options.message); } |
接下来直接使用 TraceKit.computeStackTrace(统一跨浏览器的堆栈跟踪信息)方法 进行异常的堆栈跟踪
1 2 3 4 5 6 7 8 | try { var stack = TraceKit.computeStackTrace(ex); this._handleStackInfo(stack, options); } catch (ex1) { if (ex !== ex1) { throw ex1; } } |
获取到堆栈信息之后,结果传递给_handleStackInfo方法再次进行数据处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | _handleStackInfo: function(stackInfo, options) { var frames = this._prepareFrames(stackInfo, options); this._triggerEvent('handle', { stackInfo: stackInfo, options: options }); this._processException( stackInfo.name, stackInfo.message, stackInfo.url, stackInfo.lineno, frames, options ); }, |
其中,_prepareFrames方法,处理堆栈错误,确认该堆栈错误是否是应用内部错误,并初步处理stacktrace.frames
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | _prepareFrames: function(stackInfo, options) { var self = this; var frames = []; if (stackInfo.stack && stackInfo.stack.length) { each(stackInfo.stack, function(i, stack) { var frame = self._normalizeFrame(stack, stackInfo.url); if (frame) { frames.push(frame); } }); // e.g. frames captured via captureMessage throw if (options && options.trimHeadFrames) { for (var j = 0; j < options.trimHeadFrames && j < frames.length; j++) { frames[j].in_app = false; } } } frames = frames.slice(0, this._globalOptions.stackTraceLimit); return frames; }, |
_processException方法将堆栈信息结构重新整理,处理的最终结果就是上报的最终信息,通过_send方法发送给sentry后端服务