🚦

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. アプリ側はヘッドサンプリングを 1.0(全送信) のまま。判断材料を Collector に渡しきる
  2. transform processor で本文の機密をマスク・除去する(保存先へ出る前に)
  3. tail_sampling processor で「失敗・遅延・高コストなトレース+少量の母集団」だけ残す

手順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.messagesgen_ai.output.messagesgen_ai.system_instructions は、保存して良いと判断できない限り出さないのが安全です。delete_key で削除します。なお、これは本文が span 属性として載っている前提です。実装によっては本文をログイベント側へ載せることもあり、その場合は span への delete_key では消えないので別途対応します
  • 残す属性はマスクをかける:本文は消しても、デバッグ用にユーザー入力の一部(例では app.user.query)を独自属性として残す運用はあります。そこに PII(個人を特定できる情報)が紛れうるので、replace_pattern で正規表現マスクをかけます(属性がなければ error_mode: ignore により、その文はエラーにならず処理が続きます)

gen_ai.input.messagesgen_ai.output.messagesgen_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_conditionInt(...)数値に変換してから比較します
  • ④母集団:正常・高速・安価なトレースも、傾向を見るために一定割合だけ残します(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 の検索結果。エラー・6.2秒の遅延・高トークンのトレースが残り、正常系は一部だけが残っている

残ったトレースを開くと、マスクの結果も確認できます。本文の属性は無くなり、残した属性のメールアドレスだけが置き換わっています。

Jaeger の span 詳細。gen_ai.*.messages が削除され、app.user.query のメールは [email] にマスクされている

つまずきポイントと解決

テールサンプリングは「貯めてから決める」性質ゆえの落とし穴があります。

  • トークンの閾値判定が働かないnumeric_attribute に文字列の属性を渡すと判定されません。ottl_conditionInt(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_codelatencyottl_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 のリンクはアフィリエイトリンクを含みます。

Discussion