はじめに
dotnet-6.0 preview5より、System.Diagnostics.Metrics
名前空間と、配下にAPIが追加された。
今後使うことになりそうかなあと思ったので、これについて解説しようと思う。
記事執筆時点での最新版はdotnet-6.0pre7なので、これをベースに解説する。
何をするためのものか
短く言うと、"プログラムにおける計量的な統計情報を扱うための仕組み"というべきだろうか。
ここでいう計量的な統計情報とは、"平均CPU使用率"とか、"平均秒間リクエスト数"とか、"秒間IO書き込みデータ量"とか、
とにかく数字で表せる統計情報を指す。
概念的には、PrometheusやMackerel的なものを扱っている人であれば、馴染みは深いと思う。
Windowsユーザーであれば、パフォーマンスモニター(perfmon.exe)で収集できるような情報と言えばわかりやすいだろうか。
概念的には、OpenTelemetryのMetricsをdotnetの中で実現するための基盤となる。
実際、OpenTelemetryのdotnetライブラリも、PrometheusのExporterは1.2.0-alpha1以降こちらを使うようになっている。
また、ConsoleExporterもこのバージョンからMetricsに対応している。
想定するシナリオ
登場人物としては、大雑把に言うと情報を発信する側(Instrument、Publisher)と、情報を受け取って処理する側(Collector)が存在する。
pushシナリオ
こちらは、Instrumentが、Collectorに情報を発信するということを想定する。
仕組みとしては割と単純になる。
SNMPでいうと、トラップやインフォームがこれに当たる。
利点としては、
- 全体的な仕組み(特にInstrument側)が単純
- 事象の見逃しが起こりにくい
欠点としては、
- エラー処理が大変
- Collector側の処理オーバーフロー、システムダウン等
- Collector側の不具合がInstrument側に影響を及ぼす場合がある(送信エラーのリトライ等による余分な負荷等)
- 間にBrokerを置く等、階層化することによって軽減は可能
- Instrument側がCollector側に依存することになる
- Collector側の情報(宛先等)を知っている必要がある
pullシナリオ
こちらは、 Collector側の情報取得の要求をトリガーとして値を取得する という方式である。
Prometheusはこの方式である。
SNMPでいうとポーリングがこれに当たる。
利点としては
- Collector側の負荷制御がしやすい
- 問い合わせに来なければInstrument側の負荷は無い
- エラー処理が比較的容易
- Instrument側がCollector側に依存する割合が低くなる
- Instrument側はCollector側の詳細を知る必要がない
欠点としては
- 一つのやり取りだけ見ればInstrument側のオーバーヘッドはpush型よりも大きくなりがち
- 普通Collectorは高頻度で問い合わせはしないので、多くの場合全体的な負荷は減る
- Instrument側の仕組みが複雑になりがち
- Instrument側にCollectorを受け入れる口を作る必要がある(prometheusのexporterはHttpListenerかASP.NET Coreを使って口を作るようにしている)
- pullタイミングによっては事象を見逃す可能性がある
- CPUのスパイク現象等
- Collector側が自重しないと結局Instrument側が過負荷になりやすい
EventCounterとの関連
このような仕組みを実現する似たようなものとして、.NET Frameworkの時代からEventCounterというものが存在する(pullシナリオの場合はPollingCounter(要netstandard2.1以降))。
しかし、これはEventSourceに強く結びついたもので、opentelemetryの仕組みの実現としては多少不便なものがあった。
そこで、純粋にマネージドコードのみで実装し、opentelemetryで扱いやすく設計し直したのがSystem.Diagnostics.Metrics
となる。
また、MetricsのイベントをEventSourceで取り扱うための MetricsEventSource というものがあるが、
この記事で記述すると長大になってしまうため、今回は詳しい説明は省略する。
MetricsEventSourceの中では、Counter<T>
、ObservableCounter<T>
の集計を行っている等、各Instrumentの扱いが異なるので注意
DiagnosticSourceとの関連
立ち位置的には兄弟のようなもので、pushシナリオは実はDiagnosticSourceでも実現可能。
しかし、
- DiagnosticSourceで扱うと想定されるオブジェクトはより汎用的なオブジェクトで、メトリックとして使うにはオーバーヘッドが大きくなることがある
- pullシナリオは実現が難しい
という事情があるため、Metricsが実装された。
導入
対象とするTargetFrameworkがnet6.0であれば、特に追加パッケージは必要ない。
net5.0あるいはそれ以下で使いたい場合は、System.Diagnostics.DiagnosticSourceの"6.0.0-preview.5.21301.5"以降を追加すれば、System.Diagnostics.Metrics以下が使えるようになる。
Instrument側の流れ
情報発信を行う側で登場するのは以下で、全てSystem.Diagnostics.Metrics配下に存在する。
-
Meter
- Instrumentの親となるオブジェクト
-
Counter<T>
- pushシナリオで、増分を記録するためのもの
-
Histogram<T>
- pushシナリオで、その時の値を記録するためのもの
-
ObservableCounter<T>
- pullシナリオで、増分を記録するためのもの
-
ObservableGauge<T>
- pullシナリオで、その時の値を記録するためのもの
簡単に流れを書くと、
- Meterオブジェクトの作成
- 各種Instrumentの作成
- イベントの発生
-
Meter.Dispose
で各種Instrumentオブジェクトの破棄- 破棄は必須ではない
Meterオブジェクトの作成
new System.Diagnostics.Metrics.Meter(string name, string? version)
で、大元となるMeterオブジェクトを作成する。
注意点として、グローバルなリストに登録されるため、多くの場合でstatic readonly
にして、大量生成されないようにする
使い終わったらDisposeすれば、グローバルなリストからは外される(生存期間がアプリケーションのライフタイムと一緒ならば、Disposeは必ずしもしなくていい)
Instrumentの作成
前項で作成したMeterオブジェクトから各種Instrumentを作成する。
6.0時点で作成可能なものは以下の通り
Counter<T> where T: struct
Histogram<T> where T: struct
ObservableCounter<T> where T: struct
ObservableGauge<T> where T: struct
上記の型は、Instrument<T>
から派生している。
T
で取り得る型は以下の通り
- byte
- short
- int
- long
- float
- double
- decimal
要するに基本の数値型で、他の型を使おうとすると、Create時にInvalidOperationException
が発生する。
それぞれのInstrumentについての説明は後述する。
全てに共通して言えることだが、Createした時点で、プログラム内に作成イベントが通知されるため、シングルトンで運用するのが望ましい。
また、MeterがDisposeされた段階で、Instrumentも使えなくなるので、お互いの生存期間には注意すること。
Instrument
全てのカウンターのベースクラスとなる。
持っている公開プロパティとしては、
名前 | 型 | 説明 |
---|---|---|
Name | string | 名前 |
Description | string | 説明(ユーザー任意) |
Enabled | bool | 監視しているリスナーがいるかどうか |
IsObservable | bool | Observableかどうか(pullシナリオ用かどうか) |
Meter | Meter | 親となるMeterのインスタンス |
Unit | string | 単位を表す文字列(req/s,KB等) |
がある。
Instrument<T>
Instrumentクラスから派生した、pushシナリオ用の派生クラス。
イベント発生をさせるためのprotectedメソッドであるRecordMeasurement
が追加されている。
このメソッドでは、第一引数に値を入れるが、それ以降はKeyValuePair<string, object?>
なタグデータを入れることができる。
Counter<T>
Instrument<T>
から派生。
増分を記録していくためのメトリッククラス(総リクエスト数とか)。void Add(T delta)
と、追加でメタデータを与えるオーバーライドが公開されている。
Counterとはいうが、単体で総計値を保存しているわけではないので注意すること。
値は前回送信した値からの増分を入れるように期待されている。
例:
class C1
{
static readonly Meter _M1 = new Meter("m1");
// unitとdescriptionは必須ではない
static readonly Counter<int> _C1 = _M1.CreateCounter<int>("c1", "unit", "description");
public void Method1()
{
// processing
if(_C1.Enabled)
{
// 有効ならば適当な値を入れる
_C1.Add(1);
}
}
}
Histogram<T>
Instrument<T>
から派生。
単調増加ではない数値(req/sとか)を記録していくためのメトリッククラス。void Record(T measurement)
と、追加でメタデータを与えるオーバーライドが公開されている。
Counterと何が違うのかという疑問を持つかもしれないが、単体で見ると、違いは公開メソッドの名前位である。
しかし、後述するMetricsEventSourceでは異なるイベント扱いされるので、可能ならば使い分けた方が良い。
例:
class C1
{
static readonly Meter _M1 = new Meter("m1");
// unitとdescriptionは必須ではない
static readonly Histogram<int> _H1 = _M1.CreateHistogram<int>("h1", "unit", "description");
public void Method1()
{
// processing
if(_H1.Enabled)
{
// 有効ならば適当な値を入れる
_H1.Record(10);
}
}
}
ObservableInstrument<T>
Instrumentクラスから派生した、pullシナリオ用の派生クラス。
Instrument.IsObservableがtrueになる他、protected abstract IEnumerable<Measurement<T>> Observe()
が定義されている。
Measurement<T>
は、T Value
とReadonlySpan<T> Tags
で構成される構造体となる。
ObservableCounter<T>
ObservableInstrument<T>
から派生。
増分を記録しておくためのメトリッククラス。Meter.CreateObservableCounter<T>()
で作成され、この時値を返すためのコールバックを指定する。
ここで何の値を返すかという所だが、MetricsEventSourceを見た自分なりの理解としては、 Counter<T>
と異なり、増分ではなく現在の総計値を返すように期待されているように見える。
例:
class C1
{
static readonly Meter _M1 = new Meter("m1");
static int _CachedValue = 0;
// unitとdescriptionは必須ではない
static readonly ObservableCounter<int> _OC1 = _M1.CreateObservableCounter<int>("oc1", () => _CachedValue, "unit", "description");
public void Method1()
{
// メソッド呼び出し回数を想定
Interlocked.Increment(ref _CachedValue);
}
}
ObservableGauge<T>
ObservableInstrument<T>
から派生。
観測時点の値を記録するためのメトリッククラス。Meter.CreateObservableGauge<T>()
で作成され、この時値を返すためのコールバックを指定する。
Counter<T>
とHistogram<T>
の関係と同じく、構造上異なる点は名前位なものだが、MetricsEventSourceでは異なるイベント扱いされる。
例:
class C1
{
static readonly Meter _M1 = new Meter("m1");
static int _CachedValue = 0;
static readonly Random _r = new Random();
// unitとdescriptionは必須ではない
static readonly ObservableGauge<int> _OG1 = _M1.CreateObservableCounter<int>("og1", () => _CachedValue, "unit", "description");
public void Method1()
{
// ランダムな値を入れると想定
_CachedValue = _r.Next(100));
}
}
推奨される運用
- Meter及び各種Instrumentの名前は一意に
- 後述するCollector側で監視対象を判別するため
- Prometheusのガイドライン等、有名どころのドキュメントを参考にするのが吉
- Instrumentオブジェクトはprivateないしinternalに
- 外部から勝手にイベントが追加されるのを防ぐため
- pushシナリオの場合、イベントを発生させる前に必ず
Enabled
をチェックする- オーバーヘッド、過負荷の軽減のため
Collector側の流れ
以下では、Collectorの流れを記述する。
ここで記述するのはインプロセスの話になるので、
実際はCollectorから更に他のCollectorにデータを流すことは十分に考えられることに注意。
MeterListenerの生成
new System.Diagnostics.Metrics.MeterListener()
でリスナーオブジェクトを作成する。
MeterListenerも、後述するStart時点でプログラムグローバルなリストに登録されるため、シングルトンで管理するのが望ましい。
どのInstrumentを監視するかの設定
最初に、どのInstrumentを監視対象に入れるかの設定を行う。
MeterListenerにはAction<Instrument, MeterListener> InstrumentPublished
というメンバがあるので、
ここで監視対象に入れるならば、引数として渡ってきたMeterListenerのEnableMeasurementEvents(Instrument, object?)
を実行する。
入れない場合はそのまま何もしないようにする。
第二引数には、イベント発生時のコールバックで渡したいオブジェクトを指定する(null可)。
例:
var listener = new MeterListener();
listener.InstrumentPublished = (inst, l) =>
{
if(inst.Name == "Abc" && inst.Meter.Name == "Meter1")
{
// 外側のlistenerは使わない
l.EnableMeasurementEvents(inst, null);
}
};
イベント発生時の処理の仕方の設定
実際にイベントが来た時の処理を設定するには、MeterListenerのMeterListener.SetMeasurementEventCallback<T>(MeasurementCallback<T>)
を使う。
MeasurementCallback<T>
の型は、void MeasurementCallback<T>(Instrument inst, T measurement, ReadOnlySpan<KeyValuePair<string, object?>> tags, object? state)
となる。
各引数の意味は、
-
Instrument inst
: イベントを発生させたInstrumentインスタンス -
T measurement
: イベント発生時で指定された値(Counterならdelta等) -
ReadOnlySpan<KeyValuePair<string, object?>> tags
: イベント発生時に指定されたメタデータ -
object? state
:EnableMeasurementEvents
で指定されたstate
例:
using var listener = new MeterListener();
listener.SetMeasurementEventCallback<long>((inst, measurement, tags, state) =>
Console.WriteLine($"{inst.Name}: {measurement}"));
Observable*
のコールバックでIEnumerable<Measurement<T>>
を返している場合は、コールバックが複数呼ばれる。
注意点として、Instrument側のTの型と、SetMeasurementEventCallbackで指定させるTの型は完全に一致させなければならない。
一致しない場合はコールバックが無視される。
監視解除時の設定
Collectorが監視を停止したときに何らかのコールバックを行いたい場合は、
Action<Instrument, object?> MeterListener.MeasurementsCompleted
に設定する。
第一引数は監視していたInstrumentインスタンスで、第二引数はEnableしたときに渡したstateインスタンスになる。
監視の開始
SetMeasurementEventCallbackしただけでは監視は始まらない。
監視をスタートさせるには、MeterListener.Start()
を行う必要がある。
この時、登録されたInstrumentオブジェクトがあると、InstrumentPublished
で設定したコールバックが呼ばれ、
その中でEnableMeasurementEvents
すると監視が開始される。
実際は、Start()
しないで直接EnableMeasurementEvents
しても監視は開始されるが、通常Instrumentはprivateないしはinternalな
オブジェクトで運用することが多いので、Startからのコールバックで設定、というのが想定する使われ方と思われる。
ObservableInstrument系の取得(pullシナリオ)
ObservableCounter<T>
やObservableGauge<T>
は、そのままでは値取得イベントは発生しない。
ではどうすればいいかというと、MeterListener側でvoid RecordObservableInstruments()
を実行する。
これを実行すると、Observable生成時に指定したコールバックが呼ばれ、返された値を元にしてSetMeasurementEventCallbackで指定した処理が実行される。
例:
using var m1 = new Meter("Meter1");
using var listener = new MeterListener();
listener.SetMeasurementEventCallback<int>((inst, measurement, tags, state) =>
Console.WriteLine($"{inst.Name}: {measurement}"));
// Instrumentの設定等
var oc1 = m1.CreateObservableCounter<int>("observablecounter1", () => 1);
// RecordObservableInstrumentsが呼ばれると、"() => 1"が呼ばれ、
// SetMeasurementEventCallbackで設定されたイベントが発生し、"observablecounter1: 1"が出力される
listener.RecordObservableInstruments();
監視の停止
監視を停止したい場合は、
-
MeterListener.DisableMeasurementEvents(Instrument)
を呼ぶ - MeterListenerをDisposeする
の二種類の方法があるが、DisableするにはInstrumentオブジェクトが必要なので、普通はDisposeを使うことになるだろう。
Insturment側とは違い、Collector側は何らかの終了処理を行いたい場合が多い(接続の解除や各種ハンドルのクローズ等)ので、こちらは生存期間をなるべく
決めておいた方が良いと思う。
監視を停止すると、MeasurementsCompleted
で設定したコールバックがInstrumentごとに呼ばれる。
まとめ
Metricsについてまとめた。
個人的に気を付けたいのは
- Instrument側もCollector側も両方シングルトンで動かす
- 名前は一意に
- Instrument側でpullシナリオかpushシナリオか決める
- 扱う値の型を確定させておく
辺りだろうか。
MetricsEventSourceについては今回説明を省略したが、別の記事で書ければいいかなと思う。
dotnet-counterとかで観測するために多分必要になってくるし。
後、このAPI導入のきっかけとなったopentelemetryとの連携についても、気が向けば書くかもしれない。
参考リンク
-
OpenTelemetryにおけるMetricsについての仕様
- 概念レベルでよくわからなくなってきたらここ
-
OpenTelemetryのdotnet実装
- Metricsを使うようになったのは1.2.0-alpha1から
- dotnet/runtimeのソース
-
Prometheus
- pullシナリオをサポートする代表的OSSプロダクト