Claude Media
Claude Codeにトレースの遅いスパンを探させる — Jaeger・TempoのJSON集計手順

Claude Codeにトレースの遅いスパンを探させる — Jaeger・TempoのJSON集計手順

JaegerやTempoから出したトレースJSONを、貼らずに集計スクリプトで要約して渡す手順です。自己時間の大きいスパンと、直列に並ぶ同名の呼び出しを見つけ、並列化の修正まで進めます。

自社アプリのトレースから遅いスパンを探すとき、JSONをそのままClaude Codeに読ませるのは遠回りです。数千スパンのトレースは大きすぎて、入力として通らないか、通っても肝心の時間計算が雑になります。

近道は2段に分けることです。集計はスクリプトにやらせ、Claudeには集計結果の解釈と、コードの修正を任せます。この記事では、JaegerとTempoのどちらから出したJSONでも動く集計スクリプトを軸に、直列に並んだ呼び出しを並列化する修正までを通します。

なお、Claude Code自身のテレメトリをOpenTelemetryで出す話はClaude CodeのOpenTelemetry設定の記事が扱っています。ここで解析するのは、自分たちのアプリが出したトレースです。

トレースJSONを貼ると何が起きるか

トレースJSONは、1スパンあたり属性やリソース情報を含むので、すぐ数百KBから数MBになります。Claude Codeには入力経路ごとに上限があります。

  • @ で参照したファイルは、256KBを超えると内容が会話に入りません
  • claude -p に標準入力で渡せるのは10MBまでで、超えるとエラー終了します
  • MCPツールの出力は、既定で25,000トークンが上限です。テキスト結果が50,000文字を超えると、会話には入らずファイルに保存され、パスだけが返ります

数字は上限の目安として覚えておけば十分です。肝心なのは、上限の内側に収まっても、Claudeにスパン数千件の開始時刻と所要時間を暗算させるのは向かないことです。引き算と区間の重なり判定は、スクリプトのほうが確実に正しい答えを返します。

だから分担を決めます。

作業担当
親子関係の復元、自己時間、区間の重なり判定担当スクリプト
集計結果の読み解き、原因の仮説担当Claude
該当コードを探して並列化する修正案担当Claude
修正後のトレース再取得と比較担当人が取得、集計はスクリプト

大きなログをclaude -pに渡す場合の絞り方は、claude -pにログを渡すと10MB上限で止まる場合の記事にまとめています。

JaegerとTempoからJSONを取り出す

どちらもトレースIDを指定すればHTTPでJSONが取れます。

# Jaeger: UIが使う内部JSON API
curl -s "http://localhost:16686/api/traces/<traceID>" > trace.json
 
# Tempo: トレースID検索(OpenTelemetry JSON)
curl -s "http://localhost:3200/api/traces/<traceID>" > trace.json

注意点が3つあります。

