OpenTelemetryでメトリックを計装するときによく出現するReaderやAggregationなど色々な用語をまとめて紹介します

Mackerelチームでアプリケーションエンジニアをやっているid:lufiabbです。この記事はMackerel Advent Calendar 2023の21日目の記事です。昨日はid:itchynyさんの「Mackerel REST APIの公式Goクライアントをジェネリクスを使ってリファクタリングしました」でした。

Mackerel Meetup 14で発表があったとおり、Mackerelでは「ラベル付きメトリック」という機能でOpenTelemetry Metricsへの対応を行います。ラベル付きメトリックは名前のとおりメトリックがラベルを持てます。従来のMackerelではcustom.http.response_time.2xxcustom.http.response_time.4xxのように複数のメトリックとして表現していましたが、ラベル付きメトリックではcustom.http.response_timeが1つだけで、値を記録するときの状況に応じてラベルをstatus=2xxstatus=4xxと切り替えることで、色々な状況を表現できるようになります。MackerelはPromQLのようなクエリ言語も実装するので、メトリックに与えられたラベルはクエリ言語を使って集計のために使えます。

このラベル付きメトリックをMackerelへ投稿するためには、以前から使っていただいているmackerel-agentやプラグインの代わりに、OpenTelemetry SDKやOpenTelemetry Collectorでメトリックを集める必要があります。OpenTelemetry SDKのOpenTelemetry Metricsでメトリックを計装するとき、おそらく以下のようなコードになるでしょう。

import (
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    "go.opentelemetry.io/otel/metric"
    "go.opentelemetry.io/otel/sdk/metric"
    "go.opentelemetry.io/otel/sdk/metric/metricdata"
    "go.opentelemetry.io/otel/sdk/resource"
)

...

exporter, err := otlpmetricgrpc.New(
    context.Background(),
    otlpmetricgrpc.WithTemporalitySelector(func(kind metric.InstrumentKind) metricdata.Temporality {
        switch kind {
        case metric.InstrumentKindCounter, metric.InstrumentKindHistogram, metric.InstrumentKindObservableGauge, metric.InstrumentKindObservableCounter:
            metricdata.DeltaTemporality
        default:
            metricdata.CumulativeTemporality
        }
    }),
)

view := metric.NewView(
    metric.Instrument{
        Name: "counter",
        Description: ...
        Kind: metric.InstrumentKindHistogram,
        Unit: "1",
        Scope: instrumentation.Scope{
            Name: ...
            Version: ...
            SchemaURL: ...
        },
    },
    metric.Stream{
        Name: "x.counter",
        Description: ...
        Unit: "1",
        Aggregation: metric.AggregationDefault{},
        AttributeFilter: {},
    },
)
resources, err := resource.New(context.Background(), resource.WithAttributes(
    semconv.ServiceName("my-app"),
))
meterProvider := metric.NewMeterProvider(
    metric.WithReader(
        metric.NewPeriodicReader(exporter, metric.WithInterval(time.Minute)),
    ),
    metric.WithResource(resource),
    metric.WithView(view),
)
defer meterProvider.Shutdown(context.Background())
otel.SetMeterProvider(meterProvider)

counter := otel.Meter("github.com/lufia/otel-example").Int64Counter("counter")
counter.Add(context.Background(), 1, metric.WithAttributes(
    attribute.String("attr1", "value1"),
    attribute.Int("attr2", 200),
))

InstrumentKindReaderなど、要素がいっぱいあって初見では難しいですね。この記事では、なるべく具体的な例をふまえて、それぞれの言葉を説明していこうと思います。トレースとメトリックで重複するものもありますが、ここではメトリックを中心に扱います。

OpenTelemetry SDK

言語ごとに用意されているSDKです。アプリケーションはこれを使って必要なメトリックを計装します。SDK自身と、サードパーティ用の拡張パッケージ(Contrib)で分かれています。各種ミドルウェアやクラウドサービスに強く関連するものはContribで実装されている印象があるので、便利なグッズを探すときはContribを眺めてみるといいと思います。

OpenTelemetry Collector

