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 线程上重新执行 --require preload,于是 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。

来源: Sentry Blog← 返回首页