XXHolic / blog

I wonder how~, I wonder why~

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Read TraceKit

XXHolic opened this issue · comments

目录

引子

前端异常研究时,发现了 TraceKit 这个库, sentry 里面部分功能也基于这个库再改造了,就去看了下源码。

TraceKit 版本: v0.4.6 。

简介

TraceKit 对浏览器堆栈进行解析追踪,对市场上主要的浏览器都做了测试。在浏览器异常一些方面做了比较详尽的处理。

思路

源码中的一些思路:

  1. 通过订阅的方式向外部抛出处理后的异常。
  2. 主要对 onerroronunhandledrejection 事件进行了包装,支持撤销包装。
  3. 异常信息处理,结合了正则匹配。

下面针对主要的逻辑进行介绍。

具体实现

捕获异常并解析主要有三种途径: onerror 事件、 onunhandledrejection 事件、 TraceKit.report(ex) 。

onerror 事件

源码
/**
 * Ensures all global unhandled exceptions are recorded.
 * Supported by Gecko and IE.
 * @param {string} message Error message.
 * @param {string} url URL of script that generated the exception.
 * @param {(number|string)} lineNo The line number at which the error occurred.
 * @param {(number|string)=} columnNo The column number at which the error occurred.
 * @param {Error=} errorObj The actual Error object.
 * @memberof TraceKit.report
 */
function traceKitWindowOnError(message, url, lineNo, columnNo, errorObj) {
    var stack = null;

    if (lastExceptionStack) {
        TraceKit.computeStackTrace.augmentStackTraceWithInitialElement(lastExceptionStack, url, lineNo, message);
      processLastException();
    } else if (errorObj) {
        stack = TraceKit.computeStackTrace(errorObj);
        notifyHandlers(stack, true, errorObj);
    } else {
        var location = {
          'url': url,
          'line': lineNo,
          'column': columnNo
        };

        var name;
        var msg = message; // must be new var or will modify original `arguments`
        if ({}.toString.call(message) === '[object String]') {
            var groups = message.match(ERROR_TYPES_RE);
            if (groups) {
                name = groups[1];
                msg = groups[2];
            }
        }

        location.func = TraceKit.computeStackTrace.guessFunctionName(location.url, location.line);
        location.context = TraceKit.computeStackTrace.gatherContext(location.url, location.line);
        stack = {
            'name': name,
            'message': msg,
            'mode': 'onerror',
            'stack': [location]
        };

        notifyHandlers(stack, true, null);
    }

    if (_oldOnerrorHandler) {
        return _oldOnerrorHandler.apply(this, arguments);
    }

    return false;
}

封装后的 onerror 事件处理程序中将异常分为了三类:

  1. 优先处理 lastExceptionStack 记录的值;
  2. 不符合条件 1 就处理 errorObj 有值的情况;
  3. 不符合 1 和 2 ,构造一个异常返回。

比较多的异常会在第二类中进行处理,执行 TraceKit.computeStackTrace(ex, depth) 方法。

源码
    /**
     * Computes a stack trace for an exception.
     * @param {Error} ex
     * @param {(string|number)=} depth
     * @memberof TraceKit.computeStackTrace
     */
    function computeStackTrace(ex, depth) {
        var stack = null;
        depth = (depth == null ? 0 : +depth);

        try {
            // This must be tried first because Opera 10 *destroys*
            // its stacktrace property if you try to access the stack
            // property first!!
            stack = computeStackTraceFromStacktraceProp(ex);
            if (stack) {
                return stack;
            }
        } catch (e) {
            if (debug) {
                throw e;
            }
        }

        try {
            stack = computeStackTraceFromStackProp(ex);
            if (stack) {
                return stack;
            }
        } catch (e) {
            if (debug) {
                throw e;
            }
        }

        try {
            stack = computeStackTraceFromOperaMultiLineMessage(ex);
            if (stack) {
                return stack;
            }
        } catch (e) {
            if (debug) {
                throw e;
            }
        }

        try {
            stack = computeStackTraceByWalkingCallerChain(ex, depth + 1);
            if (stack) {
                return stack;
            }
        } catch (e) {
            if (debug) {
                throw e;
            }
        }

        return {
            'name': ex.name,
            'message': ex.message,
            'mode': 'failed'
        };
    }

