この記事は、OpenTelemetry Advent Calendar 2023 23日目の記事です。
登録した時点では、metric の話になると思いましたが、なりませんでした。すみません。
ということで季節のご挨拶
都会の道はまばゆい光に包まれ、マジで寒いだけの高くて微妙な路面店を横目に「やっぱり家が一番だな」となる季節がやってまいりました。皆様いかがお過ごしでしょうか。
私はありがたいことに婚約をさせていただいて、将来への不安を抱えつつ、マジで情緒がアレになっております。最近はマジでアーキテクトやりたいなと思っており、ご祝儀代わりのオファーもお待ちしております。お応えできるか分かりませんが......。
根がクマさんなので体は勝手に冬眠モードに突入するし、そのくせ仕事はアレコレ湧いてきて、そんなこんなで思いの外 OpenTelemetry Metrics に関する進捗を出せませんでした。
Sampler for Go やっていき
ということで、今回はトレース文脈の Sampler の話をします。
これもこれで参考になる方はいらっしゃるかと思いますので、ご容赦ください。
目線合わせ
まず、そもそも Sampler とは、サンプリング してくれるマンです。トレースまたはスパンを処理・出力するかどうかを決めるものです。
Go においては、sdktrace
の Sampler インタフェース です。多分みんな otel/trace
パッケージを trace
と呼んでいて、 otel/sdk/trace
パッケージのことは sdktrace
と呼んでいると思います。もし違っても、私はそう呼んでいます(頑固)
ところで、本年ご好評いただいた CNDT供養ブログ にて私は「Collector の誘惑を振り払って、ParentBased/RatioBasedで我慢しよう!」などと申し上げており、つまるところ Collector の活用やテイル・サンプリングには乗り出しておりません。
ですので、そのような高度なことをバキバキにやっている方々には、今回の記事はあまり参考にならないかもしれません。あくまでもアプリと同一のプロセスで動作する Sampler についての話です。
なお、この記事では OpenTelemetry の Go 実装に関するコードや型定義を引用致しますが、これらは Apache 2.0 ライセンスで提供されています。より詳細にはこちらをご参照ください。
ライセンス情報(Apache 2.0)
Copyright The OpenTelemetry Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Godoc に目を通す
そんなこんなで Sampler インタフェース のドキュメントを見てみると、これでもかとばかりに ShouldSample
というメソッドがありまして、要するに本丸です。読んで字の如く「SamplingParameters
からサンプルするかどうかを決めてくれや」という話です。
type Sampler interface {
// ShouldSample returns a SamplingResult based on a decision made from the
// passed parameters.
ShouldSample(parameters SamplingParameters) SamplingResult
// Description returns information describing the Sampler.
Description() string
}
そこで SamplingParameters 構造体 を覗いてみると、結構魅力的なメンバが見当たります。
type SamplingParameters struct {
ParentContext context.Context
TraceID trace.TraceID
Name string
Kind trace.SpanKind
Attributes []attribute.KeyValue
Links []trace.Link
}
うおお!!Attributes
って対象スパンの属性?!しかも ParentContext
ってやつから親の情報も引き回せるんじゃね?!そんじゃ、こっから根掘り葉掘り情報引っ張ってきたら神サンプラー実装できるじゃんwwwwwwww余裕wwwwww
と見せかけて、実はそうでもないんですよね。
超重要: ShouldSample してる場所
焦らず、しっかり ShouldSample
が呼ばれている場所を探してみましょう。
答えは ここ です。sdktrace
における tracer
構造体の newSpan
メソッドです。どちらも unexported なので、もしかしたら変更が入るかもしれませんが、要するに スパンを開始する度に呼ばれている 訳です。
少しだけ俯瞰してみると、一般的な呼び出しにおいては以下のような関係になっています。
(あなたのアプリ) -> trace.Tracer.Start
(実態は sdktrace.tracer.Start) -> sdktrace.tracer.newSpan
そして大事なのが、SamplingParameters
の中身です。 newSpan
内のコードを抜粋すると、こんな感じです。
samplingResult := tr.provider.sampler.ShouldSample(SamplingParameters{
ParentContext: ctx,
TraceID: tid,
Name: name,
Kind: config.SpanKind(),
Attributes: config.Attributes(),
Links: config.Links(),
})
Attributes
の config
というのが目につくと思うのですが、これは言っちゃえばスパン開始(=trace.Tracer.Start
)時にオプションで渡した trace.WithAttributes
です。もちろん渡さなければ「そこには何もないですね」というだけです。
これも重要: Parent の詳細情報は引き出せない
Attributes
がダメなら ParentContext
から情報を引き出せばいいじゃん、と思うかもしれません。確かに、trace
パッケージには SpanFromContext という魅力的な関数が用意されています。
ここで、良いニュースと悪いニュースがあります。アメリカ人みたいな言い回しはさておき、親の Span
を引き出すことができました。良いニュースはここで終わりです。
悪いニュースは、trace.Span
はインタフェースであり、詳細情報を与えてくれない設計になっていることです。これは Godoc を見ていただければ明らかなことですが、なんと IsRecording() bool
および TracerProvider() TracerProvider
を除く全てが更新系です。
ここで運よく、似通った名前の SpanContextFromContext を見つける方もいらっしゃるかもしれませんが、それで引き出した SpanContext
を目の当たりにして考えつくような事をやったのが sdktrace
の ParentBased
な訳です。この辺り にその全てがあります。
何にしても、ShouldSample
のシグニチャだけ見て「こいつから情報引き回せば多分良い感じにサンプリング決定できて、良い感じに継承されるんやろな〜」みたいな甘い認識でいると、普通に間違った実装をすることになります。
改めて ParentBased って何だっけ
先ほど、こんなことを申し上げました。
焦らず、しっかり
ShouldSample
が呼ばれている場所を探してみましょう。
〜(略)〜
要するに スパンを開始する度に呼ばれている 訳です。
ということで、ShouldSample 自体はスパンを開始するたび刹那的に呼ばれている訳ですね。そこで適当に「RatioBased
召喚!!これが俺の答えや!!」みたいなことやってたら、親のスパンはサンプルされていないのに子のスパンはサンプルされてる」的な、ゾンビ現象があちこち起こって収拾がつかなくなる訳です。
そこで、先ほどの「良い感じに継承されるんやろな〜」という甘い認識に基づく緩い期待を実現してくれるのが ParentBased
なのです。逆にいうと、アプリと同一のプロセスで動作する Sampler について、「何でもかんでも AlwaysSample
」するような場合を除いて ParentBased
を使わないなんてことは論外に近いのです。
強い言葉を使ってしまって恐縮ではあるんですが、そもそも Sampler は通常グローバルに運用される TracerProvider に登録するものなので、「特定のURLへのアクセスでは常にサンプルしたい」ぐらいの話では ParentBased
を拡張して使うなり、同様のものを再発明することになります。繰り返しますが、「何でもかんでも AlwaysSample
」以外は ParentBased
なんです。
ということで、ユーザー視点で ParentBased を爆速理解していきましょう。早い話、 ソースコード を読んでください。16行なので貼り付けちゃいます。
func ParentBased(root Sampler, samplers ...ParentBasedSamplerOption) Sampler {
return parentBased{
root: root,
config: configureSamplersForParentBased(samplers),
}
}
type parentBased struct {
root Sampler
config samplerConfig
}
func configureSamplersForParentBased(samplers []ParentBasedSamplerOption) samplerConfig {
c := samplerConfig{
remoteParentSampled: AlwaysSample(),
remoteParentNotSampled: NeverSample(),
localParentSampled: AlwaysSample(),
localParentNotSampled: NeverSample(),
}
for _, so := range samplers {
c = so.apply(c)
}
return c
}
分かりやすい話ですね。基本はリモートだろうがローカルだろうが、親がサンプルされていれば子もサンプルされるし、親がサンプルされていなければ子もサンプルされない、ということです。もちろんオプションで置き換えることができます。まぁこれを無理やり拡張しなくても良いとは思いますが、結局要求を整理して実装していったら同じようなことに辿り着くはずです。
そういえばリクエスト分類はどう実装できるんだっけ
ということで、さっきの「特定のURLへのアクセスでは常にサンプルしたい」とか、そこまで行かなくとも「特定のURLへのアクセスではサンプルレートを変更したい」みたいなやつって、otelhttp
を使う場合はどうすれば良いんだっけ?ということが気になる方もいらっしゃると思います。
結論から申し上げますと、あまり綺麗な方法は存在しません。ぶっちゃけ otelhttp
が微妙なことが大きな原因です。自分自身トレースのみならずメトリクス文脈でも otelhttp
を使ってゴニョゴニョしてみましたが、残念ながら欲しいものを揃えるにはダーティハックが必要となる現状があります。「頑張らないオブザーバビリティ」のブログでも、otelhttp
を使う人への注意喚起をしましたが、それから二ヶ月、「使わないのも良い選択肢だよ」が今の心境です。
何なら otelhttp
自体が(有名な開発者とはいえ)個人リポジトリに依存しており、「準公式」と捉える程でもないと感じています。ディスみたいになると嫌ですが、読めば読むほど大したことをしている訳でもないと分かるので、そのインタフェースに縛られてダーティハックを仕込むぐらいなら自前実装も良い選択だという話です。
そんなこんなで、そのうち otelhttp
の代替実装を用意したいな〜と思いつつ、それは乞うご期待として、今回は一応ダーティハックの紹介をしておきます。
二ヶ月前のブログで申し上げたとおり、otelhttp.NewHandler
はリクエストスコープで呼ぶわけにはいきません。リクエストスコープで小細工をできるのはオプションの otelhttp.WithSpanNameFormatter
で渡すことができる func(string, *http.Request) string
だけです。これを使って、リクエストのパスを一定の規則で加工して スパン名 にできます。また、同時に otelhttp.WithSpanOptions
で スパン開始時に渡すAttributes も仕込むことができます。
これら二つによって、ShouldSample
に渡される SamplingParameters
の Name
と Attributes
を加工できることになります。なので、後者で「これはHTTPリクエストを受け付けているレイヤのスパンだよ」ということを示すシグナルを与えて、ShouldSample
内で引っかかった場合にのみ「ParentBased
とは違う動き」をさせます。それは前者の Name
からリクエストURL を抽出して、それに応じたサンプリングレートを返すというものです。
まとめ
リクエスト分類の話を見て「何が何だか分からん」という方は、もう少しお待ちください。今のプロジェクトでは、割にダーティな方で何とかしてしまった節があり反省まみれなので、年明け以降に「俺が考えた最強の HTTP 計装 for Go」を何かしらの形でドドンと公開したいと思います。ちょっと社内事情に左右されそうなのですが、流石にどこかしらで機会は掴めると見ております。
もし、「いやいや、俺はすでにクリーンな方法でリクエスト分類を実装しているが?」という方がいらっしゃったら教えてください。めっちゃ気になります。
【宣伝】会社のアドベントカレンダー
私が働いている面白法人カヤックでも、Qiita のアドベントカレンダー企画をやっています。
なんと、企画の運営をしているのは私です。
おかげさまで、今年もバラエティ豊かなエントリが出揃いました。ぜひご確認ください!
修正リクエストを受け付けています
この記事は GitHub で管理しています。以下のリポジトリに Pull Request を送っていただければ、記事内容の修正を検討いたします。(コメント欄に書いていただいても構いません。)