【Sentry JavaScript SDK v11】すべてのJSプラットフォームでトレーシングに対応

Article by: (読了時間:12分)

 

 

本記事では、Sentry JavaScript SDKの最近の変更について紹介します。JavaScriptでSentryを使っていない方にも、変更の経緯や背景を知るうえで参考になる内容です。

Sentry JavaScript SDK v11では、トレーシングインテグレーションの仕組みを作り直しました。OpenTelemetry標準には引き続き対応していますが(詳しくは後述)、以前のバージョンのようにOpenTelemetryのインストルメンテーションクラスを使うのではなく、コードトランスフォーマーで依存関係をインストルメントするようになりました。

この仕組みは、アプリケーションパフォーマンス監視(APM)ベンダー各社が共同で取り組んでいるapm-js-collabのパッケージ、@apm-js-collab/code-transformerと@apm-js-collab/tracing-hooksをベースにしています。

 

インジェクションとサブスクリプション

新しいトレーシングインストルメンテーションは、2つの部分で動作します。1つ目はモジュールの読み込み時にdiagnostics channelを注入し、2つ目はそのチャネルのイベントを受け取ってトレーシング情報を集め、Sentryに送信します。

 

パート1:インジェクション

対応するモジュールが読み込まれると、実行時でもバンドラーの処理の中でも、トランスフォームがソースを書き換え、対象となる関数をtracingChannel()の呼び出しでラップします。

各トランスフォームは、パッケージ名、semver範囲、パッケージ内のファイル、関数を指定した設定に基づいて動作します。

トランスフォーム後のmysqlモジュールは、query関数が呼ばれるたびにstart、end、error、asyncStart、asyncEndのdiagnosticsイベントを発行するようになります。

 

パート2:サブスクリプション

各Sentryインテグレーション(mysqlIntegration、redisIntegration、openAIIntegrationなど)は、それぞれのチャネルをサブスクライブし、イベントをSentryのスパンに変換します。

サブスクライバーは余分なチャネルを作らないよう、対象のモジュールが実際に読み込まれたことを確認するまで待機します。

 

実行時とビルド時のインジェクション

同じトランスフォームが2つの場所で実行され、両方を同時に有効にすることもできます。モジュールはバンドラー経由かnode_modulesからのどちらか一方で読み込まれるため、二重にラップされることはありません。

 

実行時

最近のバージョンのNodeやDeno 2.8.3以降では、Sentry.init()が自動的にregisterDiagnosticsChannelInjection()を呼び出し、Module.registerHooks APIを使って、それ以降に読み込まれる対応モジュールをインストルメントします。

DenoとNodeのSDKが提供するimportエンドポイントを使えば、アプリの読み込み前にフックを登録することもできます。

 
ビルド時

bun buildを使っている場合や、Viteやesbuildなどのバンドラーでプログラムをバンドルしている場合は、提供されているプラグインを使って、アプリケーションへのビルド時にモジュールをインストルメントできます。

@sentry/nodeはVite、Rollup、webpack、esbuild向けのプラグイン、@sentry/cloudflareはVite向けのプラグイン、@sentry/bunはbun build向けのプラグインを提供しています。Next.jsでは、webpackローダーがwebpackとTurbopackの両方に組み込まれます。

プラグインはインストルメント対象の依存関係をバンドル時にトランスフォームし、バンドルされたモジュールが最初に評価されたときに、そのモジュール(およびサブスクライバーファクトリー)をグローバルマーカーに登録するスニペットを差し込みます。

使い方は、ほかのSentryバンドラープラグインと同じです。

 

v11以前のアプローチ

v11以前、Sentryのインテグレーションはほぼすべて、OpenTelemetryのインストルメンテーションAPIをベースにしていました。オープンソースコミュニティの成果を活用できましたが、JavaScriptプラットフォームの選択肢が広がるにつれて深刻になっていくという大きな欠点がありました。

 

OpenTelemetryインストルメンテーションの内部

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

registerInstrumentations()は、2つのローダーフックをインストールします。

  • require-in-the-middle — CommonJS向けにModule._loadにパッチを当てます。
  • import-in-the-middle — Module.register()経由で、ECMAScriptモジュール(ESM)のローダーフックとして登録されます。

 

