Trace Sampling
実装:
trace-sampling// 実行:go test ./trace-sampling/
リクエストの通り道を記録したトレースは、量が多すぎて全部は保存できない。ではどれを残すか。開始時に決め打ちする head-based と、終わってから中身を見て決める tail-based の2つを作って比べる。障害のときに見たいのはエラーや遅いトレースなので、無作為に間引くと肝心なものが消える、というのがこの話の勘所。
「サンプリング」の同名別分野に注意
この章は分散トレーシングで「どのトレースを保存するか」を決める話。 LLM がテキストを生成するときのトークンサンプリングは LLM Sampling で扱う。
この章で作るもの
「全リクエストのトレースは保存できない」という前提のもとで、何を残すかを決める 2つの戦略を実装し、同じワークロードに適用して比較する。
- head-based sampling — トレースの開始時に、確率で決める
- tail-based sampling — トレースの完結後に、中身を見てから決める
- 評価器 — 保存量(コスト)とエラー捕捉率を集計する
この章の肝は3つ。
- 問題は「全量は保存できない。何を残すか」。rate-limiter 編の「何を通すか」と対になる
- head は安くて単純だが、判定時点でエラーになるかという情報がまだ存在しない。 だから低レートではエラートレースもレート通りにしか残らない
- tail はエラー捕捉率100%にできるが、判定できるまで全 span をバッファする インフラの代償を払う
前提: trace と span
- trace = 1リクエストがシステム全体を旅する全行程
- span = その行程の中の1区間。「このサービスがこの処理をした」「このDBクエリを 実行した」という単位で、開始時刻・所要時間・親 span への参照を持つ
trace は span の木になる。注文一覧画面の API 1発ならこんな形:
trace: GET /orders (1リクエストの全行程)
└─ span: GET /orders (api-gateway, 120ms)
├─ span: 認証チェック (auth サービス呼び出し, 8ms)
└─ span: 注文一覧の組み立て (orders サービス, 95ms)
├─ span: SELECT orders (DBクエリ, 12ms)
├─ span: SELECT users (DBクエリ, 7ms)
├─ span: キャッシュ参照 (redis, 1ms)
└─ span: GET /points (points サービス呼び出し, 40ms)
└─ span: SELECT points (DBクエリ, 5ms)これで8 span。通過するサービスが増え、それぞれが DB・キャッシュ・外部 API を 数回ずつ叩けば、1リクエストが数十 span になるのはすぐ。 サンプリングの判定は「trace 単位」で行うが(木の一部だけ残っても調査に使えない)、 保存コストは span の総数で効いてくる。
問題設定: 全量は保存できない
まともなトラフィックのあるサービスでは、1リクエストが数十 span のトレースになり、 秒間数千リクエストなら1日で数億 span になる。全部保存するとストレージ費用が 本体のインフラ費用を追い越しかねない。一方で、トレースが本当に見たくなるのは 障害調査のときで、そのとき見たいのはエラーや異常に遅いリクエストのトレースに偏っている。
つまりこれは「ランダムに間引けばいい」問題ではなく、 価値の高いトレース(エラー・遅延)をどれだけ効率よく残せるかの問題になる。
トレースは実物では span の木だが、サンプリング判定に必要な情報だけに縮約して扱う:
type Trace struct {
ID int
Duration time.Duration
Err bool
}head-based: 開始時に決める
リクエストが入ってきた瞬間、まだ何も起きていない時点で「このトレースは記録する/しない」を 確率で決めてしまう方式。
// HeadSampler は「トレース開始時」に記録するかを確率で決める。
// 開始時点では、そのリクエストがエラーになるか・遅くなるかはまだ存在しない情報なので、
// Keep はトレースの中身を一切見ない(見られない)。
// 実物では判定結果(sampled フラグ)がトレースコンテキストで下流サービスへ伝播され、
// 全サービスが同じ判断に従うことで「トレースの一部だけ欠ける」事態を防ぐ。
type HeadSampler struct {
rate float64
rng func() float64
}
// NewHeadSampler は rate ∈ [0,1] の HeadSampler を返す。rng が nil なら実装依存の乱数を使う。
func NewHeadSampler(rate float64, rng func() float64) (*HeadSampler, error) {
if rate < 0 || rate > 1 {
return nil, errors.New("tracesampling: rate must be in [0, 1]")
}
if rng == nil {
return nil, errors.New("tracesampling: rng must not be nil")
}
return &HeadSampler{rate: rate, rng: rng}, nil
}
// Keep はトレースの中身に関係なく、確率 rate で true を返す。
func (s *HeadSampler) Keep(_ Trace) bool {
return s.rng() < s.rate
}Keep(_ Trace) — 引数を受け取るのに見ていないのがこの実装の要点。 開始時点では Duration も Err もまだ存在しない情報なので、見たくても見られない。 テストでは「エラートレースを渡しても関係なく落とす」ことを固定してある。
メリット
- 判定が一瞬で終わりオーバーヘッドがほぼゼロ。SDK 内で完結する
- 判定結果をトレースコンテキストの sampled フラグとして下流に伝播するだけで、 トレース全体の一貫性(全サービスが同じ判断)が保てる
- 記録しないと決めたトレースは span 生成自体を省けるので、計測コストごと下がる
デメリット
- エラーになるかをまだ知らない。1%サンプリングなら、障害の証拠となる エラートレースも1%しか残らない
- 「あの障害のトレースを見たい」に対して、99%の確率で「残っていません」と答えることになる
実例
- OpenTelemetry SDK の
TraceIdRatioBased+ParentBasedサンプラー(head の標準形) - Jaeger の probabilistic sampler
- AWS X-Ray(デフォルトは秒間1件 + 5% という head 型のルール)
tail-based: 完結を見てから決める
トレースが完結して全 span が揃った後に、中身を見てから残すかを決める方式。 エラーと遅いトレースは必ず残し、正常なトレースは統計用に少しだけ残す。
// TailSampler は「トレース完結後」に中身を見てから決める。
// エラーと遅いトレースは必ず残し、普通のトレースはベースレートでだけ残す。
// この「中身を見る」ためには、判定できるようになるまで全 span をどこかに
// バッファしておく必要がある — それが tail-based の代償。
type TailSampler struct {
slowThreshold time.Duration
baseRate float64
rng func() float64
}
// NewTailSampler は遅延閾値とベースレートを持つ TailSampler を返す。
func NewTailSampler(slowThreshold time.Duration, baseRate float64, rng func() float64) (*TailSampler, error) {
if slowThreshold <= 0 {
return nil, errors.New("tracesampling: slowThreshold must be positive")
}
if baseRate < 0 || baseRate > 1 {
return nil, errors.New("tracesampling: baseRate must be in [0, 1]")
}
if rng == nil {
return nil, errors.New("tracesampling: rng must not be nil")
}
return &TailSampler{slowThreshold: slowThreshold, baseRate: baseRate, rng: rng}, nil
}
// Keep はエラーまたは遅いトレースを必ず残し、それ以外は確率 baseRate で残す。
func (s *TailSampler) Keep(t Trace) bool {
if t.Err || t.Duration >= s.slowThreshold {
return true
}
return s.rng() < s.baseRate
}メリット
- エラー・遅延トレースの捕捉率を**100%**にできる。「あの障害のトレース」が必ずある
- 「エラー」「遅い」だけでなく、特定顧客・特定エンドポイントなど任意の条件で 価値を定義できる
デメリット
- 判定できるようになるまで、全トレースの全 span をバッファに保持する必要がある。 このバッファ(コレクタ)は自前のインフラで、メモリと運用の実費がかかる
- 「完結した」をどう知るか問題(実務では「最後の span から N 秒待つ」で近似する)
- 同じトレースの span を同じコレクタに集める必要があり、 ロードバランサが traceID ベースのルーティングになる。計測基盤自体が分散システムになる
実例
- OpenTelemetry Collector の
tail_samplingプロセッサ(ポリシー式で error / latency / rate を組める) - Honeycomb Refinery(tail-based 専用のプロキシ)
- Grafana Tempo などのベンダーが提供するマネージド tail sampling
実験: 同じワークロードで対決させる
Go 実装では評価器でこの比較をテストとして固定している。
// Summary はサンプリング戦略を1つのワークロードに適用した結果の集計。
type Summary struct {
Total int // 全トレース数
Kept int // 保存したトレース数
Errors int // 全エラートレース数
ErrorsKept int // 保存できたエラートレース数
}
// Evaluate は全トレースに sampler を適用して集計する。
func Evaluate(traces []Trace, s Sampler) Summary {
sum := Summary{Total: len(traces)}
for _, t := range traces {
if t.Err {
sum.Errors++
}
if s.Keep(t) {
sum.Kept++
if t.Err {
sum.ErrorsKept++
}
}
}
return sum
}
// KeepRatio は保存率(=ストレージコストの代理指標)を返す。
func (s Summary) KeepRatio() float64 {
if s.Total == 0 {
return 0
}
return float64(s.Kept) / float64(s.Total)
}
// ErrorCaptureRate はエラートレースの捕捉率を返す。
func (s Summary) ErrorCaptureRate() float64 {
if s.Errors == 0 {
return 0
}
return float64(s.ErrorsKept) / float64(s.Errors)
}下のデモは同じシミュレーションのブラウザ版。5000件のトレースに両方式を適用する (公平のため、tail のベースレートには head と同じサンプリング率を使っている)。
試してみる: サンプリング率を 10% にしたとき、保存量はほぼ同じなのに、 エラー捕捉率は head が約10%、tail が100%になる。 サンプリング率を上げていくと head の捕捉率も上がるが、100%にするには 全量保存(サンプリングの放棄)が必要——これが head の構造的な限界。
5,000 件のトレース(うちエラー 59 件)に両方式を適用
head-based
tail-based
2方式の比較
| 方式 | 決めるタイミング | 必要なもの | エラー捕捉率 | 実例 |
|---|---|---|---|---|
| head-based | トレース開始時 | 乱数と伝播だけ | サンプリング率と同じ | OTel SDK、Jaeger、X-Ray |
| tail-based | トレース完結後 | 全 span のバッファ + traceID ルーティング | 100%にできる | OTel Collector tail_sampling、Honeycomb Refinery |
実務ではどちらか一方ではなく、head で母数を減らしてから tail で選別する二段構えや、 「エラー時だけ SDK 側で強制記録する」ハイブリッドもよく使われる。
簡略化したこと
- トレースを完結済みの要約に縮約: span の木、複数サービスにまたがるコンテキスト伝播、 sampled フラグの実際の伝わり方(W3C
traceparentヘッダ)は実装していない - tail のバッファは実装していない: 「完結後に中身が見える」という結果だけをモデル化した。 未完トレースの保持・タイムアウト・traceID ルーティングこそが tail の実装コストの本体
- レート制御なし: X-Ray の reservoir のような「秒間N件は必ず」や、 トラフィック変動に追従する適応型サンプリングは扱わない
参考資料
- OpenTelemetry: Sampling — head/tail の公式整理。図がわかりやすい
- OTel Collector tail_sampling processor — 実物のポリシー定義が読める
- Honeycomb: Refinery — tail-based 専用プロキシの設計