1つ目は、Jaegerの /api/traces/{trace-id} が、公式ドキュメントで「意図的に未文書化で、変更されうる」と位置づけられている内部APIだという点です。安定版として案内されているのは、gRPCの QueryService と、それをJSON/HTTPにした /api/v3/* で、後者はOTLPベースのJSONです。自動化に組み込むなら /api/v3/* のほうが長持ちします。手元で一度試すだけなら、UIの画面からトレースをJSONでダウンロードしても構いません。

2つ目は、Tempoの /api/traces/<traceID> が「ほぼ互換のOpenTelemetry JSON」を返す点です。Accept: application/protobuf を付けるとprotoで返ります。start と end(UNIXエポック秒)を付けると、その時間範囲のブロックだけを探すので、範囲を外すとトレースが見つからなかったり、一部しか返らなかったりします。長いトレースを取るときは、範囲を広めに取ります。

3つ目は、Tempoのv2エンドポイント(/api/v2/traces/<traceID>)の Accept: application/vnd.grafana.llm です。LLM向けに簡略化したJSONを返しますが、公式に「形式は変わりうるので、プログラムからの利用には頼らない」と書かれています。今回のようにスクリプトで集計する用途には向かないので、素のOpenTelemetry JSONを使います。

遅いトレースのIDを先に絞る

トレースIDが分からないときは、Tempoなら検索APIで遅いものを絞れます。公式の例では、tags=service.name=cartservice に minDuration=600ms を組み合わせています。

curl -G -s http://localhost:3200/api/search \
  --data-urlencode 'tags=service.name=checkout' \
  --data-urlencode minDuration=600ms | jq

検索は時間範囲の中で並列に走り、先頭のN件(既定は20件)を返す仕組みです。同じ条件でも結果は毎回少し変わります。start と end を両方付けるとバックエンドのブロックまで検索対象になり、付けないと直近のデータだけを探します。古いトレースを探すときは範囲を付けます。

集計スクリプトで見る2つの指標

遅いスパンといっても、見るべき数字は2種類あります。

  • 自己時間: スパンの所要時間から、子スパンがカバーしている区間の和集合を引いた値です。親が長く見えても、中身が子の待ち時間だけなら自己時間は小さくなります。自分自身で時間を食っているスパンを探すのに使います
  • 直列に並ぶ同名の子: 同じ親の下に、同じ名前のスパンが時間的に重ならず連続している状態です。ループ内で1件ずつAPIやDBを呼ぶ実装がトレース上でこう見えます。並列化できれば、合計時間が最長の1件分まで縮む候補です

形式の違いは、スクリプト側で吸収します。OTLP JSON(Tempo、Jaegerの/api/v3)は spanId / parentSpanId / startTimeUnixNano / endTimeUnixNano を持ちます。IDは16進文字列で、64ビット整数の時刻は10進の文字列です。Jaeger UI形式は spanID と CHILD_OF の参照、startTime と duration を使います。

#!/usr/bin/env python3
# trace_slow.py: Jaeger UI JSON / OTLP JSON から遅いスパンを集計する
import json, sys
from collections import defaultdict
 
def load_spans(path):
    doc = json.load(open(path))
    spans = []
    def walk(o):
        if isinstance(o, dict):
            if "spanId" in o and "startTimeUnixNano" in o:      # OTLP JSON
                spans.append(dict(
                    id=o["spanId"], parent=o.get("parentSpanId") or None,
                    name=o.get("name", "?"),
                    start=int(o["startTimeUnixNano"]) / 1e3,    # 単位はマイクロ秒に統一
                    dur=(int(o["endTimeUnixNano"]) - int(o["startTimeUnixNano"])) / 1e3))
            elif "spanID" in o and "startTime" in o:            # Jaeger UI JSON
                refs = [r for r in o.get("references", []) if r.get("refType") == "CHILD_OF"]
                spans.append(dict(
                    id=o["spanID"], parent=refs[0]["spanID"] if refs else None,
                    name=o.get("operationName", "?"),
                    start=float(o["startTime"]), dur=float(o["duration"])))
            else:
                for v in o.values(): walk(v)
        elif isinstance(o, list):
            for v in o: walk(v)
    walk(doc)
    return spans
 
def main(path, top=5):
    spans = load_spans(path)
    by_id = {s["id"]: s for s in spans}
    kids = defaultdict(list)
    for s in spans:
        if s["parent"] in by_id: kids[s["parent"]].append(s)
    for s in spans:  # 自己時間 = 所要 - 子の占有区間の和集合
        covered, cur_end = 0.0, None
        for a, b in sorted((c["start"], c["start"] + c["dur"]) for c in kids[s["id"]]):
            if cur_end is None or a > cur_end: covered += b - a; cur_end = b
            elif b > cur_end: covered += b - cur_end; cur_end = b
        s["self"] = max(s["dur"] - covered, 0)
    print(f"spans={len(spans)}")
    print("== 自己時間 上位 ==")
    for s in sorted(spans, key=lambda s: -s["self"])[:top]:
        print(f'{s["self"]/1e3:8.1f} ms self / {s["dur"]/1e3:8.1f} ms total  {s["name"]}')
    print("== 直列に並ぶ同名の子 ==")
    for pid, cs in kids.items():
        groups = defaultdict(list)
        for c in cs: groups[c["name"]].append(c)
        for name, g in groups.items():
            g.sort(key=lambda c: c["start"])
            if len(g) >= 2 and all(g[i]["start"] + g[i]["dur"] <= g[i+1]["start"] + 1
                                   for i in range(len(g) - 1)):
                total = sum(c["dur"] for c in g); longest = max(c["dur"] for c in g)
                print(f'{len(g)}回 直列  親={by_id[pid]["name"]}  {name}  '
                      f'合計{total/1e3:.1f}ms -> 並列なら約{longest/1e3:.1f}ms')
 
if __name__ == "__main__":
    main(sys.argv[1])

このスクリプトはClaudeに書かせても構いません。その場合は、上の2指標と「単位をマイクロ秒に統一する」「ID・名前・時間だけ出力し、属性は出さない」を指示に入れます。属性にはSQL文やURLが入りがちなので、要約に載せないほうが安全です。

Jaeger UI形式の時刻の単位は、公式に仕様が書かれていません。スクリプトはマイクロ秒前提で書いてあるので、最初に自分のJSONで startTime と duration の桁を確かめてから使います。

動作の確認例

手作業で作った8スパンのOTLP JSONで動かした結果です(開始時刻と所要時間は、この検証のために自分で決めた値で、実サービスの計測ではありません)。POST /checkout の下に、GET inventory を150msずつ4回、重ならない形で並べました。

python3 trace_slow.py sample-otlp.json
spans=8
== 自己時間 上位 ==
   150.0 ms self /    150.0 ms total  GET inventory
   150.0 ms self /    150.0 ms total  GET inventory
   150.0 ms self /    150.0 ms total  GET inventory
   150.0 ms self /    150.0 ms total  GET inventory
   130.0 ms self /    900.0 ms total  POST /checkout
== 直列に並ぶ同名の子 ==
4回 直列  親=POST /checkout  GET inventory  合計600.0ms -> 並列なら約150.0ms

自己時間の表だけを見ると、GET inventory が4行並ぶだけです。1回150msという数字は、単独ではそれほど遅くありません。直列の検出が、600msが150msに縮む余地を示します。2指標を併用するのはこのためです。

Jaeger UI形式で同様に作った5スパンのJSON(SELECT users を90msずつ4回直列)でも、同じ形式で「4回 直列」が出ることを確認しています。

集計結果をClaudeに渡して並列化まで進める

集計の出力は数十行です。@ で貼るか、claude -p にパイプで渡します。

python3 trace_slow.py trace.json | claude -p \
  "直列に並ぶ呼び出しの該当コードをこのリポジトリから探し、\
並列化できるか判断して。依存関係があれば理由も書いて"

claude -p は標準入力を読めるので、パイプでデータを流し込む使い方が公式にも紹介されています。非対話実行の権限や--bareの扱いはclaude -pオプションの記事に詳しく載っています。対話セッションで進めるなら、同じ文面をそのまま貼れば足ります。

トレースのスパン名から、コードの位置を探す段階は、Claudeの得意分野です。GET inventory のようなスパン名は、HTTPクライアントの呼び出し箇所やスパン生成コードの文字列から逆引きできます。スパン名が HTTP GET のように汎用だと探せないので、その場合は属性にある呼び出し先URLのパターンも指示に添えます。

例えば、ループ内で1件ずつ呼ぶPythonのコードがあったとします。直したあとの形は、次のようになります(Claudeの出力の再現ではなく、直し方の例です)。

# 修正前: 在庫確認を1件ずつ待つ
stocks = [await client.get_stock(sku) for sku in skus]
 
# 修正後: 同時に発行して、まとめて待つ
stocks = await asyncio.gather(*(client.get_stock(sku) for sku in skus))

JavaScriptなら Promise.all が同じ役割です。並列化の修正は小さな差分で済みますが、効果が出るかは依存先の都合に左右されます。

落とし穴

直列に見えても、並列にしてはいけない呼び出しがある

直列の検出はあくまで候補です。前の結果を次の入力に使っている、同じトランザクションの中で順序が意味を持つ、呼び出し先にレート制限がある、といった呼び出しは並列にできません。DB接続プールが小さいと、同時発行しても待ち行列が伸びるだけで縮みません。Claudeには「並列化できない理由があればその行を挙げて」と必ず求めておきます。

指示文を毎回書くのが面倒なら、リポジトリの CLAUDE.md に定型を置く方法があります。

## トレース調査
- トレースJSONはそのまま読まず、`scripts/trace_slow.py` の出力を使う
- 並列化の提案には、依存関係・レート制限・接続プール上限の確認結果を添える
- 修正後は同じ操作のトレースを取り直し、合計時間の変化を報告する

直ったかは、同じ操作のトレースで確かめる

修正して終わりにせず、同じ操作でトレースを取り直します。同じスクリプトに通して、次の3点を見ます。

  • 「直列に並ぶ同名の子」の行が消えたか、回数が減ったか
  • 親スパンの所要時間が、スクリプトが示した「並列なら約○○ms」に近づいたか
  • 別のスパンの自己時間が急に増えていないか(同時発行で、呼び出し先が詰まった可能性)

1回の比較では、ネットワークや負荷のゆらぎを区別できません。何度か取り直して、傾向で見ます。

MCPでトレースを直接読ませる場合

TempoにはMCPサーバーのエンドポイント(/api/mcp)がありますが、公式に「常に使えるとは限らない」と注記されています。MCP経由にすると、会話から直接トレースを検索できますが、1回の応答が大きいと先ほどの出力上限にかかります。トレース基盤をMCPやプラグインでつなぐ例は、Langfuse MCPの記事とDash0プラグインの記事にあります。

今回のように、手元に出したJSONを集計して渡す方法は、MCPの設定が要らず、集計ロジックを自分で検証できる点が強みです。一方で、トレースIDを毎回自分で選ぶ手間は残ります。

まとめ

遅いスパン探しでは、貼る量を減らして計算を任せるのが筋です。自己時間と直列の同名スパンという2つの指標をスクリプトに集計させ、Claudeにはコードの特定と並列化の修正を任せます。直列は候補にすぎないので、依存関係の確認をセットで求め、修正後は同じ操作のトレースで確かめます。

自社アプリ自身がまだOpenTelemetryを出していない場合は、先にSDK側の計装が要ります。AnthropicのSDKを例にした手順はAnthropic SDKのOpenTelemetryトレースの記事にあります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/headless
  2. code.claude.com/docs/en/mcp
  3. jaegertracing.io/docs/latest/apis
  4. grafana.com/docs/tempo/latest/api_docs
  5. opentelemetry.io/docs/specs/otlp
この記事を共有:XはてブLinkedIn