1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code が「いま何してた?どう詰まった?」を観測したい! ── OTEL をローカル分析

1
Last updated at Posted at 2026-05-17

背景

ご無沙汰しております。かわせです!
最近はClaudeCode, kiro-cli, GithubCopilotCLI などなどを活用して業務/趣味で開発してます!

AI Agentによって我々の役割は大きく変わりつつあるいま、皆さんは Agent定義らのカスタマイズをどのように行っているでしょうか?

私はこれまで「よっしゃ!良い感じの定義が出来た!これこそが俺の最強のAgent定義(Rules/Hook/ハーネスエンジニアリングetc.. )だ!!」とか自分を褒めながら雰囲気で改善していました。

...が、それって本当に「最強」で「さっきまでより良く」なっているんですかね?

ということで今回は、 ClaudeCodeの挙動をOTELを使って「外部サービス無し」「重たいリソース起動無し」「自分だけが見られる」「手軽に試せる」ようにして観測していきたいと思います!!

ざっくりまとめ

今回の記事でやったこと

以下の画像のような「ClaudeCodeがさっきのセッションでどう動いていて、何で詰まったのか!?」を自然言語で、自分以外の誰にも見せずに分析します!

(折り畳み)成果例

▽ 自然言語で分析できる!
image.png
image.png

▽ 分析もできればレポートも作れる!
image.png

今回構築したのは、「ローカルOTEL観測構成」です!

以下の3ステップでやりました!

  1. otelcol-contrib をローカルでプロセス起動し、~/.claude/otel/ に JSONL を1セッション1ファイルで追記しながら貯める
  2. その JSONL を読む分析 Skill を書く
  3. claude を実行してセッションを終えたあとに「さっきのセッション分析して」などと聞くと、Claudeによる分析結果や、SVG 画像入りのHTMLレポートが返ってくる

Grafana も Langfuse も不要で、WSL2 の閉域環境でも動きます!

以下、順にやっていきます。

今回のポイントは、「ClaudeCodeの挙動ログ(OTEL)をローカルに出力すること」です!
これさえできれば、あとは賢いClaudeさんに分析を依頼します。

ただし、今回対象とする挙動ログ(OTEL)では、実際に入力したプロンプトや回答そのものは記録されません
これを紐づけるネタは次回以降でご紹介できればと思います!

そもそもOTELとは!?
素晴らしいブログが既に多数存在するので、そもそもOTEL(OpenTelemetry)とは?!はこの記事では詳しく記載しません
AI Agent の文脈ではざっくり、「AI Agent の利用状況・コスト・LLM呼び出し・ツール実行を外部監視基盤へ出すための標準テレメトリ」と私は理解しています!

技術ブログ以外にも、 Claude 公式の説明などおすすめです!
https://code.claude.com/docs/ja/monitoring-usage

セットアップ(2ステップ)

まずは今回のセットアップ手順からいきます!
私の環境は WSL2 (Ubuntu 24.04) ですが、ほぼ環境に依存しない手順です。

① otelcol-contrib を取得

実行ディレクトリはどこでも問題ないです。
(私は普段の作業ディレクトリで、ClaudeCodeに教えてもらってそのままやりました・・)

curl -LO https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.121.0/otelcol-contrib_0.121.0_linux_amd64.tar.gz
tar xzf otelcol-contrib_0.121.0_linux_amd64.tar.gz
mkdir -p ~/work/claude/otel
mv otelcol-contrib ~/work/claude/otel/

~/.bashrcclaude() 関数を追加

export PATH="$HOME/work/claude/otel:$PATH"

claude() {
  local name
  name="$(basename "$(pwd -P)")_$(date +%Y%m%d_%H%M%S)"
  local otel_dir="$HOME/.claude/otel"
  local otel_config="/tmp/claude-otel-config-$$.yaml"

  mkdir -p "$otel_dir"

  # 前回の Collector が残っていたら停止(保険として・・)
  local existing_pid
  existing_pid=$(lsof -t -i :14317 2>/dev/null)
  [ -n "$existing_pid" ] && kill "$existing_pid" 2>/dev/null && sleep 1

  # YAML 設定を毎回動的生成(パスがセッション毎に変わるので、静的ファイルにはしない)
  cat > "$otel_config" <<EOF
receivers:
  otlp:
    protocols:
      http:
        endpoint: 127.0.0.1:14317

exporters:
  file:
    path: ${otel_dir}/${name}.jsonl
    format: json

service:
  telemetry:
    metrics:
      level: none
  pipelines:
    traces:  { receivers: [otlp], exporters: [file] }
    metrics: { receivers: [otlp], exporters: [file] }
    logs:    { receivers: [otlp], exporters: [file] }
EOF

  otelcol-contrib --config "$otel_config" &
  local otel_pid=$!
  sleep 1

  # どの終了経路でも Collector を止めて config を消す
  trap 'kill "$otel_pid" 2>/dev/null; wait "$otel_pid" 2>/dev/null; rm -f "$otel_config"; trap - INT TERM EXIT' INT TERM EXIT

  CLAUDE_CODE_ENABLE_TELEMETRY=1 \
  CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 \
  OTEL_METRICS_EXPORTER=otlp \
  OTEL_LOGS_EXPORTER=otlp \
  OTEL_TRACES_EXPORTER=otlp \
  OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
  OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:14317 \
  OTEL_METRIC_EXPORT_INTERVAL=10000 \
  OTEL_LOG_TOOL_DETAILS=1 \
    command claude "$@" # 環境変数をつけて、引数をそのまま渡してClaudeCodeを起動

  echo "OTEL output: ${otel_dir}/${name}.jsonl" # 終わったらすぐ確認したいことがあるのでパスを出力しておく
}

社内プロキシを使っている方へ: プロキシが 127.0.0.1 への通信をブロックしていると Collector にデータが届きません。NO_PROXY=127.0.0.1 を追加してください。

これで claude コマンドを実行するたびに、 ~/.claude/otel/ にその日の日時パスファイル名で JSONL が1本できます。

後ろでも少し触れていますが、今時点の設定は 「1セッションで1回実行する」扱いなので、5月中旬にClaudeCodeで発表された「agent view」を使われるような多数セッション同時並行ユーザの方には向いておりません・・・


実装・設定の詳細(読み飛ばしても OK! )

今回やっつけた3つのハードル

Grafana や Langfuse に OTEL を流す方法はすでにブログ記事がいくつかあります。
が、以下の3点にどれか1つでも当てはまる方、いらっしゃいませんか・・?!

:sob: :PC環境内(や自由に使える環境内)にLangfuseやGrafanaを起動するまでの余裕が無い
(所感)EC2などで作業しないと、ローカルでGrafanaらを全部起動することも可能!な環境ってなかなか無いですよねえ。。。
:spy_tone2::AWSアカウントなど、他チームメンバから見えるところで試行錯誤したくない/できない
(所感)AWSのCloudWatch側にOTELを連携することもできるので、AWS側でダッシュボード化/ログ分析するのも手です!が、認証情報の管理や課金されてもOKな自由にできるアカウントがあるか?という問題があったりしますよね・・・
:sleeping::GrafanaやLangfuseのリッチなUIから、グラフ読み取りを自分でしたくない!できない!
(所感)私はGrafanaとかでカスタムダッシュボードを作るのが好きなんですが、得意では無いです:innocent:

▽ ②のご参考:AWSにも直接OTELを送れます!CloudWatchで見るのも手です!

個人的には③は半分ネタですが本気で思ってまして、、何でもAI Agentが分析してくれるこの時代に、ダッシュボードから概要以上を人間が読み取るのは辛い!
特にコストやコンテキストのような定量的な判断基準を設けやすい値ではなく、挙動そのものに関するログを人間の目で判断するのは大変です!
※ ただし、Grafanaなど多数の OTEL 可視化ツールのダッシュボードは激かっこいいです。(私は苦手ですが..)役立てられるスキルがある人に絶対おすすめなので、これらのツールを否定する意図はまっっったくございません。環境が建てられるならそこからクエリなりしてAIが読めばいいだけなのでメリットばかりです・・。


ということで、今回は OTEL を用いて、 ClaudeCode に自然言語で「今回なんで遅かったと思う?どう直したらいいかな?じゃあそれ反映して!」とお願いできる環境を整えました。


ここからちょっと、今回の設定の工夫をご紹介します。

ポイント①:Claude Code はOTELをファイルに吐いてくれない・・・

最初に詰まったのがここでした。

Claude Code は OTEL に対応しています(ご参考)。ただし「ローカルファイルに直接書き出す」機能は現時点で用意されておらず、OTLP プロトコルで送信するだけです・・。
つまり、受け取る側(Collector)を自分で立てないとデータはどこにも残らないという仕様です。

(ちなみに GitHub Copilot CLI は COPILOT_OTEL_FILE_EXPORTER_PATH らのうち、File出力設定のパラメータを1つ設定するだけでファイル出力できます(ご参考)。...とっても便利です!)

とはいえ、逆にいえば Collector を1枚挟むということは「後から何でも足せる」とも言えます!Grafana に流したくなったら exporter を1行足せばいいし、Langfuse でも Jaeger でも同じです。まあ、そう思えば悪くないかな~~と考えています。

ただ、今回すぐに欲しいのは「さっきのセッションで何が起きていたか」だけなので、「ひとまずファイルに落として、そのまま Claude 自身に読ませてしまえばいいじゃん」という方針にしました!

ポイント②:都度起動するのは必ず忘れるので、claude() 関数に全部仕込む

ここで一工夫入れたのが「Collector の起動と停止をどう管理するか」でした。手動で otelcol-contrib --config ... & して kill するのは絶対に忘れるので、claude コマンド自体にラップしちゃって、自動的に Collector のライフサイクルを管理する方針にしました。