インストルメント対象のモジュールが読み込まれると、これらのローダーフックがモジュールのexportsオブジェクトとベースディレクトリをOpenTelemetryインストルメンテーションに渡します。インストルメンテーションはディスクから<baseDir>/package.jsonを読み込んでバージョンを調べ、宣言された範囲と照合したうえでpatch(exports)を呼び出します。パッチはshimmerを使い、exportsオブジェクトのプロパティをその場で置き換えます。置き換えられた関数がOpenTelemetryのスパンを開始し、SentryのSentrySpanProcessor、SentrySampler、SentryContextManagerがそのスパンをSentryのスパンに変換します。

ESMの名前空間オブジェクトはイミュータブルなので、import-in-the-middleは単純に値を代入できません。そのため、各モジュールのソースをディスクから読み込み、エクスポート名を字句解析し(CommonJSはcjs-module-lexer、ESMはソースのスキャン)、ミュータブルなローカルバインディングを通じて再エクスポートする代替モジュールを生成したうえで、名前空間をProxyでラップします。ここまでして、ようやくshimmerによる代入が反映されます。

 

前提の積み重ね

このプロセスの各ステップは、実行環境について何らかの前提を置いています。全体として、次の条件がそろっている必要があります。

  • すべての依存関係に対してrequire()やNodeのESM resolve/loadフックを実行する、稼働中のモジュールローダー
  • Nodeのローダー内部のModule._loadとModule.register()
  • 依存関係が独立したパッケージディレクトリとしてディスク上に存在し、package.jsonとソースファイルを読み込めること
  • パッチで再代入できるexports
  • ライブラリが最初に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文は巻き上げられ、importする側のファイルのコードより先に評価されます。そのため、ESMのエントリーポイントに書いたSentry.init()は、パッチを当てたいライブラリがすでに読み込まれた後に実行されてしまいます。v10では、パッチを早い段階でインストールする必要があるものの、まだSDKを設定できないアプリ向けに、--import=./instrument.mjs、@sentry/node/preloadエントリーポイント、preloadOpenTelemetry()、SENTRY_PRELOAD_INTEGRATIONS環境変数といった選択肢が用意されていました。

 

重さ

サポート対象のライブラリごとに、そのライブラリの内部実装に合わせて固定された専用の@opentelemetry/instrumentation-*パッケージが必要でした。さらにSentryは、スパンを受け取るために、OpenTelemetry SDK(トレーサープロバイダー、サンプラー、スパンプロセッサー、コンテキストマネージャー、プロパゲーター)を常に組み込んでおく必要がありました。

 

v11での解決策

新しいアプローチでは、ライブラリ作者が使える組み込みのチャネルと同じようにtracing channelを注入することで、この連鎖全体を置き換えます。

  • バンドルしたサーバーのデプロイがそのまま動作 — ソーストランスフォームは、生成されたオブジェクトではなくソースコードを書き換えるため、ビルド中に実行できます。バンドラープラグインは依存関係をバンドルしながらインストルメントしますが、これはローダーフックの仕組みではまったく不可能なことです。サーバーコードを自動でバンドルするVercelやNetlifyにも対応します。
  • Node以外のランタイムでも動作 — node:diagnostics_channelはNodeに組み込まれているほか、Bun、Deno、Cloudflare Workers(nodejs_compat使用時)にも実装されています。これが今回の変更の一番の理由でした。
  • exportsではなくライブラリのコードを編集 — トランスフォームはモジュール自体のソース内で関数をラップするため、パッケージがexportsをfreezeしていても、バレルファイル経由で再エクスポートしていても、バンドラーによってインライン化されていても関係ありません。exportsオブジェクトへのモンキーパッチは、これらのケースではどれも失敗します。
  • 順序のルールがひとつに — ライブラリは読み込まれた瞬間に自身をインストルメントするため、preloadOpenTelemetry()、@sentry/node/preload、SENTRY_PRELOAD_INTEGRATIONSはすべて廃止されました。実行時は、1回の登録呼び出しですべてのライブラリをカバーします。
  • インストールするものが減り、トラブルも減少 — 依存関係ツリーは、インストルメンテーションパッケージの集まりから@opentelemetry/apiだけに削減され、@sentry/node-coreは@sentry/nodeに統合されました。generateInstrumentOnce()、SentryContextManager、SentrySampler、SentrySpanProcessorは削除されています。
  • SentryがOpenTelemetryの設定に干渉しない — Sentryのスパンがパイプラインに漏れ出すことはなくなり、パイプライン側でSentryのコンテキストマネージャーやプロパゲーターを使う必要もなくなりました。後述のとおり、カスタムのOpenTelemetry設定とSentryを組み合わせる方法には、引き続きかなりの柔軟性があります。

 

