OpenTelemetry でメトリクスを計測しようとしたときに知りたかったこと

タイトルは What I Wish I Knew When Learning Haskell リスペクトです.

SDKAPI

OpenTelemetry でアプリケーションの計装をする際に使うパッケージ (モジュール) は, 計測・集約・エクスポートなどの実装の本体である SDK と, 計測のためのインターフェースである API に分離されています.

たとえば Node.js の場合, アプリケーションのエントリポイント付近で @opentelemetry/sdk-metrics パッケージを使って SDK を初期化し, グローバルな MeterProvider を設定します.

import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";

const meterProvider = new MeterProvider({
  // 各種設定
});
metrics.setGlobalMeterProvider(meterProvider);

あるいは @opentelemetry/sdk-node パッケージを使うとメトリクスだけでなくトレースとログの SDK もまとめて初期化できますが, この場合も裏側で同様の処理が行われています.

import { NodeSDK } from "@opentelemetry/sdk-node";

const sdk = new NodeSDK({
  // 各種設定
});
sdk.start();

アプリケーションの各コンポーネントでは @opentelemetry/api を使って, 抽象的なインターフェース経由で計測を行います. インターフェースの裏側にいるのは上記で設定したグローバルな MeterProvider です.

import { metrics } from "@opentelemetry/api";

const meter = metrics.getMeter("example");
const foo = meter.createGauge("foo");
foo.record(42);

このように SDKAPI が分離されていることによって, アプリケーションの各コンポーネントはどう計測するかにのみ関心を持てばよく, 計測の結果がどのように使われるのかについては関心を持たなくて済むようになっています. 例えば SDK の設定で計測結果の使い道 (集約方法やエクスポート先など) が変わっても, 計測を行っている各コンポーネントのコードは全く変更の必要がありません. サードパーティのライブラリがあらかじめ計測コードを記述しておくことも可能です.

(それはそれとして使うべきパッケージが多すぎて, 何を使ったら良いのかわかりづらかったり, ぱっと見でとっつきづらく見えるのは困る...)

Instrument や Metric Point の種類と意味

計測の道具である Instrument には主に以下の 4 つがあります.

  • Counter
    • counter.add(1) のように増加を計測
  • UpDownCounter
    • upDownCounter.add(-1) のように増減を計測
  • Gauge
    • gauge.record(42) のように現在値を計測
  • Histogram
    • histogram.record(3.14) のようにサンプル値を計測
  • (この他に Counter, UpDownCounter, Guage の asynchronous (observable) 版を加えて, 全部で 7 つあります)

どの Instrument を使っても, それぞれ一度に一つの数値を計測するという点では同じですね.

Instrument で計測した数値はメモリ上に蓄積され, エクスポートする際に集約 (Aggregation, 後述) されて Metric Point に変換されます. この Metric Point には主に以下の 3 つがあります.

  • Sum
    • 足し算に意味のある数値
    • 短調増加 (monotonic) かどうかの区別がある
  • Gauge
    • 足し算に意味のない数値
  • Histogram
    • 数値の分布
  • (この他に Histogram の亜種である Exponential Histogram とレガシーな Summary を加えて, 全部で 5 つあります)

この Metric Point は SDK 内部で使われるだけでなく, OpenTelemetry Protocol (OTLP) を通してそのままの形でエクスポートすることもできます. そして最終的には終着点となるエクスポート先のデータモデルに合わせて再び変換・保存されることになるでしょう.

Metric Point の種類ごとの違いは, Sum/Gauge と Histogram のようにデータの種類が異なる場合は明らかですが, Sum と Gauge のようにデータの意味が異なるという場合は少しわかりづらいです. 例えばメトリクス同士の演算をしたり, エクスポート先のデータモデルにも同様の意味的な区別がある場合は, これらの意味的な違いが重要になってきます.

ここで Instrument に話を戻すと, Instrument の種類ごとの違いは全て意味的なものです. 集約の際には, それぞれの意味に応じたデフォルトの集約方法が自動的に使われるようになっています.