~/.bashrcclaude() 関数を定義することで、以下を実現します。

  • ① セッションごとにユニークな JSONL ファイル名を自動生成(<作業ディレクトリ名>_<日時>.jsonl
  • ② Collector の YAML 設定を /tmp に毎回ヒアドキュメントで生成
  • ③ Collector をバックグラウンド起動
  • trap で Ctrl+C・正常終了のどちらでも Collector を確実に停止
  • ⑤ Claude Code 本体を CLAUDE_CODE_ENABLE_TELEMETRY=1 や ローカルで動いている otelcol-contrib の指定付きで起動
  • ⑥ 終了後に一時 YAML を削除

ポイント③:なぜ YAML を bashrc に直接書き込んでいるのか

今回都度生成している otel-collector-config.yaml を別ファイルとして置いてもいいのですが、OTEL の JSONL の出力パスがセッションごとに変わるので、静的な YAML に固定値を書くのは不向きです。
そこで、今回はヒアドキュメント(cat > config.yaml <<EOF ... EOF)で毎回yamlを動的生成する方式にしました。シェル変数(${otel_dir}/${name}.jsonl)をそのまま展開できるので、セッションごとの一意なパスが自然に埋め込めます。/tmp に書くので環境を汚すことを気にせず作れて、終了時に trap で消すので片付けも気にせずでOKです。

少し脱線:そもそも otelcol-contrib って何?

otelcol-contrib は、OpenTelemetry の Collector で、テレメトリデータを受け取って、加工して、どこかに送るためのミドルウェアです。Apache License Version 2.0 で公開されており、Star数も5千弱と、大人気のOSSです!

OTLP という共通プロトコルで受け取って、Prometheus や Jaeger、Datadog、あるいは「ただのファイル」などに出力できます。

この Collector には2種類のディストリビューションがあります。

種類 中身
otelcol (コア) 基本的な receivers/exporters だけで、超軽量です。今回は取り扱いません。
otelcol-contrib コミュニティ製を含む全部入りで、今回使う file exporter もここに入っています。全部入りとは言いますが軽量です。

今回やりたい「ファイルに書き出す」ためには file exporter が必要なので otelcol-contrib を使います(コア版には入っていません)。

Collector の動作は YAML ファイル1枚で全部定義します。「何から受け取って(receivers)」「どう加工して(processors)」「どこに出すか(exporters)」を記載します。今回の用途だと途中で加工する processor すら要らないので、ClaudeからOTELを受け取ってファイルに書くだけのシンプルな構成です。

▽ otelcol-contrib の定義例

receivers:
  otlp:
    protocols:
      http:
        endpoint: 127.0.0.1:14317   # ← OTLP/HTTP で受ける

exporters:
  file:
    path: /path/to/output.jsonl    # ← ファイルに追記

service:
  pipelines: # trace, metrics, logs を全て出力するように書きました
    traces:  { receivers: [otlp], exporters: [file] }
    metrics: { receivers: [otlp], exporters: [file] }
    logs:    { receivers: [otlp], exporters: [file] }

これで 127.0.0.1:14317 に OTLP を投げると JSONL で1ファイルに貯まります。
あとは Claude Code 側に「ここに投げて」と環境変数で指示すればOKです!

ポイント④:trap で二重・異常終了に強くする

Claude のセッションが終わったときに Ctrl+C で雑に抜ける(私はほぼこれを常にやっちゃいます。。笑)と Collector が残ったままになって、次回起動時にポート競合でコケることが分かったので、以下のようにClaudeが起動されるたびに事前に前のプロセスを消します。

trap 'kill "$otel_pid" 2>/dev/null; wait "$otel_pid" 2>/dev/null; rm -f "$otel_config"; trap - INT TERM EXIT' INT TERM EXIT

「INT(Ctrl+C)・TERM・EXIT のいずれでも Collector を kill して wait して config 削除」という1行を入れることで、どう終わっても綺麗に片付くようにしています。加えて関数の先頭で lsof -t -i :14317 して前回の残骸を掃除してから起動します(保険として・・)。

これによって、プロセスが被らないように2回目の起動では敢えてプロセスを落としているので、Claudeを別ターミナルから複数セッション同時に動かしたい場合は工夫が必要です。(既に Collector&Claude が動いている場合はポートを変える など)

成果のもう少し細かな紹介

出力される JSONL の中身

今回生成される OTEL の JSONL には3種類のデータが混ざっています。

▽ OTELのJSONLに含まれる情報の概要整理

種類 中身 何がわかるか
Spans interaction / llm_request / tool.execution / tool.blocked_on_user どこで何秒使ったか
Logs tool_decision, tool_result, api_error, hook_execution 何のツールを呼んだか、エラーの有無
Metrics token.usage, cost.usage トークン量・キャッシュ効率・コスト

例えば、↑でいうと tool.blocked_on_user というスパンがわりと重要でして、Claude Code が権限プロンプトを出して我々人類が許可を出すまでの待ち時間が記録されています。

繰り返しになりますが、OTEL単体では「具体的に送ったプロンプト」や「具体的にAIから返ってきた応答」は残りません
トークン数などは分かりますが、具体的なテキストを使いたい場合はセッションログから紐づけて分析しましょう!
次回こちらもネタにします(するつもりです)!


OTEL を分析してくれる Skill を作る(SVG 入りリッチ HTML を吐かせる)

mkdir -p ~/.claude/skills/claude-code-otel-analysis/scripts

~/.claude/skills/claude-code-otel-analysis/SKILL.md など、Skills ディレクトリに以下を置きます。

▽ Skill定義例
※ 元ソースは色々自分用に特化したことを書きすぎているので、こちらには簡略版を載せます。Claudeに「OTELを分析して!これが例ね!ほんでSkills化して!」で簡単に作ってくれました!

---
name: claude-code-otel-analysis
description: Claude Code の OTEL JSONL を分析し、wall/token/tool の内訳を集計して
  SVG 埋め込みのリッチHTMLレポートを生成する。~/.claude/otel/ 以下に配置された JSONL を
分析したい、どこで時間を使ったか知りたい、権限待ちの有無を確認したいときに使う。
---

メインの出力は `report.html`。SVG を inline 埋め込みし、
この1ファイルをブラウザで開くだけで全グラフが見られるように作成する。

以下のコマンドで分析を実行し、分析結果をレポート出力する。

```bash
python3 ~/.claude/skills/claude-code-otel-analysis/scripts/analyze_claude_otel.py \
  --latest --out-dir /tmp/claude-otel-analysis
```

<中略>

(もしご興味を持っていただければ..)今回利用している分析&レポート出力スクリプトのソース

▽ 分析スクリプト(scripts/analyze_claude_otel.py)
※ シンプルにClaudeCodeに生成してもらったものでしかないので、OTEL出力先ディレクトリなどを変えた場合は別途ClaudeCodeに作らせたほうが良いと思います!

#!/usr/bin/env python3
"""
Claude Code OTEL JSONL 分析スクリプト。

OTLP JSON 形式 (resourceSpans / resourceLogs / resourceMetrics) を読み込み、
interaction ごとの wall/token/tool 内訳を集計して CSV と SVG/HTML を出力する。
"""
import argparse
import csv
import json
import math
import os
import re
from collections import defaultdict
from pathlib import Path


# ────────────────────────────────────────────────────────────
#  基本ユーティリティ
# ────────────────────────────────────────────────────────────

def _attrs(attr_list: list) -> dict:
    """OTLP の [{key, value:{Xvalue: v}}] → {key: v} に変換する。"""
    out = {}
    for a in attr_list:
        k = a.get("key", "")
        v = a.get("value", {})
        for vv in v.values():
            out[k] = vv
            break
    return out


def _nano_to_sec(nano_str) -> float:
    return int(nano_str) / 1_000_000_000


def _scrub(value: str, max_len: int = 120) -> str:
    """個人情報になりやすい値を丸める。"""
    if not value:
        return ""
    s = str(value)
    # ユーザーID(SHA256 like)は短縮
    s = re.sub(r"[0-9a-f]{40,}", "<hash>", s)
    # ファイルパスのホームディレクトリを省略
    s = s.replace(str(Path.home()), "~")
    return s[:max_len]


# ────────────────────────────────────────────────────────────
#  JSONL パーサ
# ────────────────────────────────────────────────────────────

def load_jsonl(path: Path) -> dict:
    """
    JSONL を読み込み、spans / logs / metrics の3リストに分類して返す。
    spans: list of span dict (attributes を展開済み)
    logs:  list of log record dict (attributes を展開済み)
    metrics: list of (name, datapoints) で datapoints は {attrs, value}
    """
    spans = []
    logs = []
    metrics = []

    with path.open(encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            obj = json.loads(line)

            for rs in obj.get("resourceSpans", []):
                for ss in rs.get("scopeSpans", []):
                    for sp in ss.get("spans", []):
                        a = _attrs(sp.get("attributes", []))
                        start = _nano_to_sec(sp["startTimeUnixNano"])
                        end = _nano_to_sec(sp["endTimeUnixNano"])
                        spans.append({
                            "name": sp.get("name", ""),
                            "trace_id": sp.get("traceId", ""),
                            "span_id": sp.get("spanId", ""),
                            "parent_span_id": sp.get("parentSpanId", ""),
                            "start": start,
                            "end": end,
                            "wall": round(end - start, 4),
                            "attrs": a,
                        })

            for rl in obj.get("resourceLogs", []):
                for sl in rl.get("scopeLogs", []):
                    for r in sl.get("logRecords", []):
                        a = _attrs(r.get("attributes", []))
                        ts_nano = r.get("timeUnixNano", "0")
                        logs.append({
                            "body": r.get("body", {}).get("stringValue", ""),
                            "ts": _nano_to_sec(ts_nano),
                            "attrs": a,
                        })

            for rm in obj.get("resourceMetrics", []):
                for sm in rm.get("scopeMetrics", []):
                    for m in sm.get("metrics", []):
                        name = m.get("name", "")
                        dp_list = []
                        for dp_type in ("sum", "gauge", "histogram"):
                            for dp in m.get(dp_type, {}).get("dataPoints", []):
                                a = _attrs(dp.get("attributes", []))
                                val = dp.get("asDouble") or int(dp.get("asInt", 0))
                                dp_list.append({"attrs": a, "value": val})
                        metrics.append({"name": name, "datapoints": dp_list})

    return {"spans": spans, "logs": logs, "metrics": metrics}


# ────────────────────────────────────────────────────────────
#  集計ロジック
# ────────────────────────────────────────────────────────────

def _tool_name_from_logs(logs: list, span_start: float, span_end: float) -> str:
    """
    tool.execution span に対応する tool_name をログから取る。
    tool_result ログの event.timestamp が span の [start, end] に含まれるもので判定。
    """
    for log in logs:
        if log["body"] != "claude_code.tool_result":
            continue
        if span_start <= log["ts"] <= span_end + 0.05:
            return log["attrs"].get("tool_name", "unknown")
    return "unknown"


def extract_agent_calls(data: dict) -> list:
    """
    Agent(Task) tool の呼び出しを subagent 呼び出しとして抽出する。

    Claude Code 2.1.x では tool_name="Agent" として記録される。
    各要素: {subagent_type, description, prompt_snippet, start, end, wall}
    """
    calls = []
    spans = data["spans"]
    for log in data["logs"]:
        if log["body"] != "claude_code.tool_result":
            continue
        a = log["attrs"]
        # Claude Code 2.1.x: "Agent"、旧版: "Task" も念のため許容
        if a.get("tool_name") not in ("Agent", "Task"):
            continue
        subagent_type = "unknown"
        description = ""
        prompt_snippet = ""
        tool_input_raw = a.get("tool_input", "{}")
        try:
            ti = json.loads(tool_input_raw) if isinstance(tool_input_raw, str) else tool_input_raw
            subagent_type = ti.get("subagent_type", "unknown")
            description = ti.get("description", "")
            prompt_snippet = (ti.get("prompt", "") or "")[:120]
        except Exception:
            pass

        # 対応する tool.execution span を timing で探す
        match_sp = None
        for sp in spans:
            if sp["name"] != "claude_code.tool.execution":
                continue
            if sp["start"] <= log["ts"] <= sp["end"] + 0.05:
                match_sp = sp
                break
        if not match_sp:
            continue
        calls.append({
            "subagent_type": subagent_type,
            "description": description,
            "prompt_snippet": prompt_snippet,
            "start": match_sp["start"],
            "end": match_sp["end"],
            "wall": match_sp["wall"],
        })
    calls.sort(key=lambda c: c["start"])
    return calls


def aggregate(data: dict) -> dict:
    """spans / logs / metrics から分析用集計データを作成する。"""
    spans = data["spans"]
    logs = data["logs"]
    metrics = data["metrics"]

    # ── 1. interaction ごとの集計 ──
    interactions = []
    for sp in spans:
        if sp["name"] != "claude_code.interaction":
            continue
        seq = int(sp["attrs"].get("interaction.sequence", 99))
        if seq >= 2 and sp["wall"] < 1.0:
            continue  # 終了入力など短い後続ターンは除外
        interactions.append({
            "seq": seq,
            "wall": sp["wall"],
            "duration_ms": int(sp["attrs"].get("interaction.duration_ms", sp["wall"] * 1000)),
            "prompt_len": int(sp["attrs"].get("user_prompt_length", 0)),
            "start": sp["start"],
            "end": sp["end"],
        })
    interactions.sort(key=lambda x: x["seq"])

    # ── 2. tool.execution の集計 ──
    tool_calls = []
    for sp in spans:
        if sp["name"] != "claude_code.tool.execution":
            continue
        tname = _tool_name_from_logs(logs, sp["start"], sp["end"])
        tool_calls.append({
            "tool_name": tname,
            "wall": sp["wall"],
            "duration_ms": int(sp["attrs"].get("duration_ms", sp["wall"] * 1000)),
            "success": sp["attrs"].get("success", True),
            "start": sp["start"],
            "end": sp["end"],
        })

    tool_wall_by_name = defaultdict(float)
    tool_count_by_name = defaultdict(int)
    tool_fail_by_name = defaultdict(int)
    for tc in tool_calls:
        tname = tc["tool_name"]
        tool_wall_by_name[tname] += tc["wall"]
        tool_count_by_name[tname] += 1
        if not tc["success"]:
            tool_fail_by_name[tname] += 1

    # ── 3. blocked_on_user の集計 ──
    blocked_total = sum(
        sp["wall"] for sp in spans if sp["name"] == "claude_code.tool.blocked_on_user"
    )
    blocked_count = sum(
        1 for sp in spans if sp["name"] == "claude_code.tool.blocked_on_user"
    )

    # ── 4. llm_request の集計 ──
    llm_calls = [sp for sp in spans if sp["name"] == "claude_code.llm_request"]
    llm_total_wall = sum(sp["wall"] for sp in llm_calls)
    llm_count = len(llm_calls)

    # ── 5. token/cost metric の集計 ──
    token_totals = defaultdict(int)  # (model, query_source, type) -> int
    cost_totals = defaultdict(float)  # (model, query_source) -> float
    for m in metrics:
        if m["name"] == "claude_code.token.usage":
            for dp in m["datapoints"]:
                a = dp["attrs"]
                key = (a.get("model", "?"), a.get("query_source", "?"), a.get("type", "?"))
                token_totals[key] += int(dp["value"])
        if m["name"] == "claude_code.cost.usage":
            for dp in m["datapoints"]:
                a = dp["attrs"]
                key = (a.get("model", "?"), a.get("query_source", "?"))
                cost_totals[key] += float(dp["value"])

    # main/auxiliary 別合算
    def sum_tokens(query_source, token_type):
        return sum(v for (m, qs, t), v in token_totals.items() if qs == query_source and t == token_type)

    main_input = sum_tokens("main", "input")
    main_output = sum_tokens("main", "output")
    main_cache_read = sum_tokens("main", "cacheRead")
    main_cache_create = sum_tokens("main", "cacheCreation")
    aux_input = sum_tokens("auxiliary", "input")
    aux_output = sum_tokens("auxiliary", "output")

    total_cost = sum(cost_totals.values())

    # ── 6. api_error, internal_error 集計 ──
    error_counts = {
        "api_error": sum(1 for l in logs if l["body"] == "claude_code.api_error"),
        "internal_error": sum(1 for l in logs if l["body"] == "claude_code.internal_error"),
    }

    # ── 7. interaction wall の内訳 ──
    total_interaction_wall = sum(i["wall"] for i in interactions)
    tool_exec_total = sum(tc["wall"] for tc in tool_calls)

    return {
        "interactions": interactions,
        "total_interaction_wall": round(total_interaction_wall, 3),
        "llm_total_wall": round(llm_total_wall, 3),
        "llm_count": llm_count,
        "tool_exec_total": round(tool_exec_total, 3),
        "tool_wall_by_name": dict(tool_wall_by_name),
        "tool_count_by_name": dict(tool_count_by_name),
        "tool_fail_by_name": dict(tool_fail_by_name),
        "tool_calls": tool_calls,
        "blocked_total": round(blocked_total, 3),
        "blocked_count": blocked_count,
        "main_input": main_input,
        "main_output": main_output,
        "main_cache_read": main_cache_read,
        "main_cache_create": main_cache_create,
        "aux_input": aux_input,
        "aux_output": aux_output,
        "total_cost": round(total_cost, 6),
        "error_counts": error_counts,
        "token_totals": {str(k): v for k, v in token_totals.items()},
        "cost_totals": {str(k): v for k, v in cost_totals.items()},
    }


# ────────────────────────────────────────────────────────────
#  レポート出力
# ────────────────────────────────────────────────────────────

def write_report_md(agg: dict, out_dir: Path, label: str = "") -> Path:
    lines = []
    prefix = f"[{label}] " if label else ""
    ia = agg["interactions"]
    wall = agg["total_interaction_wall"]

    lines.append(f"# {prefix}Claude Code OTEL 分析レポート\n")

    lines.append("## セッション概要\n")
    lines.append(f"- ターン数(本体): {len(ia)}")
    lines.append(f"- 合計 wall time: **{wall:.1f} s**")
    lines.append(f"- LLM 推論時間: {agg['llm_total_wall']:.1f} s ({agg['llm_count']} calls)")
    lines.append(f"- ツール実行時間: {agg['tool_exec_total']:.1f} s")
    lines.append(f"- 権限待ち時間: {agg['blocked_total']:.3f} s ({agg['blocked_count']} prompts)")
    lines.append("")

    lines.append("## トークン使用量(main)\n")
    lines.append(f"- input:          {agg['main_input']:,}")
    lines.append(f"- output:         {agg['main_output']:,}")
    lines.append(f"- cache read:     {agg['main_cache_read']:,}")
    lines.append(f"- cache created:  {agg['main_cache_create']:,}")
    lines.append(f"- aux(haiku):     input={agg['aux_input']:,} output={agg['aux_output']:,}")
    lines.append(f"- 推定コスト: ${agg['total_cost']:.5f}")
    lines.append("")

    lines.append("## ツール別 wall time (上位)\n")
    sorted_tools = sorted(agg["tool_wall_by_name"].items(), key=lambda x: -x[1])
    for tname, w in sorted_tools:
        count = agg["tool_count_by_name"].get(tname, 0)
        fail = agg["tool_fail_by_name"].get(tname, 0)
        fail_str = f" ❌{fail}" if fail else ""
        lines.append(f"- `{tname}`: {w:.3f}s × {count}回{fail_str}")
    lines.append("")

    if agg["error_counts"]["api_error"] or agg["error_counts"]["internal_error"]:
        lines.append("## エラー\n")
        lines.append(f"- api_error: {agg['error_counts']['api_error']}")
        lines.append(f"- internal_error: {agg['error_counts']['internal_error']}")
        lines.append("")

    out = out_dir / "report.md"
    out.write_text("\n".join(lines), encoding="utf-8")
    return out


def write_tool_csv(agg: dict, out_dir: Path) -> Path:
    rows = []
    for tname, w in agg["tool_wall_by_name"].items():
        rows.append({
            "tool_name": tname,
            "wall_sec": round(w, 3),
            "count": agg["tool_count_by_name"].get(tname, 0),
            "fail": agg["tool_fail_by_name"].get(tname, 0),
        })
    rows.sort(key=lambda x: -x["wall_sec"])
    out = out_dir / "tool_summary.csv"
    with out.open("w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["tool_name", "wall_sec", "count", "fail"])
        w.writeheader()
        w.writerows(rows)
    return out


def write_interaction_csv(agg: dict, out_dir: Path) -> Path:
    out = out_dir / "interaction_summary.csv"
    fields = ["seq", "wall", "duration_ms", "prompt_len"]
    with out.open("w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
        w.writeheader()
        w.writerows(agg["interactions"])
    return out


# ────────────────────────────────────────────────────────────
#  SVG 生成 — stacked bar (wall 内訳)
# ────────────────────────────────────────────────────────────

_SVG_COLORS = {
    "llm": "#4C9BE8",
    "tool": "#F0A830",
    "blocked": "#E05252",
    "other": "#A0A0A0",
}


def build_wall_breakdown_svg(agg: dict, label: str = "", width: int = 680) -> str:
    """wall 内訳 stacked bar の SVG 文字列を返す。"""
    wall = agg["total_interaction_wall"]
    llm = min(agg["llm_total_wall"], wall)
    blocked = min(agg["blocked_total"], wall)
    tool = min(agg["tool_exec_total"], wall)
    other = max(0.0, wall - llm - tool - blocked)

    segments = [
        ("LLM推論", llm, _SVG_COLORS["llm"]),
        ("ツール実行", tool, _SVG_COLORS["tool"]),
        ("権限待ち", blocked, _SVG_COLORS["blocked"]),
        ("その他", other, _SVG_COLORS["other"]),
    ]
    segments = [(n, v, c) for n, v, c in segments if v > 0]

    W, H = width, 110
    bar_y, bar_h = 38, 36
    margin_l, margin_r = 16, 16
    bar_w = W - margin_l - margin_r

    svg = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" '
           f'width="100%" style="max-width:{W}px" '
           f'font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif" font-size="12">']
    title = f"{label + ': ' if label else ''}wall time 内訳 ({wall:.1f}s)"
    svg.append(f'<text x="{W//2}" y="22" text-anchor="middle" font-size="13" font-weight="600" fill="#222">{title}</text>')

    x = margin_l
    for name, val, color in segments:
        w = bar_w * (val / wall) if wall > 0 else 0
        svg.append(f'<rect x="{x:.1f}" y="{bar_y}" width="{max(w,0):.1f}" height="{bar_h}" fill="{color}" rx="3"/>')
        if w > 38:
            cx = x + w / 2
            svg.append(f'<text x="{cx:.1f}" y="{bar_y + bar_h/2 + 4:.1f}" '
                       f'text-anchor="middle" fill="white" font-size="11" font-weight="600">{val:.1f}s</text>')
        x += w

    legend_x = margin_l
    ly = bar_y + bar_h + 20
    for name, val, color in segments:
        pct = 100 * val / wall if wall > 0 else 0
        svg.append(f'<rect x="{legend_x}" y="{ly - 9}" width="11" height="11" fill="{color}" rx="2"/>')
        svg.append(f'<text x="{legend_x + 15}" y="{ly}" fill="#333">{name} {pct:.0f}%</text>')
        legend_x += 140

    svg.append("</svg>")
    return "\n".join(svg)


def write_wall_breakdown_svg(agg: dict, out_dir: Path, label: str = "") -> Path:
    out = out_dir / "wall_breakdown.svg"
    out.write_text(build_wall_breakdown_svg(agg, label, width=560), encoding="utf-8")
    return out


# ────────────────────────────────────────────────────────────
#  SVG 生成 — ツール別 horizontal bar
# ────────────────────────────────────────────────────────────

def build_tool_bar_svg(agg: dict, width: int = 680) -> str:
    """ツール別 wall time の水平バー SVG を返す。"""
    items = sorted(agg["tool_wall_by_name"].items(), key=lambda x: -x[1])
    if not items:
        return ""

    max_wall = items[0][1]
    row_h = 26
    label_w = 120
    H = row_h * len(items) + 36
    W = width
    bar_max_w = W - label_w - 120

    svg = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" '
           f'width="100%" style="max-width:{W}px" '
           f'font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif" font-size="12">']
    svg.append(f'<text x="{W//2}" y="18" text-anchor="middle" font-size="13" font-weight="600" fill="#222">ツール別 wall time</text>')

    for i, (tname, wall) in enumerate(items):
        y = 30 + i * row_h
        bw = (bar_max_w * wall / max_wall) if max_wall > 0 else 0
        count = agg["tool_count_by_name"].get(tname, 0)
        fail = agg["tool_fail_by_name"].get(tname, 0)
        color = _SVG_COLORS["blocked"] if fail else _SVG_COLORS["tool"]
        svg.append(f'<text x="{label_w - 6}" y="{y + 15}" text-anchor="end" fill="#333" font-weight="500">{tname}</text>')
        svg.append(f'<rect x="{label_w}" y="{y + 2}" width="{bw:.1f}" height="18" fill="{color}" rx="3"/>')
        fail_str = f" ❌{fail}" if fail else ""
        svg.append(f'<text x="{label_w + bw + 6}" y="{y + 15}" fill="#555">'
                   f'{wall:.3f}s × {count}回{fail_str}</text>')

    svg.append("</svg>")
    return "\n".join(svg)


def write_tool_bar_svg(agg: dict, out_dir: Path) -> Path:
    s = build_tool_bar_svg(agg, width=560)
    if not s:
        return None
    out = out_dir / "tool_bar.svg"
    out.write_text(s, encoding="utf-8")
    return out


def build_timeline_svg(data: dict, width: int = 900) -> str:
    """span タイムラインの SVG を返す (従来の HTML ではなく純 SVG)。"""
    spans = data["spans"]
    if not spans:
        return ""

    t0 = min(sp["start"] for sp in spans)
    t_end = max(sp["end"] for sp in spans)
    total = t_end - t0
    if total <= 0:
        total = 1.0

    row_h = 22
    label_w = 200
    timeline_w = width - label_w - 20

    name_order = [
        "claude_code.interaction",
        "claude_code.llm_request",
        "claude_code.tool",
        "claude_code.tool.execution",
        "claude_code.tool.blocked_on_user",
    ]
    span_colors = {
        "claude_code.interaction": "#2196F3",
        "claude_code.llm_request": "#4CAF50",
        "claude_code.tool": "#FF9800",
        "claude_code.tool.execution": "#FF5722",
        "claude_code.tool.blocked_on_user": "#F44336",
    }

    tool_log_map = {}
    for log in data["logs"]:
        if log["body"] == "claude_code.tool_result":
            tool_log_map[log["ts"]] = log["attrs"].get("tool_name", "?")

    def label(sp):
        if sp["name"] != "claude_code.tool.execution":
            return sp["name"].split(".", 1)[-1]
        for ts, tname in tool_log_map.items():
            if sp["start"] <= ts <= sp["end"] + 0.05:
                return f"exec:{tname}"
        return "exec:?"

    rows = []
    rows_by_name = defaultdict(list)
    for sp in sorted(spans, key=lambda x: x["start"]):
        rows_by_name[sp["name"]].append(sp)
    for name in name_order:
        for sp in rows_by_name.get(name, []):
            rows.append((name, sp))

    H = len(rows) * row_h + 60
    svg = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {H}" '
           f'width="100%" style="max-width:{width}px" '
           f'font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif" font-size="11">']
    svg.append(f'<text x="{width//2}" y="18" text-anchor="middle" font-size="13" font-weight="600" fill="#222">タイムライン ({total:.2f}s)</text>')

    # グリッド
    for pct in range(0, 110, 10):
        x = label_w + timeline_w * pct / 100
        svg.append(f'<line x1="{x:.0f}" y1="28" x2="{x:.0f}" y2="{H - 20}" stroke="#eee" stroke-width="1"/>')
        if pct % 20 == 0:
            t = total * pct / 100
            svg.append(f'<text x="{x:.0f}" y="{H - 6}" text-anchor="middle" fill="#aaa">{t:.1f}s</text>')

    for i, (name, sp) in enumerate(rows):
        y = 32 + i * row_h
        x1 = label_w + timeline_w * (sp["start"] - t0) / total
        x2 = label_w + timeline_w * (sp["end"] - t0) / total
        bw = max(x2 - x1, 2)
        color = span_colors.get(name, "#90A4AE")
        svg.append(f'<text x="{label_w - 6}" y="{y + row_h - 8}" text-anchor="end" fill="#333">{label(sp)}</text>')
        svg.append(f'<rect x="{x1:.1f}" y="{y}" width="{bw:.1f}" height="{row_h - 5}" '
                   f'fill="{color}" opacity="0.88" rx="2">'
                   f'<title>{name} {sp["wall"]:.3f}s</title></rect>')
        if bw > 40:
            svg.append(f'<text x="{x1 + 4:.1f}" y="{y + row_h - 8}" fill="white">{sp["wall"]:.2f}s</text>')

    svg.append("</svg>")
    return "\n".join(svg)


# ────────────────────────────────────────────────────────────
#  マルチエージェント可視化 — swim lane SVG + agent別集計
# ────────────────────────────────────────────────────────────

_AGENT_COLORS = {
    "Orchestrator": "#4C9BE8",
    "Plan": "#9333EA",
    "Explore": "#0EA5E9",
    "general-purpose": "#10B981",
    "unknown": "#64748B",
}


def summarize_agents(data: dict, agent_calls: list, interaction_wall: float) -> dict:
    """
    agent 呼び出しから agent 別の wall / 委譲回数を集計する。
    Orchestrator wall = interaction wall - sum(subagent wall)
    """
    by_type = defaultdict(lambda: {"count": 0, "wall": 0.0, "descs": []})
    for c in agent_calls:
        t = c["subagent_type"]
        by_type[t]["count"] += 1
        by_type[t]["wall"] += c["wall"]
        if c["description"]:
            by_type[t]["descs"].append(c["description"])

    subagent_total_wall = sum(a["wall"] for a in by_type.values())
    orchestrator_wall = max(0.0, interaction_wall - subagent_total_wall)

    result = {
        "Orchestrator": {
            "count": 1 if agent_calls else 0,
            "wall": round(orchestrator_wall, 3),
            "descs": [],
        }
    }
    for t, v in by_type.items():
        result[t] = {"count": v["count"], "wall": round(v["wall"], 3), "descs": v["descs"]}
    return result


def build_swimlane_svg(data: dict, agent_calls: list, width: int = 960) -> str:
    """
    エージェントごとに横レーンを作り、動いていた時間帯を帯で表示する。
    Orchestrator レーン: interaction 全体を薄く、subagent 委譲中は淡く抜く。
    subagent レーン: 該当 Task span の期間を濃く。
    委譲タイミングは縦線で接続。
    """
    if not agent_calls:
        return ""

    # 全体時間の決定
    spans = data["spans"]
    interactions = [sp for sp in spans if sp["name"] == "claude_code.interaction" and sp["wall"] > 1.0]
    if not interactions:
        return ""
    t0 = min(sp["start"] for sp in interactions)
    t_end = max(sp["end"] for sp in interactions)
    total = t_end - t0
    if total <= 0:
        return ""

    # レーン: Orchestrator + 呼ばれた subagent_type
    subagent_types = []
    seen = set()
    for c in agent_calls:
        if c["subagent_type"] not in seen:
            subagent_types.append(c["subagent_type"])
            seen.add(c["subagent_type"])

    lanes = [("Orchestrator", None)] + [(t, t) for t in subagent_types]

    label_w = 140
    lane_h = 48
    timeline_w = width - label_w - 24
    H = 40 + lane_h * len(lanes) + 20

    svg = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {H}" '
           f'width="100%" style="max-width:{width}px" '
           f'font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif" font-size="12">']
    svg.append(f'<text x="{width//2}" y="22" text-anchor="middle" font-size="14" font-weight="700" fill="#1d2430">'
               f'マルチエージェント swim lane ({total:.1f}s)</text>')

    # 時間軸グリッド
    for pct in range(0, 110, 10):
        x = label_w + timeline_w * pct / 100
        svg.append(f'<line x1="{x:.0f}" y1="36" x2="{x:.0f}" y2="{H - 20}" stroke="#eee" stroke-width="1"/>')
        if pct % 20 == 0:
            t = total * pct / 100
            svg.append(f'<text x="{x:.0f}" y="{H - 6}" text-anchor="middle" fill="#aaa" font-size="10">{t:.1f}s</text>')

    def x_pos(t):
        return label_w + timeline_w * (t - t0) / total

    # Orchestrator レーン (最上段)
    y = 40
    svg.append(f'<text x="{label_w - 8}" y="{y + lane_h//2 + 5}" text-anchor="end" fill="#222" font-weight="600">Orchestrator</text>')
    svg.append(f'<rect x="{label_w}" y="{y + 6}" width="{timeline_w:.1f}" height="{lane_h - 12}" '
               f'fill="{_AGENT_COLORS["Orchestrator"]}" opacity="0.30" rx="4"/>')
    for c in agent_calls:
        x1 = x_pos(c["start"])
        x2 = x_pos(c["end"])
        svg.append(f'<rect x="{x1:.1f}" y="{y + 6}" width="{max(x2 - x1, 1):.1f}" '
                   f'height="{lane_h - 12}" fill="#fff" opacity="0.85" rx="2"/>')
        svg.append(f'<text x="{x1 + 4:.1f}" y="{y + lane_h//2 + 4}" fill="#666" font-size="10">(待機)</text>')

    # subagent レーン
    for i, sa_type in enumerate(subagent_types, start=1):
        y = 40 + i * lane_h
        color = _AGENT_COLORS.get(sa_type, _AGENT_COLORS["unknown"])
        svg.append(f'<text x="{label_w - 8}" y="{y + lane_h//2 + 5}" text-anchor="end" fill="#222" font-weight="600">{_html_escape(sa_type)}</text>')
        # レーン背景
        svg.append(f'<rect x="{label_w}" y="{y + 6}" width="{timeline_w:.1f}" height="{lane_h - 12}" '
                   f'fill="#f7f9fc" stroke="#eee" rx="4"/>')
        # この subagent_type の呼び出しを塗る
        for c in agent_calls:
            if c["subagent_type"] != sa_type:
                continue
            x1 = x_pos(c["start"])
            x2 = x_pos(c["end"])
            bw = max(x2 - x1, 2)
            svg.append(f'<rect x="{x1:.1f}" y="{y + 6}" width="{bw:.1f}" height="{lane_h - 12}" '
                       f'fill="{color}" opacity="0.88" rx="3">'
                       f'<title>{_html_escape(sa_type)}: {c["wall"]:.2f}s / {_html_escape(c["description"])}</title></rect>')
            if bw > 60:
                svg.append(f'<text x="{x1 + 6:.1f}" y="{y + lane_h//2 + 4}" fill="white" '
                           f'font-weight="600">{c["wall"]:.1f}s</text>')
            # Orchestrator レーンとの接続(委譲線)
            orch_y_bottom = 40 + lane_h - 6
            svg.append(f'<line x1="{x1:.1f}" y1="{orch_y_bottom}" x2="{x1:.1f}" y2="{y + 6}" '
                       f'stroke="{color}" stroke-width="1.5" stroke-dasharray="3 3"/>')
            svg.append(f'<line x1="{x2:.1f}" y1="{y + lane_h - 6}" x2="{x2:.1f}" y2="{orch_y_bottom}" '
                       f'stroke="{color}" stroke-width="1.5" stroke-dasharray="3 3"/>')

    svg.append("</svg>")
    return "\n".join(svg)


