データは取れた、でも会議では使えなかった
Claude Code Analytics API を組織に入れたとき、最初に作ったのは「日別のトークン消費とコストを並べたダッシュボード」でした。数字はきれいに出ます。けれども本番運用に入って月次の予算会議に持っていくと、毎回同じ質問で止まりました。「この金額は、どの仕事に使われたお金なの?」と。
席ごとの消費量は分かります。ところが席と仕事は一対一ではありません。一人が複数のリポジトリを横断し、ある週は障害対応に張りつき、別の週は新機能を一気に書く。席単位の数字は「誰が使ったか」を答えますが、予算管理が本当に知りたいのは「何に使ったか」です。私自身、個人開発で複数のアプリとブログを並行して回しているので、コストを人ではなく仕事に紐づけないと判断材料にならないことは身に染みていました。
この記事は、Analytics API の生データを「予算会議で使える形」に変えるまでに踏んだ三つの段階 — 作業単位のコスト帰属、無駄席の検出、誤報しないアラート — を、実際に動かしているコードとともに整理したものです。導入手順そのものではなく、データを取った後に詰まる部分に焦点を当てます。
まず生データの粒度を確認する
Analytics API は日単位でメトリクスを返します。最初にやるべきは、返ってくるデータの粒度を正確に把握することです。ここを曖昧にしたまま集計すると、後段のコスト帰属が全部ずれます。
import os
import httpx
BASE = "https://api.anthropic.com/v1/organizations/usage_report/claude_code"
def fetch_daily (starting_at: str , ending_at: str ) -> list[ dict ]:
"""日別の Claude Code 利用レコードを取得する。
1レコード = 1日 × 1ユーザー × 1モデル の粒度で返る点に注意。"""
headers = {
"x-api-key" : os.environ[ "ANTHROPIC_ADMIN_API_KEY" ],
"anthropic-version" : "2023-06-01" ,
}
records, page = [], None
with httpx.Client( timeout = 30 ) as client:
while True :
params = { "starting_at" : starting_at, "ending_at" : ending_at, "limit" : 1000 }
if page:
params[ "page" ] = page
r = client.get( BASE , headers = headers, params = params)
r.raise_for_status()
body = r.json()
records.extend(body[ "data" ])
page = body.get( "next_page" )
if not page:
break
return records
ここでの勘所は二つあります。一つは、1レコードが「日 × ユーザー × モデル」の組であること。同じ人が同じ日に Sonnet と Opus を両方使えば、その人の1日分は2レコードに分かれます。集計時に sum の対象を間違えると、モデル混在の席でコストが二重に見えたり消えたりします。
もう一つは、ページネーションを必ず最後まで回すことです。limit を大きくしても全件は返りません。next_page が null になるまでループしないと、月末に近い大規模チームでは静かに数日分が欠落します。これは本番運用で実際に踏んだ落とし穴で、最初は気づかず月初の数字だけで予算を語って外しました。対処は単純で、next_page が尽きるまで必ず回すことです。
段階1: 席ではなく作業にコストを帰属させる
予算会議で効いたのは、コストを「人」ではなく「リポジトリ群=作業ライン」に寄せ直したことです。Analytics API 単体ではリポジトリ情報は細かく取れないため、社内の「誰がどのプロジェクトを主担当か」という台帳と突き合わせます。台帳は完璧でなくて構いません。主担当の8割が当たっていれば、会議の議論は一気に進みます。
from collections import defaultdict
# 台帳: ユーザー → 作業ライン(主担当プロジェクト)
SEAT_TO_LINE = {
"alice@example.com" : "決済基盤" ,
"bob@example.com" : "モバイルアプリ" ,
"carol@example.com" : "モバイルアプリ" ,
"dave@example.com" : "社内ツール" ,
}
# モデル別の概算単価(1Mトークンあたりのドル。自社の請求実績で随時更新する)
PRICE_PER_MTOK = {
"claude-opus-4-8" : { "input" : 15.0 , "output" : 75.0 },
"claude-sonnet-4-6" : { "input" : 3.0 , "output" : 15.0 },
}
def cost_of (record: dict ) -> float :
model = record.get( "model" , "" )
price = PRICE_PER_MTOK .get(model)
if not price:
return 0.0
inp = record.get( "input_tokens" , 0 ) / 1_000_000
out = record.get( "output_tokens" , 0 ) / 1_000_000
return inp * price[ "input" ] + out * price[ "output" ]
def cost_by_line (records: list[ dict ]) -> dict[ str , float ]:
totals = defaultdict( float )
for rec in records:
line = SEAT_TO_LINE .get(rec.get( "actor_email" ), "未分類" )
totals[line] += cost_of(rec)
return dict ( sorted (totals.items(), key =lambda kv: kv[ 1 ], reverse = True ))
この集計を入れてから、会議の問いが「誰が高いのか」から「どの作業ラインに投資しているのか」に変わりました。後者は責める問いではなく、判断する問いです。「決済基盤に先月の4割が乗っているが、それは意図どおりか」という会話ができると、コストの議論が前向きになります。
「未分類」が増えてきたら台帳の更新サインです。私は未分類が全体の15%を超えたら台帳を見直す、という緩い目安で運用しています。きっちり0%を目指すより、ずれを検知する仕組みを残すほうが長続きします。
段階2: 「契約しているのに使っていない席」を毎週あぶり出す
コスト削減で一番効いたのは、高い席を絞ることではなく、使われていない席を返上することでした。Claude Code はシート課金が絡むため、アクティブでない席はそのまま固定費になります。Analytics API のセッション数とコード変更行数を使うと、無駄席を機械的に拾えます。
from datetime import date, timedelta
def idle_seats (records: list[ dict ], roster: set[ str ], days: int = 14 ) -> list[ dict ]:
"""直近 days 日でほぼ稼働していない席を返す。
判定: セッション0 もしくは(セッションはあるが追加行数が極端に少ない)。"""
activity = defaultdict( lambda : { "sessions" : 0 , "lines_added" : 0 })
for rec in records:
a = activity[rec.get( "actor_email" )]
a[ "sessions" ] += rec.get( "num_sessions" , 0 )
a[ "lines_added" ] += rec.get( "lines_added" , 0 )
flagged = []
for email in roster:
a = activity.get(email, { "sessions" : 0 , "lines_added" : 0 })
if a[ "sessions" ] == 0 :
flagged.append({ "email" : email, "reason" : "完全未稼働" , ** a})
elif a[ "lines_added" ] < 20 :
flagged.append({ "email" : email, "reason" : "ほぼ稼働せず" , ** a})
return sorted (flagged, key =lambda x: (x[ "sessions" ], x[ "lines_added" ]))
ここで意図的に二段階の判定にしているのは、セッション数だけでは判断を誤るからです。ログインだけして実作業をしていない席は、セッション数では「使っている」ように見えます。追加行数という別の角度を足すと、「開いてはいるが書いていない」席が浮かび上がります。
注意したいのは、無駄席リストを人事評価に使わないことです。あくまで「席の配り直し」の材料に留めます。育休・長期の設計フェーズ・レビュー専任など、書かない理由は正当なものがいくらでもあります。私はこのリストに必ず「理由を1行添える列」を設けて、機械の判定に人の文脈を上書きできるようにしています。数字は会話のきっかけであって、結論ではありません。
段階3: 誤報で形骸化しないアラートを設計する
最初に作ったコストアラートは「1日の消費が閾値を超えたら通知」でした。これは2週間で誰も見なくなりました。週末に下がり平日に上がる自然な波で毎回鳴るからです。アラートは、鳴ったら必ず行動する状態を保てなければ意味がありません。
そこで三つの条件を組み合わせ、いずれかではなく「複数が同時に立ったとき」に通知する設計に変えました。
条件 何を捉えるか 採用した閾値
前週同曜日比 曜日の波を打ち消した上での急増 +60% 以上
7日移動平均からの乖離 じわじわ上がる傾向の検知 移動平均の 1.5倍 以上
絶対額の下限 金額が小さいうちの誤報抑制 1日 $50 未満は対象外
def should_alert (today_cost: float , same_dow_last_week: float , ma7: float ) -> bool :
if today_cost < 50 : # 金額が小さいうちは鳴らさない
return False
spike_vs_lastweek = same_dow_last_week > 0 and today_cost >= same_dow_last_week * 1.6
spike_vs_trend = ma7 > 0 and today_cost >= ma7 * 1.5
return spike_vs_lastweek and spike_vs_trend
絶対額の下限を入れたのが効きました。導入初期や小規模チームでは、率で見ると簡単に倍増しますが、金額としては誤差です。率の条件だけだと、立ち上げ期に鳴りっぱなしになって信頼を失います。
そして二つの率の条件を and で結んだのも意図的です。前週比だけだと祝日明けに鳴り、移動平均だけだと連休前の駆け込みで鳴ります。両方が同時に立つのは「曜日要因でも一時的な波でもない、傾向としての急増」のときだけです。アラートは数を絞るほど価値が上がる、というのが運用してみての実感です。
振り返り
Analytics API が返すのはあくまで素の数字で、それ自体は予算管理を解決してくれません。効いたのは、次の三点でした。
コストを人ではなく作業ラインに帰属させ、議論を前向きにすること
高い席ではなく使われていない席を毎週あぶり出すこと
アラートを複数条件の同時成立に絞って誤報を消すこと
どれも派手な機能ではなく、生データと現場の文脈をつなぐ薄い変換層です。
次の一歩としては、作業ラインの台帳をスプレッドシートで1枚用意し、まず cost_by_line だけを回してみることをおすすめします。「どの仕事にいくら使っているか」が見えた瞬間に、残りの二つが必要かどうかも自然と判断できるようになります。同じようにチーム導入のコスト管理で詰まっている方の参考になれば幸いです。