该方法中对异常分为 4 种类型进行处理:

  1. computeStackTraceFromStacktraceProp(ex) 针对 Opera 10+ 中抛出的异常进行处理;
  2. computeStackTraceFromStackProp(ex) 针对 Chrome、 Gecko 中抛出的异常进行处理;
  3. computeStackTraceFromOperaMultiLineMessage(ex) 针对 Opera 9 及其更早版本抛出的异常进行处理;
  4. computeStackTraceByWalkingCallerChain(ex) 针对 Safari 、 IE 中抛出的异常进行处理;

看看使用比较多的 Chrome 中的处理 computeStackTraceFromStackProp(ex) 方法。

源码
    /**
     * Computes stack trace information from the stack property.
     * Chrome and Gecko use this property.
     * @param {Error} ex
     * @return {?TraceKit.StackTrace} Stack trace information.
     * @memberof TraceKit.computeStackTrace
     */
    function computeStackTraceFromStackProp(ex) {
        if (!ex.stack) {
            return null;
        }

        var chrome = /^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|\/).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i,
            gecko = /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|\[native).*?|[^@]*bundle)(?::(\d+))?(?::(\d+))?\s*$/i,
            winjs = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i,

            // Used to additionally parse URL/line/column from eval frames
            isEval,
            geckoEval = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i,
            chromeEval = /\((\S*)(?::(\d+))(?::(\d+))\)/,

            lines = ex.stack.split('\n'),
            stack = [],
            submatch,
            parts,
            element,
            reference = /^(.*) is undefined$/.exec(ex.message);

        for (var i = 0, j = lines.length; i < j; ++i) {
            if ((parts = chrome.exec(lines[i]))) {
                var isNative = parts[2] && parts[2].indexOf('native') === 0; // start of line
                isEval = parts[2] && parts[2].indexOf('eval') === 0; // start of line
                if (isEval && (submatch = chromeEval.exec(parts[2]))) {
                    // throw out eval line/column and use top-most line/column number
                    parts[2] = submatch[1]; // url
                    parts[3] = submatch[2]; // line
                    parts[4] = submatch[3]; // column
                }
                element = {
                    'url': !isNative ? parts[2] : null,
                    'func': parts[1] || UNKNOWN_FUNCTION,
                    'args': isNative ? [parts[2]] : [],
                    'line': parts[3] ? +parts[3] : null,
                    'column': parts[4] ? +parts[4] : null
                };
            } else if ( parts = winjs.exec(lines[i]) ) {
                element = {
                    'url': parts[2],
                    'func': parts[1] || UNKNOWN_FUNCTION,
                    'args': [],
                    'line': +parts[3],
                    'column': parts[4] ? +parts[4] : null
                };
            } else if ((parts = gecko.exec(lines[i]))) {
                isEval = parts[3] && parts[3].indexOf(' > eval') > -1;
                if (isEval && (submatch = geckoEval.exec(parts[3]))) {
                    // throw out eval line/column and use top-most line number
                    parts[3] = submatch[1];
                    parts[4] = submatch[2];
                    parts[5] = null; // no column when eval
                } else if (i === 0 && !parts[5] && !_isUndefined(ex.columnNumber)) {
                    // FireFox uses this awesome columnNumber property for its top frame
                    // Also note, Firefox's column number is 0-based and everything else expects 1-based,
                    // so adding 1
                    // NOTE: this hack doesn't work if top-most frame is eval
                    stack[0].column = ex.columnNumber + 1;
                }
                element = {
                    'url': parts[3],
                    'func': parts[1] || UNKNOWN_FUNCTION,
                    'args': parts[2] ? parts[2].split(',') : [],
                    'line': parts[4] ? +parts[4] : null,
                    'column': parts[5] ? +parts[5] : null
                };
            } else {
                continue;
            }

            if (!element.func && element.line) {
                element.func = guessFunctionName(element.url, element.line);
            }

            element.context = element.line ? gatherContext(element.url, element.line) : null;
            stack.push(element);
        }

        if (!stack.length) {
            return null;
        }

        if (stack[0] && stack[0].line && !stack[0].column && reference) {
            stack[0].column = findSourceInLine(reference[1], stack[0].url, stack[0].line);
        }

        return {
            'mode': 'stack',
            'name': ex.name,
            'message': ex.message,
            'stack': stack
        };
    }