# ────────────────────────────────────────────────────────────
#  リッチ HTML レポート (SVG を inline で埋め込む1枚もの)
# ────────────────────────────────────────────────────────────

def _html_escape(s: str) -> str:
    return (str(s).replace("&", "&amp;").replace("<", "&lt;")
            .replace(">", "&gt;").replace('"', "&quot;"))


def write_report_html(data: dict, agg: dict, out_dir: Path, label: str = "",
                       source_path: str = "") -> Path:
    """SVG を inline 埋め込みした1枚完結のリッチ HTML レポートを生成する。"""
    wall = agg["total_interaction_wall"]
    ia = agg["interactions"]

    # 比率計算
    def pct(v):
        return (100 * v / wall) if wall > 0 else 0

    llm_ratio = pct(agg["llm_total_wall"])
    tool_ratio = pct(agg["tool_exec_total"])
    blocked_ratio = pct(agg["blocked_total"])
    cache_efficiency = 0
    total_read_in = agg["main_cache_read"] + agg["main_input"] + agg["main_cache_create"]
    if total_read_in > 0:
        cache_efficiency = 100 * agg["main_cache_read"] / total_read_in

    # 各パート SVG
    wall_svg = build_wall_breakdown_svg(agg, label=label, width=760)
    tool_svg = build_tool_bar_svg(agg, width=760) or ""
    timeline_svg = build_timeline_svg(data, width=960)

    # マルチエージェント解析
    agent_calls = extract_agent_calls(data)
    agent_summary = summarize_agents(data, agent_calls, wall)
    swimlane_svg = build_swimlane_svg(data, agent_calls, width=960)
    has_orchestration = len(agent_calls) > 0

    # ツールテーブル
    tool_rows = []
    for tname, w in sorted(agg["tool_wall_by_name"].items(), key=lambda x: -x[1]):
        c = agg["tool_count_by_name"].get(tname, 0)
        fail = agg["tool_fail_by_name"].get(tname, 0)
        avg = w / c if c else 0
        tool_rows.append(f"<tr><td><code>{_html_escape(tname)}</code></td>"
                         f"<td class='num'>{w:.3f}</td>"
                         f"<td class='num'>{c}</td>"
                         f"<td class='num'>{avg*1000:.0f} ms</td>"
                         f"<td class='num'>{fail if fail else '-'}</td></tr>")
    if not tool_rows:
        tool_rows.append("<tr><td colspan='5' class='muted'>ツール呼び出しなし</td></tr>")

    # 権限待ち内訳
    blocked_auto = sum(1 for sp in data["spans"]
                       if sp["name"] == "claude_code.tool.blocked_on_user" and sp["wall"] < 0.1)
    blocked_manual = sum(1 for sp in data["spans"]
                         if sp["name"] == "claude_code.tool.blocked_on_user" and sp["wall"] >= 0.1)
    blocked_manual_wall = sum(sp["wall"] for sp in data["spans"]
                              if sp["name"] == "claude_code.tool.blocked_on_user" and sp["wall"] >= 0.1)

    # agent セクション構築
    agent_section = ""
    if has_orchestration:
        agent_table_rows = []
        agent_order = ["Orchestrator"] + [c["subagent_type"] for c in agent_calls]
        seen = set()
        ordered_unique = []
        for a in agent_order:
            if a not in seen:
                ordered_unique.append(a)
                seen.add(a)
        for aname in ordered_unique:
            s = agent_summary.get(aname, {"count": 0, "wall": 0, "descs": []})
            pct_wall = (100 * s["wall"] / wall) if wall > 0 else 0
            color = _AGENT_COLORS.get(aname, _AGENT_COLORS["unknown"])
            desc_text = " / ".join(d[:40] for d in (s["descs"] or [])[:2]) or "-"
            role_badge = ""
            if aname == "Orchestrator":
                role_badge = '<span class="badge">メイン</span>'
            elif aname == "Plan":
                role_badge = '<span class="badge plan">Planner</span>'
            elif aname == "general-purpose":
                role_badge = '<span class="badge review">Reviewer?</span>'
            elif aname == "Explore":
                role_badge = '<span class="badge explore">Explorer</span>'
            agent_table_rows.append(
                f'<tr>'
                f'<td><span class="dot" style="background:{color}"></span>'
                f'<code>{_html_escape(aname)}</code> {role_badge}</td>'
                f'<td class="num">{s["wall"]:.2f}</td>'
                f'<td class="num">{pct_wall:.0f}%</td>'
                f'<td class="num">{s["count"]}</td>'
                f'<td class="muted">{_html_escape(desc_text)}</td>'
                f'</tr>'
            )

        planner_calls = [c for c in agent_calls if c["subagent_type"] == "Plan"]
        reviewer_calls = [c for c in agent_calls if c["subagent_type"] == "general-purpose"]
        planner_pct = (100 * sum(c["wall"] for c in planner_calls) / wall) if wall > 0 else 0
        reviewer_pct = (100 * sum(c["wall"] for c in reviewer_calls) / wall) if wall > 0 else 0

        agent_section = f"""
<h2>マルチエージェント構成 (Task 呼び出し {len(agent_calls)} 回)</h2>
<div class="summary-grid">
  <div class="kpi"><div class="label">委譲回数</div><div class="value">{len(agent_calls)}</div>
    <div class="sub">Task tool 経由</div></div>
  <div class="kpi"><div class="label">planner 比率</div><div class="value">{planner_pct:.0f}%</div>
    <div class="sub">{len(planner_calls)} 回</div></div>
  <div class="kpi"><div class="label">reviewer 比率</div><div class="value">{reviewer_pct:.0f}%</div>
    <div class="sub">{len(reviewer_calls)} 回</div></div>
</div>

<div class="card">{swimlane_svg}</div>

<div class="card">
<table>
<thead><tr><th>agent</th><th style="text-align:right">wall(s)</th>
<th style="text-align:right">比率</th><th style="text-align:right">回数</th>
<th>主な description</th></tr></thead>
<tbody>{''.join(agent_table_rows)}</tbody>
</table>
</div>
"""

    # AI 向け所見メモ (テンプレ)
    observations = []
    if llm_ratio > 70:
        observations.append(f"LLM 推論が wall の約 {llm_ratio:.0f}% を占める。ツールより推論が重い。")
    if blocked_manual > 0:
        observations.append(
            f"手動の権限待ちが {blocked_manual} 回 / 合計 {blocked_manual_wall:.1f}s 発生している。"
            f"<code>.claude/settings.json</code> で許可を追加すると削減可能。"
        )
    if agg["main_cache_read"] > 10000:
        observations.append(f"cache_read が {agg['main_cache_read']:,} tokens あり、Skill description 等のキャッシュが効いている。")
    if agg["main_cache_create"] > agg["main_cache_read"] * 0.3 and agg["main_cache_create"] > 10000:
        observations.append(f"cache_creation が {agg['main_cache_create']:,} tokens。初回セッション特有の膨張。2回目以降は減るはず。")
    if agg["error_counts"]["internal_error"] > 0:
        observations.append(f"internal_error が {agg['error_counts']['internal_error']} 件。"
                            "Claude Code 2.1.x の既知の内部 SyntaxError であれば無視可。")
    if has_orchestration:
        planner_calls = [c for c in agent_calls if c["subagent_type"] == "Plan"]
        reviewer_calls = [c for c in agent_calls if c["subagent_type"] == "general-purpose"]
        planner_wall_pct = (100 * sum(c["wall"] for c in planner_calls) / wall) if wall > 0 else 0
        if planner_wall_pct > 40:
            observations.append(f"planner が wall の {planner_wall_pct:.0f}% を占めている。"
                                "タスクに対して overkill の可能性。")
        if not reviewer_calls and planner_calls:
            observations.append("Planner を経由したが Reviewer は呼ばれていない。"
                                "レビュー段を明示的にプロンプトで指示する必要あり。")
        if len(agent_calls) >= 3:
            observations.append(f"委譲 {len(agent_calls)} 回のマルチエージェント構成を確認。"
                                "swim lane で各 agent の時間配分を確認すること。")
    if not observations:
        observations.append("特に目立つ偏りなし。")

    html = f"""<!DOCTYPE html>
<html lang="ja"><head>
<meta charset="utf-8">
<title>Claude Code OTEL レポート{' — ' + _html_escape(label) if label else ''}</title>
<style>
:root {{
  --fg: #1d2430;
  --muted: #6b7280;
  --accent: #4C9BE8;
  --border: #e5e7eb;
  --bg-box: #f7f9fc;
  --bg-code: #f1f3f5;
}}
* {{ box-sizing: border-box; }}
body {{
  font-family: -apple-system, "Hiragino Kaku Gothic ProN", "Segoe UI", Helvetica, Arial, sans-serif;
  color: var(--fg);
  margin: 0;
  padding: 32px 24px 64px;
  background: #fafbfc;
  line-height: 1.6;
}}
.container {{ max-width: 1000px; margin: 0 auto; }}
h1 {{ font-size: 22px; margin: 0 0 6px; border-bottom: 2px solid var(--accent); padding-bottom: 6px; }}
h2 {{ font-size: 16px; margin: 28px 0 10px; color: #2a3545; }}
.meta {{ color: var(--muted); font-size: 12px; margin-bottom: 20px; }}
.meta code {{ background: var(--bg-code); padding: 1px 6px; border-radius: 3px; font-size: 11px; }}

.summary-grid {{
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
  gap: 10px; margin: 16px 0 8px;
}}
.kpi {{
  background: var(--bg-box);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px 14px;
}}
.kpi .label {{ color: var(--muted); font-size: 11px; letter-spacing: .02em; }}
.kpi .value {{ font-size: 20px; font-weight: 600; margin-top: 2px; }}
.kpi .sub {{ color: var(--muted); font-size: 11px; margin-top: 2px; }}
.kpi.accent .value {{ color: var(--accent); }}
.kpi.warn .value {{ color: #d97706; }}
.kpi.alert .value {{ color: #dc2626; }}

.card {{
  background: white;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 16px 18px;
  margin: 12px 0;
}}
.card svg {{ display: block; margin: 4px auto; }}
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
table th, table td {{ padding: 7px 10px; border-bottom: 1px solid var(--border); text-align: left; }}
table th {{ color: var(--muted); font-weight: 500; background: var(--bg-box); font-size: 11px; letter-spacing: .02em; }}
table td.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
code {{ background: var(--bg-code); padding: 1px 5px; border-radius: 3px; font-size: 12px; }}
.muted {{ color: var(--muted); }}
.observations li {{ margin: 6px 0; }}
.footer {{ color: var(--muted); font-size: 11px; margin-top: 30px; border-top: 1px solid var(--border); padding-top: 10px; }}
.dot {{ display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }}
.badge {{ display: inline-block; padding: 1px 8px; margin-left: 6px; border-radius: 10px; background: #eef2f7; color: #475569; font-size: 11px; }}
.badge.plan {{ background: #ede9fe; color: #6d28d9; }}
.badge.review {{ background: #dcfce7; color: #166534; }}
.badge.explore {{ background: #e0f2fe; color: #075985; }}
</style></head>
<body><div class="container">

<h1>Claude Code OTEL レポート{' — ' + _html_escape(label) if label else ''}</h1>
<div class="meta">
  source: <code>{_html_escape(_scrub(source_path))}</code>
</div>

<h2>サマリー</h2>
<div class="summary-grid">
  <div class="kpi accent"><div class="label">wall time</div><div class="value">{wall:.1f} s</div>
    <div class="sub">ターン {len(ia)} 回</div></div>
  <div class="kpi"><div class="label">LLM 推論</div><div class="value">{agg['llm_total_wall']:.1f} s</div>
    <div class="sub">{agg['llm_count']} calls / {llm_ratio:.0f}%</div></div>
  <div class="kpi"><div class="label">ツール実行</div><div class="value">{agg['tool_exec_total']:.2f} s</div>
    <div class="sub">{sum(agg['tool_count_by_name'].values())} 回 / {tool_ratio:.0f}%</div></div>
  <div class="kpi {'alert' if blocked_manual > 0 else ''}"><div class="label">権限待ち</div>
    <div class="value">{agg['blocked_total']:.2f} s</div>
    <div class="sub">自動 {blocked_auto} / 手動 {blocked_manual}</div></div>
  <div class="kpi warn"><div class="label">推定コスト</div><div class="value">${agg['total_cost']:.4f}</div>
    <div class="sub">main + aux 合算</div></div>
  <div class="kpi"><div class="label">キャッシュ効率</div><div class="value">{cache_efficiency:.0f} %</div>
    <div class="sub">cache_read / 全入力 tokens</div></div>
</div>

<h2>wall time の内訳</h2>
<div class="card">{wall_svg}</div>

<h2>ツール別 wall time</h2>
<div class="card">{tool_svg if tool_svg else '<p class="muted">ツール呼び出しなし</p>'}</div>

<h2>ツール別詳細</h2>
<div class="card">
<table>
<thead><tr><th>tool</th><th style="text-align:right">wall 合計(s)</th>
<th style="text-align:right">回数</th><th style="text-align:right">平均</th>
<th style="text-align:right">失敗</th></tr></thead>
<tbody>{''.join(tool_rows)}</tbody>
</table>
</div>

<h2>トークン使用量</h2>
<div class="card">
<table>
<thead><tr><th>model</th><th>query_source</th>
<th style="text-align:right">input</th><th style="text-align:right">output</th>
<th style="text-align:right">cache read</th><th style="text-align:right">cache create</th></tr></thead>
<tbody>
<tr><td><code>claude-sonnet-4-6</code></td><td>main</td>
<td class="num">{agg['main_input']:,}</td><td class="num">{agg['main_output']:,}</td>
<td class="num">{agg['main_cache_read']:,}</td><td class="num">{agg['main_cache_create']:,}</td></tr>
<tr><td><code>claude-haiku (aux)</code></td><td>auxiliary</td>
<td class="num">{agg['aux_input']:,}</td><td class="num">{agg['aux_output']:,}</td>
<td class="num muted">-</td><td class="num muted">-</td></tr>
</tbody></table>
</div>

{agent_section}

<h2>タイムライン</h2>
<div class="card">{timeline_svg}</div>

<h2>所見(機械生成の見立て)</h2>
<div class="card observations">
<ul>
{''.join(f'<li>{obs}</li>' for obs in observations)}
</ul>
</div>

<div class="footer">
generated by claude-code-otel-analysis /
Claude Code OTLP JSONL / このレポートは単一 HTML ファイル (SVG は inline)
</div>

</div></body></html>
"""

    out = out_dir / "report.html"
    out.write_text(html, encoding="utf-8")
    return out


