跳到主要内容

实现

2024年05月08日
柏拉文
越努力,越幸运

一、认识


基于 Sentry 异常采集方案,将一些方法进行重写。Sentry 异常监控原理是如何做的呢?

  1. 为了能自动捕获应用异常,Sentry 劫持覆写了 window.onerrorwindow.unhandledrejection 这两个 api

  2. Sentry 内部对异常发生的特殊上下文,做了标记。这些特殊上下文包括: dom 节点事件回调、setTimeout / setInterval 回调、xhr 接口调用、requestAnimationFrame 回调等

1.1 全局捕获

1.2 单点捕获

二、window.onerror


为了能自动捕获应用异常,Sentry 劫持覆写了 window.onerror, 重写逻辑如下所示:

const oldErrorHandler = window.onerror;
window.onerror = function (msg, url, line, column, error) {
// 收集异常信息并上报
triggerHandlers('error', {
column: column,
error: error,
line: line,
msg: msg,
url: url,
});
if (oldErrorHandler) {
return oldErrorHandler.apply(this, arguments);
}
return false;
};

三、window.onunhandledrejection


为了能自动捕获应用异常,Sentry 劫持覆写了 window.onunhandledrejection, 重写逻辑如下所示:

const oldOnUnhandledRejectionHandler = window.onunhandledrejection;
window.onunhandledrejection = function (e) {
// 收集异常信息并上报
triggerHandlers('unhandledrejection', e);
if (oldOnUnhandledRejectionHandler) {
return oldOnUnhandledRejectionHandler.apply(this, arguments);
}
return true;
};

四、setTimeout


标记 setTimeout, 为了标记 setTimeout 类型的异常,Sentry 劫持覆写了原生的 setTimout 方法。新的 setTimeout 方法调用时,会使用 try ... catch 语句块包裹 callback

重写逻辑如下

const originSetTimeout = window.setTimeout;
window.setTimeout = function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var originalCallback = args[0];
// wrap$1 会对 setTimeout 的入参 callback 使用 try...catch 进行包装
// 并在 catch 中上报异常
args[0] = wrap$1(originalCallback, {
mechanism: {
data: { function: getFunctionName(original) },
handled: true,
// 异常的上下文是 setTimeout
type: 'setTimeout',
},
});
return original.apply(this, args);
}

callback 内部发生异常时,会被 catch 捕获,捕获的异常会标记 setTimeout

五、setInterval


标记 setInterval, 为了标记 setInterval 类型的异常,Sentry 劫持覆写了原生的 setInterval 方法。新的 setInterval 方法调用时,会使用 try ... catch 语句块包裹 callback

六、requestAnimationFrame


标记 requestAnimationFrame, 为了标记 requestAnimationFrame 类型的异常,Sentry 劫持覆写了原生的 requestAnimationFrame 方法。新的 requestAnimationFrame 方法调用时,会使用 try ... catch 语句块包裹 callback

七、dom event handler


标记 dom 事件 handler , 所有的 dom 节点都继承自 window.Node 对象,dom 对象的 addEventListener 方法来自 Nodeprototype 对象。为了标记 dom 事件 handlerSentryNode.prototype.addEventListener 进行了劫持覆写。新的 addEventListener 方法调用时,同样会使用 try ... catch 语句块包裹传入的 handler

重写逻辑如下

function xxx() {
var proto = window.Node.prototype;
...
// 覆写 addEventListener 方法
fill(proto, 'addEventListener', function (original) {

return function (eventName, fn, options) {
try {
if (typeof fn.handleEvent === 'function') {
// 使用 try...catch 包括 handle
fn.handleEvent = wrap$1(fn.handleEvent.bind(fn), {
mechanism: {
data: {
function: 'handleEvent',
handler: getFunctionName(fn),
target: target,
},
handled: true,
type: 'instrument',
},
});
}
}
catch (err) {}
return original.apply(this, [
eventName,
wrap$1(fn, {
mechanism: {
data: {
function: 'addEventListener',
handler: getFunctionName(fn),
target: target,
},
handled: true,
type: 'instrument',
},
}),
options,
]);
};
});
}

handler 内部发生异常时,会被 catch 捕获,捕获的异常会被标记 handleEvent, 并携带 event nameevent target 等信息。

八、xhr open、send


**XHR**通过重写(拦截)sendopen。为了标记 xhr 接口回调,Sentry 先对 XMLHttpRequest.prototype.send 方法劫持覆写, 等 xhr 实例使用覆写以后的 send 方法时,再对 xhr 对象的 onloadonerroronprogressonreadystatechange 方法进行了劫持覆写, 使用 try ... catch 语句块包裹传入的 callback

重写逻辑如下