Instrument Aggregation Metric Point
Counter Sum Sum (monotonic)
UpDownCounter Sum Sum (non-monotonic)
Gauge Last Value Gauge
Histogram Explicit Bucket Histogram Histogram

(種類の異なるものに対する用語の重複が多くて厄介ですね. 混乱しないよう注意しましょう.)

そしてこれらはあくまでデフォルトであり, View (後述) を使うと集約方法を変更することもできます (意味的な妥当性がない集約方法も選べてしまうことに注意).

以上のことを踏まえると, 計測時にどの Instrument を使うのかは, その計測結果をデフォルトではどのように扱ってほしいかを考えて決めるのが良いでしょう. 基本的には同じようなことが書いてありますが, ライブラリ作成者向けのガイドラインも参考になります.

MeterProvider/Meter/Instrument と Resouce/Scope/Metric

OTLP のメトリクスは以下のような階層構造からなっています.

  • Resouce: メトリクス発生元のリソース (マシン, OS, ランタイムなど) の情報
    • Scope: メトリクス発生元のアプリケーションコンポーネントやライブラリの情報
      • Metric

API の MeterProvider, Meter, Instrument の階層構造はこれに対応したものとなっています.

  • MeterProvider: 作成時に Resouce の情報を設定する
    • Meter: 作成時に Scope の情報を設定する
      • Instrument

また Meter の情報は View (後述) で Instrument を選択する際にも使われます.

View

通常は Instrument と同じ名前で Metric が生成されますが, これはあくまでデフォルトの設定です. View を使うと, Instrument から生成する Metric の名前や, 上で説明したように集約の方法などをデフォルトのものから変更できます.

各 View のオプションは, マッチする Instrument の条件と, マッチした Instrument から生成する Metric の設定の二つの部分からなります. 以下の例では Meter example の下の Instrument foo の計測結果に対して, 生成する Metric の名前を bar に変更しています. (マッチの条件と Metric の設定がフラットに並ぶ構造になっていて, 正直読みづらいと思う...)

const sdk = new NodeSDK({
  // ...
  views: [
    {
      // マッチする Instrument の条件
      // Meter の名前やバージョン, Instrument の名前や種類が使える
      meterName: "example",
      instrumentName: "foo",
      // マッチした Instrument から生成する Metric の設定
      // 名前, 属性 (Attributes), 集約の方法などをデフォルトのものから変更できる
      name: "bar",
    },
    // ...
  ],
  // ...
});

View のユースケースとしては上記の例のように Metric の名前を変える他にも,

  • 集約の方法を Drop に変更することで, Metric を生成しないようにする
  • 一つの Instrument に対して複数の View を設定し, 名前や集約方法を変えた複数の Metric を生成する

などがあります.

AggregationTemporality

MetricReader または MetricExporter の設定として AggregationTemporality というものがあり, メトリクスをエクスポートする際にどの期間で集約した値を出力するかを変更できます.

const sdk = new NodeSDK({
  // ...
  metricReaders: [
    new PeriodicExportingMetricReader({
      exporter: new OTLPMetricExporter({
        temporalityPreference: AggregationTemporality.DELTA,
      }),
    }),
  ],
  // ...
});

例として, 時刻 t0 から計測を始めて t1, t2, t3, ... の各時刻にメトリクスをエクスポートした場合,

  • Cumulative:
    • 出力する値は (t0, t1], (t0, t2], (t0, t3], ... の各期間で集約されたもの
    • メモリ使用量や送信するデータ量は増えるが, どこかの点が抜け落ちても時間的な解像度が下がるだけで済む
  • Delta:
    • 出力する値は (t0, t1], (t1, t2], (t2, t3], ... の各期間で集約されたもの
    • メモリ使用量や送信するデータ量は少ないが, どこかの点が抜け落ちたらその時刻の情報は完全に失われる

のような違いがあります. どちらの AggregationTemporality でも, 出力される情報量は基本的には同じ (Histogram の min, max は除く) で, 相互変換が可能 (ただしステートフルなコンポーネントが必要) です.