# ────────────────────────────────────────────────────────────
#  HTML タイムライン (スタンドアロン。リッチHTML優先だが互換のため残す)
# ────────────────────────────────────────────────────────────

def write_timeline_html(data: dict, agg: dict, out_dir: Path) -> Path:
    spans = data["spans"]
    if not spans:
        return None

    t0 = min(sp["start"] for sp in spans)
    t_end = max(sp["end"] for sp in spans)
    total = t_end - t0
    if total <= 0:
        total = 1.0

    W = 900
    timeline_w = W - 220
    row_h = 20
    name_order = [
        "claude_code.interaction",
        "claude_code.llm_request",
        "claude_code.tool",
        "claude_code.tool.execution",
        "claude_code.tool.blocked_on_user",
    ]
    span_colors = {
        "claude_code.interaction": "#2196F3",
        "claude_code.llm_request": "#4CAF50",
        "claude_code.tool": "#FF9800",
        "claude_code.tool.execution": "#FF5722",
        "claude_code.tool.blocked_on_user": "#F44336",
    }
    # tool.executionにtool名を付ける
    tool_log_map = {}
    for log in data["logs"]:
        if log["body"] == "claude_code.tool_result":
            tool_log_map[log["ts"]] = log["attrs"].get("tool_name", "?")

    def get_tool_label(sp):
        if sp["name"] != "claude_code.tool.execution":
            return sp["name"].split(".")[-1]
        # timing で紐付け
        for ts, tname in tool_log_map.items():
            if sp["start"] <= ts <= sp["end"] + 0.05:
                return f"exec:{tname}"
        return "exec:?"

    rows_by_name = defaultdict(list)
    for sp in sorted(spans, key=lambda x: x["start"]):
        rows_by_name[sp["name"]].append(sp)

    rows = []
    for name in name_order:
        for sp in rows_by_name.get(name, []):
            rows.append((name, sp))

    H = max(100, len(rows) * row_h + 50)

    html = ['<!DOCTYPE html><html><head><meta charset="utf-8">',
            '<title>Claude Code OTEL タイムライン</title>',
            '<style>',
            'body{font-family:monospace;font-size:11px;background:#fff}',
            'svg{display:block;border:1px solid #ddd}',
            '</style></head><body>',
            f'<h3>タイムライン (total {total:.2f}s)</h3>',
            f'<svg width="{W}" height="{H}">']

    # グリッド線
    for pct in range(0, 110, 10):
        x = 200 + timeline_w * pct / 100
        html.append(f'<line x1="{x:.0f}" y1="0" x2="{x:.0f}" y2="{H}" '
                    f'stroke="#eee" stroke-width="1"/>')
        if pct % 20 == 0:
            t = total * pct / 100
            html.append(f'<text x="{x:.0f}" y="{H-4}" text-anchor="middle" fill="#999">{t:.1f}s</text>')

    for i, (name, sp) in enumerate(rows):
        y = i * row_h + 4
        x1 = 200 + timeline_w * (sp["start"] - t0) / total
        x2 = 200 + timeline_w * (sp["end"] - t0) / total
        bw = max(x2 - x1, 2)
        color = span_colors.get(name, "#90A4AE")
        label = get_tool_label(sp)
        html.append(f'<rect x="{x1:.1f}" y="{y}" width="{bw:.1f}" height="{row_h-3}" '
                    f'fill="{color}" opacity="0.85" rx="2">'
                    f'<title>{name} {sp["wall"]:.3f}s</title></rect>')
        html.append(f'<text x="196" y="{y + row_h - 6}" text-anchor="end" fill="#333">{label}</text>')
        if bw > 30:
            html.append(f'<text x="{x1+2:.1f}" y="{y + row_h - 6}" fill="white">{sp["wall"]:.2f}s</text>')

    html.append('</svg>')

    # 凡例
    html.append('<p style="margin-top:8px">')
    for name, color in span_colors.items():
        short = name.split(".")[-1]
        html.append(f'<span style="background:{color};color:white;padding:2px 6px;margin:2px;'
                    f'border-radius:3px;display:inline-block">{short}</span>')
    html.append('</p></body></html>')

    out = out_dir / "timeline.html"
    out.write_text("\n".join(html), encoding="utf-8")
    return out


