2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenTelemetryAdvent Calendar 2023

Day 23

OpenTelemetry の Sampler を Go で実装するときに見るところ

Last updated at Posted at 2023-12-25

この記事は、OpenTelemetry Advent Calendar 2023 23日目の記事です。

登録した時点では、metric の話になると思いましたが、なりませんでした。すみません。

エントリ宣言

ということで季節のご挨拶

都会の道はまばゆい光に包まれ、マジで寒いだけの高くて微妙な路面店を横目に「やっぱり家が一番だな」となる季節がやってまいりました。皆様いかがお過ごしでしょうか。

私はありがたいことに婚約をさせていただいて、将来への不安を抱えつつ、マジで情緒がアレになっております。最近はマジでアーキテクトやりたいなと思っており、ご祝儀代わりのオファーもお待ちしております。お応えできるか分かりませんが......。

根がクマさんなので体は勝手に冬眠モードに突入するし、そのくせ仕事はアレコレ湧いてきて、そんなこんなで思いの外 OpenTelemetry Metrics に関する進捗を出せませんでした。

Sampler for Go やっていき

ということで、今回はトレース文脈の Sampler の話をします。

これもこれで参考になる方はいらっしゃるかと思いますので、ご容赦ください。

目線合わせ

まず、そもそも Sampler とは、サンプリング してくれるマンです。トレースまたはスパンを処理・出力するかどうかを決めるものです。

Go においては、sdktraceSampler インタフェース です。多分みんな 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(),
})

Attributesconfig というのが目につくと思うのですが、これは言っちゃえばスパン開始(=trace.Tracer.Start)時にオプションで渡した trace.WithAttributes です。もちろん渡さなければ「そこには何もないですね」というだけです。

これも重要: Parent の詳細情報は引き出せない

Attributes がダメなら ParentContext から情報を引き出せばいいじゃん、と思うかもしれません。確かに、trace パッケージには SpanFromContext という魅力的な関数が用意されています。

ここで、良いニュースと悪いニュースがあります。アメリカ人みたいな言い回しはさておき、親の Span を引き出すことができました。良いニュースはここで終わりです。

悪いニュースは、trace.Span はインタフェースであり、詳細情報を与えてくれない設計になっていることです。これは Godoc を見ていただければ明らかなことですが、なんと IsRecording() bool および TracerProvider() TracerProvider を除く全てが更新系です。

ここで運よく、似通った名前の SpanContextFromContext を見つける方もいらっしゃるかもしれませんが、それで引き出した SpanContext を目の当たりにして考えつくような事をやったのが sdktraceParentBased な訳です。この辺り にその全てがあります。

何にしても、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 に渡される SamplingParametersNameAttributes を加工できることになります。なので、後者で「これはHTTPリクエストを受け付けているレイヤのスパンだよ」ということを示すシグナルを与えて、ShouldSample 内で引っかかった場合にのみ「ParentBased とは違う動き」をさせます。それは前者の Name からリクエストURL を抽出して、それに応じたサンプリングレートを返すというものです。

まとめ

リクエスト分類の話を見て「何が何だか分からん」という方は、もう少しお待ちください。今のプロジェクトでは、割にダーティな方で何とかしてしまった節があり反省まみれなので、年明け以降に「俺が考えた最強の HTTP 計装 for Go」を何かしらの形でドドンと公開したいと思います。ちょっと社内事情に左右されそうなのですが、流石にどこかしらで機会は掴めると見ております。

もし、「いやいや、俺はすでにクリーンな方法でリクエスト分類を実装しているが?」という方がいらっしゃったら教えてください。めっちゃ気になります。

【宣伝】会社のアドベントカレンダー

私が働いている面白法人カヤックでも、Qiita のアドベントカレンダー企画をやっています。

なんと、企画の運営をしているのは私です。

おかげさまで、今年もバラエティ豊かなエントリが出揃いました。ぜひご確認ください!

修正リクエストを受け付けています

この記事は GitHub で管理しています。以下のリポジトリに Pull Request を送っていただければ、記事内容の修正を検討いたします。(コメント欄に書いていただいても構いません。)

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?