エクスポート先のデータの保存方法や機能などによって,

  • Cumulative / Delta いずれかのエクスポートにしか対応していない
  • Cumulative / Delta いずれのエクスポートにも対応しているが, どちらかに変換した上で保存される
  • Cumulative / Delta いずれのエクスポートにも対応しているが, 変換せずに保存され, 扱いはユーザーに委ねられる

のように各 AggregationTemporality へのサポート状況が異なるため, どちらを使うのが適切かはエクスポート先のドキュメントでの案内を確認しましょう.

注意したいのは, 設定箇所からもわかるように, AggregationTemporality はエクスポートの設定であって, メトリクスの意味を変えるための設定ではありません. 例えば Delta で Sum をエクスポートすると見かけ上レートのようにはなるのですが, その値をそのままレートとして解釈するのは誤りです. レートを見たい場合はエクスポート先で計算するか, Collector の Delta to Rate Processor を使って単位時間あたりのレートに変換しましょう.

付録: バージョン情報

これまでにもバージョン間で使い方が変わったことがまあまああるっぽいので, 参考情報として参照した仕様とパッケージのバージョンを記しておきます.

  • OTel Spec: 1.49.0
  • @opentelemetry/api: 1.9.0
  • @opentelemetry/exporter-metrics-otlp-grpc: 0.205.0
  • @opentelemetry/sdk-metrics: 2.1.0
  • @opentelemetry/sdk-node: 0.205.0

参考文献

公式ドキュメントを読めばだいたいわかるだろうと思って読んだけどまあまあ難解で困った.

fetch() では Host ヘッダーを設定できないし話はそこまで単純じゃない

JavaScript (TypeScript) のコードから HTTP リクエストを送る手段として, 最近では Web 標準の一つである Fetch Standard で定義された fetch() が使われることが多いですね.

await fetch("https://example.com");

リクエストヘッダーには Host を設定できない

Fetch Standard では Host をはじめとして Content-Length, Cookie, Origin など, いくつかのリクエストヘッダーを設定 (JavaScript から上書き) することが禁止されています.

いずれのヘッダーも HTTP やセキュリティ上の取り決めに従うために, ページの JavaSript が任意に設定するのではなく, ブラウザ側で設定されるべきものであると言えるでしょう.

サーバーサイドでも Host を設定できない (ことがある)

問題はここからで, 近年では fetch() はブラウザ環境だけにとどまらず, サーバー環境でも標準的な手段として使われるようになっています. 一方で Fetch Standard で定義された一部リクエストヘッダーの禁止などは基本的にブラウザ環境を念頭に置いたものであるため, サーバー環境から見るとナンセンスなものになっていることがあります.

たとえば Host ヘッダーの設定については, サーバー環境やブラウザ外で実行するスクリプトにおいては, リクエストのプロキシやサーバーの動作チェックなど, 一定のユースケースがあり得ます.

await fetch("http://localhost:8080", {
  headers: { Host: "example.com" },
});

ところが上記のスクリプトを各種サーバー環境 / ライブラリで動かしてみると, 以下の環境では headers に指定した値 example.com は無視され, リクエストの Host ヘッダーには localhost:8080 が設定されます.

  • Node.js v24.7.0
  • Deno v2.4.5
  • Cloudflare Workers (miniflare@4.20250823.1 で確認)
  • undici@7.15.0 (Node.js に組み込まれているライブラリ)

とはいえ全てのサーバ環境で足並みが揃っているわけではなく, 以下の環境では headers に指定した値 example.com がそのまま使われます.

これは一見すると環境によって Fetch Standard への準拠度合いが異っているということのように見えるのですが, 実はそう簡単な話ではありません.

話はそこまで単純じゃない

確かにサーバー環境で Host ヘッダーの設定が禁止されているのは, それが Fetch Standard で禁止されているからというので説明できるように見えます. 一方でこれらの環境の変更の履歴や実際の挙動を追ってみると, どうやらそのように説明するのはあまり正しくなさそうです.