# ────────────────────────────────────────────────────────────
#  比較モード (複数 JSONL)
# ────────────────────────────────────────────────────────────

def write_compare_html(all_agg: list[tuple[str, dict]], out_dir: Path) -> Path:
    """複数 run の wall 内訳を横並べした比較 HTML を生成する。"""
    if not all_agg:
        return None

    W_per = 200
    PAD = 30
    max_wall = max(a["total_interaction_wall"] for _, a in all_agg)
    bar_max_h = 240
    W = len(all_agg) * (W_per + PAD) + 60
    H = bar_max_h + 120

    html = ['<!DOCTYPE html><html><head><meta charset="utf-8">',
            '<title>オーケストレーション構成比較</title>',
            '<style>body{font-family:monospace;font-size:11px}</style>',
            '</head><body>',
            f'<h3>構成比較 (最大 wall {max_wall:.1f}s 基準)</h3>',
            f'<svg width="{W}" height="{H}">']

    labels_order = [("LLM推論", "llm"), ("ツール", "tool"), ("権限待ち", "blocked"), ("その他", "other")]

    for ci, (label, agg) in enumerate(all_agg):
        x_base = 40 + ci * (W_per + PAD)
        wall = agg["total_interaction_wall"]
        if max_wall <= 0:
            continue
        total_h = bar_max_h * wall / max_wall

        segs = [
            ("LLM推論", agg["llm_total_wall"], _SVG_COLORS["llm"]),
            ("ツール", agg["tool_exec_total"], _SVG_COLORS["tool"]),
            ("権限待ち", agg["blocked_total"], _SVG_COLORS["blocked"]),
            ("その他", max(0, wall - agg["llm_total_wall"] - agg["tool_exec_total"] - agg["blocked_total"]), _SVG_COLORS["other"]),
        ]
        y_top = 20 + (bar_max_h - total_h)
        y = y_top
        for seg_name, seg_val, color in segs:
            seg_h = bar_max_h * seg_val / max_wall if max_wall > 0 else 0
            html.append(f'<rect x="{x_base}" y="{y:.1f}" width="{W_per-10}" height="{max(seg_h,0):.1f}" '
                        f'fill="{color}"><title>{seg_name}: {seg_val:.2f}s</title></rect>')
            if seg_h > 14:
                html.append(f'<text x="{x_base + (W_per-10)//2}" y="{y + seg_h/2 + 4:.1f}" '
                             f'text-anchor="middle" fill="white" font-size="10">{seg_val:.1f}s</text>')
            y += seg_h

        html.append(f'<text x="{x_base + (W_per-10)//2}" y="{bar_max_h + 30:.1f}" '
                    f'text-anchor="middle" font-weight="bold">{label}</text>')
        html.append(f'<text x="{x_base + (W_per-10)//2}" y="{bar_max_h + 45:.1f}" '
                    f'text-anchor="middle" fill="#555">{wall:.1f}s</text>')

        tok = agg["main_input"] + agg["main_cache_read"]
        html.append(f'<text x="{x_base + (W_per-10)//2}" y="{bar_max_h + 60:.1f}" '
                    f'text-anchor="middle" fill="#777">in:{tok:,}tok</text>')

    # 凡例
    lx = 40
    for seg_name, key in labels_order:
        html.append(f'<rect x="{lx}" y="{H-20}" width="12" height="12" fill="{_SVG_COLORS[key]}"/>')
        html.append(f'<text x="{lx+15}" y="{H-10}" fill="#333">{seg_name}</text>')
        lx += 100

    html.append("</svg></body></html>")

    out = out_dir / "compare.html"
    out.write_text("\n".join(html), encoding="utf-8")
    return out