ソーストランスフォームでdiagnostics channelを注入するこのインストルメンテーションは、v10でもexperimentalUseDiagnosticsChannelInjection()を使えば利用できました。v11ではこれをデフォルトにし、exportsにパッチを当てるインストルメンテーションを削除しました。

 

プラットフォームの対応範囲

このアプローチを採用する前に、ほかにもさまざまな選択肢を検討しました。大きな変更ですし、オープンソースのOpenTelemetryベースのインストルメンテーションには、本番システムで信頼されてきた長い実績があります。動機のひとつは、同等のOpenTelemetryサポートをすべてのJavaScriptプラットフォームに提供することでした。

Node自体もモジュールローダーフックを別の方向へ移行させつつあるため、import-in-the-middleとrequire-in-the-middleをあらゆるJSプラットフォームに移植するのは、労力に見合わないと判断しました。新しいアプローチでは、3つのプラットフォームですぐに成果が出ました。

 
Cloudflare Workers

WorkersにはNodeのモジュールローダーがないため、v10のアプローチではフックできる対象がありませんでした。fetchとリクエストハンドラー以外をトレースするには、手動のラッパー(instrumentOpenAiClientなど)を自分で呼び出す必要がありました。

v11では@sentry/cloudflare/viteが追加されました。sentryCloudflareVitePlugin()はworkerバンドルのビルド時に依存関係をトランスフォームするため、データベースドライバーやAIクライアントがworker内でスパンを出力するようになります。サブスクライバーのインテグレーションは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では、これらはひとつもありませんでした。

 

注意点と制限事項

アップグレード時には、いくつか注意すべき点があります。よく言われるように、どんな改善も変化をともないます。要するに、移行ガイドを読み、それに従ってください。

  • 読み込み順序は引き続き重要 — トランスフォームが適用されるのは、フックの登録後に読み込まれたモジュールだけです。--importで登録するか、インストルメント対象のライブラリをimportするすべての箇所より上にSentry.init()を置いてください。
  • --requireはサポート対象外に — Nodeは、Module.register()が起動するローダースレッド上で--requireのプリロードを再実行します。そのため、instrumentファイルが、アプリのコードを実行することのないスレッド上でSentry.init()をもう一度実行してしまいます。CommonJSのアプリでも動作する--importを使ってください。
  • ビルド時の設定では、インストルメント対象のライブラリをexternalにしない — externalにした依存関係はnode_modulesから読み込まれ、トランスフォームを通らないため、チャネルが注入されないまま気づけません。Sentryのビルドプラグインは、既知のインストルメント対象パッケージをexternalから除外し、自動で修正できないものについては警告を出します。
  • debug: trueで確認 — 追加の設定なしで動作するよう設計していますが、うまく動かないケースも想定しています。init()の時点で、SDKは実行時フックが登録されたかどうか、バンドラープラグインが実行されたかどうか、それぞれがどのモジュールに注入したかを報告します。テストやCIをSentryの設定でdebug: trueにして実行すれば(特にセットアップを変更している間は)、ほとんどのミスを見つけられます。

 

v11でのカスタムOpenTelemetry設定

インストルメンテーションとOpenTelemetryは、別々の関心事になりました。SentryはOpenTelemetryと引き続き連携でき、その背後のアーキテクチャはよりシンプルになっています。

チャネルインテグレーションは、チャネルイベントから直接ネイティブのSentryスパンを作成します。そのため、以下のどの方法を選んでも、依存関係はインストルメントされたままです。

skipOpenTelemetrySetupはenableOpenTelemetrySetupに置き換えられ、意味が逆になりました。ほとんどのサーバーSDKではデフォルトがfalseで、@sentry/nextjsと@sentry/sveltekitではtrueです。この2つは、各フレームワークが出力するスパンを拾うために、引き続き軽量なトレーサープロバイダーを登録します。

 

オプション1:Sentryのみ(デフォルト)

