しかし API 呼び出しが 1 日 100 万件を超えたあたりから、Postgres の限界が見えてきます。SELECT model, sum(input_tokens), sum(output_tokens) FROM events WHERE ts > now() - interval '1 day' GROUP BY model のような集計クエリが、インデックスを工夫しても 30 秒以上かかるようになります。Postgres は OLTP(行指向トランザクション)に最適化されているため、列方向の集計には根本的に不利なのです。
ダッシュボードの裏で SELECT model, sum(cost_usd) FROM claude_api_events WHERE ts > now() - INTERVAL 1 DAY GROUP BY model を毎秒走らせる設計は、データが増えるとすぐに苦しくなります。代わりに Materialized View で事前集計テーブルを作るのが ClickHouse の流儀です。
-- 分単位の事前集計テーブルCREATE TABLE claude_minute_metrics ( minute DateTime, workspace_id LowCardinality(String), feature_id LowCardinality(String), model LowCardinality(String), request_count UInt32, success_count UInt32, error_count UInt32, total_input_tokens UInt64, total_output_tokens UInt64, total_cache_read_tokens UInt64, total_cost_usd Decimal64(6), p50_latency_ms AggregateFunction(quantile(0.5), UInt32), p95_latency_ms AggregateFunction(quantile(0.95), UInt32), p99_latency_ms AggregateFunction(quantile(0.99), UInt32))ENGINE = SummingMergeTree()PARTITION BY toYYYYMM(minute)ORDER BY (workspace_id, feature_id, model, minute);-- 元テーブルへの INSERT を自動で集計テーブルに反映する MVCREATE MATERIALIZED VIEW claude_minute_metrics_mvTO claude_minute_metricsAS SELECT toStartOfMinute(ts) AS minute, workspace_id, feature_id, model, count() AS request_count, sumIf(1, success = 1) AS success_count, sumIf(1, success = 0) AS error_count, sum(input_tokens) AS total_input_tokens, sum(output_tokens) AS total_output_tokens, sum(cache_read_tokens) AS total_cache_read_tokens, sum(cost_usd) AS total_cost_usd, quantileState(0.5)(latency_ms) AS p50_latency_ms, quantileState(0.95)(latency_ms) AS p95_latency_ms, quantileState(0.99)(latency_ms) AS p99_latency_msFROM claude_api_eventsGROUP BY minute, workspace_id, feature_id, model;
集計済みテーブルへのクエリは劇的に速くなります。
-- ダッシュボード用クエリ(数十ミリ秒で返る)SELECT minute, sum(total_cost_usd) AS cost_usd, sum(request_count) AS requests, quantileMerge(0.95)(p95_latency_ms) AS p95_msFROM claude_minute_metricsWHERE workspace_id = 'tenant_abc' AND minute >= now() - INTERVAL 24 HOURGROUP BY minuteORDER BY minute;
SELECT toStartOfDay(ts) AS day, model, sum(cost_usd) AS cost_usd, sum(input_tokens + output_tokens) AS total_tokensFROM claude_api_eventsWHERE ts >= now() - INTERVAL 30 DAYGROUP BY day, modelORDER BY day, cost_usd DESC;
プロンプトテンプレート別のコスト効率を見るクエリも頻繁に使います。
SELECT prompt_template_id, count() AS requests, sum(cost_usd) AS total_cost, avg(cost_usd) AS avg_cost, avg(output_tokens) AS avg_output_tokens, quantile(0.95)(latency_ms) AS p95_latency_msFROM claude_api_eventsWHERE ts >= now() - INTERVAL 7 DAY AND success = 1GROUP BY prompt_template_idORDER BY total_cost DESCLIMIT 20;
SELECT feature_id, prompt_template_id, count() AS suspicious_count, avg(output_tokens) AS avg_output, quantile(0.99)(output_tokens) AS p99_outputFROM claude_api_eventsWHERE ts >= now() - INTERVAL 1 HOUR AND success = 1 AND (output_tokens < 10 OR output_tokens > 3000)GROUP BY feature_id, prompt_template_idHAVING suspicious_count > 5;
レイテンシ急増の検出は z スコアを使います。
WITH baseline AS ( SELECT feature_id, avg(latency_ms) AS baseline_avg, stddevPop(latency_ms) AS baseline_std FROM claude_api_events WHERE ts BETWEEN now() - INTERVAL 1 DAY AND now() - INTERVAL 1 HOUR AND success = 1 GROUP BY feature_id)SELECT e.feature_id, avg(e.latency_ms) AS recent_avg, b.baseline_avg, (avg(e.latency_ms) - b.baseline_avg) / nullif(b.baseline_std, 0) AS z_scoreFROM claude_api_events eJOIN baseline b USING (feature_id)WHERE e.ts >= now() - INTERVAL 5 MINUTEGROUP BY e.feature_id, b.baseline_avg, b.baseline_stdHAVING z_score > 3;
z スコアが 3 を超える機能はベースラインから大きく外れているため、上流(モデル切り替え、プロンプト変更、ネットワーク)で何か起きているサインです。アラート閾値を z=3 に設定すれば、誤検知を抑えつつ本物の異常を捕捉できます。
マルチテナント環境のコスト配賦 — チャージバックと顧客単位の利益率
SaaS としてサービス提供している場合、テナント単位のコスト配賦が経営上の死活問題になります。workspace_id を ORDER BY の先頭に置いた設計が、ここで真価を発揮します。
-- テナント単位の月次コストランキングSELECT workspace_id, sum(cost_usd) AS api_cost_usd, sum(input_tokens + output_tokens) AS total_tokens, countDistinct(user_id) AS active_users, sum(cost_usd) / nullif(countDistinct(user_id), 0) AS cost_per_userFROM claude_api_eventsWHERE ts >= toStartOfMonth(now())GROUP BY workspace_idORDER BY api_cost_usd DESCLIMIT 50;
-- 月単位・モデル別の集計(Anthropic コンソールと比較する)SELECT toStartOfMonth(ts) AS month, model, sum(input_tokens) AS input_tokens, sum(output_tokens) AS output_tokens, sum(cache_read_tokens) AS cache_read_tokens, sum(cache_creation_tokens) AS cache_creation_tokens, round(sum(cost_usd), 2) AS estimated_cost_usdFROM claude_api_eventsWHERE ts >= now() - INTERVAL 3 MONTHGROUP BY month, modelORDER BY month, model;
-- 先週と今週の比較(feature_id 別)WITH last_week AS ( SELECT feature_id, sum(cost_usd) AS cost FROM claude_api_events WHERE ts BETWEEN now() - INTERVAL 14 DAY AND now() - INTERVAL 7 DAY GROUP BY feature_id),this_week AS ( SELECT feature_id, sum(cost_usd) AS cost FROM claude_api_events WHERE ts >= now() - INTERVAL 7 DAY GROUP BY feature_id)SELECT coalesce(t.feature_id, l.feature_id) AS feature_id, l.cost AS last_week_cost, t.cost AS this_week_cost, (t.cost - l.cost) / nullif(l.cost, 0) * 100 AS pct_changeFROM this_week tFULL OUTER JOIN last_week l USING (feature_id)ORDER BY this_week_cost DESC;