Sentry SDK 架构解析
一行 init 之后 Sentry 就开始收错误,靠的是改写 window.onerror。顺着这条线看 Scope 怎么给事件贴上下文、Integrations 为什么从类变成了函数,以及 v7 到 v10 之间被删掉的那几层。
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,全文就这么长:
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,干的是另一件事:把 setTimeout、setInterval、requestAnimationFrame、addEventListener 的回调包一层 try/catch。为什么需要它 —— 异步回调抛出的异常,栈已经断了,window.onerror 拿到的信息很少;包一层才能在抛出的现场把上下文抓全。
两个名字在 v7 里叫 GlobalHandlers 和 TryCatch,是类。下一节说这个变化。
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 时逐个装上。浏览器端默认就开一批,breadcrumbsIntegration、dedupeIntegration、globalHandlersIntegration、httpContextIntegration、linkedErrorsIntegration 这些不用你写。在 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.Hub和Sentry.getCurrentHub都是undefined。现在的对应物是getCurrentScope()、getIsolationScope()和withScope()。
同一类消失的还有更早的一层。v6 及以前 @sentry/core 里有个 BaseBackend,@sentry/browser 里有 FetchTransport 和 XHRTransport 这些类,负责平台相关的低层操作。v7 把 Backend 整层删了,功能并进 Client —— 迁移文档给的理由很实在:这层抽象别的语言 SDK 都没有,删掉能减小包体积。Transport 同时从类改成了函数。
所以读到讲「Backends 层 / Transports 层 / Hub 层」的架构文章,那描述的是 2022 年之前的 SDK。现在的分层要短得多:Client 持有配置和 integrations,Scope 持有上下文,Transport 是一个发请求的函数。
beforeSend 是最后一道闸
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 从来不是顶层 API,startChild 只存在于 transaction 和 span 对象上。10.70.0 里 Sentry.startTransaction 和 Sentry.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 能不能工作的前提。