# ────────────────────────────────────────────────────────────
#  CLI エントリポイント
# ────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="Claude Code OTEL JSONL 分析")
    parser.add_argument("jsonl", nargs="*", help="分析する JSONL ファイル(複数可)")
    parser.add_argument("--out-dir", default="/tmp/claude-otel-analysis",
                        help="出力ディレクトリ (default: /tmp/claude-otel-analysis)")
    parser.add_argument("--compare", action="store_true",
                        help="複数 JSONL を比較モードで集計する")
    parser.add_argument("--latest", action="store_true",
                        help="~/.claude/otel/ の最新 JSONL を自動で選ぶ")
    args = parser.parse_args()

    out_dir = Path(args.out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    jsonl_paths = [Path(p) for p in args.jsonl]
    if args.latest:
        otel_dir = Path.home() / ".claude" / "otel"
        candidates = sorted(otel_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
        candidates = [c for c in candidates if c.stat().st_size > 0]
        if not candidates:
            print("~/.claude/otel/ に有効な JSONL が見つかりません")
            return
        jsonl_paths = [candidates[0]]
        print(f"最新 JSONL: {jsonl_paths[0]}")

    if not jsonl_paths:
        parser.print_help()
        return

    if args.compare and len(jsonl_paths) > 1:
        all_agg = []
        for p in jsonl_paths:
            data = load_jsonl(p)
            agg = aggregate(data)
            label = p.stem
            all_agg.append((label, agg))
            sub = out_dir / label
            sub.mkdir(exist_ok=True)
            write_report_md(agg, sub, label)
            write_tool_csv(agg, sub)
            write_report_html(data, agg, sub, label=label, source_path=str(p))
        cmp = write_compare_html(all_agg, out_dir)
        print(f"compare:  {cmp}")
        print(f"per-run:  {out_dir}/<name>/report.html (SVG 埋め込み1枚もの)")
    else:
        p = jsonl_paths[0]
        data = load_jsonl(p)
        agg = aggregate(data)
        write_report_md(agg, out_dir, p.stem)
        write_tool_csv(agg, out_dir)
        write_interaction_csv(agg, out_dir)
        write_report_html(data, agg, out_dir, label=p.stem, source_path=str(p))

    print(f"report:   {out_dir}/report.html  ← ブラウザで開いて確認(SVG inline、1枚もの)")
    print(f"markdown: {out_dir}/report.md")
    print(f"csv:      {out_dir}/tool_summary.csv")


if __name__ == "__main__":
    main()


私が望むフォーマットでHTMLをいつも作ってほしいので、好きな構成をSkillで固定しています。


scripts/analyze_claude_otel.py がやることは以下の流れです。
①:tool.execution スパンを tool_result ログの tool_name と紐付けて集計(スパン側にはツール名が入らないので、ログとタイムスタンプで紐付けます)
②:token.usage を main/auxiliary モデル別・cache read/create 別で整理
③:tool.blocked_on_user の wall を「権限待ち時間」として分離
④:report.html という1枚完結のリッチHTMLを生成(SVG を inline 埋め込み)

なぜ HTML を1ファイルにまとめたか

最初は「wall_breakdown.svg」「tool_bar.svg」「timeline.html」とバラバラに出していたんですが、ファイル管理や添付が面倒でした・・

1ファイル化するとブラウザで開くだけで全部見られる(複数ファイルの相対パスを気にしなくていい)し、誰かに共有したいときには report.html を1つ投げれば済むのは魅力的です。

(ちょうどこれを試していたころ、世間で「mdではなくHTML出力の時代だ!」的な盛り上がりが始まっていて運命を感じました 笑)

今回の Skill で作った report.html の中身

生成されるのはこんな構成の1枚ものです!

(折り畳み)サンプル①:単体Agentによる単発処理

FireShot Capture 009 - Claude Code OTEL レポート — B_A_0 - [wsl.localhost].png

(折り畳み)サンプル②:SubAgent 使用を命じた連携処理(Agent定義を作るまではせず)

FireShot Capture 008 - Claude Code OTEL レポート — B_orch_1 - [wsl.localhost].png

「所見」セクションの設定が個人的なお気に入りで、充実化しようとしています。
LLM比率・手動待ち・キャッシュ状況・エラーからスクリプトが自動で見立てメモを書いてくれます。ここを読むだけで「次に何を改善すればいいか」がだいたい分かります(分かるようにしたいです)。


ということで、以下で実際に動かした実例をご紹介します。

実例①)実際に動かしてみて結果を理解する流れ

お試し用にFlaskプロジェクトをほぼサンプルで作ってみたので、これの改修タスクを例に分析します。
「このリポジトリに /health エンドポイントを追加して」という実装タスクを単一エージェントで流したセッションを分析してみたときのセッション概要が以下になります。

セッション概要
- wall time:    144.8 s
- LLM 推論:     29.9 s  (8 calls)
- ツール実行:   1.1 s   (Bash, Edit, Read)
- 権限待ち:     120.3 s  ← !!★
- 推定コスト:   $0.137

上記のClaudeの分析結果から、権限待ちがおよそ2分あることが分かります。Edit ツールへの許可プロンプトを手動で承認した(うっかり私がよそ事をしていた)ためです。wall の大部分が「人間が Enter を押してくれるのを待っていた時間」でした(実質の処理時間は20秒程度)。

今回のケースなら、 .claude/settings.json"Edit""Write" の許可を追加するだけで、当然この時間はゼロになります。またはhooksで自分がすぐ気付けるような通知を仕込むのもいいですね!(私は勝手にファイルを更新してほしくないときは、すぐ気付けるようにチャットアプリで自分宛に通知を送っています)

こういった改善余地をログから自然言語で分析できるのが面白いところで、Grafana のダッシュボードを眺めるよりも「次に何をすればいいか」の行動につながり易いのでは?!と考えています。

実例②)キャッシュ効率も数字で見える

