2014 年からの 12 年、AdMob 月収 150 万円規模の壁紙アプリ群を 1 人で回してきて学んだのは、観測性は「あったほうがよい」ではなく「ないと夜中に起きる回数が増える」ものだということでした。Claude API を本番サービスに組み込んでから半年、私が真っ先に詰まったのも観測性の標準化です。Anthropic の SDK は usage オブジェクトを返してくれますが、それをどう Prometheus と Datadog と Grafana にまたがる既存の運用ダッシュボードへ載せるか、Cloudflare Workers と Node ランタイムでスパン属性をどう揃えるかは自分で決めるしかありません。
OpenTelemetry には 2024 年に GenAI 向けセマンティック規約 (Semantic Conventions for GenAI) のドラフトが入り、2025 年に主要属性が安定化しています。これに揃えておくと、後で AWS Bedrock や Vertex AI と相乗りすることになっても、ダッシュボードのクエリが基本的に壊れません。本稿はその規約に沿って Claude API のスパンと指標を設計し直したときの記録です。Dolice Labs の 4 サイト並行運用 (Claude Lab / Gemini Lab / Antigravity Lab / Rork Lab) と、個人開発のアプリ群の両方で実装した結果を、コードと数値つきで残します。
なぜ独自スパン属性ではなく OpenTelemetry GenAI 規約に寄せるのか
最初の 3 ヶ月、私は anthropic.prompt_tokens anthropic.output_tokens anthropic.model といった独自属性で計装していました。動きはしましたが、3 つの実害が出ました。
ひとつ目は、Gemini Lab 側で Gemini API に同じダッシュボードを当てたい場面で、属性名が違うため Grafana の sum by 集約が機能しなかったこと。ふたつ目は、Datadog の APM 側でモデル別レイテンシを比較したかったのに、属性名が独自すぎて検索 UI に予測候補が出ず、毎回タグ名をコピペする運用になっていたこと。3 つ目は、新しく入る OSS の LLM ゲートウェイ製品 (LiteLLM, Helicone, Cloudflare AI Gateway) が GenAI 規約を前提に出してくるようになり、自分の独自属性とぶつかって混乱したことです。
OpenTelemetry GenAI 規約はその名の通り「生成 AI 共通」を狙っているので、gen_ai.system = "anthropic" gen_ai.request.model = "claude-sonnet-4-6" のようにベンダー名と実モデルを分けて持つだけで、後から系列が増えても整理がつきます。本番で 3 系統 (Claude / Gemini / OSS) を混ぜている個人開発者にとって、ここを揃えておくメリットは想像より大きく出ます。
Claude API 呼び出しに当てるスパン属性の最小セット
私が現在 4 サイト全体で標準化している属性は以下の通りです。OpenTelemetry GenAI 規約のうち、Anthropic API で意味があるものだけを抜粋しています。
// src/lib/otel-claude.ts
import { trace, SpanStatusCode } from "@opentelemetry/api" ;
import Anthropic from "@anthropic-ai/sdk" ;
const tracer = trace. getTracer ( "dolice.claude-api" , "1.0.0" );
const client = new Anthropic ();
export async function tracedMessage ( params : {
model : string ;
system ?: string ;
messages : Array <{ role : "user" | "assistant" ; content : string }>;
max_tokens : number ;
cache_control ?: { type : "ephemeral" };
}) {
return await tracer. startActiveSpan (
`chat ${ params . model }` ,
{
attributes: {
// GenAI semantic conventions
"gen_ai.system" : "anthropic" ,
"gen_ai.operation.name" : "chat" ,
"gen_ai.request.model" : params.model,
"gen_ai.request.max_tokens" : params.max_tokens,
// 自社固有 — 本番ではテナント単位課金やレート制限で必須
"dolice.tenant_id" : currentTenantId (),
"dolice.surface" : currentSurface (), // "claudelab" | "gemilab" | "app:wallpaper" 等
},
},
async ( span ) => {
try {
const startedAt = performance. now ();
const res = await client.messages. create ({
model: params.model,
system: params.system,
messages: params.messages,
max_tokens: params.max_tokens,
});
span. setAttributes ({
"gen_ai.response.id" : res.id,
"gen_ai.response.model" : res.model, // 実際にサーブされたモデル名
"gen_ai.response.finish_reasons" : JSON . stringify ([res.stop_reason]),
"gen_ai.usage.input_tokens" : res.usage.input_tokens,
"gen_ai.usage.output_tokens" : res.usage.output_tokens,
// Anthropic 固有 — キャッシュ系は規約外なのでベンダープレフィックス推奨
"anthropic.usage.cache_creation_input_tokens" :
res.usage.cache_creation_input_tokens ?? 0 ,
"anthropic.usage.cache_read_input_tokens" :
res.usage.cache_read_input_tokens ?? 0 ,
"dolice.latency_ms" : Math. round (performance. now () - startedAt),
});
span. setStatus ({ code: SpanStatusCode. OK });
return res;
} catch (err) {
span. recordException (err as Error );
span. setStatus ({
code: SpanStatusCode. ERROR ,
message: (err as Error ).message,
});
if (err && typeof err === "object" && "status" in err) {
span. setAttribute ( "http.response.status_code" , (err as any ).status);
}
throw err;
} finally {
span. end ();
}
},
);
}
ここで意図的に外した属性が 2 つあります。gen_ai.prompt (入力本文) と gen_ai.completion (出力本文) です。規約では opt-in になっており、私は 2 つの理由で本番ではデフォルト OFF にしています。
PII / 著作物の漏洩リスク 。私のアプリでは引き寄せ系・癒し系の入力に個人の悩み事が入ることがあり、観測基盤に流すべきではありません。
Datadog のインデックスコスト 。1 件のスパンに数百〜数千トークン分の本文を載せるとログ単価が一気に跳ねます。月 300 万呼び出しで実測しましたが、本文を載せたケースは載せないケースの 7.3 倍のストレージ料金になりました。
代わりに、後述するリプレイ用ダンプは別バケットに JSONL で隔離し、TTL 30 日で消す運用にしています。
メトリクスは「ヒストグラム + テナント別カウンタ」の二段で持つ
スパン属性だけだと、月末に「テナント A の今月のトークン消費は?」を答えるのにトレース全件スキャンが必要になります。私は以下の 4 つのメトリクスを並走させています。
// src/lib/otel-metrics.ts
import { metrics } from "@opentelemetry/api" ;
const meter = metrics. getMeter ( "dolice.claude-api" , "1.0.0" );
export const claudeLatency = meter. createHistogram (
"gen_ai.client.operation.duration" ,
{
description: "End-to-end latency of Claude API calls" ,
unit: "s" ,
advice: {
explicitBucketBoundaries: [
0.1 , 0.25 , 0.5 , 1 , 2 , 4 , 8 , 16 , 32 , 64 ,
],
},
},
);
export const claudeTokens = meter. createHistogram (
"gen_ai.client.token.usage" ,
{
description: "Token usage per request" ,
unit: "{token}" ,
advice: {
explicitBucketBoundaries: [
16 , 64 , 256 , 1024 , 4096 , 16384 , 65536 , 200000 ,
],
},
},
);
export const claudeCostJpy = meter. createCounter ( "dolice.claude.cost_jpy" , {
description: "Estimated JPY cost per call (rolled up from token counts)" ,
});
export const claudeErrors = meter. createCounter (
"gen_ai.client.error.count" ,
{ description: "Error count grouped by error type" },
);
書き込み側は前節の tracedMessage の最後でまとめて記録します。コスト換算は単価 (2026 年 5 月時点の Sonnet 4.6: 入力 $3 / 100 万トークン、出力 $15 / 100 万トークン、為替 150 円 = 約 ¥450 / ¥2,250) を環境変数で持ち、ホットリロード可能にしておくと月次の請求書ズレが追えます。
function recordUsage ( model : string , res : Anthropic . Message , latencyMs : number ) {
const attrs = {
"gen_ai.system" : "anthropic" ,
"gen_ai.request.model" : model,
"gen_ai.response.model" : res.model,
"dolice.tenant_id" : currentTenantId (),
"dolice.surface" : currentSurface (),
};
claudeLatency. record (latencyMs / 1000 , attrs);
claudeTokens. record (res.usage.input_tokens, {
... attrs,
"gen_ai.token.type" : "input" ,
});
claudeTokens. record (res.usage.output_tokens, {
... attrs,
"gen_ai.token.type" : "output" ,
});
const jpy =
res.usage.input_tokens * PRICING [model].inputJpy +
res.usage.output_tokens * PRICING [model].outputJpy +
(res.usage.cache_creation_input_tokens ?? 0 ) *
PRICING [model].cacheCreationJpy +
(res.usage.cache_read_input_tokens ?? 0 ) *
PRICING [model].cacheReadJpy;
claudeCostJpy. add (jpy, attrs);
}
ヒストグラムのバケットを 0.1〜64 秒の指数列にしているのは、Sonnet 4.6 の 200K コンテキスト + キャッシュなし呼び出しが平均 18 秒、最遅は 50 秒前後まで張り付くからです。デフォルトの ms 単位バケットは Claude 用には全く向きません。
コスト追跡の整合性は「Anthropic 課金画面との誤差 5% 以内」を SLI にする
観測性の本気度は、月次の Anthropic 課金画面に出てくる金額と、自分の OTel メトリクスから計算した金額がどれだけ揃うかで分かります。私は 4 ヶ月連続で誤差 ±5% を達成していますが、最初は 23.4% ずれていました。原因と対処は以下の通りです。
-- ClickHouse / TimescaleDB 共通: テナント別の日次コスト集計
SELECT
toDate( timestamp ) AS day ,
attributes['dolice.tenant_id'] AS tenant,
attributes['gen_ai.response.model'] AS model,
sum ( value ) AS jpy_total
FROM otel_metric_sum
WHERE name = 'dolice.claude.cost_jpy'
AND timestamp >= now () - INTERVAL 30 DAY
GROUP BY day , tenant, model
ORDER BY day DESC , jpy_total DESC ;
誤差を生んでいた主因は 3 つあります。
response.model と request.model を取り違えていた 。claude-sonnet-4-6 をリクエストしても、Anthropic 側が claude-sonnet-4-6-20251015 のような snapshot 名で応答することがあり、単価テーブルのキーがマッチせずデフォルト単価で計算していました。対処は response.model をプレフィックス一致で正規化する関数を入れることでした。
キャッシュ系トークンの単価を 0 にしていた 。Anthropic のプロンプトキャッシュは「キャッシュ作成 = 通常の 1.25 倍」「キャッシュ読み込み = 通常の 0.1 倍」の課金です。これを cache_*_tokens カウンタに入れ忘れていると、キャッシュを多用するワークロードでは 12% 前後の過小評価になります。
失敗リクエストの扱い 。429 は課金されませんが、500 系の一部 (特に途中まで生成された場合) は出力トークン分だけ課金される運用ガードがあります。私はエラー時もリスポンスの usage が返っていれば記録する、というルールに変更しました。
これを直してから、4 月の実測誤差は ¥1,247 / ¥31,890 = 3.91%、5 月は 2.18% に収まっています。SLI として「月次差異 ≤ 5%」を Grafana の SLO ダッシュボードに置いておくと、SDK のバージョン上げで snapshot 名のフォーマットが変わったときにすぐ気づけます。
モデル切替時のリプレイ性をどう設計するか
個人開発の現場で観測性が一番効くのは、Anthropic がモデルを更新したり、私が claude-sonnet-4-6 から claude-opus-4-6 に乗り換えるかどうかを判断するタイミングです。新モデルが既存ユーザー体験に対して回帰しないかを、本番トラフィックの「影」を流して比べるシャドーモードが安全策です。
# scripts/replay_shadow_traffic.py
"""24 時間分のリクエストを別モデルにリプレイしてレスポンス品質を比較するスクリプト。
Usage:
python replay_shadow_traffic.py \
--from claude-sonnet-4-6 \
--to claude-opus-4-6 \
--hours 24 \
--sample 0.02
"""
import argparse
import asyncio
import json
import os
import statistics
from datetime import datetime, timedelta
from anthropic import AsyncAnthropic
import clickhouse_connect
client = AsyncAnthropic()
async def replay_one (row, target_model: str ) -> dict :
payload = json.loads(row[ "request_payload" ])
started = datetime.now()
res = await client.messages.create(
model = target_model,
system = payload.get( "system" ),
messages = payload[ "messages" ],
max_tokens = payload[ "max_tokens" ],
)
elapsed = (datetime.now() - started).total_seconds()
return {
"original_id" : row[ "response_id" ],
"original_model" : row[ "model" ],
"shadow_model" : target_model,
"original_output_tokens" : row[ "output_tokens" ],
"shadow_output_tokens" : res.usage.output_tokens,
"shadow_latency" : elapsed,
"shadow_text" : res.content[ 0 ].text[: 2000 ],
}
async def main (args):
ch = clickhouse_connect.get_client( host = os.environ[ "CH_HOST" ])
since = datetime.now() - timedelta( hours = args.hours)
rows = ch.query(
"""
SELECT response_id, model, request_payload, output_tokens
FROM claude_request_replay
WHERE timestamp >= %(since)s
AND model = %(from_model)s
AND rand() / pow(2, 32) < %(sample)s
""" ,
parameters = {
"since" : since,
"from_model" : args.from_model,
"sample" : args.sample,
},
).named_results()
sem = asyncio.Semaphore( 4 ) # 同時 4 本まで
async def bounded (row):
async with sem:
try :
return await replay_one(row, args.to)
except Exception as e:
return { "error" : str (e), "original_id" : row[ "response_id" ]}
results = await asyncio.gather( * (bounded(r) for r in rows))
ok = [r for r in results if "error" not in r]
latencies = [r[ "shadow_latency" ] for r in ok]
ratio_tokens = [
r[ "shadow_output_tokens" ] / max ( 1 , r[ "original_output_tokens" ])
for r in ok
]
print ( f "Replayed: { len (ok) } / { len (results) } (errors { len (results) - len (ok) } )" )
print ( f "Shadow latency p50/p95: { statistics.median(latencies) :.2f } s / "
f " { statistics.quantiles(latencies, n = 20 )[ - 1 ] :.2f } s" )
print ( f "Output token ratio (shadow / original) median: "
f " { statistics.median(ratio_tokens) :.2f } " )
ch.insert( "claude_shadow_results" , ok)
if __name__ == "__main__" :
p = argparse.ArgumentParser()
p.add_argument( "--from" , dest = "from_model" , required = True )
p.add_argument( "--to" , required = True )
p.add_argument( "--hours" , type = int , default = 24 )
p.add_argument( "--sample" , type = float , default = 0.02 )
asyncio.run(main(p.parse_args()))
この仕組みの肝は、本番呼び出しを通すときに claude_request_replay テーブルへリクエスト原文を 1 行残しておくことです。私はキャッシュ料金を抑えるため、本番側はキャッシュ ON で呼び、リプレイ側はキャッシュ OFF で呼ぶよう分けています。比較する観点は「出力トークン数の比 (極端な短縮/増加は何かが壊れているサイン)」「レイテンシの p95」「人手スポットチェック用に出力本文を 2,000 文字だけ保存」の 3 つです。
実際に Sonnet 4.6 → Opus 4.6 で 24 時間 / サンプル率 2% のシャドーを流したときの結果が以下でした。
リプレイ件数 1,489 / エラー 6 件 (0.40%)
p95 レイテンシ: 8.4 秒 → 18.7 秒 (約 2.2 倍)
出力トークン比率の中央値: 1.18 (Opus の方がやや饒舌)
推定コスト増分: ¥3,420 / 24h → 月換算 ¥102,600
この数値を見て、私は引き寄せ系アプリの通常チャットでは Opus は割に合わないと判断し、深掘りの「相談モード」 (有料機能) のみ Opus にルーティングする構成にしました。観測性が揃っていなかった頃は、こういう判断を「体感」でやっていて、後で課金画面を見て青ざめる、というのを何度かやっています。
本番運用でつまずいた 4 つの落とし穴
仕組みを作って終わりではなく、ここからが運用です。半年回してハマったポイントを 4 つ書いておきます。
1. Cloudflare Workers では @opentelemetry/sdk-node が動かない
ノードランタイム前提の SDK は Workers では import すら通りません。私は @opentelemetry/api だけを共通インタフェースとして使い、エクスポーターは Workers 側で自前実装 (OTLP/HTTP を fetch で投げる小さなラッパ) に差し替えました。これにより、Next.js のサーバアクション側と Workers 側で同じ計装コードが書けるようになっています。
2. 429 のリトライをスパンに含めるか分けるか
最初は exponential backoff のリトライを 1 スパンにまとめていましたが、Datadog の APM 側で「外れ値の遅いリクエスト」として可視化され、レイテンシ SLI を悪化させていました。リトライ毎に子スパンを切り、親スパンには retry.attempts 属性で回数だけ持つ構造に直しました。
3. ストリーミングレスポンスは usage が最後のチャンクに来る
Server-Sent Events で受け取っているとき、トークン数は message_delta イベントの最後でしか確定しません。私は途中で例外で落ちた場合に備えて、message_start 時点で input_tokens だけ先に属性に書き込み、message_stop で output_tokens を追記する 2 段書き込みにしています。これで「途中で死んだリクエストの入力トークン課金が消える」事故が減りました。
4. テナント ID の伝播を AsyncLocalStorage で揃える
Next.js のサーバアクション、Cron、Webhook で計装コードを書き分けるとテナント ID がスパン属性から落ちます。Node 側は AsyncLocalStorage、Workers 側は executionCtx への詰め込みで揃えました。Workers では c.executionCtx.props.tenantId のようなパターンで橋渡しすると一貫します。
私が今のところお勧めする構成
Anthropic から派生した OSS の OTel 計装ライブラリ (@arizeai/openinference-instrumentation-anthropic 等) はよく出来ているのですが、私のように複数ベンダーを混在させる小規模個人開発では、ベンダー横断の薄い自前ラッパを 1 枚かませる方が長期的に楽でした。GenAI 規約に揃えておけば、後から OpenInference や Langfuse のような専用基盤を導入したくなったときも、属性名がほぼそのまま使えます。
経験上、最初の 1 ヶ月で揃えるべきは「スパン属性 + コストカウンタ + 月次差異 SLI」の 3 点だけです。シャドートラフィックやリプレイは、サービスが育って課金額が見える形になってから入れても遅くありません。私自身、シャドー基盤を整えたのはサービス開始から 5 ヶ月目で、それまでは月次差異の異常値だけ追いかけていました。
次の一手としては、シャドーリプレイの結果を Claude 自身に評価させて、出力品質を 1〜5 のスコアで自動採点する評価ハーネスを組むのが面白いと思います。Sonnet と Opus で出力を比べる作業を人手でやるのは、サンプルが 100 件を超えたあたりで現実的でなくなりました。
このメモが、Claude API を本番に出した後の「計器盤を作り直す」フェーズの参考になれば嬉しいです。私もまだ運用しながら直し続けていますので、もし観測性まわりで揉まれた知見がある方は、Linktree からどこかしらに繋いでお話しできれば幸いです。最後までお付き合いいただき、ありがとうございました。