「分散APIを追跡できるTrace Context設計|traceparent・tracestate・相関ID」の内容を表す技術イラスト
UNIT 00LAB NOTES FIG.00 · EYECATCH

分散APIを追跡できるTrace Context設計|traceparent・tracestate・相関ID

W3C Trace Contextで分散APIを追跡する方法を解説。traceparentの構造、相関ID・冪等性キーとの違い、HTTP・キュー伝播、Python検証、ログ・DB設計、セキュリティを実例で整理します。

DOC NO.EKP-00-1094
DATE
REVB
READ約 13 分

API連携が一つのサービスだけで完結するうちは、エラーログの時刻やURLから原因を追えることがあります。しかし、入口API、認証、DB、キュー、worker、外部サービスへ処理がまたがると、個別のログだけでは「同じ要求から生じた処理」を結び付けられません。

W3C Trace Contextは、分散した処理へ共通の追跡情報を渡すための標準です。中心となるtraceparentを正しく検証・更新・伝播すれば、異なる製品や言語をまたいでも一つの処理経路を関連付けられます。本記事では、相関IDや冪等性キーとの役割の違い、HTTPと非同期メッセージへの載せ方、Pythonによる最小実装まで整理します。

目次

Trace Contextで解決すること

分散システムでは、一つの操作が複数のrequestやjobを生みます。たとえば図面変換APIが、認可確認、属性DB参照、変換job登録、worker処理、結果通知を順に呼ぶ構成です。

各サービスが独自のrequest IDだけを発行すると、局所的なログは検索できても、境界を越えた全体像が途切れます。Trace Contextでは、同じ一連の処理にtrace-idを引き継ぎ、各処理単位には別のparent-idを設定します。

client
  └─ API受付                 trace-id=T / span-id=A
       ├─ DB参照             trace-id=T / span-id=B
       └─ job登録            trace-id=T / span-id=C
            └─ worker実行    trace-id=T / span-id=D

trace-idが同じなので全体を検索でき、span単位のIDが異なるので、どの処理から次の処理が派生したかも表現できます。

traceparentのデータ構造

W3C Trace Contextのtraceparentは、ハイフンで区切った4つのfieldを持ちます。

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
field長さ意味
version2桁形式の版。現行仕様は00を前提とする
trace-id32桁trace全体を識別する16 byteの値
parent-id16桁呼び出し元が認識する処理単位の8 byteの値
trace-flags2桁記録判断などを表すbit field

各値は小文字16進数で表します。trace-idとparent-idは全桁0を許可しません。version 00では全体が55文字になり、trace-flagsの最下位bitがsampledです。01なら呼び出し元がtrace dataを記録した可能性を示しますが、受信側への強制命令ではありません。

重要なのは、下流requestを送るときにtrace-idを維持し、parent-idを現在の処理を表す新しい値へ更新することです。同じparent-idを処理経路全体で使い回すと、親子関係を復元できません。

tracestateは製品固有情報を補う

tracestateは、追跡製品や事業者に固有の情報をkey/valueのlistとして伝える任意headerです。

tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE

traceparentが製品共通の契約、tracestateが製品固有の補助情報という関係です。listは最大32 memberという文法で、同じkeyを重複させません。更新したentryは左側へ移し、受信した他社entryの順序を保ちます。

独自情報が不要なら、無理にtracestateを作る必要はありません。特に、利用者ID、メールアドレス、API key、token、業務データを埋め込んではいけません。W3C仕様も、traceparentとtracestateを個人情報や機密情報の運搬に使わないよう定めています。

相関ID・request ID・冪等性キーを分ける

似たIDを一つに統合すると、再送や非同期処理で意味が崩れます。

識別子対象再送時の基本主用途
trace-id一連の分散処理新しい実行なら通常は新規観測・障害調査
span ID一つの処理単位処理単位ごとに新規親子関係と所要時間
request ID一つのHTTP受付受付ごとに新規応答とサーバーログの照合
operation ID長時間jobなど業務処理同じjobでは維持状態照会と業務追跡
idempotency key同じ命令clientの再送で維持二重実行の防止

