10
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

C#Advent Calendar 2021

Day 10

EventCounterについて

Last updated at Posted at 2021-12-09

はじめに

dotnetには、アプリケーションの統計情報収集フレームワークの一つとして、EventCounterというものが存在している。
この記事では、このEventCounterについて解説する。

なお、EventCounterはEventSourceの上に成り立っているので、EventSourceについての理解は必須だが、長くなるのでこの記事では詳しく解説しない。
そちらの理解がまだならば、 公式ドキュメント 等を参照のこと。

また、主な実装はsrc/libraries/System.Private.CoreLib/System/Diagnostics/Tracingにあるので、実装を見るときはそちらで。
インターフェイスを見たい場合はsrc/libraries/System.Diagnostics.Tracingを参照。
詳細は後述するが、インターフェイスはMetrics API に似ている(EventCounterが先発)。
なので、Metrics APIも併せて知っておくと、より理解が深まるかもしれない。

EventCounterは何のために使うか

dotnetの場合は、GCの回数や所要時間等、数値でモニタリングしたい指標というものがある。
EventCounterというのはそのような情報を外部に対して発信したいときに使うものとなる。
別記事で紹介している Metrics API はあくまでインプロセスで処理を行う必要があり、外部から観測するには何らかの処理追加が必要になるが、EventCounterはEventSourceの上に乗っているものなので、外部からアドホックに取得可能というのが利点としてある。
また、通常のEventSourceに無い特徴として、値の集計等を自動でしてくれるという利点があり、さらに、発信側から一方的に情報を送るのではなく、要求に応じて値を発信するというプル型のアプローチも可能なので、EventSourceで何らかの数値的な指標がある場合は検討してみてもいいと思う。

ただし、あくまでEventSourceの上に乗るものなので、そちら由来の制約もあることに注意すること。

EventCounterの基本的な実装

情報を発信するため、EventCounterを使用する。
EventCounterを使うには、まず紐づけるためのEventSourceを作る必要がある。
EventSourceは必ずしもイベントを作る必要はなく、以下のように空でも構わない。
ただ、各インスタンスの生存期間等を考えると、実用上はEventCounterはEventSourceに内包させ、
何かのイベントに付随させて値を記録することが多いので、ここでも後でそのようにする。
また、クラスに付与するEventSourceの名前は、EventCounterの特定に必要なので、しっかり考える必要がある。

using System.Diagnostics.Tracing;
// 別名を付けたい場合はName属性を設定する。明示的につけなければ、クラス名が名前として使われる。
[EventSource(Name = "My-Event-Source")]
class MyEventSource : EventSource
{
    // シングルトンにしたいため、勝手にインスタンス化されないようにする
    private MyEventSource() {}
    public static readonly MyEventSource Log = new MyEventSource();
}

そして、作ったEventSourceにEventCounterを作って紐づける。
注意点として、

以上より、多くの場合はEventSourceの初期化時に一緒にインスタンスを作成することが多い。
追加の属性としてstring DisplayUnitsstring DisplayNameが設定できる。
また、その他のメタ属性を設定したい場合は、EventCounter.AddMetaData(string, string)を使うことができる。
これらは後述するイベントを取得する側に渡されるので、イベント取得時の振り分けや表示等に役立てることができる。

using System.Diagnostics.Tracing;
// 別名を付けたい場合はName属性を設定する。明示的につけなければ、クラス名が名前として使われる。
[EventSource(Name = "My-Event-Source")]
class MyEventSource : EventSource
{
    private readonly EventCounter _Counter;
    // シングルトンにするため、勝手にインスタンス化されないようにする
    private MyEventSource()
    {
        // EventCounterインスタンス生成時にEventSource紐づける必要がある
        _Counter = new EventCounter("CounterName", this);
    }
    public static readonly MyEventSource Log = new MyEventSource();
    // イベント発生と同時に値を記録するパターン
    // WriteEventとWriteMetricの順番はどちらが先でも構わない
    [Event(1)]
    public void Event1()
    {
        WriteEvent(1);
        if(IsEnabled())
        {
            _Counter?.WriteMetric(1);
        }
    }
    // イベントの記録無しで値だけ記録するパターン
    // EventSource内でpublicなメソッドを追加する場合は、
    // NonEventAttributeかEventAttributeを明示的に追加しないと、既存のイベントとIDが競合する場合があり、
    // 正常にイベントの監視ができなくなる
    [NonEvent]
    public void CountOnly()
    {
        if(IsEnabled())
        {
            _Counter?.WriteMetric(1);
        }
    }
    // コンストラクタ以外で作るパターン
    // OnEventCommandとは、EventListenerや外部からEventSourceの監視開始、終了等が通知された時に発生するイベント
    protected override void OnEventCommand(EventCommandEventArgs args)
    {
        if (args.Command == EventCommand.Enable)
        {
            _Counter ??= new EventCounter("CounterName", this);
            {
                DisplayName = "counter description",
                DisplayUnits = "metrics_unit"
            };
        }
    }
    protected override void Dispose(bool disposing)
    {
        // EventSourceの生存期間>カウンターの生存期間にすること
        _Counter?.Dispose();
        _Counter = null;
        base.Dispose();
    }
}

