Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
4
Help us understand the problem. What are the problem?
@skitoy4321

計量的な統計を扱うためのSystem.Diagnostics.Metrics API

はじめに

dotnet-6.0 preview5より、System.Diagnostics.Metrics名前空間と、配下にAPIが追加された。
今後使うことになりそうかなあと思ったので、これについて解説しようと思う。
記事執筆時点での最新版はdotnet-6.0pre7なので、これをベースに解説する。

何をするためのものか

短く言うと、"プログラムにおける計量的な統計情報を扱うための仕組み"というべきだろうか。
ここでいう計量的な統計情報とは、"平均CPU使用率"とか、"平均秒間リクエスト数"とか、"秒間IO書き込みデータ量"とか、
とにかく数字で表せる統計情報を指す。

概念的には、PrometheusやMackerel的なものを扱っている人であれば、馴染みは深いと思う。
Windowsユーザーであれば、パフォーマンスモニター(perfmon.exe)で収集できるような情報と言えばわかりやすいだろうか。

概念的には、OpenTelemetryMetricsをdotnetの中で実現するための基盤となる。
実際、OpenTelemetryのdotnetライブラリも、PrometheusのExporterは1.2.0-alpha1以降こちらを使うようになっている。
また、ConsoleExporterもこのバージョンからMetricsに対応している。

想定するシナリオ

登場人物としては、大雑把に言うと情報を発信する側(Instrument、Publisher)と、情報を受け取って処理する側(Collector)が存在する。

pushシナリオ

こちらは、Instrumentが、Collectorに情報を発信するということを想定する。
仕組みとしては割と単純になる。

push-sequence.png

利点としては、

  • 全体的な仕組み(特にInstrument側)が単純
  • 事象の見逃しが起こりにくい

欠点としては、

  • エラー処理が大変
    • Collector側の処理オーバーフロー、システムダウン等
    • Collector側の不具合がInstrument側に影響を及ぼす場合がある(送信エラーのリトライ等による余分な負荷等)
    • 間にBrokerを置く等、階層化することによって軽減は可能

pullシナリオ

こちらは、 Collector側の情報取得の要求をトリガーとして値を取得する という方式である。
Prometheusはこの方式である。

pull-sequence.png

利点としては

  • Collector側の負荷制御がしやすい
    • 問い合わせに来なければInstrument側の負荷は無い
  • エラー処理が比較的容易

欠点としては

  • 一つのやり取りだけ見ればInstrument側のオーバーヘッドはpush型よりも大きくなりがち
    • 普通Collectorは高頻度で問い合わせはしないので、多くの場合全体的な負荷は減る
  • Instrument側の仕組みが複雑になりがち
  • 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シナリオで、その時の値を記録するためのもの

簡単に流れを書くと、

  1. Meterオブジェクトの作成
  2. 各種Instrumentの作成
  3. イベントの発生
  4. 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 measurement)と、追加でメタデータを与えるオーバーライドが公開されている。
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 ValueReadonlySpan<T> Tagsで構成される構造体となる。

ObservableCounter<T>

ObservableInstrument<T>から派生。
増分を記録しておくためのメトリッククラス。Meter.CreateObservableCounter<T>()で作成され、この時値を返すためのコールバックを指定する。
こちらも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にデータを流すことは十分に考えられることに注意。

MetricListenerの生成

new System.Diagnostics.Metrics.MetricListener()でリスナーオブジェクトを作成する。
MetricListenerも、後述するStart時点でプログラムグローバルなリストに登録されるため、シングルトンで管理するのが望ましい。

どのInstrumentを監視するかの設定

最初に、どのInstrumentを監視対象に入れるかの設定を行う。
MetricListenerにはAction<Instrument, MetricListener> InstrumentPublishedというメンバがあるので、
ここで監視対象に入れるならば、引数として渡ってきたMetricListenerのEnableMeasurementEvents(Instrument, object?)を実行する。
入れない場合はそのまま何もしないようにする。
第二引数には、イベント発生時のコールバックで渡したいオブジェクトを指定する(null可)。

例:

var listener = new MetricListener();
listener.InstrumentPublished = (inst, l) =>
{
   if(inst.Name == "Abc" && inst.Meter.Name == "Meter1")
   {
      // 外側のlistenerは使わない
      l.EnableMeasurementEvents(inst, null);
   }
};

イベント発生時の処理の仕方の設定

実際にイベントが来た時の処理を設定するには、MetricListenerのMetricListener.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 MetricListener();
listener.SetMeasurementEventCallback<long>((inst, measurement, tags, state) =>
                                           Console.WriteLine($"{inst.Name}: {measurement}"));

Observable*のコールバックでIEnumerable<Measurement<T>>を返している場合は、コールバックが複数呼ばれる。

注意点として、Instrument側のTの型と、SetMeasurementEventCallbackで指定させるTの型は完全に一致させなければならない。
一致しない場合はコールバックが無視される。

監視解除時の設定

Collectorが監視を停止したときに何らかのコールバックを行いたい場合は、
Action<Instrument, object?> MetricListener.MeasurementsCompleted に設定する。
第一引数は監視していたInstrumentインスタンスで、第二引数はEnableしたときに渡したstateインスタンスになる。

監視の開始

SetMeasurementEventCallbackしただけでは監視は始まらない。
監視をスタートさせるには、MetricListener.Start()を行う必要がある。
この時、登録されたInstrumentオブジェクトがあると、InstrumentPublishedで設定したコールバックが呼ばれ、
その中でEnableMeasurementEventsすると監視が開始される。

実際は、Start()しないで直接EnableMeasurementEventsしても監視は開始されるが、通常Instrumentはprivateないしはinternalな
オブジェクトで運用することが多いので、Startからのコールバックで設定、というのが想定する使われ方と思われる。

ObservableInstrument系の取得(pullシナリオ)

ObservableCounter<T>ObservableGauge<T>は、そのままでは値取得イベントは発生しない。
ではどうすればいいかというと、MetricListener側でvoid RecordObservableInstruments()を実行する。
これを実行すると、Observable生成時に指定したコールバックが呼ばれ、返された値を元にしてSetMeasurementEventCallbackで指定した処理が実行される。

例:

using var m1 = new Meter("Meter1");
using var listener = new MetricListener();
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();

監視の停止

監視を停止したい場合は、

  • MetricListener.DisableMeasurementEvents(Instrument)を呼ぶ
  • MetricListenerをDisposeする

の二種類の方法があるが、DisableするにはInstrumentオブジェクトが必要なので、普通はDisposeを使うことになるだろう。
Insturment側とは違い、Collector側は何らかの終了処理を行いたい場合が多い(接続の解除や各種ハンドルのクローズ等)ので、こちらは生存期間をなるべく
決めておいた方が良いと思う。

監視を停止すると、MeasurementsCompletedで設定したコールバックがInstrumentごとに呼ばれる。

まとめ

Metricsについてまとめた。
個人的に気を付けたいのは

  • Instrument側もCollector側も両方シングルトンで動かす
  • 名前は一意に
  • Instrument側でpullシナリオかpushシナリオか決める
  • 扱う値の型を確定させておく

辺りだろうか。

MetricsEventSourceについては今回説明を省略したが、別の記事で書ければいいかなと思う。
dotnet-counterとかで観測するために多分必要になってくるし。
後、このAPI導入のきっかけとなったopentelemetryとの連携についても、気が向けば書くかもしれない。

参考リンク

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
4
Help us understand the problem. What are the problem?