アプリケーションから送られてくるメトリックやトレースなどの情報を、集約したり、加工したり、送出するサービスを切り替えたりするコンポーネントです。アプリケーションとは別のプロセスとして実行します。役割としてはfluentdが近いかもしれません。

OpenTelemetry Collectorの使い方は過去の記事でも紹介しているので、そちらを見てください。

Exporter

Exporterは、計装したメトリックやトレースをアプリケーションの外へ送るための仕組みです。大きく分けて、OpenTelemetry SDKのExporterOpenTelemetry CollectorのExporterがありますが、文脈によって判断できます。

exporter, err := otlpmetricgrpc.New(context.Background())

OpenTelemetry SDKにはPrometheusに送るためのprometheus、OTLP(gRPC)経由で送るためのotlpmetricgrpc, otlptracegrpc、stdoutに書き出すためのstdoutmetric, stdouttraceなどがあります。OpenTelemetry CollectorではSDKより多くのExporterが用意されています。

OpenTelemetry Collectorへ送る場合、またはバックエンドサービス自体がOTLP(OpenTelemetry Protocol)に対応している場合は、アプリケーションはotlpmetricgrpc等を使えばいいでしょう。OTLPに対応していないバックエンドサービスへ送出したい場合、アプリケーションはまずOpenTelemetry Collectorへ送出し、Collectorを経由してサービスへ送ります。

余談ですが、最初に書いたコード例ではotlpmetricgrpc.Newを使っているけれども、言語によってはautoexportが用意されている場合があります。autoexportは環境変数の値によりotlp, prometheus, consoleなどExporterを切り替えられるようなので、そちらを使ったほうが柔軟かもしれませんね。

Goでもopentelemetry-go-contribリポジトリにautoexportが置かれていますが、2023年12月時点ではREADMEもまだないので、おそらくまだ安定していないと思います。

Attribute

OpenTelemetryのトレースやメトリックは、複数のキーと値で属性(Attribute)を表現します。例えばHTTPのレイテンシを計装した場合、属性でstatus=200path=/usersを与えたくなるでしょう。その場合はotel/attributeパッケージが提供している以下の関数で属性を作成して、メトリックの値を更新するときにオプションとして渡します。

  • attribute.Bool(k string, v bool) attribute.KeyValue
  • attribute.Float64(k string, v float64) attribute.KeyValue
  • attribute.Int(k string, v int) attribute.KeyValue
  • attribute.Int64(k string, v float64) attribute.KeyValue
  • attribute.String(k, v string) attribute.KeyValue

また、上記の型それぞれにスライスを作成する関数もあります。

  • attribute.BoolSlice(k string, v []bool) attribute.KeyValue
  • attribute.Float64Slice(k string, v []float64) attribute.KeyValue
  • attribute.IntSlice(k string, v []int) attribute.KeyValue
  • attribute.Int64Slice(k string, v []float64) attribute.KeyValue
  • attribute.StringSlice(k, v []string) attribute.KeyValue

構造体などの場合は、fmt.Stringerインターフェイスを実装しておくと簡単に使えます。

  • attribute.Stringer(k string, v fmt.Stringer) KeyValue

Goで文字列型の属性を書くと以下のようになります。

atrr := attribute.String("clientID", task.ClientID)

属性は階層になっていて、どこで与えた属性なのかによって若干ですが扱いが変わります。上の層から順にリソース、スコープ、メトリックです。

  • ResourceAttribute
    • ScopeAttribute
      • DataPointAttribute

Resource

Attributeのうち、リソースに関する属性を特別にResourceと呼びます。リソースに関する属性は、例えばホスト名やコンテナID、プロセスIDなどが想定されます。アプリケーション上では、metric.NewMeterProviderのオプションとしてリソースの属性を渡しておくと、そのMeterProviderで計装するメトリックはすべてリソースの属性を持ちます。

このとき、計装した側の都合で属性の名前がpidだったりprocess_idだったりなどと表現の差異があると混乱するので、よく使われるものは事前にprocess.pidなどが定義されています。

Goの場合、otel/semconvパッケージはバージョンごとに分かれているので、好みのバージョンを選んで使います。

import semconv "go.opentelemetry.io/otel/semconv/v1.17.0" // バージョンは現在v1.21.0が出ています