後は MyEventSource.Log インスタンスを通して操作するだけである。

EventCounterの種類

EventCounterが代表的存在ではあるが、他にも以下のようなカウンターが存在する。

  • IncrementingEventCounter
  • PollingCounter(netcoreapp3.1以降)
  • IncrementingPollingCounter(netcoreapp3.1以降)

また、EventCounterを含めてこれらの型のベースとなるDiagnosticCounterが存在する

共通項目(DiagnosticCounter)

全てのカウンターはDiagnosticCounterというクラスから派生している。
各カウンタ共通で設定できる項目は以下の通り

  • DisplayName: 表示名
  • DisplayUnits: 表示単位
    • あくまで表示用なので、制約はない
  • Name: プログラム上で扱う名前
    • 識別用に使うので、一意かつプログラム的に処理しやすい名前である方が良い

また、その他独自のメタデータをAddMetaData(string, string)として設定できる。
これらの項目はスレッドセーフではないので、生成時にまとめてやっておくのが吉

EventCounter

代表的なカウンター。
EventCounter.WriteMetric(double)で値を記録する。
カウンターで現れる値は、監視周期の間に発行された値の平均値標準偏差となる。
収集イベント発生時に渡される値の型はEventPayloadだが、入っている値は後述するCounterPayload

IncrementingCounter

IncrementingCounter.Increment(double)で、値の平均ではなく増分値を記録する。
カウンターで現れる値は、監視周期の間に発行された値のトータルの値となる。
収集イベント発生時に渡される値の型はEventPayloadだが、入っている値は後述するIncrementingCounterPayload

PollingCounter

netcoreapp3.1以降で使用可能

EventCounterとは異なり、下記のように、収集時に値を取得するためのコールバック(Func<double>)を設定する。

PollingCounter PC1 = new PollingCounter("[名前]", eventSource, () => [返す値]);

注意が必要なのが、このコールバックはスレッドセーフとは限らないため、
内部で値を操作する場合は、Interlocked等の排他処理を忘れないようにすること。
収集イベント発生時に渡される値の型はEventPayloadだが、入っている値は後述するCounterPayload

IncrementingPollingCounter

netcoreapp3.1以降で使用可能

PollingCounterと同じくコールバック方式で、イベントリスナーの方には
今回の値から前回の値を減算した値が渡されるため、その時の総計値を返すようにする

IncrementingPollingCounter IPC1 = new IncrementingPollingCounter("[名前]", eventSource, () => [値の総計値]);

こちらも同じく呼び出しはスレッドセーフではないため、中で値を操作する場合は排他処理を忘れないようにしよう。
TimeSpan DisplayRateTimeScaleなるプロパティが存在するが、これは監視側がどの程度の間隔で監視したら良いかという目安を示すもので、
監視側は必ずしもこれに従う必要はない。
収集イベント発生時に渡される値の型はEventPayloadだが、入っている値は後述するIncrementingCounterPayload

EventPayload

定義は src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/TraceLogging/EventPayload.cs にある。
内部的に宣言されている型なので、直接アクセスすることはできないが、IDictionary<string, object>を実装しているので、キャストして使うことができる。
カウンターの各クラスは、イベント発行時にそれぞれの型からこのEventPayloadに値を詰め込み、最終的にEventSourceのPayloadに入れている。

CounterPayload

EventCounter及びPollingCounterを使った時にEventPayloadに詰め込まれる値の型。

取り出せる値は以下。

キー 内容
Name string Counterの名前
DisplayName string DisplayNameの値そのまま
Mean double 平均値
StandardDeviation double 標準偏差
Count int 前回の収集から発行された回数
Min double 最低値
Max double 最大値
IntervalSec float 前回収集からの間隔(秒)
Series string 現在は固定でIntervalSec=[監視間隔(ミリ秒)]
CounterType string 固定で"Mean"
Metadata string AddMetaDataで追加した値が[キー1]:[値1],[キー2]:[値2]のように入る
DisplayUnits string CounterのDisplayUnitsそのまま

定義は src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/CounterPayload.cs にある。

Seriesフィールドのみ存在意義が不明だが、コメントを見る限り、将来的にマルチセッションの時にフォーマットが変わるかもしれない。

IncrementingCounterPayload

IncrementingCounterとIncrementingPollingCounterを使ったときに、EventPayloadに詰め込まれる値の型

