Sentry Raven.js学习

最近看看要不要在网上学习下性能监测和告警的解决方案,加在项目里。已经调研了一下才发现,项目里已经用上Raven.js了。实际上,各大公司也都有自己的实现方式,除了sentry的Raven.js外,还有腾讯的badjs,淘宝的JSTracker,阿里巴巴的FdSafe,支付宝的saijs等。早在几年前,就已经有许多解决方案了。

异常监测和信息采集的需要实现的主要功能点包括:

  • 前端SDK实现包括错误拦截和监控,错误信息包装、信息上报、API设计等
  • 提供一个可视化的管理后台
  • 可以正确定位错误位置
  • 可以对上报的日志进行筛选、查询、聚类等操作
  • 可以用邮件、短信或集成在其他平台中通知开发者

从一个前端初学者的角度,下面更多聊一下前端SDK的细节。

前端SDK实现

前端实现上的技术重点有三:错误捕获和封装AJAX上报JSON字符串化参数

在raven-js的vendor目录下,引用json-stringify-safeTracekit。前者为了避免JSON.stringify中出现的循环引用的情况,下面主要介绍后者。

Tracekit

常见的方案就是拦截window.onerror方法,在做完自己的工作后,调用原来的window.onerror。自己的工作里包括对错误信息的同一美化和包装。raven.js在这里是借助Tracekit.js完成的。

Tracekit主要分为两部分,Tracekit.report()Tracekit.computeStackTraceWrapper()。前者主要用来绑定和解绑错误监听函数、拦截错误;后者主要用来格式化错误信息。

Tracekit.report()

report()里,整体的设计和基本的观察者设计模式一样,内部成员handlers保存所有的事件消费者,与事件处理函数相关的有四个:

  • subscribe(),绑定一个监听错误的函数,并在绑定第一个函数时替换原有的window.onerror
  • unsubscribe(),解绑一个监听错误的函数,需要提供函数的引用
  • unsubscribeAll(),解绑所有监听错误的函数,还原原有的window.onerror
  • notifyHandlers(),触发错误时,将处理过的错误分发给各handlers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function notifyHandlers(stack, isWindowError) {
var exception = null;
if (isWindowError && !TraceKit.collectWindowErrors) {
return;
}
for (var i in handlers) {
if (handlers.hasOwnProperty(i)) {
try {
handlers[i].apply(null, [stack].concat(_slice.call(arguments, 2)));
} catch (inner) {
exception = inner;
}
}
}

if (exception) {
throw exception;
}
}

另外,函数installGlobalHandler()uninstallGlobalHandler()就是上文中用来拦截window.onerror的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function installGlobalHandler() {
if (_onErrorHandlerInstalled) {
return;
}
_oldOnerrorHandler = _window.onerror;
_window.onerror = traceKitWindowOnError;
_onErrorHandlerInstalled = true;
}
function uninstallGlobalHandler() {
if (!_onErrorHandlerInstalled) {
return;
}
_window.onerror = _oldOnerrorHandler;
_onErrorHandlerInstalled = false;
_oldOnerrorHandler = undefined;
}

