@opentelemetry/instrumentation-expressはどうやって計測しているのか

最近 OpenTelemetryに入門 してみています。

OpenTelemetryのNode.jsでの計装サンプルを参考に以下のように記述したとき、expressでのリクエスト - レスポンス間の処理がトレースに現れることを期待したのですが、そうならずに困っていました。

// server.ts

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { context, trace } from '@opentelemetry/api';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import {
  PeriodicExportingMetricReader,
  ConsoleMetricExporter,
} from '@opentelemetry/sdk-metrics'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
import { Resource } from '@opentelemetry/resources'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const sdk = new NodeSDK({
  resource: new Resource ({
    [ATTR_SERVICE_NAME]: 'my-service-backend',
  }),
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:55681/v1/traces',
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new ConsoleMetricExporter(),
  }),
  instrumentations: [getNodeAutoInstrumentations()],
})

sdk.start()

import express from 'express'

const app = express()
const router = express.Router()

router.get('/', async (_req, res) => {
  // テンプレートを返却するごく簡単なサンプル
  let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8')

  res.send(template)
})

app.use(router)

app.listen(5174)

ということで、ソースコードを追うことにしてみます。

まず、上記コードにおける getNodeAutoInstrumentations() は、 @opentelemetry/auto-instrumentations-node が提供する処理で、Node.jsアプリケーションで使いそうな計装モジュールをひととおり返却してくれるものです。 このパッケージのREADMEを確認すると、@opentelemetry/instrumentation-express は特段の設定をしなくても読み込みされるとあります。これはgetNodeAutoInstrumentations() の実装を確認してみると、たしかにそのようになっています。

つづいて、@opentelemetry/instrumentation-express の計装処理を見ていきます。

@opentelemetry/instrumentation-expressの初期化処理 を見ていくと、expressの routeuse を何かでラップしている雰囲気があります。どうやら、オリジナルのexpress実装に計装処理のためのパッチをあてているようです。スパンを作成するソースコードが確認できます

これらの処理は、getNodeAutoInstrumentations()でInstrumentationのインスタンス化をする際に、先ほどの計装処理の基底クラスとなる@opentelemetry/instrumentationのInstrumentationBaseクラスから呼び出され、同クラスの enable() メソッドによってパッチが有効になります。 ここでモジュールにパッチをあてるために、import-in-the-middlerequire-in-the-middle を利用していることが確認できます。

import-in-the-middle は、あるモジュールを解決する処理をフックして、対象のモジュールに影響を与えるためのパッケージのようです。 このREADMEに、

This requires the use of an ESM loader hook, which can be added with the following command-line option.

とあります。Node.jsには、モジュールを解決する際の処理をカスタマイズするためのフックがあるようです。

https://nodejs.org/api/module.html#customization-hooks

このフックを import-in-the-middle パッケージでは利用しているのですが、Node.jsの起動時に、このカスタム処理を利用するように指示する --loader オプションが必要ということのようです。

そして、なんと @opentelemetry/instrumentation のREADMEにも、 --loader オプションを用いて @opentelemetry/instrumentation/hook.mjs を指定するように指示がありました。

というわけで、以下のように --loader オプションをつけて起動してみます。

node --loader=@opentelemetry/instrumentation/hook.mjs server.ts

(なお、--loader オプションは非推奨扱いで、代替の方法があることが import-in-the-middle のREADMEで案内されています。)

これで、expressで提供しているエンドポイントのリクエストの様子が「my-service-backend」としてトレースできるようになりました。

トレースの様子

ちゃんとREADMEを読んでいれば...と悔まれるところでした。 まだまだ入門したてで慣れていないのですが、計測のために依存するモジュールが多い印象があり、これらの役割について少々理解に手間取ってしまいました。 このあたりをソースコードリーディングしながら(自分の理解のために)整理していけるとよいかなと思いました。

ではでは。

クリックすると匿名でいいねできます。

(@piyoppi/counter-tools を使っています )