なぜ Analytics API が必要か
Claude Code を組織全体に展開している場合、「本当に生産性は上がっているのか?」「誰がどれだけ使っているのか?」「コストは適切か?」という経営・管理層からの問いに答えることが重要になります。
Anthropic が提供する Claude Code Analytics Admin API は、こうした問いに定量的なデータで答えるための強力なツールです。Analytics API がどの粒度で何を返してくれるのかを押さえたうえで、実際に動くカスタムダッシュボードを組み上げるところまで、手を動かしながら進めます。
この記事を読み終えると、あなたは次のことができるようになります:
- Admin API キーを発行し、Analytics API を呼び出す
- TypeScript + Node.js で複数日分のデータを効率的に取得する
- チームごとの生産性メトリクス(コード行数・承認率・コスト)を集計する
- React を使ったリアルタイム更新可能なダッシュボードを構築する
- 本番環境で安全に運用するためのセキュリティ設計を実装する
対象読者は、Claude Code を組織で利用しているエンジニアまたは DevEx(開発者体験)チームのメンバーです。TypeScript と React の基礎知識があることを前提とします。
前提知識・環境構築
必要なもの
- Claude チームまたは Enterprise プランのアカウント(個人アカウントでは Analytics API は利用不可)
- 組織の Admin ロール(Admin API キーの発行に必要)
- Node.js 20 以上
- npm または pnpm
環境構築
まず、ダッシュボードプロジェクトのディレクトリを作成します:
mkdir claude-analytics-dashboard
cd claude-analytics-dashboard
npm init -y
npm install typescript tsx @types/node dotenv
npm install @anthropic-ai/sdk # 任意:SDK利用時
npm install express cors # APIサーバー用
npm install react react-dom @vitejs/plugin-react vite # フロントエンド用
npx tsc --init.env ファイルを作成し、Admin API キーを設定します:
# .env
ADMIN_API_KEY=sk-ant-admin-YOUR_KEY_HERE
ORGANIZATION_ID=your-org-id
PORT=3001Admin API キーの発行方法: Claude Console → Settings → Admin Keys から発行できます。通常の API キー(
sk-ant-api...)とは別物で、Admin キーはsk-ant-admin...で始まります。
プロジェクト構成
claude-analytics-dashboard/
├── src/
│ ├── api/
│ │ ├── analytics.ts # Analytics API クライアント
│ │ └── server.ts # Express APIサーバー
│ ├── components/
│ │ ├── Dashboard.tsx # メインダッシュボード
│ │ ├── MetricsCard.tsx # メトリクスカード
│ │ └── ProductivityChart.tsx # チャート
│ └── types/
│ └── analytics.ts # 型定義
├── .env
└── package.json
概念と設計思想
Analytics API のデータモデル
Analytics API は「1日 × 1ユーザー = 1レコード」という設計です。つまり、ある日にチームメンバーが 10 人 Claude Code を使えば、その日のレスポンスには 10 件のレコードが返ります。
各レコードには次の3種類のデータが含まれます:
コアメトリクス(生産性指標)
num_sessions:Claude Code セッション数lines_of_code.added/removed:追加・削除されたコード行数commits_by_claude_code:Claude Code 経由のコミット数pull_requests_by_claude_code:Claude Code 経由の PR 作成数
ツールアクション(承認率)
edit_tool.accepted/rejected:Edit ツールの採用・却下数write_tool.accepted/rejected:Write ツールの採用・却下数notebook_edit_tool.accepted/rejected:NotebookEdit の採用・却下数
モデル別コスト内訳
model_breakdown[].tokens:入出力・キャッシュのトークン数model_breakdown[].estimated_cost.amount:推定コスト(セント単位、USD)
なぜカーソルベースのページネーションが重要か
大規模な組織では、1日に数百人が Claude Code を使います。API は1回のリクエストで最大 1000 件まで返せますが、has_more: true の場合はページネーションが必要です。
カーソルは「このページの最後の位置」を表す不透明な文字列で、データの追加・更新があっても安定した結果を返す設計になっています。これにより、集計処理中にデータが変わっても、重複や欠落なく全件取得できます。
ステップバイステップ実装
Step 1:Analytics API クライアントの実装
まず、型定義と API クライアントを作成します:
// src/types/analytics.ts
export interface Actor {
type: "user_actor" | "api_actor";
email_address?: string; // user_actorの場合
api_key_name?: string; // api_actorの場合
}
export interface ToolActions {
edit_tool: { accepted: number; rejected: number };
multi_edit_tool: { accepted: number; rejected: number };
write_tool: { accepted: number; rejected: number };
notebook_edit_tool: { accepted: number; rejected: number };
}
export interface ModelBreakdown {
model: string;
tokens: {
input: number;
output: number;
cache_read: number;
cache_creation: number;
};
estimated_cost: {
currency: string;
amount: number; // セント単位(USD)
};
}
export interface AnalyticsRecord {
date: string;
actor: Actor;
organization_id: string;
customer_type: "api" | "subscription";
terminal_type: string;
core_metrics: {
num_sessions: number;
lines_of_code: { added: number; removed: number };
commits_by_claude_code: number;
pull_requests_by_claude_code: number;
};
tool_actions: ToolActions;
model_breakdown: ModelBreakdown[];
}
export interface AnalyticsResponse {
data: AnalyticsRecord[];
has_more: boolean;
next_page: string | null;
}// src/api/analytics.ts
import "dotenv/config";
import { AnalyticsRecord, AnalyticsResponse } from "../types/analytics";
const ADMIN_API_KEY = process.env.ADMIN_API_KEY!;
const BASE_URL = "https://api.anthropic.com/v1/organizations";
/**
* 特定の日付の全ユーザーのAnalyticsデータを取得する
* カーソルベースのページネーションで全件取得する
*/
export async function fetchDailyAnalytics(
date: string // YYYY-MM-DD形式
): Promise<AnalyticsRecord[]> {
const allRecords: AnalyticsRecord[] = [];
let page: string | undefined = undefined;
let hasMore = true;
while (hasMore) {
const params = new URLSearchParams({
starting_at: date,
limit: "1000",
...(page ? { page } : {}),
});
const url = `${BASE_URL}/usage_report/claude_code?${params}`;
const response = await fetch(url, {
headers: {
"anthropic-version": "2023-06-01",
"x-api-key": ADMIN_API_KEY,
"User-Agent": "ClaudeAnalyticsDashboard/1.0.0",
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Analytics API error ${response.status}: ${error}`);
}
const data: AnalyticsResponse = await response.json();
allRecords.push(...data.data);
hasMore = data.has_more;
page = data.next_page ?? undefined;
console.log(` 取得済み: ${allRecords.length}件 (has_more: ${hasMore})`);
}
return allRecords;
}
/**
* 複数日分のデータを並列取得する(最大5日分を同時取得)
* YYYY-MM-DD形式の日付配列を受け取る
*/
export async function fetchMultiDayAnalytics(
dates: string[]
): Promise<Map<string, AnalyticsRecord[]>> {
const results = new Map<string, AnalyticsRecord[]>();
// 5件ずつ並列実行(レート制限を考慮)
const BATCH_SIZE = 5;
for (let i = 0; i < dates.length; i += BATCH_SIZE) {
const batch = dates.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async (date) => {
console.log(`📊 ${date} のデータを取得中...`);
const records = await fetchDailyAnalytics(date);
return { date, records };
})
);
for (const { date, records } of batchResults) {
results.set(date, records);
}
// バッチ間に少し間隔を空ける
if (i + BATCH_SIZE < dates.length) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
return results;
}
/**
* 過去N日分の日付配列を生成する(当日から遡る)
*/
export function getDateRange(days: number): string[] {
const dates: string[] = [];
const now = new Date();
for (let i = 1; i <= days; i++) {
const date = new Date(now);
date.setUTCDate(now.getUTCDate() - i);
dates.push(date.toISOString().split("T")[0]);
}
return dates;
}Step 2:集計ロジックの実装
生のレコードを集計して、ダッシュボードで表示しやすい形に変換します:
// src/api/aggregation.ts
import { AnalyticsRecord } from "../types/analytics";
export interface UserSummary {
email: string;
totalSessions: number;
linesAdded: number;
linesRemoved: number;
commits: number;
pullRequests: number;
editAcceptRate: number; // %
writeAcceptRate: number; // %
totalCostUSD: number; // ドル単位
activedays: number;
}
export interface TeamSummary {
activeUsers: number;
totalSessions: number;
totalLinesAdded: number;
totalLinesRemoved: number;
totalCommits: number;
totalPullRequests: number;
avgEditAcceptRate: number;
totalCostUSD: number;
topUsers: UserSummary[]; // コード行数順 Top 10
}
/**
* 複数日分のレコードをユーザーごとに集計する
*/
export function aggregateByUser(
recordsByDate: Map<string, AnalyticsRecord[]>
): Map<string, UserSummary> {
const userMap = new Map<string, UserSummary>();
for (const [, records] of recordsByDate) {
for (const record of records) {
const email =
record.actor.type === "user_actor"
? record.actor.email_address!
: `api:${record.actor.api_key_name}`;
const existing = userMap.get(email) ?? {
email,
totalSessions: 0,
linesAdded: 0,
linesRemoved: 0,
commits: 0,
pullRequests: 0,
editAcceptRate: 0,
writeAcceptRate: 0,
totalCostUSD: 0,
activedays: 0,
// 内部集計用(後で計算)
_editAccepted: 0,
_editTotal: 0,
_writeAccepted: 0,
_writeTotal: 0,
} as UserSummary & Record<string, number>;
const entry = existing as UserSummary & Record<string, number>;
entry.totalSessions += record.core_metrics.num_sessions;
entry.linesAdded += record.core_metrics.lines_of_code.added;
entry.linesRemoved += record.core_metrics.lines_of_code.removed;
entry.commits += record.core_metrics.commits_by_claude_code;
entry.pullRequests += record.core_metrics.pull_requests_by_claude_code;
entry.activedays += 1;
// ツール承認数の累積
const et = record.tool_actions.edit_tool;
entry._editAccepted += et.accepted;
entry._editTotal += et.accepted + et.rejected;
const wt = record.tool_actions.write_tool;
entry._writeAccepted += wt.accepted;
entry._writeTotal += wt.accepted + wt.rejected;
// コスト集計(セントからドルへ変換)
for (const mb of record.model_breakdown) {
entry.totalCostUSD += mb.estimated_cost.amount / 100;
}
userMap.set(email, entry);
}
}
// 承認率を計算
for (const [, user] of userMap) {
const u = user as UserSummary & Record<string, number>;
u.editAcceptRate = u._editTotal > 0
? Math.round((u._editAccepted / u._editTotal) * 100)
: 0;
u.writeAcceptRate = u._writeTotal > 0
? Math.round((u._writeAccepted / u._writeTotal) * 100)
: 0;
}
return userMap;
}
/**
* チーム全体のサマリーを計算する
*/
export function calculateTeamSummary(
userSummaries: Map<string, UserSummary>
): TeamSummary {
const users = Array.from(userSummaries.values());
const totalEditAcceptRates = users
.filter((u) => u.editAcceptRate > 0)
.map((u) => u.editAcceptRate);
const avgEditAcceptRate =
totalEditAcceptRates.length > 0
? Math.round(
totalEditAcceptRates.reduce((a, b) => a + b, 0) /
totalEditAcceptRates.length
)
: 0;
const topUsers = [...users]
.sort((a, b) => b.linesAdded - a.linesAdded)
.slice(0, 10);
return {
activeUsers: users.length,
totalSessions: users.reduce((s, u) => s + u.totalSessions, 0),
totalLinesAdded: users.reduce((s, u) => s + u.linesAdded, 0),
totalLinesRemoved: users.reduce((s, u) => s + u.linesRemoved, 0),
totalCommits: users.reduce((s, u) => s + u.commits, 0),
totalPullRequests: users.reduce((s, u) => s + u.pullRequests, 0),
avgEditAcceptRate,
totalCostUSD: users.reduce((s, u) => s + u.totalCostUSD, 0),
topUsers,
};
}Step 3:Express API サーバーとフロントエンドの構築
// src/api/server.ts
import express from "express";
import cors from "cors";
import { fetchMultiDayAnalytics, getDateRange } from "./analytics";
import { aggregateByUser, calculateTeamSummary } from "./aggregation";
const app = express();
app.use(cors());
app.use(express.json());
// 過去7日間のチームサマリーを返す
app.get("/api/team-summary", async (req, res) => {
try {
const days = Number(req.query.days ?? 7);
const dates = getDateRange(Math.min(days, 30)); // 最大30日
console.log(`📊 過去${days}日間 (${dates.length}件) のデータを取得中...`);
const recordsByDate = await fetchMultiDayAnalytics(dates);
const userSummaries = aggregateByUser(recordsByDate);
const teamSummary = calculateTeamSummary(userSummaries);
res.json({
period: { start: dates[dates.length - 1], end: dates[0] },
team: teamSummary,
users: Array.from(userSummaries.values()),
});
} catch (error) {
console.error("エラー:", error);
res.status(500).json({ error: String(error) });
}
});
const PORT = process.env.PORT ?? 3001;
app.listen(PORT, () => {
console.log(`✅ Analytics API サーバー起動: http://localhost:${PORT}`);
});応用パターン・拡張方法
Slack への週次レポート自動送信
Claude Code のコストや生産性指標を毎週月曜に Slack へ送信する仕組みを作ることができます。スケジュールタスク機能と組み合わせることで、完全自動化が実現します。
// src/jobs/weekly-report.ts
async function sendWeeklyReport() {
const dates = getDateRange(7);
const recordsByDate = await fetchMultiDayAnalytics(dates);
const userSummaries = aggregateByUser(recordsByDate);
const summary = calculateTeamSummary(userSummaries);
const message = {
text: "📊 *Claude Code 週次レポート*",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: [
`*📊 Claude Code 週次レポート*`,
`期間: 過去7日間`,
``,
`👥 アクティブユーザー: *${summary.activeUsers}名*`,
`💻 総セッション数: *${summary.totalSessions.toLocaleString()}*`,
`📝 追加コード行数: *${summary.totalLinesAdded.toLocaleString()}行*`,
`✅ Edit 承認率: *${summary.avgEditAcceptRate}%*`,
`💰 総コスト: *$${summary.totalCostUSD.toFixed(2)}*`,
].join("\n"),
},
},
],
};
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
console.log("✅ Slack レポート送信完了");
}
sendWeeklyReport().catch(console.error);チームごとのコスト配分(FinOps)
大企業では、チームごとのコスト配分が求められることがあります。ユーザーのメールドメイン・部門名で分類するパターンが実用的です:
// src/api/cost-allocation.ts
export interface TeamCostBreakdown {
team: string;
users: string[];
totalCostUSD: number;
percentOfTotal: number;
}
/**
* メールアドレスのサブドメインや命名規則でチーム分類する例
* 実際の運用では、ユーザー管理システムのAPIと連携することが多い
*/
export function allocateCostsByTeam(
userSummaries: Map<string, UserSummary>,
teamConfig: Record<string, string[]> // { "frontend": ["alice@", "bob@"], ... }
): TeamCostBreakdown[] {
const totalCost = Array.from(userSummaries.values())
.reduce((s, u) => s + u.totalCostUSD, 0);
return Object.entries(teamConfig).map(([team, emailPrefixes]) => {
const teamUsers = Array.from(userSummaries.values()).filter((u) =>
emailPrefixes.some((prefix) => u.email.startsWith(prefix))
);
const teamCost = teamUsers.reduce((s, u) => s + u.totalCostUSD, 0);
return {
team,
users: teamUsers.map((u) => u.email),
totalCostUSD: teamCost,
percentOfTotal: totalCost > 0
? Math.round((teamCost / totalCost) * 100)
: 0,
};
});
}OpenTelemetry との統合
より細粒度のリアルタイム監視が必要な場合は、Claude Code の OpenTelemetry 連携と組み合わせることができます。Analytics API は日次集計データを提供し、OpenTelemetry はリアルタイムのセッションレベルデータを提供するため、用途によって使い分けます。
トラブルシューティング
よくあるエラーと解決策
403 Forbidden エラー
Admin API キーを持っているか確認してください。通常の sk-ant-api... キーでは Analytics API にアクセスできません。Claude Console → Settings → Admin Keys からキーを確認・発行してください。
# キーの種類を確認(admin キーは sk-ant-admin で始まる)
echo $ADMIN_API_KEY | grep -c "sk-ant-admin"
# 1が返れば正常。0の場合はキーが間違っています400 Bad Request: starting_at is required
starting_at パラメータは必須で、YYYY-MM-DD 形式の UTC 日付を指定する必要があります。当日のデータは1時間以上遅延するため、前日以前の日付を指定してください。
// ❌ 間違い:今日の日付(データがまだない可能性)
const today = new Date().toISOString().split("T")[0];
// ✅ 正しい:昨日の日付
const yesterday = new Date(Date.now() - 86400000).toISOString().split("T")[0];データが空(data: [])で返る
- 組織で Claude Code が利用されているか確認
- 個人アカウントでは Analytics API は利用不可(Team/Enterprise が必要)
- 対象日付に Claude Code の使用実績があるか確認
レート制限エラー(429 Too Many Requests)
Admin API にもレート制限があります。バッチ処理時は適切に間隔を設けてください。詳細は レート制限ガイドを参照してください。
// エクスポネンシャルバックオフの実装
async function fetchWithRetry(url: string, maxRetries = 3): Promise<Response> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, { /* headers */ });
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? 5);
const delay = retryAfter * 1000 * Math.pow(2, attempt);
console.log(`レート制限。${delay}ms 後にリトライ...`);
await new Promise((r) => setTimeout(r, delay));
continue;
}
return response;
}
throw new Error("最大リトライ回数に達しました");
}パフォーマンス・セキュリティ考慮事項
データキャッシュの実装
Analytics API のデータは1時間単位で更新されるため、毎回フェッチするのは無駄です。Redis や簡易的なインメモリキャッシュでレスポンスを改善できます:
// src/api/cache.ts
const cache = new Map<string, { data: unknown; expiresAt: number }>();
export function withCache<T>(
key: string,
ttlSeconds: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
console.log(`✅ キャッシュヒット: ${key}`);
return Promise.resolve(cached.data as T);
}
return fetcher().then((data) => {
cache.set(key, { data, expiresAt: Date.now() + ttlSeconds * 1000 });
return data;
});
}
// 使用例:1時間キャッシュ
const data = await withCache(
`analytics:${date}`,
3600,
() => fetchDailyAnalytics(date)
);セキュリティ設計
Admin API キーは組織の全ユーザーのメールアドレスとコスト情報にアクセスできます。本番環境では次の対策が必須です:
- 環境変数の分離:Admin API キーをコードにハードコードしない(
.envや Secret Manager を使用) - バックエンドのみで使用:フロントエンドから直接 Admin API を呼ぶと、キーがブラウザに露出します。必ずサーバーサイドの API 経由でアクセスすること
- アクセス制御:ダッシュボードの閲覧を管理職・チームリードのみに制限する(OAuth やセッション認証を実装)
- ログの注意:レスポンスにはユーザーのメールアドレスが含まれるため、ログファイルへの書き出しは最小限に
// 開発環境でのみ詳細ログを出す例
const log = process.env.NODE_ENV === "development"
? console.log
: () => {}; // 本番では無効化大規模組織での最適化
メンバーが 1000 人を超える組織では、毎回全件フェッチするのではなく、インクリメンタルな差分取得を設計します:
// 最終取得日時を記録し、未取得分のみフェッチ
async function incrementalSync(dbLastSync: Date): Promise<void> {
const missedDays = getDatesAfter(dbLastSync);
if (missedDays.length === 0) {
console.log("最新のデータは取得済みです");
return;
}
const newData = await fetchMultiDayAnalytics(missedDays);
await saveToDatabase(newData);
await updateSyncTimestamp(new Date());
}まとめと次のステップ
Claude Code Analytics Admin API を使い、組織の開発生産性を可視化するシステムをここまで組み上げてきました。
実装したポイントをまとめます。カーソルベースのページネーションで全件取得する API クライアント、複数日分のデータを並列取得して集計する仕組み、週次 Slack レポートやチームごとのコスト配分などの応用パターン、本番環境でのキャッシュとセキュリティ設計です。
Analytics API から得られるデータは、「Claude Code の導入 ROI を経営に説明する」「コストが膨らんでいるチームを特定する」「ツール承認率から開発者体験の改善点を見つける」といった意思決定に直結します。
次のステップとして、Claude API ストリーミング実装ガイドで Claude API そのものの高度な使い方を学ぶか、Cowork × SEO 自動化でダッシュボードを自動生成する応用にチャレンジしてみてください。