report()中最主要的函数是traceKitWindowOnError()。它的工作流程如下:

  1. 查看lastException是否有正在处理的error,如果有则说明是当前错误引起的,使用computeStackTrace.augmentStackTraceWithInitialElement()追加到当前的错误栈前。调用processLastException(),将lastException的信息交给handler处理,并将lastException置空。
  2. 如果lastException为空,且Error为错误对象,使用computeStackTrace()格式化错误信息,再交给错误消费者。
  3. 如果lastException为空,且Error不是错误对象(如字符串),则自行包装错误信息,交给消费者
  4. 使用原来的window.onerror()处理事件
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
function traceKitWindowOnError(message, url, lineNo, colNo, ex) {
var stack = null;

if (lastExceptionStack) {
TraceKit.computeStackTrace.augmentStackTraceWithInitialElement(
lastExceptionStack,
url,
lineNo,
message
);
processLastException();
} else if (ex && utils.isError(ex)) {
// non-string `ex` arg; attempt to extract stack trace
stack = TraceKit.computeStackTrace(ex);
notifyHandlers(stack, true);
} else {
// 自行封装
// ...
notifyHandlers(stack, true);
}

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

Tracekit.computeStackTraceWrapper()

这一部分主要由下面几个函数组成:

  • computeStackTraceFromStackProp(),处理Chrome和Gecko浏览器下的错误信息格式化
  • computeStackTraceByWalkingCallerChain(),处理IE和Safari浏览器下的错误信息格式化
  • augmentStackTraceWithInitialElement(),在当前错误栈底新增新的错误信息,用于computeStackTraceByWalkingCallerChain()和第一部分的processLastException()
  • computeStackTrace(),格式化错误栈信息

其中computeStackTraceFromStackProp()通过换行符得到stack信息,并通过正则格式化所需要的错误信息,computeStackTraceByWalkingCallerChain()是利用arguments.caller得到错误栈信息并格式化。

computeStackTrace()代码如下:

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
function computeStackTrace(ex, depth) {
var stack = null;
depth = depth == null ? 0 : +depth;

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

try {
stack = computeStackTraceByWalkingCallerChain(ex, depth + 1);
if (stack) {
return stack;
}
} catch (e) {
if (TraceKit.debug) {
throw e;
}
}
return {
name: ex.name,
message: ex.message,
url: getLocationHref()
};
}

除了Tracekit所做的工作外,raven本身也对console的log/warning/assert/error方法,setTimeoutsetInterval,requestAnimationFrame()以及各种事件handler进行了拦截。

这里有个坑,跨域的问题无法拦截错误,解决办法就是对跨域的script标签加入crossorigin属性,并在后台配置Access-Control-Allow-Origin=*

Raven

实际上,Tracekit本身已经完成对错误捕获和封装。Raven为了便于在管理后台展示和管理,进一步提出了DSN、context等设计。raven-js的源码主要在src/raven.js中。剩下两部分也是在其中实现的。下面分部分介绍一些:

DSN

DSN(Data Source Name)是Sentry对一个项目的定义。它由协议、端口、用户、密码、后台Sentry服务器地址、项目名组成。通过Raven.config()设置。在config()中通过正则匹配用户输入的DSN字符串,得到后台地址。

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
config: function(dsn, options) {
var self = this;

// ...
if (!dsn) return self;

var globalOptions = self._globalOptions;

// 设置全局参数
if (options) {
each(options, function(key, value) {
// tags and extra are special and need to be put into context
if (key === 'tags' || key === 'extra' || key === 'user') {
self._globalContext[key] = value;
} else {
globalOptions[key] = value;
}
});
}

self.setDSN(dsn);

// 屏蔽跨域的无效错误
globalOptions.ignoreErrors.push(/^Script error\.?$/);
globalOptions.ignoreErrors.push(/^Javascript error: Script error\.? on line 0$/);

// ...

// return for chaining
return self;
},

安装和卸载

install()uninstall()函数中完成。

install()中完成了下面的工作:

  • 借助Tracekit监听了全局的错误事件
  • 监听try catch和一些浏览器事件过程(如console,click,fetch等)中的信息
  • 安装插件
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
install: function() {
var self = this;
if (self.isSetup() && !self._isRavenInstalled) {
// 订阅所有错误事件
TraceKit.report.subscribe(function() {
self._handleOnErrorStackInfo.apply(self, arguments);
});

// 下方的函数会修改原回调函数
// 需要修改函数的toString方法
self._patchFunctionToString();

// 封装定时器和事件回调函数以提供更好的错误监控
if (self._globalOptions.instrument && self._globalOptions.instrument.tryCatch) {
self._instrumentTryCatch();
}

// 一些浏览器原生方法的封装,以捕获事件
// 设置里可关闭
if (self._globalOptions.autoBreadcrumbs) self._instrumentBreadcrumbs();

// 安装所有插件
self._drainPlugins();

// 更新状态
self._isRavenInstalled = true;
}

Error.stackTraceLimit = self._globalOptions.stackTraceLimit;
return this;
}

uninstall中还原了对浏览器原方法的修改,并卸载了Tracekit的report。

封装函数

相关函数:context()wrap()。完成的主要工作是对浏览器原生方法的拦截,使得能更好地捕获其中的错误,在对象内部使用。

capture相关

用来捕获事件,有三种用法。

  • captureException(),最典型的用法,借助Tracekit捕获页面的异常,之后进一步封装成frame后交给_send()发送
  • captureMessage(),最常用的用法,类似埋点,将信息封装成frame后交给_send()发送
  • captureBreadcrumb,类似captureMessage(),不过储存信息在this._breadcrumbs,并不交给_send()
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
captureException: function(ex, options) {
// ex不是错误时的处理
// ...

// Get actual Error from ErrorEvent
if (isErrorEvent(ex)) ex = ex.error;

// Store the raw exception object for potential debugging and introspection
this._lastCapturedException = ex;

// TraceKit.report will re-raise any exception passed to it,
// which means you have to wrap it in try/catch. Instead, we
// can wrap it here and only re-raise if TraceKit.report
// raises an exception different from the one we asked to
// report on.
try {
var stack = TraceKit.computeStackTrace(ex);
this._handleStackInfo(stack, options);
} catch (ex1) {
if (ex !== ex1) {
throw ex1;
}
}
return this;
}

值得注意的是captureMessage中可以设置rate,使一些消息不上报。白名单、正则过滤也是在这里完成的。captureException则是在_processException中完成的。

设置context

context包括三部分:

  • tags,用于从不同维度标识错误或信息,使用setTagsContext()全局配置
  • users,用于标识错误来源,使用setUsersContext()配置
  • extra,用来携带额外的信息,这部分信息不会被索引,使用setExtraContext()配置

它们都放在Raven._globalContext中。涉及的函数还有clearContext()getContext()

同时environmentrelease也放在Raven._globalContext中,可以通过setEnvironmentsetRelease设置

BreadCrumb

这部分功能是在_instrumentTryCatch_instrumentBreadcrumbs方法里实现的。它们通过重写原方法,捕获其中的错误和事件。在卸载时,通过restoreBuiltin还原。

发送

  • send()方法中,会使用封装好的数据附加上_globalOptions中的数据,附带浏览器的状态信息(_getHttpdata()中实现)之后交由_sendProcessedPayload()
  • _sendProcessedPayload()中,会裁剪过长的信息(message, stack, url, referer等)添加请求头,设置发送目标,传入成功和失败回调调用发送函数_makeRequest()
  • _makeRequest()中,为了跨域发送,会优先尝试fetch,然后尝试带有withCredentials字段的XMLHttpRequest,最后采用XDomainRequest对象发送。
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
_makeRequest: function(opts) {
// Auth is intentionally sent as part of query string (NOT as custom HTTP header) to avoid preflight CORS requests
var url = opts.url + '?' + urlencode(opts.auth);

if (supportsFetch()) {
return _window
.fetch(url, {
method: 'POST',
body: stringify(opts.data)
})
.then(function(response) {
if (response.ok) {
opts.onSuccess && opts.onSuccess();
} else {
// ..
}
})
['catch'](function() {
opts.onError &&
opts.onError(new Error('Sentry error code: network unavailable'));
});
}

var request = _window.XMLHttpRequest && new _window.XMLHttpRequest();
if (!request) return;

// if browser doesn't support CORS (e.g. IE7), we are out of luck
var hasCORS = 'withCredentials' in request || typeof XDomainRequest !== 'undefined';

if (!hasCORS) return;

if ('withCredentials' in request) {
request.onreadystatechange = function() {
if (request.readyState !== 4) {
return;
} else if (request.status === 200) {
opts.onSuccess && opts.onSuccess();
} else if (opts.onError) {
var err = new Error('Sentry error code: ' + request.status);
err.request = request;
opts.onError(err);
}
};
} else {
request = new XDomainRequest();
// xdomainrequest cannot go http -> https (or vice versa),
// so always use protocol relative
url = url.replace(/^https?:/, '');

// onreadystatechange not supported by XDomainRequest
if (opts.onSuccess) {
request.onload = opts.onSuccess;
}
if (opts.onError) {
request.onerror = function() {
var err = new Error('Sentry error code: XDomainRequest');
err.request = request;
opts.onError(err);
};
}
}

request.open('POST', url);
request.send(stringify(opts.data));
}

至此,错误捕获和封装AJAX上报JSON字符串化参数都已完成。

可视化后台

在自己设计异常监控系统时,需要和后台商量好接口的设定。用Express + React/Vue等方案快速搭建。

参考