Trace Contextは観測のための相関情報であり、重複排除の契約ではありません。同じidempotency keyのrequestが再送されたとき、既存結果を返しつつ、再送という新しい通信を別traceとして記録する設計も可能です。

入力から出力までの伝播契約

受信側の処理は、次の順序に固定します。

  • inboundのtraceparentを文字列として取得する
  • version、桁数、小文字16進数、全桁0を検証する
  • 有効ならtrace-idとsampling判断を引き継ぐ
  • 無効または未指定なら、新しいtraceを開始する
  • 現在処理用のspan IDを生成し、構造化logへ記録する
  • 下流requestでは、同じtrace-idと新しいparent-idを送る
  • tracestateはtraceparentと一体で検証・伝播する

無効headerを理由に業務request全体を400へする必要は通常ありません。W3Cの処理modelは、解析不能または無効なtraceparentでは新しい値を作り、関連するtracestateを破棄する流れを示しています。外部から来た追跡情報を信用済みデータとして扱わないことが重要です。

Pythonでtraceparentを検証・生成する

次の例は、version 00だけを扱う小さな実装です。乱数にはPython標準libraryのsecrets.token_hex()を使います。

from dataclasses import dataclass
import re
import secrets


TRACEPARENT_V00 = re.compile(
    r"^00-(?P<trace_id>[0-9a-f]{32})-"
    r"(?P<parent_id>[0-9a-f]{16})-"
    r"(?P<trace_flags>[0-9a-f]{2})$"
)


@dataclass(frozen=True)
class TraceContext:
    trace_id: str
    parent_id: str
    trace_flags: int

    @property
    def sampled(self) -> bool:
        return (self.trace_flags & 0x01) == 0x01


def parse_traceparent(value: str) -> TraceContext:
    match = TRACEPARENT_V00.fullmatch(value)
    if match is None:
        raise ValueError("version 00のtraceparent形式ではありません")

    trace_id = match.group("trace_id")
    parent_id = match.group("parent_id")
    if trace_id == "0" * 32:
        raise ValueError("trace-idは全桁0にできません")
    if parent_id == "0" * 16:
        raise ValueError("parent-idは全桁0にできません")

    return TraceContext(
        trace_id=trace_id,
        parent_id=parent_id,
        trace_flags=int(match.group("trace_flags"), 16),
    )


def start_trace(*, sampled: bool = False) -> str:
    trace_id = secrets.token_hex(16)
    parent_id = secrets.token_hex(8)
    flags = "01" if sampled else "00"
    return f"00-{trace_id}-{parent_id}-{flags}"


def make_outbound_traceparent(inbound: str) -> str:
    context = parse_traceparent(inbound)
    new_parent_id = secrets.token_hex(8)
    return (
        f"00-{context.trace_id}-{new_parent_id}-"
        f"{context.trace_flags:02x}"
    )

実運用では、対応していない将来versionを単純にversion 00の不正値と同一視するのではなく、利用するtracing libraryのversion処理へ任せる方が安全です。この例は自作契約の最小範囲を明確にするため、version 00以外を受理しません。

正常系だけでなく、次をtestします。

  • 32桁のtrace-idと16桁のparent-idを受理する
  • 大文字、桁不足、余分な区切りを拒否する
  • 全桁0のIDを拒否する
  • trace-flagsが09でもbit maskによりsampledを真と判定する
  • 下流用headerでtrace-idを維持し、parent-idだけを更新する
  • 不正入力では新しいtraceへ切り替え、tracestateを破棄する

HTTP以外の非同期処理へ渡す

queueやeventではHTTP headerがないため、message属性として保持する契約を決めます。業務payloadへ無制限に混ぜず、追跡用metadataを分離すると再利用しやすくなります。

{
  "message_id": "msg_01K7X9R2",
  "event_type": "drawing.convert.requested",
  "trace_context": {
    "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
    "tracestate": null
  },
  "data": {
    "job_id": "job_01842",
    "drawing_id": "dwg_00319"
  }
}

このobjectでは、message_idが配信message、job_idが業務処理、trace_contextが観測情報を識別します。consumerは受信したtraceparentを親として新しいspanを開始します。一方、重複配信の排除にはmessage_idや業務上の一意制約を使い、trace-idでは判定しません。