おそらくサーバー環境で最初に Host ヘッダーの設定を禁止したのは Deno で, そのきっかけとしては受け取った Request オブジェクト (そう, サーバーはリクエストを受け取る側でもあるのです) を再利用したことで意図せず Host ヘッダーを設定してしまうトラブルがあったようです. これはアプリケーション側で解決すべき問題のような気もするのですが, 実行環境の側で解決されることになったようです (明言はないものの, もしかするとその背景に Fetch Standard の存在があったのかもしれません).

これに合わせて Fetch Standard で禁止されたヘッダーを設定できてしまうという issue も閉じられているのですが, 上記の変更で禁止されたのはあくまで Host ヘッダーのみで, Cookie や Origin などのヘッダーについては設定が可能なまま現状維持という方向性のようです.

Node.js (undici) ではリダイレクトが発生した際のトラブルをきっかけとして Host ヘッダーを禁止しています.

やはり Fetch Standard で禁止されたヘッダーを設定できてしまうという issue も閉じられているのですが, こちらも同じく禁止されたのは Host ヘッダーのみで, Cookie や Origin などは引き続き設定できます.

ちなみに node-fetch にも undici と同様の issue が作成されていますが, こちらは未解決のままとなっています.

またサーバー環境で Host ヘッダーの設定を禁止している仕組みについても, Fetch Standard とは全く異なるものになっています.

Fetch Standard によれば, リクエストに使う Headers オブジェクトに対しては, 禁止されたヘッダーを設定すること自体ができません. 以下のスクリプトをブラウザで実行すると, headers には Host ヘッダーを設定できて [["Host", "example.com"]] が出力されるのに対して, reqHeaders には設定できず [] が出力されることが確認できます.

// 文脈がなければ任意のヘッダーを設定できる
const headers = new Headers();
headers.append("Host", "example.com");
console.log([...headers]); // => [["Host", "example.com"]]

// リクエストに使う場合は Host など一部のヘッダーは設定できない
const reqHeaders = new Request("http://localhost:8080").headers;
reqHeaders.append("Host", "example.com");
console.log([...reqHeaders]); // => []

一方でサーバー環境でこのスクリプトを実行すると, 上で挙げたいずれの環境でも, headersreqHeaders 共に Host ヘッダーを設定できて [["Host", "example.com"]] が出力されます. 最終的な HTTP リクエストで Host ヘッダーが設定されない (headers に設定した値で上書きされない) のは, より後段の実際にリクエストを送信する部分で処理を行なっているようです.

まとめると, いずれのサーバー環境でも Fetch Standard で定義されたリクエストヘッダーの禁止には基本的には従っておらず, Host ヘッダーが禁止されている場合も (Fetch Standard が理由づけの一つであった可能性はあるものの) 主な理由は別にあったようです.

なお WinterCG (現 WinterTC) でサーバー環境にも適合するように Fetch Standard を変更する試みがあり, 特に Cookie や Origin などの一部リクエストヘッダの解禁について手が入れられていたようですが, その後これをサーバー環境の新たな標準としたり Fetch Standard 本体が変更を取り込むところまでは現在のところは至っていないようです.

おまけ: 各種サーバー環境で Host ヘッダーを指定する方法

Node.js の場合は fetch() の代わりに undici.request() などを使えば Host ヘッダーを設定できます.

import { request } from "undici";

await request("http://localhost:8080", {
  headers: { Host: "example.com" },
});

Deno の場合は fetch() に拡張オプション client が用意されており, ここに allowHost オプションを有効にした Deno.HttpClient を渡すことで Host ヘッダーの設定が可能です.

await fetch("http://localhost:8080", {
  headers: { Host: "example.com" },
  client: Deno.createHttpClient({ allowHost: true }),
});

あるいはそもそも Host ヘッダーを通常と異なる値に設定しようとする前に, 代わりに ForwardedX-Forwarded-Host など Host とは別の / より適したヘッダーが使えないかも検討しましょう.