Skip to content

Trace Sampling

実装: trace-sampling/ / 実行: go test ./trace-sampling/

リクエストの通り道を記録したトレースは、量が多すぎて全部は保存できない。ではどれを残すか。開始時に決め打ちする head-based と、終わってから中身を見て決める tail-based の2つを作って比べる。障害のときに見たいのはエラーや遅いトレースなので、無作為に間引くと肝心なものが消える、というのがこの話の勘所。

「サンプリング」の同名別分野に注意

この章は分散トレーシングで「どのトレースを保存するか」を決める話。 LLM がテキストを生成するときのトークンサンプリングは LLM Sampling で扱う。

この章で作るもの

「全リクエストのトレースは保存できない」という前提のもとで、何を残すかを決める 2つの戦略を実装し、同じワークロードに適用して比較する。

  1. head-based sampling — トレースの開始時に、確率で決める
  2. tail-based sampling — トレースの完結後に、中身を見てから決める
  3. 評価器 — 保存量(コスト)とエラー捕捉率を集計する

この章の肝は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 の木だが、サンプリング判定に必要な情報だけに縮約して扱う:

go
type Trace struct {
	ID       int
	Duration time.Duration
	Err      bool
}

head-based: 開始時に決める

リクエストが入ってきた瞬間、まだ何も起きていない時点で「このトレースは記録する/しない」を 確率で決めてしまう方式。

go
// 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
}
アプリ (SDK)ここで判定する
コレクタ素通し
ストレージ
head-based の判定位置。リクエストが入った瞬間、アプリ内の SDK が決める。捨てると決めたトレースは計測もされずに消える

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 が揃った後に、中身を見てから残すかを決める方式。 エラーと遅いトレースは必ず残し、正常なトレースは統計用に少しだけ残す。

アプリ (SDK)全 span を送る
コレクタバッファして完結後に判定
ストレージ
tail-based の判定位置。アプリは全 span を送り、コレクタが溜めてから決める。判定が下流に移った分、バッファのコストをコレクタが払う
go
// 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 実装では評価器でこの比較をテストとして固定している。

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

保存量482 件 (9.6%)
エラー捕捉率13.6% (8/59)

tail-based

保存量649 件 (13.0%)
エラー捕捉率100.0% (59/59)

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件は必ず」や、 トラフィック変動に追従する適応型サンプリングは扱わない

参考資料