resources, err := resource.New(context.Background(), resource.WithAttributes(
    semconv.ServiceName("my-app"),
))

Scope

ミドルウェア等のパッケージ名とバージョンを扱う層です。ここでは詳細は省きます。

Instrument

メトリック名、InstrumentKindUnitをまとめたものです。Instrumentは計装のための箱で、データポイントごとの値はMeasurementが表現します。

Goで書く場合はMeterのメソッドで作成します。

counter := otel.Meter("github.com/lufia/otel-example").Int64Counter("counter")

Int64Counterのほかにもいろいろな型があります。現時点での一覧はInstrumentKindの表を見てください。

Measurement

データポイントごとの値と属性をまとめたものです。InstrumentMeasurementを組み合わせて特定のメトリック点を表します。

Goで書く場合はInstrumentごとのメソッドでメトリックを記録することが該当します。metric.MeasurementOptionとして名前が残っていますが、GoのSDKではMeasurement型などは見当たりませんでした。

counter.Add(context.Background(), 1, metric.WithAttributes(
    attribute.String("attr1", "value1"),
    attribute.Int("attr2", 200),
))

Unit

UnitはResource/Scope/DataPointの階層でいえばDataPointのオプションで、メトリックの単位を表現します。OpenTelemetry単体では利用可能な単位のリストを持っておらず、単にUCUM(The Unified Code for Units of Measure)へ従うようにと書かれています。2023年末時点では、どの単位が使えるのかはバックエンドサービスごとに異なるようです。

バックエンドサービスごとに対応状況が異なるとはいえ、1(単位なし)とs(秒)は比較的どのサービスでも対応しているんじゃないでしょうか。また、対応していない単位で計装したからといって、メトリックの保存がエラーになったりはしないと思います。

InstrumentKind

Instrumentの種類(型)を表すものです。Goの場合、meter.Meterが提供する関数のどれを使ってInstrumentを作成したかによって、InstrumentKindが決まります。例えばmeter.Meter.Int64Counterで作成した場合はInstrumentCounterと決まります。

大きく同期と非同期のKindがあり、単純な演算をするものは同期で良いでしょう。OSのシステムコールやAPIなどを使う必要があって、単純な演算より時間を要するものは非同期が適しています。

以下のリストは、どの関数でどのInstrumentKindになるかをまとめたものです。

作成した関数 InstrumentKind 同期/非同期
Int64Counter Counter 同期
Float64Counter Counter 同期
Int64UpDownCounter UpDownCounter 同期
Float64UpDownCounter UpDownCounter 同期
Int64Histogram Histogram 同期
Float64Histogram Histogram 同期
Int64ObservableCounter ObservableCounter 非同期
Float64ObservableCounter ObservableCounter 非同期
Int64ObservableUpDownCounter ObservableUpDownCounter 非同期
Float64ObservableUpDownCounter ObservableUpDownCounter 非同期
Int64ObservableGauge ObservableGauge 非同期
Float64ObservableGauge ObservableGauge 非同期

2023年12月時点では、ドキュメントに同期的なGaugeも記述されていますが、まだExperimental状態です。

Temporality

Temporalityはカウントしたメトリックを集計するときの振る舞いを表現するオプションです。現在、Temporalityには累積(Cumulative)と差分(Delta)の2つがあります。

  • CumulativeTemporality
  • DeltaTemporality

カウントしたメトリックは、OpenTelemetry SDKによって一定周期(Readerによる)で送出されますが、累積の場合、起動した時点から現在までのカウンタ値の合計をメトリックの値としてバックエンドサービスへ送ります。例えば周期ごとに[1, 2, 2]とカウントがあった場合、送出される値は[1, 3, 5]のように累積値として表現されます。差分の場合は増加したカウンタの値を送るので[1, 2, 2]のようになります。

Temporalityを変更するにはTemporalitySelectorをオプションとして渡します。TemporalitySelectorにはInstrumentKindが渡ってくるので、累積または差分どちらで扱うのかを返しましょう。

または、OpenTelemetry Collectorではcumulativetodeltaプロセッサを使っても、同様の変換ができます。

Reader