調査タスクで「このリポジトリの認証の仕組みを説明して」というお試しもやってみました。
こちらは権限待ちほぼゼロでしたが別の発見がありました。

トークン (claude-sonnet-4-6 / main)
- input:         530
- output:        661
- cache read:    93,644  ← !
- cache created: 32,933
- 推定コスト:    $0.165

cache read: 93,644 tokens と、input/output よりも明らかに多いです。これは ~/.claude/skills/ に入っている Skill の description 群がキャッシュから読まれているものです。
私はつい Skill をたくさん入れてしまいがちなのですが、それがプロンプトキャッシュとして効いてくる ことが数字でよくわかりました。
※ 「キャッシュしてくれてありがたい!」という話と、「キャッシュもタダじゃないから適当にSkill入れないように気をつけなきゃ・・」という思いが混ざって複雑ですね..。そもそも Skills も入れるほどトークンを使ってしまうので、たくさん入れりゃいいってもんじゃないことが実感できました。


ちなみに、期待通り otelcol-contrib のプロセスは(比較的)軽い!

ざっくり、Claudeが350MBのメモリ消費で、otelcol-contrib は CPUはごくわずかで、メモリ消費は150MBといったところでした!

image.png

軽め(150MB使うならそれなりにリッチとも言えますが・・)のリソース消費で動かせて嬉しいです!