对异常信息中 stack 处理的思路:

  1. stack 中字符串信息,以 \n 进行分割得到数组,然后进行遍历进行正则匹配,提供了 3 种匹配规则,分别是 chromegeckowinjs
  2. 遍历过程中,优先进行 chrome 匹配,如果不符合,再进行 winjs ,如果不符合 ,再进行 gecko 匹配;
  3. 如果以上 3 种匹配都不符合,就重新进行下个循环,如果符合其中之一,接着推测是否有函数并组装;
  4. 接着对异常所处的上下环境进行猜测并组装;
  5. 以上步骤处理完后,放入数组中,开始下一个循环。

这里需要提一下针对 Safari、 IE 中的处理,添加一个额外的参数 incomplete 参数,表示是否完成了异常的处理。在另外一个地方会用到。

onunhandledrejection 事件

源码
function installGlobalUnhandledRejectionHandler() {
  if (_onUnhandledRejectionHandlerInstalled === true) {
      return;
  }

  _oldOnunhandledrejectionHandler = window.onunhandledrejection;
  window.onunhandledrejection = traceKitWindowOnUnhandledRejection;
  _onUnhandledRejectionHandlerInstalled = true;
}

function traceKitWindowOnUnhandledRejection(e) {
  var stack = TraceKit.computeStackTrace(e.reason);
  notifyHandlers(stack, true, e.reason);
}

onunhandledrejection 事件处理程序同样是使用了 TraceKit.computeStackTrace(ex, depth) 方法。

TraceKit.report(ex)

这是一种主动的上报方式,也是对外暴露的一个方法。

源码
/**
 * Cross-browser processing of unhandled exceptions
 *
 * Syntax:
 * ```js
 *   TraceKit.report.subscribe(function(stackInfo) { ... })
 *   TraceKit.report.unsubscribe(function(stackInfo) { ... })
 *   TraceKit.report(exception)
 *   try { ...code... } catch(ex) { TraceKit.report(ex); }
 * ```
 *
 * Supports:
 *   - Firefox: full stack trace with line numbers, plus column number
 *     on top frame; column number is not guaranteed
 *   - Opera: full stack trace with line and column numbers
 *   - Chrome: full stack trace with line and column numbers
 *   - Safari: line and column number for the top frame only; some frames
 *     may be missing, and column number is not guaranteed
 *   - IE: line and column number for the top frame only; some frames
 *     may be missing, and column number is not guaranteed
 *
 * In theory, TraceKit should work on all of the following versions:
 *   - IE5.5+ (only 8.0 tested)
 *   - Firefox 0.9+ (only 3.5+ tested)
 *   - Opera 7+ (only 10.50 tested; versions 9 and earlier may require
 *     Exceptions Have Stacktrace to be enabled in opera:config)
 *   - Safari 3+ (only 4+ tested)
 *   - Chrome 1+ (only 5+ tested)
 *   - Konqueror 3.5+ (untested)
 *
 * Requires TraceKit.computeStackTrace.
 *
 * Tries to catch all unhandled exceptions and report them to the
 * subscribed handlers. Please note that TraceKit.report will rethrow the
 * exception. This is REQUIRED in order to get a useful stack trace in IE.
 * If the exception does not reach the top of the browser, you will only
 * get a stack trace from the point where TraceKit.report was called.
 *
 * Handlers receive a TraceKit.StackTrace object as described in the
 * TraceKit.computeStackTrace docs.
 *
 * @memberof TraceKit
 * @namespace
 */
