今いるFRAIMという会社ではOpenTelemetryの導入を進めています。
BackendはRustで書かれており、Applicationから出力するMetricsに関してもOpenTelemetryのMetricsを利用したいと考えています。
既にtracingを導入しているので、tracing-opentelemetryを利用することでApplicationにMetricsを組み込めないか検討していました。
その際に必要な機能があったのでPRをtracing-opentelemetryに送ったところマージしてもらえました。
本記事ではその際に考えたことや学びについて書いていきます。
TL;DR
RustのapplicationでOpenTelemetryのMetricsをtracingから出力するためにtracing-opentelemetryを利用している。
その際にAttributeをMetricsに関連づける機能が必要だったので、tracing-subscriberのPer-Layer Filteringを利用して実装した。
前提
まず前提としてtracing-opentelemetryのMetricsLayer
を利用すると、tracingのEvent
を利用してOpenTelemetryのMetricsを出力することができます。
例えば
let provider = builder.build;
.with.init;
registry
!;
info
とすると、foo
Counterをincrementすることができます。
tracing-opentelemetryのversionは0.20.0
です。 その他の関連crateは以下の通りです。
[]
= "0.20.0"
= "0.1.35"
= "0.3.0"
PRで実装した機能の概要
tracingからMetricsを出力する際に、Attributeを付与できないといった制約がありました。 Attributeとは、OpenTelemetryにおけるkey valueのペアです。
例えば
! info;
とした場合に、request_count
Counterは出力されるのですが、http.route="/foo"
やhttp.response.sattus_code=200
といった情報をmetricsに付与できませんでした。
この点については、feature requestのissueも上がっており、MetricsLayer
にEvent
のkey valueをmetricsに紐付ける機能がリクエストされていました。
この機能については自分も必要だったので、やってみることにしました。
MetricsLayer
の仕組み
まずMetricsLayer
の仕組みについてみていきます。
tracing_subscriber::layer::Layer
traitを実装して、tracing-subscriberとcomposeすることで、UserはEvent
の各種lifecycle時に任意の処理を実行することができます。
例えば、on_event()
はtracing::info!()
等でEvent
が作成された際に呼ばれます。 tracing/tracing-subscriberの詳細な仕組みについては以前ブログに書いたのでよければ読んでみてください。
MetricsLayer
はSpanを処理しないので、以下のようにon_event()
のみを実装しています。
source
S: Subscriber + for<'span> LookupSpan<'span>
としてLayer<S>
でLayerに渡しているS
はLayerがtracing_subscriberのRegistry
にcomposeされることを表現しており、これによってEvent処理時に今いるSpanの情報を利用できます。が、今回は特に利用しないので気にしなくて大丈夫です。
tracingはEvent
に格納されたField
にアクセスする手段としてVisit
traitを用意してくれています。
この仕組みにより、tracing::info!(key = "foo")
のようにstr型のfieldを使うと、tracing側で、Visit::record_str()
を呼んでくれます。
MetricVisitor
でもこのようにVisit
が定義されています。
Metricsは数値なので、record_bool()
やrecord_str()
は実装されていないこともわかります。
さらに処理を追っていきます。例として、record_i64()
をみてみると
const METRIC_PREFIX_MONOTONIC_COUNTER: &str = "monotonic_counter.";
const METRIC_PREFIX_COUNTER: &str = "counter.";
const METRIC_PREFIX_HISTOGRAM: &str = "histogram.";
となっており、field名にMetricsLayer
が処理の対象とするprefix(monotonic_counter,counter,histogram
)が利用されている場合のみ、self.instruments.update_metric()
でmetricsの処理を実施していることがわかりました。
Instruments
の仕組み
というわけで次にInstruments
について見ていきます。Instruments
は以下のように定義されています。
use ;
type MetricsMap<T> = ;
pub
Metricsの種別(Instrument)ごとにmetrics名とmetricsの実装(Counter
等)をHashMap
で保持しています。
opentelemetry::metrics
から利用している、{Counter, UpDownCounter, Histogram}
はmetricsの実装です。
opentelemetry_{api,sdk}
crateでmetricsがどう実装されているかについても書きたいのですがかなり複雑なので、今回はふれません。
(以下はMetrics関連の処理を読んでいて、metricsが生成されてからexportされるまでの流れを追う際のメモです)
Metricsの生成からexportまでの流れ
Metrics関連のstructの関係
Counter
はメモリに現在の値を保持して、incrementしつつ、定期的にexportしていくだけなのでシンプルかと考えていたのですが思ったより色々なコンポーネントが関与していました。
なので本記事ではCounter::add()
したら、Metricsが出力されるくらいの理解でいきます。
use Meter;
type MetricsMap<T> = ;
MetricsLayer
がmetricsを処理する際によばれるself.instruments.update_metric()
は上記のような処理となっています。概ね以下のことがわかります。
- 毎回
RwLock::read()
によるlockが発生する - 初回は
Meter
によるInstrument(Counter等)の生成処理が実行される Counter::add()
する際の第二引数には空sliceが渡されている(今回の変更点に関連する)
この処理を確認したことで、MetricsLayer
のdocumentの説明がより理解できます。
例えば
No configuration is needed for this Layer, as it's only responsible for pushing data out to the
opentelemetry
family of crates.
とあるようにMetricsLayer
は特に実質的な処理を行っておらずtracing-opentelemetryというcrate名の通り、tracingとopentelemetryのecosystemをつなぐ役割のみを担っています。
また
# Implementation Details
MetricsLayer
holds a set of maps, with each map corresponding to a type of metric supported by OpenTelemetry. These maps are populated lazily. The first time that a metric is emitted by the instrumentation, aMetric
instance will be created and added to the corresponding map. This means that any time a metric is emitted by the instrumentation, one map lookup has to be performed. In the future, this can be improved by associating eachMetric
instance to its callsite, eliminating the need for any maps.
(意訳: MetricLayer
はOpenTelemetryでサポートされているmetricの種別ごとにmapを保持している。metricsが出力される際にMetric
インスタンスが生成され、これらのmapに追加される。これはmetricsが出力されるたびにmapのlookupが実行されることを意味する。将来的にはMetric
インスタンスをcallsiteに紐付けることでmapが必要なくなり改善することができるかもしれない。) という説明もなるほどと理解できます。
ちなみにcallsiteというのは、tracing::info!()
macro呼び出し時にstaticで生成されるmacro呼び出し時の情報を指しています。
というわけで、MetricsLayer
がmetricsを出力する方法の概要が理解できました。
機能を追加する上での問題点
まず今回追加したい機能を整理します。
Counterに関する仕様では以下のように定められています。
Counter operations Add This API MUST accept the following parameter: * A numeric increment value. * Attributes to associate with the increment value.
Users can provide attributes to associate with the increment value, but it is up to their discretion. Therefore, this API MUST be structured to accept a variable number of attributes, including none.
として、Counterをincrementする際にAttributesを渡せなければならないとされています。
これを受けて、RustのOpenTelemetryの実装でも
のようにincrement時にattributeとして、KeyValue
を渡せるようになっています。
ということで、追加したい機能はMetricsLayer
でKeyValue
を作って、Counter:add()
時に渡せば良さそうです。
そんなに難しくなさそうと思い、数行の変更でいけるのではと思っていました。
Visitorパターンと相性が悪い
着手してわかったことですが、Counter::add()
呼び出し時にmetrics(counter.foo
)以外のfieldをKeyValue
に変換して渡すというのはVisitorパターンと相性が悪いということでした。
というのも、例えば以下のようなEvent
を考えます。
!
info
このEvent
を処理する際、さきほどみたMetricVisitor
はmonotonic_counter.foo
を処理する際はまだ、bar,qux
fieldを処理していないので、Counter::add()
を呼び出せないということです。
そのため、visit時に対象のfieldが処理対象なら処理するのではなく、一度、すべてのfieldのvisitを完了させたのちに、Counter::add()
を呼び出す必要があります。 そうなると、visit時にVec
等にmetricsの情報や変換したKeyValue
を保持する必要があります。
しかしながら、そうしてしまうと以下のようにmetricsを含まないEvent
を処理する際に問題があります。
! info;
Event
としては、metricsを含まない方が多いのが自然ですが、MetricsLayer
としては、visitしている途中ではそのEvent
にmetricsが含まれているかわからないので、Vec
の確保やpush等を行う必要があります。
(まずvisitしたのち、metricsが含まれている場合はもう一度visitする方法も考えられますが非効率)
Event
初期化時に判定する
問題としては、あるLayerは特定のEvent
にのみ関心があり、その判定をEvent
処理時ではなく、いずれかの初期化時に一度だけ行いたいという状況です。
これは特定の機能を提供するLayerとしては一般に必要になりそうなので、Layer
traitにそういったmethodがないかみていたところ、それらしきものがありました。
Registers a new callsite with this layer, returning whether or not the layer is interested in being notified about the callsite, similarly to Subscriber::register_callsite.
とあるので、tracing::info!()
を呼び出すと一度だけ、MetricsLayer::register_callsite()
を呼んでくれそうです。
またMetadata::callsite() -> Identirifer
から、Event
の識別子も取得できるのでEvent
がmetricsかどうか事前に一度だけ判定したいという機能は実現できそうに思われました。
もっとも、注意が必要なのは
Note: This method (and Layer::enabled) determine whether a span or event is globally enabled, not whether the individual layer will be notified about that span or event. This is intended to be used by layers that implement filtering for the entire stack. Layers which do not wish to be notified about certain spans or events but do not wish to globally disable them should ignore those spans or events in their on_event, on_enter, on_exit, and other notification methods.
(意訳: このメソッドはspanやeventがグローバルで有効かを判定するもので、レイヤー単位でspanやeventの通知を制御するものではない。これはスタック全体のフィルタリングを実装するレイヤーを意図したもの。特定のspanやeventを処理の対象とはしないが、グローバルで無効にしたくないレイヤーは単にon_eventでそれらを無視すればよい。)
とあるように、register_callsite()
を利用しても、MetricsLayer
でmetrics以外のeventでon_event()
が呼ばれなくなるわけではないということです。(そのような仕組みは別であり、のちほど言及します。) この仕組みはEvent
やSpan
を特定のLevelでfilterする(DEBUGを無視する等) LevelFilter
向けであると思われます。 ということで、あるEvent
がmetricsを含んでおり処理対象かどうかは自前で管理する必要がありそうということがわかりました。
こうした背景から、最初のPRでは以下のように、callsiteごとの判定結果をRwLock
で保持する実装を行いました。
use Identifier;
自分としてもEvent
毎にRwLocl::read()
が走る実装は厳しいだろうなと思いつつ、レビューをお願いしましたが、やはり別の方法を考える必要がありました。 jtescherさんはtracing-opentelemetryのmaintainer
Per-Layer Filtering
なにか良い方法がないかと思いLayer
のdocを読んでいると、Per-Layer Filteringというものを見つけました。
Sometimes, it may be desirable for one Layer to record a particular subset of spans and events, while a different subset of spans and events are recorded by other Layers.
(意訳: 時に、あるレイヤーで特定のspanやeventsのみを処理しつつ、他のレイヤーには影響をあたえたくない場合がある)
まさに、今この状況なのでこの機能使えるのではと思いました。
さらにこの機能はtracing-subscriberのregistry
featureが必要なのですがtracing-opentelemetryは既にこのfeatureに依存しているので、breaking changeともならなそうでした。
Metricsを含むEvent
に限定できれば、on_event()
の処理開始時にField
にmetricsが含まれていることがわかるので、visit時にそれぞれの値を保持しておくアプローチがとれるので、この機能を組み込んでみようと思いました。
Filter
とFiltered
まず考えたのが、MetricsLayer
にFilter
traitを実装するということでした。
しかしながら、docを読んだり手元で動かしてみたりしてわかったのですが、あるLayer
にFilter
を実装して、tracing subscriberにcomposeしても意図した効果は得られないということでした。
というのも、tracing subscriberへのcomposeは実装的には、Layered
という型に変換されてそれらが全体として、tracingのSubscriber
traitを実装するという形になっているのですが、その実装の中で、Filter
traitは特に参照されていません。
tracing-subscriberのAPI設計的に、Filter
の実装を渡して、Filtered
というLayer
を使う必要があることがわかりました。
それがなぜかといいますと
thread_local!
}
あまり実装の詳細には踏み込みませんが、概ね以下の点がわかります。
Filtered
はLayer
を実装しているので、Layerとして振る舞うenabled()
では例え、wrapしているFilter
の実装がfalseを返しても戻り値としてはtrueを返す(Globalで無効にならない)- Thread localとはいえGlobalに値を保持している(
FILTERING
) on_event()
実装時にGlobalのFILTERING
を参照してPer Filter機能を実現
と中々hack的な実装となっており、自前でFilter
を実装するだけではだめで、あくまでFiltered
を使わないといけないということがわかりました。
ということで、MetricsLayer
からFiltered
を利用することが次の目標です。
Filtered
の生成についてはtracing-subscriberがwith_filter()
を用意してくれているのですが、問題は返り値がFiltered
ということです。
どういうことかといいますと、今やりたいことは、Filtered<MetricsLayer,F,S>
という型をtracing-opentelemetryのuserに返したいということなのですが
tracing-opentelemetryとしてはMetricsLayer::new()
がpublicなAPIなのでここを変えてしまうと破壊的変更となってしまうことです。
pub type MetricLayer<S> = `
上記のようにaliasを使うかも考えたのですが、これにも問題があります。たとえば将来的に、MetricsLayer
に追加の設定が必要となり
let layer = new.with_foo;
のようにwith_
で設定できるAPIが必要になった場合、Filtered
は外部の型なので、methodは追加できないということです。
ここで思ったのが、実体としてはtracing-subscriberのFiltered
なのだけど、型としてはuserにそれをみせたくないという状況どこかでみたことあるということでした。
そうです、tracing-subscriberのSubscriber
です。
Genericsは無視して、着目したいのが、実体としては、Layered
なのですが、それをinnerとしてwrapした型をuserにみせているということです。
この時初めて、tracing-subscriberがどうしてこうのように実装しているかの気持ちがわかりました(勝手に)。
ということで、MetricsLayer
にFiltered
を組み込んだ結果以下のような実装となりました。
形としては同じです。今までのMetricsLayer
の責務はInstrumentLayer
が担い、Filter
はMetricsFilter
に実装する形にしました。
デメリットとして、すべてinnerに移譲する形で、Layer
を実装する必要がありますが、Subscriberもそうしていたので受け入れることにしました。
これで、破壊的変更を行うことなく、Per-Layer Filteringの機能を取り込むことができました。
Allocationも避けたい
ここまでで、機能的には目標を達成できたのですが、1点不満がありました。
それは以下のように、Event
visit前に、Vec
のallocationが発生してしまう点です。
Vec
が必要なのは、tracing::info!()
の中にmetricsと他のfieldがいくつあるかわからないからです。例えば、以下のような入力は可能です。
! info;
一応、tracing側で、最大field数の制限(32)があるのですが、その分のArrayを確保するのもresourceの効率的な利用の観点から後退してしまうように思われました。また最大field数が増える可能性もあります。
ここでの問題は、多くの場合、高々数個だが、例外に対応できるように個数制限は設けられないという状態です。
こういうケースにはまるものないかなと調べていてみつけたのがsmallvec crateです。
smallvecの説明には
Small vectors in various sizes. These store a certain number of elements inline, and fall back to the heap for larger allocations. This can be a useful optimization for improving cache locality and reducing allocator traffic for workloads that fit within the inline buffer.
とあるので、自分の理解が正しければ、指定の数まではstack上に確保され、それを超えた場合のみ、通常のVec
のようにheapのallocationが発生するというものです。
ということで、SmallVec
をvisit時に利用することにしました。
pub
このあとテストを追加し、無事レビューが通りました。
Benchmark
trace用のlayerのbenchmarkはあったのですがMetricsLayer
のbenchmarkはなかったので、追加しました。
本来は旧実装と比較したかったのですが、benchmarkするには、なんらかの形で旧実装を公開する必要があったので断念しました。
こういうときどうすればいいんですかね?
Flamegraph
criterionのbenchmarkにpprofを設定できるのは知りませんでした。利用してみたところ以下のような結果を得られました。 Metrics更新時のlock処理に時間を使っていることがわかります。
まとめ
tracing-opentelemetryにPRを出してみた際考えたことを書いてみました。
Per-Layer Filteringの仕組みを知れたことや、OpenTelemetryのmetrics関連の実装についての学びがありました。
今後もOpenTelemetryやtracing ecosystemへの理解を深められればと思っております。