Claude で「自然言語による分析」も!当然いけます!

Skill を入れた状態で「さっきのセッションを分析して」と言うと、Claude は以下のように応答してくれました。

最新の JSONL を分析しました。report.html を出力しています。

気になる点:

  • 手動の権限待ちが 2 回 / 合計 10.3s 発生しています。.claude/settings.jsonEditWrite の許可を追加すると削減可能です。
  • cache_creation が 5,712 tokens。2回目以降のセッションでは cache_read に変わるため、同じリポジトリで連続作業するとコストが下がっていきます。

エラー: internal_error が 1 件。Claude Code 2.1.x の既知の内部 SyntaxError と思われ、タスクには影響なし。

何かリッチなツールを使っていた場合あるあるの「グラフを見に行く」手間がなくなって、「気になる点と改善の方向」まで自然言語で返ってくる 点がありがたいです!

今回は詳細記載していませんが、Agent間で役割を分けてTDDを厳密に進めて・・などしていると、「何のせいで遅いのか」「変なときにやたら待っているのはなぜか」などが自然言語で分析できて、更にその改善がまる分かりになるので開発体験がとっても良かったです。


今回のまとめ(勝手にGrafanaやLangfuse構成をライバルに..)

ローカル OTEL + Skill Grafana/Langfuse/CloudWatch
セットアップ otelcol-contrib + bashrc 関数 外部サービス or セルフホスト でアプリ起動
観測方法 Claude自身に「最新のセッションを分析して」と聞く ダッシュボードを開いてクエリ
出力形式 SVG 入りリッチHTML + 自然言語 グラフ
向いている場面 個人・閉域環境・即席で見たいとき 長期トレンド・チーム共有 ★これは圧倒的

今回は、個人利用のケースを意識して OTEL を「Grafana らの o11y ツールに渡す素材」ではなく「Claude 自身に読ませる入力」として使いました。
AI Agentを用いた開発の観測/改善のコスト(気の重さ)を軽くして、改善のループ取り入れ易くなることが一番の価値だと思っています。

ただし、あくまでこの方法は個人利用に強いだけなので、長い目で見て長期トレンドはどう変わっているか?組織のみんながどのくらい・どんなモデルを使っているか?などを知りたいときは変わらず Grafana/Langfuse/CloudWatch らを使いたいですね!

ご参考として、、anthropics リポジトリでも、以下のようにダッシュボード定義が提供されています。


展望として、今後やりたいこと

このセットアップを活かして、以下なども進めていきたいとてます!

  • コンパクション前後の影響: Claudeの過去セッションファイルと紐づけることで、コンパクションの発生に伴ってどのように前提が抜け落ちたか・どう悪影響を及ぼしたのかを実際の入力から確認する
  • 構成の比較: 同じタスクを「単一エージェント」「Explore 先行」「Plan→Impl→Review」「並列 fan-out」で流して wall/tokens を並べると、「どの構成がどのタスクに向いているか」が数字で出せそう
  • Skill 数の影響: ~/.claude/skills/ の Skill が増えると内部処理(auxiliary の haiku モデル)の負荷が上がる。何個まで入れても体感速度・コストに影響が出ないか測ってみたい
  • 権限待ち削減の効果: fewer-permission-prompts Skill で許可リストを整備したとき、tool.blocked_on_user がどれだけ減るか定量的に見たい
  • モデル性能の定点?観測「あれ?なんか昨日からモデルが 賢くorアホ になった?!」を定量的に測る
  • などなど

↑らもこの先記事にしていく予定(またはどなたか記事にされましたらリンクさせてください・・笑)なので、ぜひまたお付き合いください!


この記事はここまでです!ありがとうございました!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?