TraceKit.report = (function reportModuleWrapper() {
    var handlers = [],
        lastException = null,
        lastExceptionStack = null;

    /**
     * Add a crash handler.
     * @param {Function} handler
     * @memberof TraceKit.report
     */
    function subscribe(handler) {
        installGlobalHandler();
        installGlobalUnhandledRejectionHandler();
        handlers.push(handler);
    }

    /**
     * Remove a crash handler.
     * @param {Function} handler
     * @memberof TraceKit.report
     */
    function unsubscribe(handler) {
        for (var i = handlers.length - 1; i >= 0; --i) {
            if (handlers[i] === handler) {
                handlers.splice(i, 1);
            }
        }

        if (handlers.length === 0) {
            uninstallGlobalHandler();
            uninstallGlobalUnhandledRejectionHandler();
        }
    }

    /**
     * Dispatch stack information to all handlers.
     * @param {TraceKit.StackTrace} stack
     * @param {boolean} isWindowError Is this a top-level window error?
     * @param {Error=} error The error that's being handled (if available, null otherwise)
     * @memberof TraceKit.report
     * @throws An exception if an error occurs while calling an handler.
     */
    function notifyHandlers(stack, isWindowError, error) {
        var exception = null;
        if (isWindowError && !TraceKit.collectWindowErrors) {
          return;
        }
        for (var i in handlers) {
            if (_has(handlers, i)) {
                try {
                    handlers[i](stack, isWindowError, error);
                } catch (inner) {
                    exception = inner;
                }
            }
        }

        if (exception) {
            throw exception;
        }
    }

    var _oldOnerrorHandler, _onErrorHandlerInstalled;
    var _oldOnunhandledrejectionHandler, _onUnhandledRejectionHandlerInstalled;

    /**
     * Ensures all global unhandled exceptions are recorded.
     * Supported by Gecko and IE.
     * @param {string} message Error message.
     * @param {string} url URL of script that generated the exception.
     * @param {(number|string)} lineNo The line number at which the error occurred.
     * @param {(number|string)=} columnNo The column number at which the error occurred.
     * @param {Error=} errorObj The actual Error object.
     * @memberof TraceKit.report
     */
    function traceKitWindowOnError(message, url, lineNo, columnNo, errorObj) {
        var stack = null;

        if (lastExceptionStack) {
            TraceKit.computeStackTrace.augmentStackTraceWithInitialElement(lastExceptionStack, url, lineNo, message);
    	    processLastException();
        } else if (errorObj) {
            stack = TraceKit.computeStackTrace(errorObj);
            notifyHandlers(stack, true, errorObj);
        } else {
            var location = {
              'url': url,
              'line': lineNo,
              'column': columnNo
            };

            var name;
            var msg = message; // must be new var or will modify original `arguments`
            if ({}.toString.call(message) === '[object String]') {
                var groups = message.match(ERROR_TYPES_RE);
                if (groups) {
                    name = groups[1];
                    msg = groups[2];
                }
            }

            location.func = TraceKit.computeStackTrace.guessFunctionName(location.url, location.line);
            location.context = TraceKit.computeStackTrace.gatherContext(location.url, location.line);
            stack = {
                'name': name,
                'message': msg,
                'mode': 'onerror',
                'stack': [location]
            };

            notifyHandlers(stack, true, null);
        }

        if (_oldOnerrorHandler) {
            return _oldOnerrorHandler.apply(this, arguments);
        }

        return false;
    }

    /**
     * Ensures all unhandled rejections are recorded.
     * @param {PromiseRejectionEvent} e event.
     * @memberof TraceKit.report
     * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onunhandledrejection
     * @see https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent
     */
    function traceKitWindowOnUnhandledRejection(e) {
        var stack = TraceKit.computeStackTrace(e.reason);
        notifyHandlers(stack, true, e.reason);
    }

    /**
     * Install a global onerror handler
     * @memberof TraceKit.report
     */
    function installGlobalHandler() {
        if (_onErrorHandlerInstalled === true) {
            return;
        }

        _oldOnerrorHandler = window.onerror;
        window.onerror = traceKitWindowOnError;
        _onErrorHandlerInstalled = true;
    }

    /**
     * Uninstall the global onerror handler
     * @memberof TraceKit.report
     */
    function uninstallGlobalHandler() {
        if (_onErrorHandlerInstalled) {
            window.onerror = _oldOnerrorHandler;
            _onErrorHandlerInstalled = false;
        }
    }

    /**
     * Install a global onunhandledrejection handler
     * @memberof TraceKit.report
     */
    function installGlobalUnhandledRejectionHandler() {
        if (_onUnhandledRejectionHandlerInstalled === true) {
            return;
        }

        _oldOnunhandledrejectionHandler = window.onunhandledrejection;
        window.onunhandledrejection = traceKitWindowOnUnhandledRejection;
        _onUnhandledRejectionHandlerInstalled = true;
    }

    /**
     * Uninstall the global onunhandledrejection handler
     * @memberof TraceKit.report
     */
    function uninstallGlobalUnhandledRejectionHandler() {
        if (_onUnhandledRejectionHandlerInstalled) {
            window.onunhandledrejection = _oldOnunhandledrejectionHandler;
            _onUnhandledRejectionHandlerInstalled = false;
        }
    }

    /**
     * Process the most recent exception
     * @memberof TraceKit.report
     */
    function processLastException() {
        var _lastExceptionStack = lastExceptionStack,
            _lastException = lastException;
        lastExceptionStack = null;
        lastException = null;
        notifyHandlers(_lastExceptionStack, false, _lastException);
    }

    /**
     * Reports an unhandled Error to TraceKit.
     * @param {Error} ex
     * @memberof TraceKit.report
     * @throws An exception if an incomplete stack trace is detected (old IE browsers).
     */
    function report(ex) {
        if (lastExceptionStack) {
            if (lastException === ex) {
                return; // already caught by an inner catch block, ignore
            } else {
              processLastException();
            }
        }

        var stack = TraceKit.computeStackTrace(ex);
        lastExceptionStack = stack;
        lastException = ex;

        // If the stack trace is incomplete, wait for 2 seconds for
        // slow slow IE to see if onerror occurs or not before reporting
        // this exception; otherwise, we will end up with an incomplete
        // stack trace
        setTimeout(function () {
            if (lastException === ex) {
                processLastException();
            }
        }, (stack.incomplete ? 2000 : 0));

        throw ex; // re-throw to propagate to the top level (and cause window.onerror)
    }

    report.subscribe = subscribe;
    report.unsubscribe = unsubscribe;
    return report;
}());