messageを再処理するときも、目的を明確にします。同じ配信のretryを同じtraceへ連結するか、再実行を新しいtraceとして元のjob IDで関連付けるかは運用方針です。どちらの場合も、冪等性の保証はDB制約や処理済みmessage tableで別に実装します。

構造化ログとDBへ保存する

文字列messageへIDを埋め込むだけでは検索が不安定です。logのfieldとして分離します。

{
  "timestamp": "2026-10-17T02:15:43.281Z",
  "level": "ERROR",
  "service": "drawing-worker",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "3a12f98d75c8410e",
  "job_id": "job_01842",
  "event": "conversion_failed",
  "error_code": "SOURCE_FORMAT_UNSUPPORTED"
}

検索・監査用DBには、業務recordとtraceを同一主キーにせず、必要な期間だけ関連情報を保存します。

CREATE TABLE job_runs (
    run_id       TEXT PRIMARY KEY NOT NULL,
    job_id       TEXT NOT NULL,
    trace_id     TEXT NOT NULL CHECK (length(trace_id) = 32),
    status       TEXT NOT NULL
        CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
    started_at   TEXT NOT NULL,
    finished_at  TEXT
);

CREATE INDEX job_runs_trace_id_idx ON job_runs(trace_id);

trace_idは外部入力になり得るため、存在するだけで閲覧権限を与えてはいけません。trace検索APIにも通常の認証・認可を適用し、保存期間と閲覧範囲を決めます。

失敗しやすい設計

trace-idを業務IDにする

traceは実行ごとに変わり得ます。図面、注文、jobなど長期間存続する対象には安定した業務IDを持たせ、trace IDは実行履歴から参照します。

全サービスで同じparent-idを転送する

同じ値をそのまま転送するだけでは、どの処理がどの下流処理を呼んだかを表せません。参加するserviceは現在の処理を表す新しいparent IDを下流へ設定します。

sampledを保存保証と解釈する

sampled flagは呼び出し元の記録判断を伝える信号です。障害時に必ずtraceが残る保証ではありません。重要な業務監査は、trace systemとは別の監査logやDBで保持します。

外部headerをそのまま内部へ通す

攻撃者は任意のtrace IDやsampling flagを送れます。桁数と文字種を検証し、信頼境界ではtraceの再開始、rate limit、未知のtracestate削除を検討します。追跡情報は認証情報ではありません。

CAD・DB・API連携へ再利用する

CAD変換、BOM展開、属性同期のような処理は、API受付後に複数のDB query、file取得、worker、通知へ分かれます。各systemで共通のTrace Contextを伝播し、業務IDを構造化logへ併記すると、「どの図面の処理か」と「どの実行経路で失敗したか」を両方追えます。

再利用しやすい契約は次の分離を守ります。

  • 業務data:図面ID、revision、job ID、変換条件
  • 配信data:message ID、attempt、発生日時
  • 観測data:traceparent、tracestate、service名
  • 制御data:idempotency key、schema version
  • 秘密情報:認証tokenや接続credential。payloadやtrace情報へ入れない

同じfield名と型をAPI、queue、logでそろえれば、障害調査だけでなく、処理時間分析、遅い依存先の特定、再処理結果の比較にも使えます。

まとめ

Trace Contextは、分散したAPIや非同期処理を一つの実行経路として関連付けるためのデータ契約です。

  • traceparentはversion、trace-id、parent-id、trace-flagsで構成する
  • 同じtraceではtrace IDを維持し、処理単位ごとにparent IDを更新する
  • 無効なheaderは信用せず、新しいtraceを開始する
  • tracestateは任意の製品固有情報であり、個人情報や秘密情報を入れない
  • trace ID、request ID、業務ID、idempotency keyの役割を分ける
  • queueでは追跡metadataを業務payloadから分離する
  • 重複排除、認証、監査保存はTrace Contextとは別に設計する

境界ごとの検証と伝播規則を固定すれば、HTTP、queue、DB、Python、CAD処理をまたぐ障害でも、推測ではなく同じtraceを根拠に調査できるようになります。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次