Sentry SDK 架构解析

一行 init 之后 Sentry 就开始收错误,靠的是改写 window.onerror。顺着这条线看 Scope 怎么给事件贴上下文、Integrations 为什么从类变成了函数,以及 v7 到 v10 之间被删掉的那几层。

预计
6 分钟

Sentry.init({ dsn }) 是一行代码,之后应用里任何没被捕获的异常都会出现在后台。这一行里发生了什么,是理解整个 SDK 的入口。

答案不神秘:它把 window.onerror 换成了自己的函数。

Sentry 的 JS SDK 在 v7、v8、v9 之间连着改过三轮 API,网上的文章十有八九对不上你装的那版。本文的代码和结论按 @sentry/react 10.70.0 写,历史差异会在每一处点名。想知道自己手上是哪一版,node -p "require('@sentry/react/package.json').version"

init 之后,SDK 改写了 window.onerror

@sentry/core 里负责这件事的文件是 instrument/globalError.js,全文就这么长:

@sentry/core 10.70.0 · instrument/globalError.js
function instrumentError() {
_oldOnErrorHandler = GLOBAL_OBJ.onerror;
GLOBAL_OBJ.onerror = function(msg, url, line, column, error) {
const handlerData = { column, error, line, msg, url };
triggerHandlers("error", handlerData);
if (_oldOnErrorHandler) {
return _oldOnErrorHandler.apply(this, arguments);
}
return false;
};
GLOBAL_OBJ.onerror.__SENTRY_INSTRUMENTED__ = true;
}

三行高亮说明了全部机制:先把原来的 handler 存起来,装上自己的,跑完之后再把原来那个调一遍。unhandledrejection 是完全对称的一份,在隔壁 instrument/globalUnhandledRejection.js,patch 的是 GLOBAL_OBJ.onunhandledrejection

这里有个常被写错的细节:它用的是 handler 属性赋值,不是 addEventListener 差别是实打实的 —— 属性只有一个槽位,你在 init 之后再写一句 window.onerror = myHandler,Sentry 那份就被顶掉了,从此一个全局错误都收不到,而且没有任何警告。反过来 Sentry 装在后面时会把你的保留下来,因为它显式地 apply 了旧的那个。

这套逻辑归 globalHandlersIntegration。另有一个 browserApiErrorsIntegration,干的是另一件事:把 setTimeoutsetIntervalrequestAnimationFrameaddEventListener 的回调包一层 try/catch。为什么需要它 —— 异步回调抛出的异常,栈已经断了,window.onerror 拿到的信息很少;包一层才能在抛出的现场把上下文抓全。

两个名字在 v7 里叫 GlobalHandlersTryCatch,是类。下一节说这个变化。

Integrations 从类变成了函数

v7 的写法是 new Sentry.BrowserTracing(),v8 起换成 Sentry.browserTracingIntegration()。这不是风格调整,是 v8 的破坏性变更之一,官方迁移文档里专门有一节 Removal of class-based integrations。类版本在 v9 之后彻底没有了。

自己验一遍最省事:

看你装的这版到底导出了什么
node -e "const S=require('@sentry/react');
console.log('BrowserTracing:', typeof S.BrowserTracing);
console.log('browserTracingIntegration:', typeof S.browserTracingIntegration)"

10.70.0 上前者是 undefined,后者是 function。同一招可以用来判断任何一篇教程还能不能照抄。

Integration 本身就是一个带 name 和若干生命周期钩子的对象,SDK 在 init 时逐个装上。浏览器端默认就开一批,breadcrumbsIntegrationdedupeIntegrationglobalHandlersIntegrationhttpContextIntegrationlinkedErrorsIntegration 这些不用你写。在 init 里传 integrations 是往这批默认的上面加,不是替换。

ErrorBoundary 是组件,不是高阶组件

@sentry/react 这两样都给了,很多文章把它们混成一个:

两种用法,别混
// ErrorBoundary:一个 React 类组件,当标签用
<Sentry.ErrorBoundary fallback={<p>出错了</p>}>
<App />
</Sentry.ErrorBoundary>
// withErrorBoundary:这个才是高阶组件
const Safe = Sentry.withErrorBoundary(MyComponent, { fallback: <p>出错了</p> });

Sentry.withProfiler 也是高阶组件,但它管的是渲染性能,不是错误。

边界要划清楚的是 ErrorBoundary 收不到什么。它走的是 React 的错误边界机制,只兜得住子树渲染期间抛出的异常。事件处理函数里抛的、setTimeout 回调里抛的、Promise 没接住的 rejection,全都不经过它 —— 那些归上一节那两个全局 integration。所以「包了 ErrorBoundary 就不用管全局了」是错的,两套是互补关系。

