Sentry JavaScript SDK v11 为所有 JS 平台新增追踪功能
Sentry 的 v11 JavaScript SDK 使用代码转换器而非 OpenTelemetry 插桩来追踪依赖,将其可覆盖的平台扩展到 Cloudflare Workers、Bun 和 Deno。
中文
复制

Sentry JavaScript SDK v11 为所有 JS 平台加入 tracing
这篇文章讲的是 Sentry JavaScript SDK 最近的一次改动。如果你不在 JavaScript 里用 Sentry,看看我们为什么改、怎么改的,可能也有点参考价值。
在 Sentry JavaScript SDK v11 中,我们重做了 tracing integration 的工作方式。OpenTelemetry 标准我们仍然支持(下面会详细说),但插桩不再像之前几个版本那样用 OpenTelemetry 的 instrumentation 类,而是改用一个代码转换器来插桩你的依赖。
这套方案建立在 apm-js-collab 的 @apm-js-collab/code-transformer 和 @apm-js-collab/tracing-hooks 两个包之上,是多家 application performance monitoring(APM) 厂商共同推进的项目。
注入与订阅
新的 tracing instrumentation 分两部分工作。第一部分在模块加载时把 diagnostics channel 注入进去,第二部分监听这些 channel 事件,收集 tracing 信息发给 Sentry。
第一部分:注入
当受支持的模块被加载时——无论是运行时加载还是打包器构建时加载——转换器都会改写源码,把我们关心的函数包进一次 tracingChannel() 调用。
每个转换都由一份配置驱动,配置里写明包名、semver 范围、包内的某个文件,以及一个函数:
{
channelName: 'query',
module: {
name: 'mysql',
versionRange: '>=2.0.0 <3',
filePath: 'lib/Connection.js',
},
functionQuery: { expressionName: 'query', kind: 'Auto' },
}
转换完成后,mysql 模块在 query 函数每次被调用时,都会发布 start、end、error、asyncStart 和 asyncEnd 这些 diagnostics。
第二部分:订阅
每个 Sentry integration(mysqlIntegration、redisIntegration、openAIIntegration 等等)订阅自己对应的 channel,把事件转成 Sentry span。
订阅方会等到确认自己的模块确实已加载后才开始工作,以免创建多余的 channel。
运行时注入与构建时注入
同一个转换器会在两个地方运行,而且两者可以同时生效。一个模块要么由打包器加载,要么由 node_modules 加载,不会两者兼有,所以不存在重复包装的可能。
运行时
在较新的 Node 版本以及 Deno 2.8.3 及以上版本中,Sentry.init() 会自动调用 registerDiagnosticsChannelInjection(),后者会使用 Module.registerHooks API 对之后加载的所有受支持模块进行插桩。
你也可以在应用加载之前就注册这些 hook,直接使用 Deno 和 Node SDK 提供的 import 端点:
node --import @sentry/node/import app.js
deno run --preload=@sentry/deno/import app.ts
构建时
如果你使用 bun build,或者用 Vite、esbuild 之类的打包工具打包程序,可以用官方提供的插件,在模块被构建进应用的过程中完成插桩。
@sentry/node 提供了 Vite、Rollup、webpack 和 esbuild 的插件;@sentry/cloudflare 包含一个 Vite 插件;@sentry/bun 提供了 bun build 的插件;Next.js 则把 webpack loader 同时接入了 webpack 和 Turbopack。
打包时,插件会转换每一个被插桩的依赖,并插入一段代码:当打包后的模块首次求值时,把该模块(以及它的 subscriber 工厂)注册到一个全局标记上。
用法和其他 Sentry 打包插件一样:
// vite.config.ts
import { sentryVitePlugin } from '@sentry/node/vite';
export default {
plugins: [sentryVitePlugin({ org: '...', project: '...' })],
};
v11 之前的做法
v11 之前,Sentry 的集成几乎完全建立在 OpenTelemetry 的插桩 API 之上。
这让我们能直接享用开源社区的成果,但也带来了明显的缺点,而且随着越来越多的 JavaScript 平台流行起来,这些缺点愈发严重。
OpenTelemetry 插桩内部
一个 OpenTelemetry 插桩包,比如 @opentelemetry/instrumentation-express,会导出一个继承自 InstrumentationBase 的类。它声明包名、支持的 semver 范围,以及一个 patch(moduleExports, version) 函数。它还可以声明 InstrumentationNodeModuleFile 条目,只对包内的个别文件打补丁。
registerInstrumentations() 会安装两个 loader hook:
require-in-the-middle,为 CommonJS 修补Module._load。import-in-the-middle,通过Module.register()注册为 ECMAScript 模块(ESM)的 loader hook。
被插桩的模块加载时,这些 loader hook 会把模块的 exports 对象和它的基础目录交给 OpenTelemetry 插桩。插桩从磁盘读取 <baseDir>/package.json 拿到版本号,与声明的范围比对,然后调用 patch(exports)。补丁用 shimmer 就地替换 exports 对象上的属性。替换后的函数会开启一个 OpenTelemetry span,Sentry 的 SentrySpanProcessor、SentrySampler 和 SentryContextManager 再把这个 span 转换成 Sentry span。
ESM 的命名空间对象是不可变的,所以 import-in-the-middle 没法直接给它赋值。它会从磁盘读取每个模块的源码,词法分析出导出名(CommonJS 用 cjs-module-lexer,ESM 则扫描源码),生成一个通过可变局部绑定重新导出这些名字的替代模块,再把命名空间包进 Proxy。只有到这一步,shimmer 的赋值才能生效。
一连串的假设
这个流程的每一步都对环境有所假设。合在一起,它们需要:
- 一个活跃的模块加载器,为每个依赖运行
require()或 Node ESM 的 resolve/load 钩子。 - Node 加载器的内部机制,
Module._load和Module.register()。 - 依赖以独立包目录的形式存在于磁盘上,带有可读的
package.json和可读的源文件。 - 补丁能够重新赋值的导出。
- 钩子在库首次被 import 之前就已安装。OpenTelemetry 在时机太晚时会发出警告:“Module x has been loaded before y so it might not work”。
打包器受到的种种限制
打包器把依赖内联之后,上述假设没有一条成立。require('express') 变成对同一文件上方某个函数的引用,于是没有加载事件可以拦截。没有 node_modules/express/package.json 可读,版本检查也就无从比对。打包产物内部的 ESM const 绑定无法重新赋值,而调用点可能在任何补丁运行之前就已经持有对该函数的直接引用。
各种变通做法把负担推回给了用户:把每个需要插桩的包标记为 external,在打包产物旁边附带一棵 node_modules 目录树,并接受这会让打包的大部分意义荡然无存。而像 Vercel 和 Netlify 这样替你打包服务端代码的平台,根本没有地方施加这种变通。
只适用于 Node.js
这套方法的每一处都是 Node 特有的。几年前 Sentry 的 JS SDK 走上这条路时,这个限制还算合理,因为 Node.js 是当时服务端 JavaScript 的主要运行时。随着越来越多的运行时流行起来,它就不再合理了。
Cloudflare Workers 完全不具备这条链上的任何一环:没有 CommonJS 加载器,没有 Module.register(),没有文件系统可以读取 package.json,模块解析在构建时由 Workers 打包器完成。Bun 和 Deno 实现了 Node 模块系统面向用户的大部分内容,但没有实现 Module._load 补丁,而较老的 Module.register 钩子在这些平台上是空的桩函数。
顺序与预加载
由于 ESM import 语句会被提升,并在导入文件中的任何代码之前求值,ESM 入口点里的 Sentry.init() 会在它想要打补丁的库已经加载之后才运行。在 v10 中,可选方案有 --import=./instrument.mjs、@sentry/node/preload 入口点、preloadOpenTelemetry(),以及一个 SENTRY_PRELOAD_INTEGRATIONS 环境变量,供那些需要尽早安装补丁但还无法配置 SDK 的应用使用。
体积
每个受支持的库都需要自己的 @opentelemetry/instrumentation-* 包,并与该库的内部实现绑定,而 Sentry 必须保留 OpenTelemetry SDK(tracer provider、sampler、span processor、context manager 和 propagator)才能接收这些 span。
在 v11 中修复这些问题
新方案替换掉了整条链路,改为注入 tracing channel,就像内置 channel 库作者可以使用的方式一样:
- 打包的服务器部署开箱即用。 源码转换可以在构建期间运行,因为它修改的是源代码,而不是生成后的对象。bundler 插件在依赖被打包时对其进行插桩,这是 loader hook 系统完全做不到的。这覆盖了 Vercel 和 Netlify,它们会替你打包服务器代码。
- 非 Node 运行时可用。
node:diagnostics_channel内置于 Node,并由 Bun、Deno 和 Cloudflare Workers(配合nodejs_compat)实现。这是这次改动的主要原因。 - 它修改的是库代码,而不是导出。 转换在模块自身的源码内部包装函数,因此包是否冻结导出、是否通过 barrel 文件重新导出、是否已被 bundler 内联,都无关紧要。对导出对象做 monkey-patching 在这些情况下都会失败。
- 只有一条顺序规则,而不是好几条。 库在被加载的那一刻就完成自我插桩,所以
preloadOpenTelemetry()、@sentry/node/preload和SENTRY_PRELOAD_INTEGRATIONS全都不需要了。运行时只需一次注册调用,即可覆盖所有库。 - 要安装的东西更少,出问题的可能也更少。 依赖树缩减到只剩
@opentelemetry/api,不再是一整棵 instrumentation 包树,@sentry/node-core也已并回@sentry/node。generateInstrumentOnce()、SentryContextManager、SentrySampler和SentrySpanProcessor已被移除。 - Sentry 不再介入你的 OpenTelemetry 配置。 Sentry span 不再泄漏到你的 pipeline 中,你的 pipeline 也不必再使用 Sentry 的 context manager 和 propagator。在自定义 OpenTelemetry 配置下使用 Sentry 仍有相当大的灵活性,详见下文。
这套插桩机制通过 source transform 注入诊断通道;在 v10 中,它已存在于 experimentalUseDiagnosticsChannelInjection() 之后,v11 则将其设为默认,并移除了修补导出的插桩方式。
平台覆盖
在采用这一方案之前,我们探索了不少其他选项。这是一次大改动,而基于 OpenTelemetry 的开源插桩在生产系统中已有长期可靠的使用历史。部分动机是为所有 JavaScript 平台提供类似的 OpenTelemetry 支持。
Node 自身的模块加载器钩子正朝另一个方向发展,因此把 import-in-the-middle 和 require-in-the-middle 移植到每个 JS 平台似乎不值得。新方案让我们立刻在三个平台上拿到了结果。
Cloudflare Workers
Workers 没有 Node 模块加载器,所以 v10 的做法无从挂载。想在 fetch 和请求处理函数之外做追踪,只能自己调用手动包装器(例如 instrumentOpenAiClient 之类)。
v11 新增了 @sentry/cloudflare/vite。sentryCloudflareVitePlugin() 会在构建 worker bundle 时转换你的依赖,因此数据库驱动和 AI 客户端能在 worker 内部产生 span。订阅方集成由注入的代码片段注册,而不是通过 import 引入,所以未使用该插件构建的 worker 不会带上这些代码。
Bun
@sentry/bun/plugin 为 bun build 导出了 sentryBunPlugin()。
遗憾的是,目前这仅限构建时。由于一个已知问题,Bun 中运行时 onLoad 插件返回的任何模块都会丢失其 CommonJS 具名导出(oven-sh/bun#34112),因此运行时钩子会破坏它所插桩的库。
所以用 bun run 运行未打包的源码不会获得任何通道插桩,我们认为这比覆盖不完整或直接损坏要好。Bun 团队正在处理该问题,我们预计这只是暂时的。与此同时,Bun 上的生产系统通常本来就会用 bun build 做优化。
Deno
2026 年,Deno 大幅改善了 diagnostics_channel 覆盖以及对模块加载器钩子的支持,因此在 Deno 上追踪现在与在 Node 上追踪基本一致。
deno run --preload=@sentry/deno/import 注册运行时钩子。Deno 的最低支持版本(2.8.3)已经具备稳定的同步模块钩子,所以这与现代 Node 走的是同一条代码路径。(这些是 Module.registerHooks(),与 OpenTelemetry 插桩使用的旧版钩子是不同的 API。)
@sentry/deno 现在已导出整套 channel 集成,涵盖数据库连接器、Web 框架、AI 服务商集成,并且还在持续增加。而在 v10 中,这些一个都没有。
坑与注意事项
升级时有几件事需要留意。每个改进都意味着一次变更,这话不假。简而言之:阅读迁移指南,照着做即可。
- 加载顺序依然重要。 transform 只对 hook 注册之后加载的模块生效。请通过
--import注册,或者把Sentry.init()放在所有被插桩库的 import 之前。 - 不再支持
--require。 Node 会在Module.register()启动的 loader 线程上重新执行--requirepreload,于是 instrument 文件会在一个永远不会执行你代码的线程上第二次运行Sentry.init()。请改用--import,它对 CommonJS 应用同样有效。 - 不要在构建期配置里把被插桩的库 externalize。 external 依赖从
node_modules加载,永远走不到 transform,它的 channel 就被悄无声息地漏掉了。Sentry 构建插件会把已知的被插桩包从external里剔除,无法修复的会给出警告。 - 用
debug: true验证。 我们设计这套系统时就是奔着免额外配置去的,但也考虑到了不生效的情况。在init()时,SDK 会报告运行时 hook 是否注册、bundler 插件是否运行、各自注入了哪些模块。如果在 Sentry 设置里用debug: true跑测试和 CI(尤其是在改动配置期间),大多数错误都能被抓出来。
v11 中的自定义 OpenTelemetry 配置
插桩和 OpenTelemetry 现在是两件独立的事。Sentry 仍然兼容 OpenTelemetry,而且背后的架构更简单了。
Channel 集成直接从 channel 事件生成原生 Sentry span,所以无论你选下面哪种方式,依赖都保持被插桩状态。
skipOpenTelemetrySetup 被 enableOpenTelemetrySetup 取代,含义相反。在大多数服务端 SDK 上它默认为 false,在 @sentry/nextjs 和 @sentry/sveltekit 上默认为 true——这两个框架仍会注册一个轻量 tracer provider,用来接收它们发出的 span。
方案一:仅使用 Sentry(默认)
设置 tracesSampleRate 即可。
方案二:OpenTelemetry 兼容模式
设置 enableOpenTelemetrySetup: true。Sentry 会注册一个最小化的 tracer provider、context manager 和 propagator,因此通过 @opentelemetry/api 创建的 span 会成为原生 Sentry span。这不是通用的 OpenTelemetry 管道:没有 exporter,也没有 OpenTelemetry Protocol (OTLP) 输出。如果你已经注册过 provider,Sentry 会拒绝注册自己的 provider,改为记录一条警告。
方案三:使用你自己的 OpenTelemetry,并将 Sentry 接入其中
让 enableOpenTelemetrySetup 保持为 false,不设置 tracesSampleRate,运行你自己的 provider,然后添加 Sentry.otlpIntegration():
const provider = new NodeTracerProvider({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter(Sentry.getOtlpTracesEndpoint('__DSN__')),
)
],
});
provider.register();
Sentry.init({
dsn: '__DSN__',
integrations: [Sentry.otlpIntegration()],
});
otlpIntegration() 会把 Sentry 的 errors、logs、metrics 和 crons 关联到当前活跃的 OpenTelemetry span 上,这样所有遥测数据都会落在同一条 trace 里。它不会创建 exporter、span processor 或 tracer provider,出站传播交给你的 propagator 处理。
如果你在 v10 中有自定义配置,迁移要点如下:
- 不再有办法把你自己的 provider 产生的 span 作为 Sentry span 送入 Sentry。
SentryContextManager、SentrySampler和SentrySpanProcessor已被移除。改用 OTLP 导出,并把 exporter 指向Sentry.getOtlpTracesEndpoint(dsn)。 otlpIntegration的位置变了。 它现在从每个服务端 SDK 的主入口导出,所以@sentry/node-core/light/otlp已不存在。setupOtlpTracesExporter和collectorUrl已被移除。该 integration 上报的名称是Otlp,而不是OtlpIntegration。- 注意重复的 span。 Sentry 会插桩许多与你相同的库。最简单的做法是不设置
tracesSampleRate。如果你希望 Sentry span 与自己的 span 并存,就过滤掉重叠的 integration,并使用httpIntegration({ spans: false })和nativeNodeFetchIntegration({ spans: false }),而不是直接移除它们,因为httpIntegration还提供请求隔离、请求数据和会话跟踪。 - HTTP 和 fetch span 的行为变了。 在 v10 中,
skipOpenTelemetrySetup: true也会关闭这些 span。在 v11 中,只要启用了 tracing,Sentry 就会发出它们。 - 不再收集 resources。
contexts.otel.resource已从事件中移除,SDK 也不再读取OTEL_SERVICE_NAME或OTEL_RESOURCE_ATTRIBUTES。
升级到 v11
v11 把 tracing 从 OpenTelemetry 插桩包迁移到源码转换和 diagnostics channel 上。在 Node 上,这意味着更少的依赖,以及一条顺序规则取代原来的好几条。如果你运行在 Cloudflare Workers、Deno 或 Bun(配合 bun build)上,或者把打包后的服务端代码部署到 Vercel 或 Netlify,你的依赖现在会自动获得 tracing,而在此之前它们几乎没有或完全没有。
升级时先看迁移指南,第一次运行新版本时打开 debug: true,就能看到哪些模块被插桩了。
转换逻辑和 hooks 来自 apm-js-collab 系列包,这是我们与其他 APM 厂商共同构建的。如果某些行为不符合预期,请在 sentry-javascript 仓库提 issue。