Readerは、SDKがカウントした値をExporterへ送出するものです。2023年12月時点では2つのReaderが存在します。

  • PeriodicReader
  • ManualReader

PeriodicReaderはmetric.NewPeriodicReader(exporter, options...)で初期化します。このReaderは一定周期で送出するための実装で、intervalとtimeoutで挙動を制御します。一定周期で送出するため、開発者は特になにもする必要がありません。

ManualReaderはmetric.NewManualReader(options...)で初期化します。こちらにはAggregationまたはTemporalityを変更するためのオプションが存在します。PeriodicReaderと異なり、送出する場合は開発者がreader.Collectreader.Exportで明示的に指示をする必要があります。

以下の記事では、PeriodicReaderとManualReaderのどちらを使うといいのか具体例をふまえて紹介されていました。

Aggregation

AggregationはTemporalityと似ていますが、ざっくり表現すると、Temporalityは上で書いたように値の連続性を表現するもので、対してAggregationは「1つの周期内で複数回の更新が発生したときの扱い方」を表現するものです。例えば1分ごとに送出するPeriodicReaderが周期をコントロールしている場合に「HTTPハンドラが送信したバイト数」を扱う場合は合計がほしいのでSumを使うでしょうし、現時点のメモリ使用量を知りたい場合は最後の値がわかれば十分なのでLastValueになるでしょう。

2023年12月時点では、見落としがなければ以下の6つが用意されています(それぞれの挙動は名前のとおりですがsdk/metric/pipeline.go:448:486に実装されています)。

  • Default
  • LastValue
  • Sum
  • ExplicitBucketHistogram
  • Base2ExponentialHistogram
  • Drop

Aggregationを変更するには、ManualReaderならReaderのオプションとしてAggregationSelectorを渡せます。けれどもPeriodicReaderの場合は同等のオプションがないので、以下で紹介するViewを使うしかないようです。

Explicit HistogramとExponential Histogramの違いはid:rmatsuokaの記事に書かれていたので読んでみてください。

各種デフォルト値

InstrumentKindTemporalityAggregationがどのように関わっているのかよく混乱するので、それぞれのデフォルト値を以下の表にまとめておきます。

Point kinds InstrumentKind Temporality Aggregation
Counter Counter Cumulative Sum
Counter UpDownCounter Cumulative Sum
Histogram Histogram Cumulative ExplicitBucketHistogram
Counter ObservableCounter Cumulative Sum
Counter ObservableUpDownCounter Cumulative Sum
Gauge ObservableGauge Cumulative LastValue

View

個人的には、これはとても強力な概念だなと思っています。Viewは特定のInstrumentについて、SDK内部でリネームしたり、単位を変更したり、Aggregationを変更したり等の操作が行えます。

要素が多いので一部省略した表記をしますが、GoでViewを作成するときはmetric.NewViewの第1引数で「マッチする条件」を渡します。各フィールドがゼロ値の場合はすべてにマッチします。第2引数は、マッチしたInstrumentをどのように変化させるかを指定します。

view := metric.NewView(
    metric.Instrument{
        Name: "counter",
        Description: "",
        Kind: metric.InstrumentKindHistogram,
        Unit: "1",
        Scope: instrumentation.Scope{
            Name: ...
            Version: ...
            SchemaURL: ...
        },
    },
    metric.Stream{
        Name: "x.counter",
        Description: "sugoi counter",
        Unit: "1",
        Aggregation: metric.AggregationBase2ExponentialHistogram{
            MaxSize:  160,
            MaxScale: 20,
        },
        AttributeFilter: attribute.NewDenyKeysFilter("..."),
    },
)

Viewはやれることが多いので、ここで紹介するのは難しいのですが、上に貼ったManual Instrumentation/Registering Views | OpenTelemetrymetric.NewViewのExampleを見ると雰囲気が掴めると思います。メトリックの名前にワイルドカードを使ってマッチさせたりもできるようです。

おわりに

Mackerelではラベル付きメトリック機能のベータ版テストの参加者を募集しています。参加してみたいという方は、ぜひ以下のフォームからご連絡ください!

Mackerel Advent Calendar 2023、明日は@mashiikeさんです。