它相对 captureException 的价值在于位置:错误在组件树里被截住,上报时天然带着出错组件的位置,而不只是一段压缩过的调用栈。

Scope 决定错误带什么上下文

异常对象本身信息很少 —— 一句 message 加一段栈。真正让它在后台可查的是用户 ID、当前路由、几条面包屑,这些存在 Scope 上,在事件发出前被合并进去。

给一段代码单独加上下文
Sentry.withScope((scope) => {
scope.setTag('checkout_step', 'payment');
scope.setUser({ id: userId });
Sentry.captureException(err);
});

withScope 里的改动只影响这一块,出去就恢复。要全局生效用 Sentry.getCurrentScope().setUser(...)

这里是历史包袱最重的地方。 老文章里管这件事的叫 Hub:一个维护 Scope 栈的对象,配一个 getCurrentHub()。要点有两个:

  • Hub 不是单例。它的构造函数是公开的,可以建多个;getCurrentHub() 返回的是挂在全局 carrier 上的那个默认实例。说「Hub 用单例模式保证全局只有一个」是不准确的。
  • Hub 在 v8 被标记废弃,在 v9 被彻底删掉。10.70.0 上 Sentry.HubSentry.getCurrentHub 都是 undefined。现在的对应物是 getCurrentScope()getIsolationScope()withScope()

同一类消失的还有更早的一层。v6 及以前 @sentry/core 里有个 BaseBackend@sentry/browser 里有 FetchTransportXHRTransport 这些类,负责平台相关的低层操作。v7 把 Backend 整层删了,功能并进 Client —— 迁移文档给的理由很实在:这层抽象别的语言 SDK 都没有,删掉能减小包体积。Transport 同时从类改成了函数。

所以读到讲「Backends 层 / Transports 层 / Hub 层」的架构文章,那描述的是 2022 年之前的 SDK。现在的分层要短得多:Client 持有配置和 integrations,Scope 持有上下文,Transport 是一个发请求的函数。

beforeSend 是最后一道闸

Sentry.init 的常用配置
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: 'my-app@1.4.2',
environment: 'production',
sampleRate: 1.0, // 错误事件的采样率
tracesSampleRate: 0.1, // 性能事务的采样率,两者互不相干
beforeSend(event, hint) {
// 返回 null 就丢弃这个事件
if (hint.originalException instanceof AbortError) return null;
event.request?.headers && delete event.request.headers.Authorization;
return event;
},
});

beforeSend 拿到的是即将发走的 event 和一个 hint(里面有原始的异常对象),返回改过的 event 或者 null。它是脱敏的正确位置:token、身份证号、用户输入的表单内容,在这里删掉才不会离开浏览器。

两个容易踩的点。一是它不管性能事务 —— 那是 beforeSendTransaction,签名对称,得单独写。二是执行时机在 Scope 合并之后,你在这儿看到的 event 已经带上了 tag 和 user,所以想删 Scope 贴上去的东西,也得在这里删。

采样那两个数字是完全独立的两个旋钮,共用一个名字后缀而已:sampleRate 掷骰子决定这条错误发不发,tracesSampleRate 决定这条事务发不发,各掷各的。另外 tracesSampleRate 还兼着开关的职责 —— 它和 tracesSampler 都不设,性能追踪根本不启用。

追踪的 API 也换过一轮:v7 是 Sentry.startTransaction() 加实例上的 transaction.startChild(),v8 起统一成 startSpan / startSpanManual / startInactiveSpan。顺带纠正一个流传的错误:Sentry.startChild 从来不是顶层 APIstartChild 只存在于 transaction 和 span 对象上。10.70.0 里 Sentry.startTransactionSentry.startChild 都是 undefined

Source Map 现在靠 debug ID,不再靠 release

线上跑的是压缩过的代码,没有 source map,栈就是一堆 a.b is not a function。上传 map 的推荐做法是装官方的打包器插件,webpack、Vite、Rollup、esbuild 各有一个,构建时自动上传并做好关联。它们该挂在构建流程的哪一步,取决于你用的是哪套 —— Webpack 5 打包原理详解emit 之前那个位置就是这类插件干活的地方,打库时的 Rollup 打包原理同理。

需要更新的常识是关联方式。老做法是靠 release:init 里设一个版本号,上传时报同一个,两边对上才能还原。现在的机制是 debug ID —— 构建时往压缩产物里注入一行 //# debugId=…,source map 里存一份相同的 ID,靠 ID 直接配对。所以「必须设 release 才能还原 source map」已经过时了。

release 本身仍然值得设,只是职责变了:它现在服务于版本健康度和「这个错误是哪个版本引入的」这类回归判断,不再是 source map 能不能工作的前提。