tracesSampleRateを設定するだけで完了です。

 

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

enableOpenTelemetrySetup: trueを設定します。Sentryが最小限のトレーサープロバイダー、コンテキストマネージャー、プロパゲーターを登録するため、@opentelemetry/apiを通じて作成されたスパンはネイティブのSentryスパンになります。これは汎用的なOpenTelemetryパイプラインではなく、エクスポーターもOpenTelemetry Protocol(OTLP)の出力もありません。すでにプロバイダーを登録している場合、Sentryは自身のプロバイダーを登録せず、代わりに警告をログに出力します。

 

オプション3:独自のOpenTelemetryにSentryを連携

enableOpenTelemetrySetupはfalseのまま、tracesSampleRateは未設定のままにして、独自のプロバイダーを実行し、Sentry.otlpIntegration()を追加します。

otlpIntegration()は、Sentryのエラー、ログ、メトリクス、cronを、その時点でアクティブなOpenTelemetryのスパンに紐づけるため、すべてのテレメトリーがひとつのトレースにまとまります。エクスポーター、スパンプロセッサー、トレーサープロバイダーは一切セットアップせず、送信側のプロパゲーションは独自のプロパゲーターに任せます。

v10でカスタム設定を使っていた場合、移行のポイントは次のとおりです。

  • 独自プロバイダーのスパンをSentryスパンとしてSentryに送る方法は廃止 — SentryContextManager、SentrySampler、SentrySpanProcessorは削除されました。代わりにOTLPでエクスポートし、エクスポーターの送信先をSentry.getOtlpTracesEndpoint(dsn)に向けてください。
  • otlpIntegrationの提供場所が変更 — すべてのサーバーSDKのメインエントリーからエクスポートされるようになったため、@sentry/node-core/light/otlpは廃止されました。setupOtlpTracesExporterとcollectorUrlは削除されています。インテグレーション名はOtlpIntegrationではなくOtlpとして報告されます。
  • スパンの重複に注意 — Sentryは、ユーザー側でインストルメントしているのと同じライブラリの多くをインストルメントします。いちばんシンプルな対処法は、tracesSampleRateを未設定のままにすることです。独自のスパンと並べてSentryのスパンも使いたい場合は、重複するインテグレーションを除外してください。ただし、httpIntegrationはリクエストの分離、リクエストデータ、セッショントラッキングも担っているため、削除するのではなくhttpIntegration({ spans: false })やnativeNodeFetchIntegration({ spans: false })を使ってください。
  • HTTPとfetchのスパンの扱いが変更 — v10では、skipOpenTelemetrySetup: trueにするとこれらもオフになっていました。v11では、トレーシングが有効になっていれば、Sentryは常にこれらを出力します。
  • リソースの収集を廃止 — イベントからcontexts.otel.resourceが削除され、SDKはOTEL_SERVICE_NAMEやOTEL_RESOURCE_ATTRIBUTESを読み込まなくなりました。

 

v11へのアップグレード

v11では、トレーシングの仕組みを、OpenTelemetryのインストルメンテーションパッケージから、ソーストランスフォームとdiagnostics channelへ移行しました。Nodeでは依存関係が減り、順序のルールも複数からひとつになります。Cloudflare Workers、Deno、Bun(bun build使用時)で実行している場合や、バンドルしたサーバーコードをVercelやNetlifyにデプロイしている場合は、これまでほとんど、あるいはまったくトレースされていなかった依存関係が自動でトレースされるようになります。

アップグレードの際は、まず移行ガイドを確認し、新しいバージョンを初めて実行するときはdebug: trueをオンにして、どのモジュールがインストルメントされたかを確認してください。

トランスフォームとフックは、ほかのAPMベンダーと共同で開発しているapm-js-collabのパッケージによるものです。想定どおりに動作しない場合は、sentry-javascriptリポジトリでissueを作成してください。

 

 

Original Page: Sentry JavaScript SDK v11 adds tracing for all JS platforms

 


IchizokuはSentryと提携し、日本でSentry製品の導入支援、テクニカルサポート、ベストプラクティスの共有を行なっています。Ichizokuが提供するSentryの日本語サイトについてはこちらをご覧ください。またご導入についての相談は「お問い合わせ」からお気軽にお問い合わせください。

日本語ブログホームへ戻る

 

シェアする

Recent Posts

;