TL;DR
- Claude Code は
CLAUDE_CODE_ENABLE_TELEMETRY=1で、セッション数・トークン量・コスト・ツール判断といった指標を OTLP で正式にエクスポートできる。 - これを重量級バックエンド(Grafana スタック等)を立てずに、OTel Collector → JSON Lines → SQLite の最小構成で durable に受ける方法を作った。
- ハマりどころが3つあった。(1) redaction を書いたのに account identity が剥がれていない コンテキスト階層の取り違え、(2) Collector 再起動でファイルが truncate されて欠損、(3) 設定を入れても 「次の新規セッション」からしか効かない。
- 教訓: 計測パイプラインの検証は、課金される実セッションを回すのではなく 合成 OTLP データで回すのが速くて安全。
なぜエージェントの「運用」を計測するのか
AI にコードを書かせる運用を続けていると、モデルのベンチマークよりも「自分たちのエージェント運用が実際どう動いているか」のほうが知りたくなる。1日に何セッション走ったか、トークンとコストはどう推移しているか、コード編集ツールの提案はどれくらい受理/却下されているか——こういう 運用のゲージ は、per-task の Markdown レポートを眺めているだけでは見えてこない。
幸い Claude Code は OpenTelemetry を第一級でサポートしていて、環境変数ひとつで metrics / logs / traces(beta) を吐き出せる。今回はまず metrics だけ を、できるだけ軽い常駐で継続収集する基盤を作った。この記事はその実装手順と、途中で踏んだ落とし穴の記録です。
計測対象は自社の AI ワーカー運用リポジトリ(複数の AI エージェントが同じマシンを共有して動く構成)。固有名詞は伏せますが、構成自体は汎用的なので読み替えて再現できるはずです。
先に用語を揃えておく
以降の話は、次の4つが分かっていれば読めます。逆にここが曖昧だと、実装パートが「なぜこの設定を書くのか」が見えないまま呪文になってしまうので、先に済ませておきます。
- OpenTelemetry(OTel): アプリケーションが出す「メトリクス(数値の計測値)」「ログ」「トレース(処理の流れの記録)」を、特定のベンダーに縛られない共通フォーマットで集めるための業界標準。ざっくり言えば「計測データの共通言語」。今回使うのは metrics(数値の計測値)だけ。
- OTLP: OpenTelemetry がデータをやり取りするときの通信プロトコル(OpenTelemetry Protocol の略)。Claude Code は、動いている最中の状態(セッション数やコストなど)を、このOTLP形式でネットワーク越しに送り出せる。
-
OTel Collector: OTLPで送られてきたデータを受け取り、必要なら中身を加工・フィルタして、好きな保存先に流す「中継役」のプログラム。今回使う
otelcol-contribはそのCollectorの実装のひとつ。届いたデータをそのまま右から左に流すだけでなく、「この項目は途中で消す」といった処理(後述のtransform)を挟めるのがポイント。 - 属性(attribute): 1件のデータに付いてくる「タグ」や「メタデータ」。例えば「どのモデルを使ったか」「どのセッションか」といった情報が、数値そのものとは別に付随してくる。この記事のハマりどころの多くは、この「属性がどこに付いているか」を巡る話。
これだけ分かれば、以降は「OTLPでデータを送る側(Claude Code)→ OTLPで受けて中継するCollector → 最終的な保存先(SQLite)」という3段構えの話として読めます。
全体像
Claude Code (OTLP exporter)
│ grpc://localhost:4317
▼
OTel Collector (otelcol-contrib)
│ receivers: otlp
│ processors: transform ← account identity を削る
│ exporters: file ← JSON Lines で追記
▼
data/otel_raw/events.jsonl
│ 5分ごとに ingest(systemd timer)
▼
SQLite (whitelist schema)
│
▼
効果測定・ゲージレポート
「なぜ Prometheus + Loki + Grafana にしないのか」というと、常駐プロセスを4つ増やす前に そもそも継続収集して何が見えるか を安く確かめたかったから。既存の運用が Markdown 中心だったので、まずは SQLite に貯めて後から集計、という最小構成から始めた。有用性が見えたらフルスタックへ拡張する、という段取りです。
実装
1. Claude Code 側でテレメトリを有効化
ユーザー設定(~/.claude/settings.json)に環境変数を足す。
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"
}
}
ここでの判断がひとつ。ドキュメントには OTEL_LOGS_EXPORTER もあるが、今回は入れなかった。後述する Collector が metrics パイプラインしか持っていないので、logs を送っても受け側に行き場がなくエラーになるだけだからです。最小構成では「送る信号」と「受ける信号」を一致させておくのが素直。
なお、プロンプト本文・応答本文・ツール入出力は OTEL_LOG_* 系フラグがすべてデフォルト OFF なので出ません。今回集めるのは件数・トークン数・コスト・ツール判断といった 集計指標だけ です。
2. OTel Collector の設定
otelcol-contrib(今回は v0.156.0)を使う。設定はこれだけ。
receivers:
otlp:
protocols:
grpc:
endpoint: localhost:4317
http:
endpoint: localhost:4318
processors:
transform:
metric_statements:
- context: datapoint
statements:
- delete_key(attributes, "user.email")
- delete_key(attributes, "user.account_uuid")
- delete_key(attributes, "user.account_id")
- delete_key(attributes, "organization.id")
exporters:
file:
path: data/otel_raw/events.jsonl
append: true
service:
pipelines:
metrics:
receivers: [otlp]
processors: [transform]
exporters: [file]
transform プロセッサで account identity を削っているのがポイント。これは事前検証で分かった 重要な事実 に対応している——Claude Code は redaction フラグの設定に関係なく、user.email / user.account_uuid / user.account_id / organization.id を すべてのシグナル(metrics も含む)に標準属性として常時付与する。OTEL_LOG_* フラグはプロンプト/応答/ツール内容をゲートするだけで、この account identity には効かない。だから収集側で明示的に落とす必要がある。
(この delete_key の書き方には落とし穴があった。後述します。)
3. ファイル出力を SQLite に取り込む
Collector が吐いた events.jsonl を、5分ごとに読んで SQLite(軽量なファイルベースのデータベース)に upsert する小さな Python を書いた。肝は スキーマをホワイトリストにする こと——「入れていい項目だけをあらかじめ決めておき、それ以外は保存の時点で無視する」という設計です。
CREATE TABLE IF NOT EXISTS events (
event_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
event_type TEXT NOT NULL,
timestamp TEXT NOT NULL,
value REAL,
decision TEXT,
model TEXT,
issue_number INTEGER
);
必要なカラムしか持たないので、仮に生 JSON 側に余計な属性が残っていても、分析対象の DB には構造的に入り込めない。redaction を二重にしておく発想です(後述のハマりで、この二重化が効いてくる)。
OTLP metric 名は自分たちの event_type に写像する。
METRIC_NAME_MAP = {
"claude_code.cost.usage": "cost.usage",
"claude_code.token.usage": "token.usage",
"claude_code.session.count": "session.count",
"claude_code.code_edit_tool.decision": "tool.decision",
"claude_code.active_time.total": "active_time.total",
}
4. systemd で常駐・定期実行
Collector も取り込みスクリプトも、手動で都度実行するのではなく、OS 起動時から裏で動かし続けたい。Linux にはそのための仕組み(systemd)が標準で入っているので、それを使う。Collector は常駐サービス、取り込みは 5 分ごとの timer にした(いずれも自分のユーザー権限だけで動く「user unit」という設定単位)。
# collector.service(抜粋)
[Service]
Type=simple
WorkingDirectory=%h/<repo>
ExecStart=%h/.local/bin/otelcol-contrib --config %h/<repo>/tools/otel_collector_config.yaml
Restart=on-failure
# ingest.timer(抜粋)
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
systemctl --user daemon-reload
systemctl --user enable --now agent-telemetry-collector.service agent-telemetry-ingest.timer
これで Everything is ready. Begin running and processing data. が出れば受信待ち状態。
ハマった3つのこと
ここからが本題。動かすまでに踏んだ落とし穴を、直した順ではなく 効いた順 に。
ハマり1: redaction を書いたのに identity が剥がれていない
transform の context: datapoint は、その名のとおり データポイント階層の属性 を対象にする。ところが OpenTelemetry の慣習では、user.* や organization.* のような属性は リソース階層(resource attributes) に置かれるのが定石。
つまり「datapoint の属性を消す」文をいくら並べても、リソース階層に付いた identity は素通りする。
合成データで検証したときに、これが可視化された。データポイントに付けた user.account_uuid はちゃんと消えたのに、リソースに付けた user.email は生の JSONL にそのまま残っていた。
// events.jsonl の1行(抜粋)— datapoint の uuid は消えたが…
"resource": { "attributes": [
{ "key": "service.name", "value": {"stringValue": "claude-code"} },
{ "key": "user.email", "value": {"stringValue": "***@***"} } // ← 残っている
]}
この時点で分かったのは、次の一般則。
datapoint 階層だけを対象にした transform は、resource 階層の属性を削除しない。
教訓は単純で、「どの階層に付いている属性を消したいのか」を先に確認してから transform を書く。resource 階層も落としたいなら context: resource の文を別途足す必要がある。redaction は「書いたつもり」が一番あぶない。
救いだったのは、取り込み側の SQLite を ホワイトリストスキーマ にしていたこと。生 JSONL に identity が残っても、DB のカラムに user.email を受ける場所がないので、分析対象には流入しない。さらに生 JSONL 自体は .gitignore 済みでローカルから出ない。「収集側 redaction」と「保存側スキーマ」の二段構えにしておくと、片方の取りこぼしをもう片方が受け止めてくれる。とはいえ transform の取りこぼしはバグなので、直すべきは直す。
正直に補足すると、「実際の Claude Code が identity を resource 階層に付けるか datapoint 階層に付けるか」は、本番データを流して最終確認するべきところ。合成データで分かったのは「datapoint しか消していない transform は resource 階層を取りこぼす」という一般的な事実まで。ここは断定せず、自分のテレメトリ源がどちらに付けるかを必ず自分の目で確かめてほしい。
ハマり2: Collector 再起動で JSONL が truncate される
file exporter は、デフォルトだと 起動のたびにファイルを truncate する。systemd の Restart=on-failure と組み合わさると、Collector が落ちて再起動するたびに events.jsonl が空に戻る。
これ単体でも痛いが、取り込み側が 「何行目まで読んだか」をカーソルで覚えている 設計だと二次被害が出る。
Collector が再起動
↓
events.jsonl が空になる
↓
取り込み側のカーソルは以前の行数を覚えたまま
↓
「もうそこまで読んだ」と誤認する
↓
再起動後のテレメトリを恒久的に読み飛ばす
対策は exporter に append: true を足すだけ。直したら手動で Collector を再起動し、(1) 再起動前の行がファイルに残っている、(2) 再起動後のデータが末尾に追記される、(3) SQLite 取り込みのカーソルが連続して進む、の3点を確認しておくと安心です。この1行を入れ忘れると、症状が「たまにデータが飛ぶ」という再現しづらい形で出る。データ欠損は後から気づいても穴が埋まらないので、実装時のコードレビューで早めに潰しておきたい類のバグです。
ハマり3: 設定を入れても「今このセッション」には効かない
~/.claude/settings.json に telemetry の環境変数を入れても、すでに起動しているセッションからはテレメトリが出ない。環境変数は起動時に読まれるので、効くのは 次に立ち上げる新規セッションから。
当たり前と言えば当たり前だが、「設定した→すぐ確認しよう」としてデータが1件も来ず、Collector や設定を疑って時間を溶かしがち。切り分けに迷わないよう、確認の順番を決めておくとよい。
- Collector を起動する
-
settings.jsonを更新する - 既存の Claude Code セッションを終了する
- 新しいセッションを起動する
- JSONL への出力を確認する
パイプラインが生きているかどうかは、実セッションを待たずに次の方法で切り分けるのが速い。
パイプラインの検証は合成データで
計測基盤の検証で、わざわざ 課金される実 Claude Code セッションを回す必要はない。確かめたいのは「モデルがどう振る舞うか」ではなく「wire format が通るか(受信 → 変換 → 書き出し → 取り込み)」という配管の話だから。
やることは、OTLP/HTTP のエンドポイントに手で1発 POST するだけ。
curl -s -X POST http://localhost:4318/v1/metrics \
-H "Content-Type: application/json" --data @otlp_smoke.json
# → {"partialSuccess":{}} / HTTP 200
otlp_smoke.json には claude_code.session.count を1件、わざと account identity 属性を混ぜて 入れておく。resource 階層と datapoint 階層の両方に仕込んでおくと、どちらの階層で取りこぼしているかまで一度に切り分けられる(実データではなく検証用の固定文字列を使う)。
{
"resourceMetrics": [{
"resource": {
"attributes": [
{ "key": "user.email", "value": { "stringValue": "SHOULD_BE_REMOVED" } }
]
},
"scopeMetrics": [{
"metrics": [{
"name": "claude_code.session.count",
"gauge": { "dataPoints": [{
"asInt": "1",
"attributes": [
{ "key": "user.account_uuid", "value": { "stringValue": "SHOULD_BE_REMOVED" } },
{ "key": "session.id", "value": { "stringValue": "synthetic-session" } }
]
}]}
}]
}]
}]
}
こうすると、この1回のPOSTで次を同時に確かめられる。
- Collector が OTLP を受信できるか
- transform で identity が削除されるか(resource / datapoint 両階層とも)
- JSONL に出力されるか
- SQLite に正しく取り込まれるか
- ホワイトリスト外の属性が DB へ入らないか
events.jsonl の末尾と、取り込み後の SQLite を見れば、配管の全区間が一度に検証できる。ハマり1(identity が残る)に気づけたのも、この合成テストのおかげでした。
tail -n 1 data/otel_raw/events.jsonl # SHOULD_BE_REMOVED が残っていないか
SELECT event_type, session_id, value
FROM events
WHERE session_id = 'synthetic-session';
-- session.count | synthetic-session | 1 が返れば取り込みOK
PRAGMA table_info(events);
-- user.email / user.account_uuid 用のカラムが存在しないことも確認
実データを待つより速いし、課金もされないし、壊れたときにどの区間で壊れたかが切り分けやすい。計測系を作るときの定石としておすすめです。
おまけ: レビューは「書き忘れ」にも効いた
余談だが、今回の redaction 実装は、初回パスで transform プロセッサを 丸ごと入れ忘れていた。事前検証で「identity は全シグナルに常時付く」と分かっていたのに、設定に落とすのを忘れていた。これを拾ったのは、このプロジェクトでローカルの Codex CLI にかけているコードレビューの工程だった。
コードレビューというと「書いたコードの粗探し」を思い浮かべがちだが、計測基盤のような "設定=コード" の領域では、「書くべきだったのに書かれていないコード」の検出 のほうがむしろ効く。append: true の入れ忘れも transform の入れ忘れも、どちらも同じ「書き忘れ」型のバグだった。
結局どうなったか
| 項目 | 状態 |
|---|---|
| metrics 収集 | 稼働中。次の新規セッションから session.count / token.usage / cost.usage / code_edit_tool.decision が SQLite に貯まっていく |
| 導入前スナップショット | 別途取得済み。データが貯まれば前後比較(本当に運用が可視化されたか)ができる |
| 残タスク① | resource 階層の redaction を実データで最終確認して塞ぐ |
| 残タスク② | 貯まった指標を Markdown のゲージレポートに落とす |
OpenTelemetry の良いところは、こういう「重たいバックエンドを立てるほどではないが、生ログよりは構造化して継続的に貯めたい」という半端な要求に、受信と変換の部分だけ標準化された部品として乗ってくれること。出力先は SQLite でも Grafana でも後から差し替えられる。まず配管を通して、必要になったら広げる——という順番が取りやすいのが実感でした。
まとめ
- Claude Code の運用指標は OTLP でそのまま取れる。バックエンドは軽くて良い。
- redaction は どの階層の属性か を確認してから書く。取りこぼす前提で保存側スキーマも絞る。
-
fileexporter はappend: true。カーソル方式の取り込みと truncate は相性が最悪。 - 設定は次セッションから。配管の検証は合成 OTLP で。
参考:
- Claude Code monitoring & usage docs — 標準属性、
OTEL_METRICS_INCLUDE_ACCOUNT_UUID、OTEL_LOG_*コンテンツフラグ- OpenTelemetry Collector — transform processor
- OpenTelemetry Collector — file exporter
- OTLP specification



