EventCounterについて

# はじめに

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

なお、EventCounterはEventSourceの上に成り立っているので、EventSourceについての理解は必須だが、長くなるのでこの記事では詳しく解説しない。
そちらの理解がまだならば、 [公式ドキュメント](https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.tracing.eventsource?view=net-6.0) 等を参照のこと。

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

# EventCounterは何のために使うか

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

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

# EventCounterの基本的な実装

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

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

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

* [生成時にグローバルで管理しているリストの中に登録するため、重複してインスタンスを作らないようにする](https://github.com/dotnet/runtime/blob/4822e3c3aa77eb82b2fb33c9321f923cf11ddde6/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/DiagnosticCounter.cs#L55)
* 個々のカウンターは名前で識別するため、同じEventSource内で名前が被らないようにする

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

```csharp
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>`)を設定する。

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

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

## IncrementingPollingCounter

**netcoreapp3.1以降で使用可能**

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

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

## EventPayload

定義は [src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/TraceLogging/EventPayload.cs](https://github.com/dotnet/runtime/blob/v6.0.0/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](https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/CounterPayload.cs) にある。

`Series`フィールドのみ存在意義が不明だが、[コメント](https://github.com/dotnet/runtime/blob/4822e3c3aa77eb82b2fb33c9321f923cf11ddde6/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/EventCounter.cs#L125)を見る限り、将来的にマルチセッションの時にフォーマットが変わるかもしれない。

## 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](https://github.com/dotnet/runtime/blob/v6.0.0/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で設定した秒数ごとに、監視イベントが発生する。
サンプルコードは以下。

```csharp
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](https://docs.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters)というツールがある。
これは、dotnetのプロセスにEventPipeでアタッチして、指定したカウンターの値を定期的に取得するプログラムとなる。

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

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

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

という動作をする。
詳しくは [公式ドキュメント](https://docs.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters) を見てもらうとして、とりあえず使いたい場合は、

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の登場により今後相対的に影が薄くなりそうだが、外部からの情報取得がツールでサポートされているというのは利点として大いにあるので、今後もなくなることはないと思われる。
まだ調べきれてない所がちらほらあるので、何かあれば随時書き足していきたい。

## 参考リンク

* [EventCounter概要](https://docs.microsoft.com/en-us/dotnet/core/diagnostics/event-counters)
* [EventCounterの定義](https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.tracing.eventcounter?view=net-6.0)
* [実装コード(6.0)](https://github.com/dotnet/runtime/tree/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing)
* [既知のカウンター(.NET Core以降)](https://docs.microsoft.com/en-us/dotnet/core/diagnostics/available-counters)