Claude Codeでシーケンス図をMermaidで起こす — 実行トレースとコードの食い違い検出
テスト実行時のOpenTelemetryトレースを小さなスクリプトでMermaidのsequenceDiagramに変換し、Claude Codeにコードと突き合わせて食い違いを指摘させる手順です。テスト直後に図を作るhookも載せます。
設計書のシーケンス図は、コードが変わるたびに実態から離れていきます。テストを1回走らせたときのOpenTelemetryトレースから図を起こせば、実際に起きた呼び出しの順序がそのまま手に入ります。その図を設計意図やコードと突き合わせる作業に、Claude Codeが向いています。
この記事では、OTLP形式のトレースをMermaidのsequenceDiagramに変換するスクリプトを用意し、Claude Codeにコードとの食い違いを指摘させる流れを示します。テストが終わるたびに図を作るhookと、図を作る段階で踏みやすいつまずきも扱います。
トレース起点の図は、設計書の図と何が違うのか
コードや設計書から描いた図は、作者が「こう動くはず」と考えた構造です。トレースから起こした図は、あるテスト1回で実際に通った経路です。両者のずれが、調べる価値のある箇所になります。
ただし、トレースから起こした図が全体像になるわけではありません。トレースに映るのは、そのテストが通した分岐だけです。条件付きの呼び出しがトレースに無くても、コードに無いとは言えません。逆にトレースにあってコードに見当たらなければ、ミドルウェアやHTTPクライアントのリトライ設定など、別の層が呼んでいる可能性を疑います。
この非対称性が、依頼の書き方を決めます。Claude Codeに「図とコードが一致しているか」を問うのではなく、「トレースにあってコードに説明が付かない呼び出し」と「順序の食い違い」を探させます。
材料にするトレースの形を決める
変換の入力は、OTLPのJSON表現です。OTLP/JSONには、通常のProtobufのJSON変換と異なる決まりがいくつかあります。スクリプトを書く前に押さえておくと、読み違いを防げます。
traceIdとspanIdはbase64ではなく、16進文字列で表される- enumは名前ではなく整数で表される。
kindがSPAN_KIND_SERVERなら2 - 64ビット整数は10進の文字列で表される。
startTimeUnixNanoもこの扱い - キー名はlowerCamelCaseになる。
parent_span_idではなくparentSpanId
スパンの種類(kind)は、1がINTERNAL、2がSERVER、3がCLIENT、4がPRODUCER、5がCONSUMERです。ルートスパンのparentSpanIdは空になります。
この記事のスクリプトは、resourceSpansの中にサービス名(service.name)をリソース属性として持つ、次の形のJSONを読みます。以降の例では、注文APIが在庫・決済・クーポンの各サービスを呼ぶテストのトレースを使います。サンプル用に手で作った値です。
{
"resourceSpans": [
{
"resource": {
"attributes": [
{ "key": "service.name", "value": { "stringValue": "order-api" } }
]
},
"scopeSpans": [
{
"spans": [
{
"traceId": "5b8efff798038103d269b633813fc60c",
"spanId": "b2",
"parentSpanId": "b1",
"name": "reserve",
"kind": 3,
"startTimeUnixNano": "1700000000010000000",
"endTimeUnixNano": "1700000000060000000",
"status": { "code": 0 }
}
]
}
]
}
]
}トレースをJSONファイルに落とす方法は、使っているSDKやコレクターによって変わります。テスト用のインメモリエクスポーターから書き出す構成でも、コレクターのファイル出力でも、上の形に揃えば同じスクリプトで読めます。
スパンをsequenceDiagramに変換するスクリプト
トレースが数十スパンを超えると、LLMに直接読ませて図を書かせるのは割に合いません。親子関係と時刻の整合を毎回正しく保てる保証がなく、同じ入力でも図が変わります。そこで、図を作る部分は決定的なスクリプトにし、Claude Codeには図を読んで判断する部分を任せます。
変換の考え方は単純です。
| トレース側 | Mermaid側 |
|---|---|
service.name | Mermaid側participant |
| 親と別サービスにあるスパンの開始 | Mermaid側呼び出しの矢印(->>+) |
| そのスパンの終了 | Mermaid側戻りの矢印(-->>-) |
| ステータスコード2(ERROR) | Mermaid側戻りのラベルをERRORにする |
同じサービスの中のスパン(関数呼び出しやDBアクセスの内部スパン)は、図が長くなりすぎるので落とします。サービスをまたぐ呼び出しだけを描くのが、食い違いを見つけやすい粒度です。
import json, sys
data = json.load(open(sys.argv[1]))
svc_of, spans = {}, []
for rs in data["resourceSpans"]:
attrs = {a["key"]: a["value"].get("stringValue", "")
for a in rs["resource"]["attributes"]}
for ss in rs["scopeSpans"]:
for s in ss["spans"]:
svc_of[s["spanId"]] = attrs["service.name"]
s["svc"] = attrs["service.name"]
spans.append(s)
events = [] # (時刻, 種別, span)
for s in spans:
parent = s.get("parentSpanId")
if parent and svc_of.get(parent) not in (None, s["svc"]):
events.append((int(s["startTimeUnixNano"]), 1, s))
events.append((int(s["endTimeUnixNano"]), 0, s))
events.sort(key=lambda e: (e[0], e[1]))
def pid(name): # 矢印の記号と紛れないよう、ハイフンは ID から外す
return name.replace("-", "_")
lines = ["sequenceDiagram", " autonumber"]
seen = []
for _, _, s in events:
for name in (svc_of[s["parentSpanId"]], s["svc"]):
if name not in seen:
seen.append(name)
lines.append(f" participant {pid(name)} as {name}")
for _, is_call, s in events:
caller = pid(svc_of[s["parentSpanId"]])
callee = pid(s["svc"])
if is_call:
lines.append(f" {caller}->>+{callee}: {s['name']}")
else:
mark = "ERROR" if s.get("status", {}).get("code") == 2 else "OK"
lines.append(f" {callee}-->>-{caller}: {mark}")
print("\n".join(lines))ハイフン入りのサービス名は、participant order_api as order-apiのように別名(エイリアス)を付けて受けます。Mermaidではasで、短い識別子と表示用のラベルを分けられます。矢印の記号にも-が使われるので、識別子側からハイフンを外しておけば、読み違いの余地がなくなります。
用意したサンプルのトレースで実行すると、次の出力になります。
python3 scripts/trace2mmd.py traces/last.json > traces/last.mmd
cat traces/last.mmdsequenceDiagram
autonumber
participant test_runner as test-runner
participant order_api as order-api
participant inventory as inventory
participant payment as payment
participant coupon as coupon
test_runner->>+order_api: POST /orders
order_api->>+inventory: Inventory.Reserve
inventory-->>-order_api: OK
order_api->>+payment: Payment.Charge
payment-->>-order_api: ERROR
order_api->>+payment: Payment.Charge
payment-->>-order_api: OK
order_api->>+coupon: Coupon.Lookup
coupon-->>-order_api: OK
order_api-->>-test_runner: OKautonumberで矢印に番号が付くので、Claude Codeとのやり取りで「3番と5番」のように箇所を指せます。->>+と-->>-の組は、Mermaidの活性化(アクティベーション)の省略記法で、呼び出し中のサービスに縦の帯が描かれます。
この図で目に付くのは3点です。在庫確保が決済より先に起きていること、決済が2回呼ばれて1回目がERRORであること、クーポン参照が最後に走っていることです。
並行呼び出しは図に出さない
上のスクリプトは、開始時刻の順に矢印を並べます。2つのサービスを同時に呼ぶ実装では、時間が重なるスパンも直列の矢印になり、実装とは違う順序の印象を与えます。
Mermaidには並行を表すpar ... and ... endがあります。重なりを検出してparに包む処理を足すこともできますが、最初から作り込む必要はありません。図が直列に見えるときは、Claude Codeへの依頼に「時刻が重なるスパンは並行として扱って」と添えれば、parで書き直させられます。その場合でも、重なりの判定に使った時刻は、元のトレースのスパンから読ませます。
Claude Codeに食い違いを指摘させる
図ができたら、コードの該当箇所と並べて依頼します。ここでは注文APIの処理が、次のように書かれているとします(サンプルです)。
def create_order(req):
payment_client.charge(req.amount)
inventory_client.reserve(req.items)
if req.coupon_code:
coupon_client.lookup(req.coupon_code)セッションでは、たとえば次のように頼みます。
traces/last.mmd は、テスト1回分の実行トレースから起こしたシーケンス図。
order-api の create_order と、図の呼び出しを突き合わせて。
- 図にあってコードから説明が付かない呼び出し
- コードと図で順序が違う呼び出し
この2種類を、根拠のファイル名と行番号つきで挙げて。
- コードにあって図に無い呼び出しは、まず「このテストが通さなかった分岐」
の可能性を考えて、テストコードで確かめてから報告して
- 推測と確認済みの事実は分けて書いて最後の2項目が、この依頼の要点です。トレースに無いという事実だけでは、コードの誤りにはなりません。テストが分岐を通したかどうかを確かめさせると、誤検知が減ります。
返ってくる内容の例を、表の形で示します。実際の出力ではなく、このサンプルに対して期待できる指摘の形です。
| 種別 | 指摘 | 次に確かめること |
|---|---|---|
| 順序 | 指摘コードは決済→在庫確保だが、図は在庫確保→決済 | 次に確かめること在庫確保を呼ぶ別の層(デコレータやミドルウェア)の有無 |
| 過剰な呼び出し | 指摘決済が2回あり、1回目がERROR | 次に確かめることHTTPクライアントのリトライ設定。コードに再試行の記述が無い |
| 条件付き | 指摘クーポン参照はcoupon_codeがある場合のみ | 次に確かめることテストがcoupon_codeを渡しているか。これだけでは食い違いと言えない |
3行目が「指摘しない」ための行です。コードのifの中にある呼び出しがトレースに現れたのは、テストが条件を満たしたからで、食い違いではありません。
順序の食い違いが見つかったとき、設計意図と実装のどちらが正しいかはトレースからは決まりません。決済の失敗時に在庫を戻す補償処理があるかどうかは、図の外で確かめる事項です。Claude Codeには、補償処理の有無をコードから探させ、見つからなければその旨を報告させます。
テストの直後に自動で図を作るhook
テストを走らせるたびに変換コマンドを手で打つのは面倒です。hookで自動化します。hooksの仕組みの基本は別の記事にあり、ここでは今回の用途に必要な設定だけを扱います。
使うイベントはPostToolUseです。Bashツールのnpm testの完了後に発火し、入力にはツールの引数(tool_input)と結果(tool_response)が入ります。発火の絞り込みは、matcherでツール名を、ifでコマンドの形を指定します。ifには権限ルールの書式を1つだけ書けます。
注意点が1つあります。PostToolUseは、ツールが成功して完了したときだけ動きます。テストが失敗した回は、PostToolUseFailureが発火します。失敗したテストのトレースこそ読みたいので、同じhookを両方のイベントに登録します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(npm test*)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/trace-diagram.sh"
}
]
}
],
"PostToolUseFailure": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(npm test*)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/trace-diagram.sh"
}
]
}
]
}
}この設定は.claude/settings.jsonに書きます。スクリプトの参照には${CLAUDE_PROJECT_DIR}を使うと、Claudeが別のディレクトリに移動していても、プロジェクトルートのスクリプトを呼べます。
hookのスクリプトは、標準入力でイベントのJSONを受け取ります。ここでは変換して、結果のパスをClaudeに伝えます。
#!/bin/bash
# テスト直後に、新しいトレースがあれば Mermaid に変換して Claude に知らせる
EVENT=$(jq -r '.hook_event_name')
TRACE="${CLAUDE_PROJECT_DIR}/traces/last.json"
OUT="${CLAUDE_PROJECT_DIR}/traces/last.mmd"
# 1分以内に更新されたトレースだけを対象にする(古い図を見せない)
[ -n "$(find "$TRACE" -mmin -1 2>/dev/null)" ] || exit 0
python3 "${CLAUDE_PROJECT_DIR}/scripts/trace2mmd.py" "$TRACE" > "$OUT" || exit 0
jq -n --arg event "$EVENT" --arg path "$OUT" '{
hookSpecificOutput: {
hookEventName: $event,
additionalContext: ("直前のテストのトレースを " + $path + " に図にしました。")
}
}'additionalContextに入れた文字列は、システムリマインダーとして、ツールの結果の隣でClaudeの文脈に追加されます。図そのものではなくファイルのパスだけを渡すのは、大きな図で文脈を埋めないためです。Claudeが必要と判断したときに、そのファイルを読みます。
更新時刻を確かめる行は、テストがトレースを書き出さなかった回の対策です。古いlast.jsonが残っていると、前回のテストの図を今回のものとして見せてしまいます。
Claude Code自身のトレースとの関係
Claude Code自体も、OpenTelemetryのトレースを出せます。ただし、ベータの機能です。CLAUDE_CODE_ENABLE_TELEMETRY=1とCLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1を設定し、OTEL_TRACES_EXPORTERで送信先を選びます。ユーザーのプロンプトは1回ごとにclaude_code.interactionをルートとするトレースになり、API呼び出し(claude_code.llm_request)とツール呼び出し(claude_code.tool)がその子として記録されます。
この記事で扱うのは、テスト対象のアプリが出すトレースです。両者は別物ですが、つなぐ手段はあります。トレースが有効なとき、BashやPowerShellの子プロセスにはTRACEPARENT環境変数が渡されます。W3Cトレースコンテキスト形式で、実行中のツールのスパンを指します。テスト対象のアプリがこの変数を読んで親として使う実装なら、アプリのスパンはClaude Codeのツール実行スパンの下に並びます。
つなぐと、「どのプロンプトで走ったテストの、どの呼び出しか」を1つのトレースで追えます。つながなくても、この記事の手順は成立します。Claude Codeのセッション側をトレースで見る方法は、Langfuseプラグインでセッションをトレースする手順にあります。
Claude Code側のスパンは、既定ではプロンプトの本文、ツールの入力の詳細、ツールの内容を隠します。含めるにはOTEL_LOG_USER_PROMPTS=1、OTEL_LOG_TOOL_DETAILS=1、OTEL_LOG_TOOL_CONTENT=1を設定します。送信先に機微な内容が流れるため、必要になるまで有効にしない設定です。
図を作る段階でのつまずき
複数のホストをまたぐと、時刻の順序が崩れる
スパンの開始時刻は、スパンを作ったマシンの時計で記録されます。サーバー側のスパンの開始時刻も、そのサーバーの時計です。ホスト間で時計がずれていると、時刻で並べ替えたときに、呼び出しの前に戻りが来るような図になります。
テストをすべて1台の開発機のコンテナで動かしているなら、この問題は起きません。複数のホストにまたがるテスト環境では、並び替えをやめて、親子関係の木を深さ優先で辿る方式に変えるほうが安全です。
非同期のメッセージは、親子では表せない
PRODUCERとCONSUMERのスパンは、メッセージブローカーを挟む呼び出しです。ブローカーに送った側の処理が終わっても、受け側の処理は続きます。呼び出しと戻りを対にした図に押し込むと、戻りの無い呼び出しが活性化したまま残ります。
Mermaidには、非同期を表す開いた矢印-)があります。kindが4のスパンは-)で描き、戻りの矢印を出さない分岐をスクリプトに足すと、実態に近づきます。
リトライは別のスパンとは限らない
サンプルのトレースでは、決済の2回の呼び出しが別々のスパンでした。計装によっては、リトライが1つのスパンの属性やイベントに畳まれ、呼び出し回数が図に出ません。図に出ないリトライを疑うなら、スパンの属性に試行回数を持つものがないかを、元のJSONで探します。
図が大きくなりすぎる
1回のテストが数百の呼び出しを含むと、図は読めなくなります。対象を絞る手は2つあります。ルートのスパン名で1つのリクエストに限定する方法と、テスト1本ずつ別のトレースファイルに出す方法です。Claude Codeに見せる図は、1枚あたり数十本の矢印に収まるくらいが読みやすい大きさです。
Mermaidの記法で壊れる文字
メッセージ文にセミコロンがあると、Mermaidは改行の代わりとして扱います。含めたいときは#59;と書きます。endという単語もキーワードと衝突して、図が壊れる原因になります。スパン名がendだけの場合は、括弧や引用符で囲みます。HTTPのパスや関数名をそのまま矢印のラベルにするなら、変換時にこれらの文字を置き換える処理を入れておくと安全です。
図の構文を書き出し前に確かめる自動化は、PlantUMLの構文検証をhookで回す手順と同じ考え方で組めます。データベース側の図は、DDLからER図を起こす手順で扱っています。
まとめ
トレースから起こした図は、設計書の図が語らない「実際に起きたこと」を示します。ただし、見えているのはそのテストが通した経路だけです。食い違いを探すときは、トレースにあってコードで説明が付かないものと、順序が違うものに絞ります。トレースに無いものは、まずテストが分岐を通したかを疑います。
図を作る部分はスクリプトで決定的にし、読んで判断する部分をClaude Codeに任せる分担にすると、同じトレースから毎回同じ図が得られます。テストが終わるたびにhookで図を作っておけば、失敗したテストの直後に、Claude Codeがその図を読んで原因の絞り込みに入れます。テストの失敗をPlaywrightのトレースから切り分ける進め方は、Playwright traceでテスト失敗の原因を切り分ける手順に別の入口があります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。