function fill(source, name, replacementFactory) {
var original = source[name];
var wrapped = replacementFactory(original);
source[name] = wrapped;
}
// xhr
function instrumentXHR(): void {
// 保存真实的xhr的原型
const xhrproto = XMLHttpRequest.prototype;
// 拦截open方法
fill(xhrproto, 'open', function (originalOpen: () => void): () => void {
return function (this: SentryWrappedXMLHttpRequest, ...args: any[]): void {
const xhr = this;
const onreadystatechangeHandler = function (): void {
if (xhr.readyState === 4) {
if (xhr.__sentry_xhr__) {
xhr.__sentry_xhr__.status_code = xhr.status;
}
// // 上报sentry
triggerHandlers('xhr', {
args,
endTimestamp: Date.now(),
startTimestamp: Date.now(),
xhr,
});
}
};

if ('onreadystatechange' in xhr && typeof xhr.onreadystatechange === 'function') {
// 拦截onreadystatechange方法
fill(xhr, 'onreadystatechange', function (original: WrappedFunction): Function {
return function (...readyStateArgs: any[]): void {
onreadystatechangeHandler();
// 返回原来的方法
return original.apply(xhr, readyStateArgs);
};
});
} else {
xhr.addEventListener('readystatechange', onreadystatechangeHandler);
}
// 调用原来的方法
return originalOpen.apply(xhr, args);
};
});
// fill其实就是拦截的一个封装originalSend就是原来的send方法
fill(xhrproto, 'send', function (originalSend: () => void): () => void {
return function (this: SentryWrappedXMLHttpRequest, ...args: any[]): void {
// 上报sentry
triggerHandlers('xhr', {
args,
startTimestamp: Date.now(),
xhr: this,
});
// 返回原来方法
return originalSend.apply(this, args);
};
});
}

callback 内部发生异常时,会被 catch 捕获,捕获的异常会被标记对应的请求阶段。

九、fetch


fetch 通过拦截整个方法

// 重写fetch
function instrumentFetch() {
if (!supportsNativeFetch()) {
return;
}
fill(global$2, 'fetch', function (originalFetch) {
return function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var handlerData = {
args: args,
fetchData: {
method: getFetchMethod(args),
url: getFetchUrl(args),
},
startTimestamp: Date.now(),
};
triggerHandlers('fetch', __assign({}, handlerData));
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return originalFetch.apply(global$2, args).then(function (response) {
triggerHandlers('fetch', __assign(__assign({}, handlerData), { endTimestamp: Date.now(), response: response }));
return response;
}, function (error) {
triggerHandlers('fetch', __assign(__assign({}, handlerData), { endTimestamp: Date.now(), error: error }));
throw error;
});
};
});
}

十、console.log


function instrumentConsole() {
if (!('console' in global$2)) {
return;
}
['debug', 'info', 'warn', 'error', 'log', 'assert'].forEach(function (level) {
if (!(level in global$2.console)) {
return;
}
fill(global$2.console, level, function (originalConsoleLevel) {
return function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
// 上报sentry
triggerHandlers('console', { args: args, level: level });
// this fails for some browsers. :(
if (originalConsoleLevel) {
Function.prototype.apply.call(originalConsoleLevel, global$2.console, args);
}
};
});
});
}

十一、Vue.config.errorHandler


// sentry中对Vue errorHandler的处理
function vuePlugin(Raven, Vue) {
var _oldOnError = Vue.config.errorHandler;
Vue.config.errorHandler = function VueErrorHandler(error, vm, info) {
// 上报
Raven.captureException(error, {
extra: metaData
});

if (typeof _oldOnError === 'function') {
// 为什么这么做?
_oldOnError.call(this, error, vm, info);
}
};
}
module.exports = vuePlugin;

十二、React ErrorBoundary


ErrorBoundary的定义: 如果一个class组件中定义了 static getDerivedStateFromError()componentDidCatch()这两个生命周期方法中的任意一个(或两个)时,那么它就变成一个错误边界。当抛出错误后,请使用static getDerivedStateFromError()渲染备用 UI ,使用componentDidCatch()打印错误信息

// ErrorBoundary的示例
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

componentDidCatch(error, info) {
this.setState({ hasError: true });
// 在这里可以做异常的上报
logErrorToMyService(error, info);
}

render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}

<ErrorBoundary>
<MyWidget />
</ErrorBoundary>

那么Sentry是怎么实现的呢?

// ts声明的类型,可以看到sentry大概实现的方法
/**
* A ErrorBoundary component that logs errors to Sentry.
* Requires React >= 16
*/
declare class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState;
componentDidCatch(error: Error, { componentStack }: React.ErrorInfo): void;
componentDidMount(): void;
componentWillUnmount(): void;
resetErrorBoundary: () => void;
render(): React.ReactNode;
}

// 真实上报的地方
ErrorBoundary.prototype.componentDidCatch = function (error, _a) {
var _this = this;
var componentStack = _a.componentStack;
// 获取到配置的props
var _b = this.props, beforeCapture = _b.beforeCapture, onError = _b.onError, showDialog = _b.showDialog, dialogOptions = _b.dialogOptions;
withScope(function (scope) {
// 上报之前做一些处理,相当于axios的请求拦截器
if (beforeCapture) {
beforeCapture(scope, error, componentStack);
}
// 上报
var eventId = captureException(error, { contexts: { react: { componentStack: componentStack } } });
// 开发者的回调
if (onError) {
onError(error, componentStack, eventId);
}
// 是否显示sentry的错误反馈组件(也是一种收集错误的方式)
if (showDialog) {
showReportDialog(__assign(__assign({}, dialogOptions), { eventId: eventId }));
}
// componentDidCatch is used over getDerivedStateFromError
// so that componentStack is accessible through state.
_this.setState({ error: error, componentStack: componentStack, eventId: eventId });
});
};

十三、Axios


axios通过请求/响应拦截器 注意:sentry支持自动收集和手动收集两种错误收集方法,但是还不能捕捉到异步操作、接口请求中的错误,比如接口返回404500等信息,此时我们可以通过Sentry.caputureException()进行主动上报。