OTel Collector で LLM トレースを絞る:テールサンプリングと本文マスキング
この記事について
- 対象読者:Claude などの LLM 呼び出しを OpenTelemetry で計測していて、全トレースを保存するコストや、プロンプトに混じる機密が気になり始めた人
- 得られること:アプリのコードは変えず、OTel Collector 側だけで「残すトレースを賢く絞る(テールサンプリング)」「本文の機密をマスク・除去する」を実現する設定。なぜアプリではなく Collector でやるのか、その理由も
- 前提・環境:OpenTelemetry Collector contrib v0.155.0(執筆時点で動作を確認したバージョン)。LLM 呼び出しを span として送るアプリがあること(本記事は Spring Boot × Claude の例を前提にしますが、設定は言語・SDK を問いません)
この記事は「Spring Boot で Claude API 呼び出しを OpenTelemetry で計測する」の続編です。前回はアプリ側の計装を扱い、検証用に全トレースを送る構成のまま、全トレースを送るとコストがかさむ点を次の課題として残しました。本記事はそれを Collector 側で解き、あわせて span へ本文を載せる運用で避けて通れないマスキングも同じ Collector に寄せます。
Collector の processor 名・設定キーは**執筆時点(2026年7月)**のものです。最新は公式ドキュメントで確認してください。
なぜアプリではなく Collector でやるのか
「サンプリングやマスキングを、アプリのコードでやればいいのでは」と思うかもしれません。ですが、この2つは Collector 側に置くほうが理にかなっています。
- テールサンプリングは、トレースが終わるまで判断できない:「失敗したトレースだけ残す」「遅いものだけ残す」は、span が出そろってからでないと決められません。送信時(head)には結果がまだ分からないからです。Collector でトレース全体を見てから決めます
- 方針を変えてもアプリを再デプロイしなくていい:閾値や残す割合は運用しながら調整します。Collector の設定だけで変えられれば、アプリは触りません
- マスキングを一箇所に集約できる:複数のサービスから本文が流れてきても、出口の Collector で一度マスクすれば、漏れの経路を1つに絞れます
つまり、アプリは「全部ありのまま送る」だけにして、残す・隠すの判断は Collector に寄せる。これが見通しのよい分担です。
用語:ヘッドサンプリングは送信側が「送るか」を即決する方式(速いが、結果を見て選べない)。テールサンプリングは Collector がトレース全体を貯めてから「残すか」を決める方式(エラーや遅延で選べるが、貯める分のメモリが要る)。
結論(先に全体像)
やることは Collector のパイプラインに 2つの processor を挟むだけです。アプリは全トレースを送り続けます。
- アプリ側はヘッドサンプリングを 1.0(全送信) のまま。判断材料を Collector に渡しきる
-
transformprocessor で本文の機密をマスク・除去する(保存先へ出る前に) -
tail_samplingprocessor で「失敗・遅延・高コストなトレース+少量の母集団」だけ残す
手順1:アプリは全トレースを送る
テールサンプリングを機能させる前提は、アプリが間引かずに全部送ることです。途中で捨てられると、Collector が「失敗したトレース」を選べません。前回のアプリ設定(application.yaml)はすでに全送信なので、そのままで構いません。
management:
tracing:
sampling:
probability: 1.0 # 全送信。残す判断は Collector(tail_sampling)に委ねる
「全送信ならコストは減らないのでは」と思うかもしれません。アプリ → Collector 間は同一ネットワーク内のことが多く、**課金が生じるのはその先(保存先のバックエンド)**です。Collector でそこへの量を絞れば、保存・転送のコストは下がります。
手順2:本文の機密をマスク・除去する
LLM のプロンプトや応答を span へ載せる運用なら、保存先へ届く前にマスクします。transform processor は OTTL(OpenTelemetry Transformation Language) で属性を書き換えられます。
processors:
transform/redact:
error_mode: ignore
trace_statements:
- context: span
statements:
# 本文そのものは保存先に出さない(属性ごと削除)
- delete_key(attributes, "gen_ai.input.messages")
- delete_key(attributes, "gen_ai.output.messages")
- delete_key(attributes, "gen_ai.system_instructions")
# デバッグ用に独自に残す属性は、PII をパターンでマスク(例:メールアドレス)
- replace_pattern(attributes["app.user.query"], "[\\w.+-]+@[\\w-]+\\.[\\w.-]+", "[email]")
ポイントは2段構えです。
-
本文を属性ごと削除:
gen_ai.input.messages・gen_ai.output.messages・gen_ai.system_instructionsは、保存して良いと判断できない限り出さないのが安全です。delete_keyで削除します。なお、これは本文が span 属性として載っている前提です。実装によっては本文をログイベント側へ載せることもあり、その場合は span へのdelete_keyでは消えないので別途対応します -
残す属性はマスクをかける:本文は消しても、デバッグ用にユーザー入力の一部(例では
app.user.query)を独自属性として残す運用はあります。そこに PII(個人を特定できる情報)が紛れうるので、replace_patternで正規表現マスクをかけます(属性がなければerror_mode: ignoreにより、その文はエラーにならず処理が続きます)
gen_ai.input.messages・gen_ai.output.messages・gen_ai.system_instructionsは、OpenTelemetry の GenAI セマンティック規約でもまだ実験段階(Development)の属性です。しかも既定では記録しない(オプトインで初めて span に載る)位置づけで、名前や形も今後変わりえます。いずれにせよ「載せるなら消す・マスクする」を出口で徹底しておけば、規約の変化に振り回されずに済みます。
より厳格にやるなら、許可リスト方式の redaction processor もあります。「許可した属性以外はすべて削除する」挙動なので、新しい属性が増えても既定で漏れない設計にできます。ただしその裏返しで、残したい属性は allowed_keys に明示する必要があります(空のままだと全属性が削除されます)。
手順3:残すトレースを賢く絞る(tail_sampling)
本題のテールサンプリングです。tail_sampling processor は複数のポリシーを OR で評価し、どれかに当たったトレースを残します。LLM 運用なら、次の4つが有効です。
processors:
tail_sampling:
decision_wait: 10s # トレース完了を待つ猶予(p99 の処理時間をカバーする値に)
policies:
# ① 失敗したトレースは必ず残す
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
# ② 遅いトレースは残す(5秒超)
- name: slow
type: latency
latency:
threshold_ms: 5000
# ③ 高コスト(出力トークンが多い)トレースは残す
- name: expensive
type: ottl_condition
ottl_condition:
error_mode: ignore # 属性が無い / 変換できないトレースは黙って対象外にする
span:
- 'Int(attributes["gen_ai.usage.output_tokens"]) > 1000'
# ④ 上記に当たらない正常系も、母集団として一部だけ残す(10%)
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 10
4つのポリシーは、それぞれ違う問いに答えます。
-
①失敗:エラーは見逃せないので全部残す(
status_codeポリシー) -
②遅延:レイテンシ悪化の調査用に、遅いものを残す(
latencyポリシー) -
③高コスト:トークンを多く消費したリクエストを残す。ここが LLM ならではです。トークン属性は文字列で載っていることが多いので、
ottl_conditionのInt(...)で数値に変換してから比較します -
④母集団:正常・高速・安価なトレースも、傾向を見るために一定割合だけ残します(
probabilisticポリシー)
これで「異常は取りこぼさず、正常系は薄く」残せます。保存量を大きく減らしながら、見たいトレースは手元に残せます。
③が
ottl_conditionなのには理由があります。numeric_attributeポリシーは属性が数値型であることを前提にしますが、Micrometer の span 属性は文字列です。Int()で変換を挟むottl_conditionなら、文字列で載ったトークン数でも閾値判定できます。
訂正(2026年8月):上の「Micrometer の span 属性は文字列です」は、前提を1つ省いていました。文字列になるのは Micrometer の
KeyValueで載せた場合です。同じ span には、OpenTelemetry の API(AttributeKey.longKey(...))で整数を載せることもできます。本シリーズで規約に沿わせた計装(「OpenTelemetry の GenAI 規約に沿って Claude 呼び出しを計装する」)は、実際にそちらで載せています。つまりnumeric_attributeが使えるかどうかは、Collector の設定ではなくアプリがどの型で載せているかで決まります。なおInt()を挟む上の書き方は整数でも文字列でも拾えるので、この記事の設定はそのまま使えます。整数と文字列の両方を2つの書き方へ流した実測は「OpenTelemetry 計装でハマった順に7段階」にまとめました。
手順4:パイプラインに組み込んで検証する
手順2・3 の 2つの processor を、batch と合わせて マスク → サンプリング → バッチ の順で traces パイプラインに並べます。マスクをサンプリングより前に置くのは、保存先に出る経路すべてでマスク済みを保証するためです。あわせて、受信口(otlp receiver)・送信先(otlp/jaeger exporter)・後段の batch も定義しておきます。
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
# transform/redact と tail_sampling は手順2・手順3で定義済み
exporters:
# サンプリング後に残ったトレースを Jaeger(OTLP 受信)へ転送
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [transform/redact, tail_sampling, batch]
exporters: [otlp/jaeger]
設定の妥当性は、Collector の検証コマンドで起動前に確かめられます。
# 設定ファイルの構文・コンポーネント存在チェック(起動はしない)
otelcol-contrib validate --config otel-collector-config.yaml
あとはローカルなら docker compose up -d で起動し、アプリから何度かリクエストを流して、失敗・遅延・高トークンのトレースだけが保存先に出ることを確認します。

