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

Read TraceKit #69

Open
XXHolic opened this issue Nov 25, 2020 · 0 comments
Open

Read TraceKit #69

XXHolic opened this issue Nov 25, 2020 · 0 comments

Comments

@XXHolic
Copy link
Owner

XXHolic commented Nov 25, 2020

目录

引子

前端异常研究时,发现了 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

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