traceparentヘッダー徹底解説:W3C Trace Context 完全ガイド
traceparentヘッダーは、分散トレーシングの土台にある1行のASCII文字列で、リクエストの身元を、通過するすべてのサービスへ運ぶ。現行バージョンではちょうど55文字、ハイフン区切りの4つのフィールドからなる。
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ │ │ └─ trace-flags (2 hex, 1 byte)
│ │ └─ parent-id (16 hex, 8 bytes)
│ └─ trace-id (32 hex, 16 bytes)
└─ version (2 hex, 1 byte)
このうち2つのフィールドは、リクエストが進むにつれて違う振る舞いをする。trace-id はどのホップでも同一のままで、エッジプロキシから最後のデータベース呼び出しまで、そのリクエストの名前であり続ける。一方 parent-id はホップごとに書き換わる。呼び出し元のスパンを指す値であって、リクエストを指す値ではないからだ。この2つの取り違えは、「トレースの様子がおかしい」という相談の定番だ。
解剖図としてはこれで全部だ。厄介なのはフィールド表に書けない部分のほうである。どんなヘッダーが無効と判定されるのか、仕様に準拠した受信側は無効なヘッダーを受け取ったとき何をするのか、そして両方ともトレーシング対応をうたう2つのサービスのあいだで、ヘッダーはどこで消えるのか。手元に実物のヘッダーがあるなら、無料の traceparent デコーダー に貼り付けながら読み進めるといい。フィールドを分解し、フラグのバイトを1ビットずつ展開し、壊れたヘッダーがどの規則に抵触したかを名指ししてくれる。
traceparentヘッダーの全体像
traceparentヘッダーとは、分散トレースをサービスからサービスへ受け渡す1本のHTTPヘッダーのことだ。中身はハイフン区切りの16進フィールド4つ、version、trace-id、parent-id、trace-flags からなり、バージョン 00 の現行仕様ではちょうど55文字になる。trace-id がリクエスト全体を名指し、parent-id は自分を呼び出したスパンを名指す。
| フィールド | 16進の桁数 | バイト数 | 何を識別するか | ホップごとに変わるか |
|---|---|---|---|---|
version | 2 | 1 | 残りのフィールドがどの形式に従うか。現時点では常に 00 | いいえ |
trace-id | 32 | 16 | リクエスト全体(端から端まで) | いいえ |
parent-id | 16 | 8 | 呼び出し元のスパン(呼び出し元のスパンID) | はい |
trace-flags | 2 | 1 | 8ビットのフィールド。ビット0が sampled | まれに |
52桁の16進数にハイフンを3本足して55文字。この数字は覚えておくといい。バージョン 00 のヘッダーはこれ以外の長さならすべて無効であり、しかも長さは目視で最も速く確認できる項目だからだ。
ヘッダーの中身はすべて小文字の16進数である。「16進数、大文字小文字は不問」ではない。小文字だけだ。W3C Trace Context 勧告 の文法が認めるのは 0-9 と a-f だけで、それ以外は一切通らない。値としては完全に正しい大文字のトレースIDが、それでも下流で捨てられるのはこのためである。
フィールドごとの解剖
4つのフィールドはそれぞれ桁数が固定されており、それぞれに固有の無効値と、固有の壊れ方がある。
version:「どうせ 00 だろう」で済まない理由
現在のバージョンバイトは 00 であり、当分のあいだ 00 のままだろう。ただし ff は明確に禁止されている。仕様が無効値として予約しているので、ff で始まるヘッダーは後ろに何が続いていようと、到着した時点で死んでいる。
面白いのは、見たことのないバージョンに関する規則のほうだ。if (version !== '00') reject() と書くパーサーは間違っており、しかも代償の大きい間違い方をしている。仕様は、バージョンが既知のものより新しく、かつヘッダーが既知の形式と同じかそれ以上の長さであれば、受信側にパースを試みるよう求めている。認識できるフィールドだけを読み、末尾の余分なデータは黙って許容し、そのまま処理を続ける。ここで拒否してしまうと、上流の誰かがバージョンを上げた瞬間に、自分のサービスがトレースの途切れる境界、つまり新しいトレースが始まる場所になってしまう。
// 誤り:自分のサービスをトレースの墓場にしてしまう
if (version !== '00') throw new Error('bad traceparent');
// 正しい:理解できる先頭部分だけをパースする
if (version !== '00' && header.length >= 55) {
// version、trace-id、parent-id、trace-flags を読み、残りは無視する
}
trace-id:16バイト、リクエスト全体の同一性
小文字の16進数32桁。トレースが生きているあいだ、値は変わらない。最初にどのサービスが生成したものであれ、各ホップはそのまま次へ写して渡す。オブザーバビリティのバックエンドでトレースを検索するとき、貼り付けるのはこの文字列だ。
値を縛る規則は2つ。16進数32桁であること、そしてすべてゼロであってはならないこと。00000000000000000000000000000000 は「まだデータのないトレース」ではない。仕様はこれを無効値として名指しし、受信側にヘッダー全体を無視するよう要求している。実務でオールゼロのトレースIDが出てきたら、初期化されないまま終わったSDKか、転送すべき本物のコンテキストを持たないミドルウェアがプレースホルダーを差し込んだかのどちらかである。
trace-id は128ビットで、UUIDと同じ幅を持つが、UUIDではない。バージョンビットもバリアントビットもハイフンもなく、構造と呼べるものは何ひとつない。不透明な16バイトがあるだけだ。ここから v4 は読み取れないし、逆にハイフンを取り除いたUUIDが自動的に有効な trace-id になるわけでもない。バージョンとバリアントのニブルがあるせいで、ランダム性が一様ではなくなるからだ。UUIDがその128ビットの中に何を予約しているのかを見たいなら、UUIDが実際にエンコードしている中身 がレイアウトを一通り追っている。UUID生成 & デコードツール を使えば、バージョンビットとバリアントビットが実際にどこへ収まるかを確認できる。
parent-id:8バイト、あなたを呼び出したスパン
16進数16桁で、ホップごとに書き換えられる。このフィールドは中身の単純さに比べ、名前のせいで混乱を招きやすい。W3Cの仕様はこれを parent-id と呼び、OpenTelemetryは同じ8バイトを スパンID と呼ぶ。同じものを別の方向から見ているだけだ。自分のサービスから見れば親であり、呼び出し元から見れば、外向きのリクエストのために今しがた作ったスパンのIDである。
だからサービスAがサービスBを呼ぶとき、Aは parent-id の枠に A自身の スパンIDを入れる。Bは子スパンを作り、BがCを呼ぶときにはそこへBのスパンIDを入れる。trace-id は最初から最後まで触られない。伝播アルゴリズムはこれで全部だ。
オールゼロの parent-id も、trace-id と同じ理由で無効になる。0000000000000000 は呼び出し元が本物のスパンを渡さなかったという意味であり、中途半端に受け入れるのではなくヘッダーごと破棄すべきである。
trace-flags:真偽値に見えて、実体は8ビット
実際に目にするヘッダーはほぼすべて 01 で終わるので、このフィールドをはい/いいえの2択として読んでしまうのは自然な反応だ。だがこれは1バイトであり、各ビットには役割が割り当てられている。
- ビット0(マスク
0x01):sampled - ビット1(マスク
0x02):random-trace-id。Trace Context Level 2 で追加された - ビット2〜7:予約領域。受信時は無視し、外向きのリクエストではクリアする
組み合わせをデコードすると次のようになる。
| 16進 | 2進 | sampled | random-trace-id | flags === 0x01 は成立するか |
|---|---|---|---|---|
00 | 00000000 | false | false | false |
01 | 00000001 | true | false | true |
02 | 00000010 | false | true | false |
03 | 00000011 | true | true | false ← ここがバグ |
最後の行をもう一度見てほしい。フラグが 03 のトレースはサンプリングされている。にもかかわらず、バイト全体を 01 と比較するコードはこれを「サンプリングされていない」と報告する。しかも何のエラーも出さず、Level 2 のフラグがたまたま立っているトラフィックだけで起きる。故障の形としては最悪の部類だ。パースのバグではなくサンプリング率の問題に見えてしまうからである。
const flags = parseInt(traceFlags, 16);
// 誤り:ビットフィールドを列挙型のように扱っている
const sampled = traceFlags === '01';
// 正しい
const sampled = (flags & 0x01) !== 0;
const randomTraceId = (flags & 0x02) !== 0;
では random-trace-id は実際に何を主張しているのか。trace-id の少なくとも右端7バイトが一様な乱数で生成されている、ということだ。理屈っぽい話に聞こえるが、一貫サンプリング(consistent sampling)を考えると意味が変わる。下流のシステムがトレースの1%を残したい、しかもどの1%を残すかを各サービスが独立に一致させたい、という場合、IDをいったんハッシュにかける代わりに、その数バイトを何かで割った剰余をそのまま使える。このフラグは「そうしても安全だ」という上流からの約束である。
traceparentが無効になる条件
ベンダーのナレッジベースが決まって飛ばすのがここだ。バージョン 00 のヘッダーが拒否される条件を漏れなく並べる。
| 症状 | 規則 | 結果 |
|---|---|---|
00-4BF92F35...-01 | 文法は小文字の16進数しか認めない | 無効。値は正しいがヘッダーは拒否される |
ff-... | バージョン ff は仕様で禁止されている | 無効 |
trace-id が 00000000000000000000000000000000 | オールゼロの trace-id は名指しされた無効値 | 無効 |
parent-id が 0000000000000000 | オールゼロの parent-id は名指しされた無効値 | 無効 |
| trace-id が16進数32桁でない | 桁数は固定 | 無効 |
| parent-id が16進数16桁でない | 桁数は固定 | 無効 |
| trace-flags が16進数2桁でない | 桁数は固定 | 無効 |
バージョン 00 でヘッダーがちょうど55文字でない | 末尾の追加データは将来のバージョンでのみ許される | 無効 |
0-9a-f とハイフン以外の文字が含まれる | 16進数ではない | 無効 |
ここから導かれる帰結が、デバッグでは一番効いてくる。
仕様に準拠した受信側は、無効な traceparent ヘッダーを修復しないし、そのまま転送もしない。ヘッダーを破棄し、新しく生成した trace-id でまったく新しいトレースを開始する。
つまり画面に出ている症状は、壊れたトレース1本ではない。互いに切れた短いトレース2本だ。一方は不正なヘッダーを送り出したサービスで唐突に終わり、もう一方はそれを受け取ったサービスから何の脈絡もなく始まっているように見える。どこにもエラーは記録されない。単体で眺めればどちらのトレースも健全だ。両者をつなぐ失われた環を求めて午後をまるごと溶かす人がいるが、答えはミドルウェアが16進文字列を大文字にしたか、手組みのヘッダーが54文字で出力されていたか、そのどちらかだったりする。
長さと大文字小文字は、いくら睨んでも気づけない2つの故障モードである。ヘッダーを デコーダー に貼り付ければ、桁を数える手間なしに、どの規則に引っかかったかがそのまま出る。
tracestate:みんなが取り違える相棒ヘッダー
traceparent は標準化された同一性を運ぶ。tracestate ヘッダーが運ぶのは、各ベンダーがその横に添えたい任意の情報で、カンマ区切りの key=value メンバーとして並ぶ。
tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE
キーを認識できない実装は、それをそのまま転送しなければならない。設計目標はこれに尽きる。ベンダーは独自の状態を標準のトレースに相乗りさせられるし、途中のホップがその中身を理解する必要はない。
とはいえ文法は思ったより厳しく、そのうち3つの規則は本番で実際に起きる症状に直結する。
リストメンバーは32個が絶対上限である。 これは助言ではなく文法そのものだ:list = list-member 0*31( OWS "," OWS list-member )。メンバーが33個あればヘッダーは無効で、受信側は破棄してかまわない。これは、一見すると魔法のように見える症状の正体でもある。エッジでは存在し、2ホップ先でもまだ存在し、5ホップ目には跡形もなく消えているベンダーデータ。各ホップが自分のメンバーを追加し続けた結果リストが32を超え、そこから先はヘッダーが切り詰められるのではなく、まるごと落とされていたわけだ。
値は1〜256文字で、空にはできない。 値の生成規則は必ず空白以外の文字で終わるので、vendor= は「値のないキー」ではなく構文エラーだ。使えるのは印字可能なASCIIだけで、値にカンマや等号は入れられない。
キーの文法は Level 1 と Level 2 で変わった。 Level 1 はキーを tenant@vendor という生成規則で定義しており、@ は構造上の区切り文字だった。Level 2 はこれをフラットな文字クラスに置き換えた。キーは小文字か数字で始まり、以降は a-z、0-9、_、-、*、/、@ が使える。Level 2 では @ はただの文字であり、キーが数字で始まってもよく、a@b@c は完全に合法なキーになる。Level 1 の生成規則ならこれを拒否する。Level 1 で検証するプロキシと Level 2 のキーを出すサービスが混在すると、片方が受け入れるものをもう片方が拒否し、ちょうど1ホップだけでヘッダーが消える。
知っておくべき規則があと2つ。キーの重複は問答無用で無効だ。そして traceparent の parent-id を書き換えたなら、自分の tracestate エントリをリストの先頭へ移動しなければならない。リストは新しいものが先という順序で並んでいるからだ。この移動を省くと、古いベンダー状態が先頭に居座り、読み手はそれを最新の値として扱ってしまう。
最後は優しい規則。空のリストメンバーは合法だ。ミドルボックスがエントリを削除するとき、カンマだけが残って rojo=1,,congo=2 になることはよくある。仕様はこれを明示的に許しているので、パーサーはヘッダーを不正と断じるのではなく、空のメンバーを捨てて処理を続けるべきである。デコーダーの tracestate ビュー は全メンバーを一覧にして、メンバーごとの検証結果と、32メンバー上限に対する現在の個数を表示する。たいていはカンマを数えるより速い。
分散トレーシングのヘッダーはどう旅をするか:1つのリクエストと4ホップ
エッジプロキシ、APIサービス、そして下流の2つのサービスを通る、1本のリクエストを追ってみる。
Client
│ (no traceparent — the edge is the root)
▼
Edge proxy generates trace-id 4bf9…4736, span 00f0…02b7
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
▼
API service reads it, creates span a1b2c3d4e5f60718
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-a1b2c3d4e5f60718-01
▼
Orders service reads it, creates span 9f8e7d6c5b4a3928
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-9f8e7d6c5b4a3928-01
▼
Inventory service
どのホップも同じ3つのことをする。受信したヘッダーを読み、外向きの呼び出しごとに parent-id を自分のスパンIDへ差し替え、trace-id とフラグはそのまま転送する。受信ヘッダーがそもそも存在しない場合、上の図でいえばクライアントからの入り口では、受け取ったサービスがルートになる。trace-id を生成し、そこから下流すべてのサンプリング判断を下す。
連鎖を端から端まで試すなら、ヘッダーを手で注入すればいい。
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' \
https://example.com/api
本番で採取したヘッダーをステージングに向けて再生すれば、同じ trace-id がバックエンドに現れる様子を確認できる。認証情報やボディを足したくなったら cURLコマンドジェネレーター がオプションを組み立ててくれるし、curl チートシート にはデバッグ中に使いたくなるヘッダー系と詳細出力系のオプションがまとまっている。
送ったつもりの内容ではなく、サービスが実際に受け取った内容を見たいなら、使い捨てのエコーサーバーを立てて1ホップだけそこへ向ければいい。
python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
class Echo(BaseHTTPRequestHandler):
def do_GET(self):
for name, value in self.headers.items():
print(f"{name}: {value}")
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok\n")
HTTPServer(("127.0.0.1", 8080), Echo).serve_forever()
PY
あとは curl -H 'traceparent: …' http://127.0.0.1:8080/ を叩いて、反対側から出てきたものを読むだけだ。「プロキシがヘッダーを食っている」系の調査は、半分がここで終わる。
trace-flags の sampled:上流の決定であって、保証ではない
trace-flags の sampled ビットが 1 であることは、上流のサービスがこのトレースを記録すると 決定した という意味でしかない。データがバックエンドに届いたことを保証するものではない。
ヘッドベースサンプリングは、まだ何も起きていないルートの時点で判断し、それを下流へ伝播させる。安価でサービス間の一貫性も保てるが、目が見えない。そのリクエストがこれから失敗することを知りようがないからだ。テールベースサンプリングはトレースが完了するまでスパンをバッファしてから判断するので、エラーを含むトレースをすべて残せる。その代わり、スパンをメモリに抱え、全サービスのスパンを同じコレクターへ集める必要がある。
テールベースサンプリングでは、どのホップでも 01 が立っていたトレースが、最後になって捨てられることがある。レート制限やエクスポートのクォータでも落ちる。だからエッジで 01 なのにUIにトレースが出てこないからといって、必ずしも伝播のバグとは限らない。ヘッダーを疑う前に、コレクター自身のドロップメトリクスを確認したほうがいい。
日々の運用では逆のケースのほうが多い。受信したフラグが 00 なら、呼び出し元がサンプラーを走らせたうえで記録しないと決めたということだ。自分のサービスの設定が壊れているわけではないし、自分のサンプラーを点検する時間は無駄になる。問うべきなのは、上流のどのサービスがサンプリングしないと判断しているのか、である。
伝播フォーマット間の変換
W3C Trace Context が勝者になったとはいえ、それより古い方言を話すシステムはいまだに数多くあり、ゲートウェイがそのあいだを翻訳している。同じ traceparent の例を4つのフォーマットで書き分けると次のようになる。
| フォーマット | ヘッダー | この例での値 |
|---|---|---|
| W3C | traceparent | 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 |
| B3 単一ヘッダー | b3 | 4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1 |
| B3 複数ヘッダー | X-B3-TraceId、X-B3-SpanId、X-B3-Sampled | 4bf92f3577b34da6a3ce929d0e0e4736、00f067aa0ba902b7、1 |
| Datadog | x-datadog-trace-id、x-datadog-parent-id、_dd.p.tid タグ | 11803532876627986230、67667974448284343、4bf92f3577b34da6 |
| AWS X-Ray | X-Amzn-Trace-Id | Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1 |
Datadog:上位・下位64ビットの分割
Datadogの識別子は128ビットのトレースIDより前から存在しており、その互換用の継ぎ当てこそ、変換の大半が壊れる場所だ。x-datadog-trace-id が運ぶのは 下位 64ビットを10進文字列にしたものである。上位 64ビットは別扱いで、16進数のまま、x-datadog-tags ヘッダーが運ぶ _dd.p.tid タグに載る。
const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const [, traceId, parentId] = traceparent.split('-');
const datadogTraceId = BigInt('0x' + traceId.slice(16)).toString(10);
const higher64Hex = traceId.slice(0, 16);
const datadogParentId = BigInt('0x' + parentId).toString(10);
console.log('x-datadog-trace-id:', datadogTraceId); // 11803532876627986230
console.log('x-datadog-tags:', higher64Hex); // _dd.p.tid=4bf92f3577b34da6
console.log('x-datadog-parent-id:', datadogParentId); // 67667974448284343
古典的な間違いは、128ビット全部を1つの10進数に変換してしまうことだ。
BigInt('0x' + traceId).toString(10);
// 100985939111033328018442752961257817910 — UI上のどのトレースにも一致しない
この値は計算が間違っているのではない。間違った量を正しく10進表記したものであり、だからこそレビューを通過し、そのあと静かに0件のトレースにしか一致しなくなる。
2つめの罠は数値の精度だ。64ビットの識別子は Number.MAX_SAFE_INTEGER、すなわち 9007199254740991 を超えるので、トレースIDがJavaScriptの数値になる経路が1つでもあれば下の桁が壊れる。トレースIDは文字列のまま持ち、算術が本当に必要になったときだけ BigInt を使う。JSONの中でクォートされずに届いたIDは、目にした時点ですでに壊れている。
AWS X-Ray:そこには無いはずのタイムスタンプ
X-RayのトレースIDは 1-{8 hex}-{24 hex} という形をしていて、先頭8桁の16進数が作成時刻をエポック秒で表す。W3Cからの変換は機械的だ。
const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
const epochHex = traceId.slice(0, 8); // 4bf92f35
const epochSeconds = parseInt(epochHex, 16); // 1274621749
const xrayId = `1-${epochHex}-${traceId.slice(8)}`;
// 1-4bf92f35-77b34da6a3ce929d0e0e4736
// X-Amzn-Trace-Id: Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1
new Date(epochSeconds * 1000).toISOString(); // 2010-05-23T13:35:49.000Z
この日付を見てほしい。仕様書の例に載っているヘッダーをデコードすると2010年5月になる。明らかに意味をなさないが、それこそが要点だ。W3Cの trace-id にタイムスタンプは入っていない。 ランダムな16バイトでも、先頭4バイトを1つの数値として読めば、それらしく見えるエポック値をいくらでも作り出す。その識別子が本当にX-Ray由来でない限り、その数字には何の意味もない。任意の trace-id から時刻を復元するのは、乱数を読んで信じ込むのと同じである。
IDが本当にX-Ray由来なら、この変換は役に立つ。先頭8桁の16進数を Unixタイムスタンプ変換 に放り込めば読める日付になるし、エポック秒の完全ガイド には、そのあと必ず引っかかる秒とミリ秒の取り違えやタイムゾーンの罠がまとめてある。
B3:Zipkinの系譜
B3はZipkin由来で、古いサービスメッシュでよく出会うフォーマットだ。単一ヘッダー形式は traceId-spanId-sampled で、sampled のフィールドは16進バイトではなく 1 か 0 である。そのため Level 2 の random-trace-id ビットには行き場がなく、変換の途中で単純に失われる。複数ヘッダー形式は同じ値を X-B3-TraceId、X-B3-SpanId、X-B3-Sampled に分けて載せる。
歴史的なねじれは幅にある。B3のトレースIDは64ビットのことがあり、その場合は32桁ではなく16桁になる。64ビットのB3 IDをW3Cへ変換するときは左をゼロで詰めて32桁にし、逆方向へ戻すときは切り詰めるかどうかを決めることになる。左のゼロ詰めは安全だが、切り詰めは安全ではない。上位バイトだけが違う2本のトレースが、1本に潰れてしまうからだ。
本番で traceparent が消える場所
ここまでの話は、ヘッダーが届くことを前提にしていた。届かないことは珍しくない。ヘッダーが行方不明になる場所は4つある。
ブラウザがクロスオリジンの呼び出しで落とす
症状: フロントエンドのトレースもバックエンドのトレースも存在するのに、両者がまったくつながらない。あるいはクロスオリジンのリクエストがCORSエラーで丸ごと失敗する。
原因: traceparent はカスタムヘッダーなので、付けた時点でリクエストが単純リクエストではなくなり、プリフライトの OPTIONS が飛ぶ。サーバーのプリフライト応答が Access-Control-Allow-Headers にこのヘッダーを挙げていなければ、ブラウザは本来のリクエストをブロックする。これとは別に、OpenTelemetryのブラウザ計装は、どのオリジンを許すか指定しない限り、クロスオリジンのリクエストにトレースヘッダーを注入しない。
対処: サーバー側では、プリフライトに対して Access-Control-Allow-Headers: traceparent, tracestate を返す。ブラウザSDK側では、propagateTraceHeaderCorsUrls に自分のAPIオリジンと一致するパターンを設定する。両方が必要で、片方だけでは症状は変わらない。プリフライトが想定外のステータスで返ってきたなら、ヘッダーのせいと決めつける前に HTTPステータスコード完全早見表 と突き合わせてみる価値がある。
プロキシ・WAF・ロードバランサーが未知のヘッダーを削る
症状: サービスへ直接 curl するとヘッダーは付いているのに、同じリクエストをゲートウェイ経由にすると消える。
原因: 許可リスト方式の転送だ。プロキシの設定、WAFのルールセット、マネージドなロードバランサーの多くは、自分が知っているヘッダーだけを転送する。traceparent は既定のリストに入っていない。さらに一部のサービスメッシュはヘッダーを 書き換え、自前の trace-id を生成してこちらの値を捨てる。
対処: 先ほどのエコーサーバーで二分探索する。各ホップの後ろに順番に置いて、どの層でヘッダーが落ちるかを見る。層が特定できたら、その層の転送ルールで traceparent と tracestate を明示的に許可する。プロキシがnginxなら、どのブロックがそのルートを処理するかによって通すヘッダーが決まること、そして担当ブロックは必ずしも思っているブロックではないことに注意したい。Nginx locationの優先順位 の規則を読めば、ヘッダーの設定がまるごと無視されているように見える理由が分かる。
メッセージキューにHTTPヘッダーは存在しない
症状: リクエストがバックグラウンドジョブに変わった瞬間、トレースが終わる。
原因: その境界にはHTTPリクエストが存在しないので、ヘッダーを載せる乗り物がない。Kafkaにはレコードヘッダーが、SQSにはメッセージ属性があるが、どちらもHTTPの計装が勝手に埋めてくれるものではない。
対処: プロデューサー側でコンテキストをメッセージに注入し、コンシューマー側で取り出す。OpenTelemetryのSDKはどれもこの用途に inject と extract を用意しており、ワイヤーフォーマットは同じW3Cの文字列のままだ。変わるのは、キャリアがHTTPヘッダーのマップからメッセージのメタデータになる点だけである。言語ごとのキャリアインターフェースは OpenTelemetryのコンテキスト伝播ドキュメント にまとまっている。
大文字小文字と、HTTP/2が実際に小文字化するもの
症状: Traceparent という表記でよいのかどうかで、コードレビューが紛糾する。
原因: 別々の規則が1つに混ざっている。HTTP/1.1ではヘッダーの 名前 が大文字小文字を区別せず、HTTP/2はその名前をワイヤー上で小文字にエンコードすることを求める。ここまではすべて名前の話だ。それとは独立に、ヘッダーの 値 に含まれる16進数は小文字でなければならない。W3Cの文法がそう定めているからで、どのプロトコルバージョンもこれを肩代わりしてはくれない。
対処: 名前は traceparent で送り、値は決して大文字にしない。ヘッダー名を正規化するゲートウェイも、16進の桁までは正規化しない。大文字のトレースIDはあらゆるトランスポート層を素通りしたあと、最後にパースするアプリケーションで初めて拒否される。
受信した traceparent を信用してよいか
公開インターネットから届く traceparent は、ユーザーが自由に操作できる入力である。匿名のクライアントが選んだ文字列でしかないのに、大半のサービスはそれを何も考えずに受け入れている。
具体的なリスクは3つ。1つめは トレースの接ぎ木(trace splicing)。どこかで観測した trace-id を送りつけてきた攻撃者は、自分のリクエストを既存のトレースへ縫い込める。グラフが汚れるだけでなく、そのトレースを読める相手に内部の処理時間が漏れることもある。2つめは クォータの焼き切り。01 を決め打ちで送られるとすべてのリクエストがサンプリング対象になり、ちょっとした洪水が巨額のインジェスト料金に化ける。もっと悪ければ、本当に必要だったトレースが押し出されて消える。3つめは テナントをまたいだ相関。異なるテナントのリクエストで同じ trace-id が使い回されると、無関係なレコードがツール上では1つの論理的な操作として扱われてしまう。
現実的な構えは、エッジで受け取りはするが信用はしない、というものだ。文法を検証し、不正なヘッダーは内側へ流さずに拒否する。認証されていないトラフィックについては、受信したフラグをそのまま尊重するのではなく、自分のサンプリング判断をやり直す。そうすれば外部のクライアントが自分のサンプラーを「常に記録」に固定できなくなる。認証済みのトラフィックなら相手が誰か分かっているので、呼び出し元の判断を尊重してたいてい問題ない。
trace-id そのものは公開情報として扱う。秘密ではないし、最初からそうだった。ログにも、エラーページにも、レスポンスヘッダーにも、サポート問い合わせに貼られたスクリーンショットにも出てくる。ユーザーIDやテナント名、その他意味のある値を埋め込んではいけないし、認可の鍵として使ってもいけない。あくまで相関のための識別子であり、それ以上の役割を持たせるべきではない。
FAQ
traceparent と tracestate の違いは何か
traceparent は標準化された同一性、つまり trace-id、parent-id、サンプリングのフラグを運ぶ。すべての実装がこれを理解しなければならない。tracestate はベンダー固有の状態を運び、見知らぬ実装はそれをそのまま転送する。両者は連動していて、traceparent が無効なときは tracestate も無視するよう仕様が要求している。
呼び出し連鎖の途中でトレースが始まり直してしまうのはなぜか
トレースが途中で始まり直すのは、ほぼ確実に、どこかのホップが文法に反したヘッダーを受け取り、それを破棄して新しい trace-id を生成したからだ。大文字の16進数、オールゼロの trace-id、ちょうど55文字でないヘッダーは、いずれもこれを引き起こす。ヘッダーが整形式なら、次に疑うのはプロキシによる削除と、クロスオリジンのプリフライト失敗である。
ブラウザから traceparent を送るにはCORSの設定が必要か
CORSの設定は必要だ。traceparent はカスタムヘッダーなのでリクエストが単純リクエストでなくなり、プリフライトが飛ぶ。サーバーは Access-Control-Allow-Headers に traceparent を挙げなければならない。加えてOpenTelemetryのブラウザ計装には propagateTraceHeaderCorsUrls の設定が要る。既定ではクロスオリジンにトレースヘッダーを注入しないからだ。
KafkaやSQSを越えてトレースコンテキストを伝播するにはどうするか
プロデューサー側で traceparent の値をKafkaのレコードヘッダーかSQSのメッセージ属性へ書き込み、コンシューマー側で読み戻してコンテキストを復元する。OpenTelemetryのSDKはどの言語でもこのために inject と extract を公開している。フォーマットは変わらず、HTTPヘッダーのマップだったキャリアが変わるだけだ。
トレースIDはログやレスポンスに出しても安全か
トレースIDを出しても安全だ。身元情報が埋め込まれておらず、認可の力も持たない乱数の識別子である。ただしシステムをまたいでレコードを相関させる力はあるので、ユーザーIDやテナント名を埋め込んではいけないし、何かの証明として受け取ってもいけない。公開された相関キーとして扱う限り、ログにもレスポンスにも出してかまわない。
traceparentヘッダーは誰が生成するのか
traceparentヘッダーを生成するのは、それが付いていないリクエストを最初に処理したサービスだ。多くの場合はエッジプロキシかAPIゲートウェイ、あるいはブラウザのSDKで、そのサービスがトレースのルートになる。trace-id を生成し、最初のスパンを作り、サンプリングを判断する。以降のホップがやるのは parent-id の書き換えだけである。
traceparentヘッダーは必須なのか
traceparentヘッダーは必須ではない。プロトコルの水準では任意であり、付いていないリクエストも完全に有効だ。受け取ったサービスが新しいトレースのルートになるだけである。必須なのは実務上の意味においてだけで、これが無ければサービス境界をまたいだ処理を1本のトレースへ相関させられない。
traceparent は測れるほどのオーバーヘッドになるか
意味のある差は出ない。traceparent は55バイトで、tracestate が付いてもせいぜい数百バイト増える程度だ。TLSハンドシェイクや実際のペイロードに比べれば無視できる。トレーシングでコストがかかるのは、分散トレーシングのヘッダーをワイヤーに載せる側ではなく、サンプリングされたスパンをエクスポートして保存する側だ。