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 の2つのパッケージを土台にしており、複数の application performance monitoring(APM) ベンダーが共同で進めているプロジェクトだ。

注入と購読

新しい tracing instrumentation は2つの部分から成る。1つ目はモジュールのロード時に diagnostics channel を注入し、2つ目はその channel のイベントを監視して tracing 情報を収集し Sentry に送る。

その1:注入

対象のモジュールがロードされるとき——実行時のロードでも、バンドラーによるビルド時のロードでも——トランスフォーマーがソースコードを書き換え、関心のある関数を tracingChannel() 呼び出しで包む。

各変換は設定ファイルで定義され、そこにはパッケージ名、semver の範囲、パッケージ内の特定のファイル、そして1つの関数が書かれている:

{
  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 を発行する。

その2:購読

各 Sentry integration(mysqlIntegration、redisIntegration、openAIIntegration など)は対応する channel を購読し、イベントを Sentry span に変換する。

購読側は、自分のモジュールが実際にロードされたことを確認してから動き出す。余計な channel を作らないためだ。

実行時注入とビルド時注入

同じトランスフォーマーが2か所で動き、両方が同時に有効になることもある。モジュールはバンドラーか node_modules のどちらか一方からロードされ、両方からになることはないので、二重に包まれる心配はない。

実行時

比較的新しい Node では、Deno 2.8.3 以降も含めて、Sentry.init() が自動的に registerDiagnosticsChannelInjection() を呼び出す。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 の integration はほぼ完全に OpenTelemetry の計装 API の上に成り立っていた。

これによりオープンソースコミュニティの成果をそのまま利用できたが、明らかな欠点もあり、JavaScript プラットフォームが増えるにつれてそれが深刻になっていった。

OpenTelemetry 計装の内部

@opentelemetry/instrumentation-express のような OpenTelemetry の計装パッケージは、InstrumentationBase を継承したクラスをエクスポートする。このクラスはパッケージ名、対応する semver の範囲、patch(moduleExports, version) 関数を宣言する。また InstrumentationNodeModuleFile の項目を宣言し、パッケージ内の特定のファイルだけにパッチを当てることもできる。

registerInstrumentations() は2つの 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()、そしてパッチを早く設置する必要があるが SDK をまだ設定できないアプリ向けの SENTRY_PRELOAD_INTEGRATIONS 環境変数が選択肢だった。

サイズ

サポートするライブラリごとに専用の @opentelemetry/instrumentation-* パッケージが必要で、しかもそのライブラリの内部実装に縛られる。これらの span を受け取るには、Sentry が OpenTelemetry SDK(tracer provider、sampler、span processor、context manager、propagator)を抱え続けなければならない。

v11 でこれを直す

新しい方式は連鎖全体を置き換え、組み込みの channel ライブラリの作者が使える方法と同じく、tracing channel を注入する。

  • バンドルされたサーバーデプロイがそのまま動く。 ソース変換はビルド中に実行できる。変更するのは生成後のオブジェクトではなくソースコードだからだ。bundler プラグインは依存がバンドルされる際にそれを計装する。loader hook システムには決してできない芸当である。これはサーバーコードのバンドルを代行する Vercel と Netlify をカバーする。
  • Node 以外のランタイムでも動く。 node:diagnostics_channel は Node に組み込まれており、Bun、Deno、Cloudflare Workers(nodejs_compat と併用)が実装している。今回の変更の主な理由はこれだ。
  • 変更するのはエクスポートではなくライブラリのコード。 変換はモジュール自身のソース内部で関数をラップするので、パッケージがエクスポートを凍結していようと、barrel ファイル経由で再エクスポートしていようと、すでに bundler にインライン化されていようと関係ない。エクスポートオブジェクトへの monkey-patching は、これらのどの場合も失敗する。
  • 順序のルールが1つで済む。 ライブラリはロードされた瞬間に自己を計装するので、preloadOpenTelemetry()、@sentry/node/preload、SENTRY_PRELOAD_INTEGRATIONS はすべて不要になる。ランタイム側は登録呼び出し1回で、すべてのライブラリをカバーできる。
  • インストールするものが減り、壊れる箇所も減る。 依存ツリーは instrumentation パッケージの山ではなく @opentelemetry/api だけに縮み、@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 プラットフォームへ移植する価値はないと判断した。新しい方式なら、3 つのプラットフォームで即座に結果が出せる。

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 はチャネル統合の一式をエクスポートするようになった。データベースコネクタ、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 に到達しないので、そのチャネルは黙って漏れる。Sentry のビルドプラグインは既知の計装対象パッケージを external から除外し、修正できないものについては警告を出す。
  • debug: true で検証する。 追加設定なしで動くことを狙って設計したが、動かない場合も想定している。init() のとき、SDK は実行時フックが登録されたか、bundler プラグインが動いたか、それぞれがどのモジュールを注入したかを報告する。Sentry の設定で debug: true を使ってテストと CI を回せば(とくに設定を変更している間)、ほとんどのミスは捕まる。

v11 でのカスタム OpenTelemetry 設定

計装と OpenTelemetry は今や別々のものだ。Sentry は引き続き OpenTelemetry と互換で、裏側のアーキテクチャはむしろ単純になった。

Channel 統合は channel イベントから直接ネイティブの Sentry span を生成するので、以下どちらの方式を選んでも依存関係は計装されたままになる。

skipOpenTelemetrySetup は enableOpenTelemetrySetup に置き換わり、意味は逆になる。ほとんどのサーバーサイド SDK ではデフォルトが false、@sentry/nextjs と @sentry/sveltekit では true だ。この 2 つのフレームワークは、自身が発行する span を受け取るための軽量な tracer provider を引き続き登録する。

方式 1:Sentry のみを使う(デフォルト)

tracesSampleRate を設定すればよい。

オプション2:OpenTelemetry 互換モード

enableOpenTelemetrySetup: true を設定する。Sentry は最小限の tracer provider、context manager、propagator を登録するため、@opentelemetry/api で作成した span はネイティブの Sentry span になる。これは汎用の OpenTelemetry パイプラインではない。exporter も OpenTelemetry Protocol (OTLP) 出力もない。すでに provider を登録済みの場合、Sentry は自身の provider の登録を拒否し、代わりに警告をログに出す。

オプション3:自分の 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 が報告する名前は OtlpIntegration ではなく Otlp になる。
  • 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 では依存関係が減り、複数あった順序のルールが1つに置き換わる。Cloudflare Workers、Deno、Bun(bun build と併用)で動かしている場合や、バンドルしたサーバーコードを Vercel や Netlify にデプロイしている場合、これまで tracing がほとんど、あるいはまったく得られなかった依存関係に自動で tracing が入るようになる。

アップグレードするときはまず移行ガイドを読み、新しいバージョンを初めて動かすときに debug: true を有効にすれば、どのモジュールが計装されたか確認できる。

変換ロジックと hooks は apm-js-collab シリーズのパッケージによるもので、他の APM ベンダーと共同で構築した。挙動がおかしい箇所があれば sentry-javascript リポジトリで issue を立ててほしい。

出典: Sentry Blog← ホームへ戻る