取り出せる値は以下

キー 内容
Name string Counterの名前
DisplayName string DisplayNameの値そのまま
Increment double 前回収集から記録された値の合計値
IntervalSec double 前回収集から経過した時間(sec)
Metadata string AddMetaDataで追加した値が[キー1]:[値1],[キー2]:[値2]のように入る
Series string 現在は固定でIntervalSec=[監視間隔(ミリ秒)]
CounterType string 固定で"Sum"
DisplayUnits string CounterのDisplayUnitsそのまま

定義はCounterPayloadと同じ src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/CounterPayload.cs にある。

観測方法

EventListener

監視開始

インプロセスでイベントの値を取得するにはEventSourceと同様に System.Diagnostics.Tracing.EventListener を使用する。
ただし、EventSourceの監視開始時(EventListener.EnableEvents())に、オプションとしてEventCounterIntervalSec=[監視周期(秒)]を追加する必要がある。
EventCounterのイベントはLogAlwaysレベルで発生するので、特にEventLevelに何を指定するかは問わない。
また、EventKeywordも特に設定はされていない。
オプションを指定した状態でEventListenerでの監視を開始すると、EventCounterIntervalSecで設定した秒数ごとに、監視イベントが発生する。
サンプルコードは以下。

using System.Diagnostics.Tracing;
// このクラスをnewすれば監視開始できる(Disposeで監視停止)
class MyEventListener: EventListener
{
    protected override void OnEventSourceCreated(EventSource eventSource)
    {
        if(eventSource.Name == "[EventSourceの名前]")
        {
            this.EnableEvents(eventSource, EventLevel.Critical, EventKeywords.All,
                new Dictionary<string, string>() { ["EventCounterIntervalSec" = "1.0" });
        }
    }
    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        // イベント発生時の処理
        if(eventData.EventSource.Name == "[EventSourceの名前]" && eventData.EventName == "EventCounters")
        {
            // カウンターで発行された値の取り出し
            if(eventData.Payload.Count == 1 && eventData.Payload[0] is IDictionary<string, object> payload)
            {
                foreach(var (key, value) in payload)
                {
                    // 表示等
                }
            }
        }
    }
}

イベント発生時に来るデータ

上記サンプルコードのOnEventWritten内で、来たデータの処理を行うわけだが、
この時に来るeventDataの中身を記述する。

名前 中身
EventName 固定で"EventCounters"
Keywords EventKeywords.None
Level EventLevel.LogAlways
OpCode EventOpCode.Info
Tags EventTags.None
PayloadNames "Payload"のみ
Payload IDictionary<string, object> となるインスタンスが一つ

Payloadに入っているデータは、カウンターの型に応じて前述のCounterPayloadか、IncrementingCounterPayloadの値が詰め込まれたEventPayloadが入る。

dotnet-countersによる計測

dotnet-countersというツールがある。
これは、dotnetのプロセスにEventPipeでアタッチして、指定したカウンターの値を定期的に取得するプログラムとなる。

公式サイトから単体EXEをDLすることも可能だが、dotnetグローバルツールでもあるので、dotnet tool install -g dotnet-counters でもインストールは可能。

サブコマンドで主に使うのは"ps","monitor","collect"となる。
それぞれかいつまんで言うと、

  • "ps": モニタリングできるプロセスのPIDの一覧を出力する
  • "monitor": コンソール上に、指定したカウンタを指定した秒数ごとに更新して出力する
  • "collect": 収集した結果をファイルに出力する

という動作をする。
詳しくは 公式ドキュメント を見てもらうとして、とりあえず使いたい場合は、

  1. 対象アプリを起動
  2. dotnet-counters ps で対象PIDを確認
  3. dotnet collect(またはmonitor) -p [プロセスID] --counters [カウンターに紐づけたEventSourceの名前]
    • カウンタを指定しないと"System.Runtime"が採用される

とすれば、収集を開始してくれる。

注意点

詳細は不明だが、EventListenerでインプロセス監視をしている所に、後でdotnet-countersで監視をして、dotnet-countersを停止したところ、EventListenerの方の監視が止まってしまうという現象が発生した。
機序が特定できてないので詳細は書かないが、複数の監視ルートがある時は注意が必要かもしれない。

終わりに

EventCounterというのは多くの場合はEventSourceの通常のイベントで事足りる場合が多いが、自動的に値の集計や平均を出してくれるのはありがたい場面もあるだろう。
また、Metrics APIの登場により今後相対的に影が薄くなりそうだが、外部からの情報取得がツールでサポートされているというのは利点として大いにあるので、今後もなくなることはないと思われる。
まだ調べきれてない所がちらほらあるので、何かあれば随時書き足していきたい。

参考リンク

10
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?