源码中是一个立即执行函数,返回了 report(ex) 函数,并给这个函数增加了 subscribeunsubscribe 属性,分支指向了 subscribe(handler) 函数和 unsubscribe(handler) 函数。

接下来看看执行 report 第一次上报的时候做了什么:

  1. 调用 TraceKit.computeStackTrace 方法,处理后结果赋给 lastExceptionStack ,源异常 ex 赋给 lastException
  2. setTimeout 执行了一个逻辑:如果 lastException === ex ,则执行 processLastException() 方法,该方法会重置 lastExceptionlastExceptionStack ,把已处理的异常通知给订阅者。延时的时间由上面提过的参数 incomplete 决定。
  3. 最终会 throw 给上一层,触发 window.onerror
  4. onerror 中, lastExceptionStack 有值会优先处理,并会添加了 incomplete 参数,最终也会执行 processLastException() 方法。

数据格式

处理之后数据格式中可能有的属性:

{
  'incomplete': false,
  'mode': 'stack',
  'name': 'name',
  'message': 'message',
  'partial': true,
  'stack': []
}
  • incomplete : 信息是否不完整。
  • mode : 解析异常信息的方法途径。 stack 表示从异常 ex.stack中解析; stacktrace 表示从 ex.stacktrace 中解析,针对的是 Opera 10+ ; multiline 表示从 ex.message 中进行解析,针对的是 Opera 9 及更早版本 ; callers 表示根据 arguments.caller 进行解析,主要针对 Safari 和 IE; onerror 表示 onerror 事件中处理特殊一类异常; failed 表示解析失败。
  • name : 异常名称。
  • message : 异常描述信息。
  • partial : 抛出的异常中能获取到 url(导致异常的脚本路径) 和 lineNo (导致异常的脚本行数),才会有 partial 属性。
  • stack : 存储解析后栈帧。

其中 stack 的存放对象格式:

{
  'url': '', // 脚本或 html 路径
  'func': '', // 函数名称,匿名函数可能为空
  'args': '', // 函数参数
  'line': 12, // 所处行数
  'column': 32, // 所处列数
  'context': [] // 猜测的相关源码
}

特殊情况

上面是分析正常情况下的逻辑,比较极端的情况,需要同时结合几种处理逻辑进行判断。

情况 1

情景: 没有使用 TraceKit.report(ex) 方法,应用程序中出现了死循环,一直抛出异常。

预计: 全局的异常捕获,会跟随死循环不停的上报异常,这也是一个风险点。

情况 2

情景: 使用 TraceKit.report(ex) 方法,异常 A 进入 ,刚执行完,这时异常 B 进入了。

预计: 这时 lastExceptionStack 可能仍有值,那么就会进入到 processLastException() 中,但 A 异常抛到 onerror ,在 onerror 处理程序中,这时 lastExceptionStack 有值,就会处理,最终也会执行 processLastException() 。如果捕获 A 异常的 onerror 事件处理程序先执行了,那么 B 异常可以按照正常逻辑处理;如果 B 异常处理先执行,那么 A 异常的处理就会少一步,这样就会导致同类上报的信息产生了差异。尝试模拟这样的情况没有达到成功,是否有这样的情况,不太确定。

参考资料

🗑️

大事化小,小事化了。

68-poster