残ったトレースを開くと、マスクの結果も確認できます。本文の属性は無くなり、残した属性のメールアドレスだけが置き換わっています。
![Jaeger の span 詳細。gen_ai.*.messages が削除され、app.user.query のメールは [email] にマスクされている](https://static.zenn.studio/user-upload/deployed-images/15a786ae17677e5207530819.png?sha=d5ea20c7e292c73a1633f6b559b8839c8fc6aead)
つまずきポイントと解決
テールサンプリングは「貯めてから決める」性質ゆえの落とし穴があります。
-
トークンの閾値判定が働かない:
numeric_attributeに文字列の属性を渡すと判定されません。ottl_conditionでInt(attributes["..."]) > Nと変換してから比較します。 -
長いトレースが途中で切れる:
decision_waitを過ぎた時点で判断が確定し、それ以降に届いた span は判定に入りません。ストリーミングなど処理が長い呼び出しでは、p99 の処理時間をカバーする値に延ばします(ただし延ばすほどメモリを使います)。 - Collector が複数台だと正しく選べない:テールサンプリングは「同じトレースの span が同じ Collector に集まる」前提です。多段・冗長構成では、トレース ID で振り分ける load-balancing exporter を手前に置き、同じトレースの span が同じ Collector へ届くようにします。
-
マスクの置き場所を間違える:
tail_samplingの後ろにマスクを置くと、サンプリングで分岐した別経路に、マスク前のデータがそのまま出ていく経路が生まれます。マスクを先に置きます。
まとめ
アプリを触らず、Collector 側だけで LLM トレースを運用に耐える形へ絞れます。
- 残す・隠すの判断は Collector に寄せる。アプリはヘッドサンプリング 1.0 で全送信し、判断材料を渡しきる
-
本文はマスク・除去を先に。
transform(OTTL)でgen_ai.*.messagesを取り除き、残す属性も正規表現でマスク。厳格にやるなら許可リスト方式のredaction -
テールサンプリングで異常を取りこぼさず、正常系は薄く残す。
status_code・latency・ottl_condition(高トークン)・probabilisticを OR で並べる - 文字列で載るトークン数は
Int()で変換してから閾値判定。decision_waitと複数 Collector 構成に注意
計装そのものはアプリの仕事ですが、「どれを残し、何を隠すか」は運用の判断です。それを Collector に集約しておくと、方針が変わるたびにアプリを触らずに済みます。LLM のようにコストと機密の両方が絡む対象では、この分担がとくに有効です。
ここでは「Collector でトレースを絞る」ことに絞りましたが、仕様定義から実装・テスト・デプロイまでを Claude Code と一気通貫で進める流れは、拙著にまとめています。Spring Security / JPA / Flyway や本番デプロイ(Railway)まで、AI と対話しながら 1つの Web アプリを完成させる構成です。
📘 『Claude Codeと生み出す Spring Boot実践開発 ~AI日記アプリを仕様定義からデプロイまで~』
- Amazon(紙書籍): https://amzn.to/4gH5T35
- BOOTH(電子・PDF): https://propagandist.booth.pm/items/8536937
- Google Play Books(電子): https://play.google.com/store/books/details?id=p83tEQAAQBAJ
※ Amazon のリンクはアフィリエイトリンクを含みます。
Discussion