Claude CodeのOTelでapi_refusalイベントから拒否を数える
モデルの拒否は成功応答で返るためapi_errorに出ません。claude_code.api_refusalの属性と、server_fallback_hopやcategoryの読み方をまとめます。
Claude Codeのテレメトリで拒否を数えるなら、見るべきはclaude_code.api_refusalイベントです。api_errorをいくら眺めても、モデルの拒否は1件も出てきません。
拒否はHTTPエラーではなく、stop_reason: "refusal"を持つ成功レスポンスとして返ります。エラー監視だけを組んだダッシュボードでは、拒否が増えていても線が動きません。api_refusalはこの穴を埋めるために用意されたイベントで、api_requestやapi_errorと同じ属性で切り分けられます。
なぜapi_errorでは拒否が見えないのか
Claude Codeは失敗したAPIリクエストを内部で再試行し、諦めた時点でclaude_code.api_errorを1件だけ出します。ステータスコードやエラー文が入るのは、HTTPレベルで失敗したときです。
拒否はこの経路を通りません。APIは応答ストリームを正常に返し、その終端でstop_reasonがrefusalになります。APIの仕様では、拒否時のstop_detailsにポリシーカテゴリが入り、拒否以外の停止理由ではnullです。
つまり次の対応になります。
| 起きたこと | 出るイベント | 見る属性 |
|---|---|---|
| HTTPエラー・接続失敗・再試行切れ | 出るイベントapi_error | 見る属性status_code、attempt |
| モデルが応答を拒否 | 出るイベントapi_refusal | 見る属性server_fallback_hop、has_category |
| 正常に応答が完了 | 出るイベントapi_request | 見る属性cost_usd、トークン数 |
公式の説明は、拒否が「成功したレスポンスストリーム上で届く」ためapi_errorが発火しない、というものです。拒否は障害ではなく、モデルの判断として別に数える対象です。
api_refusalに載る属性
イベント名はclaude_code.api_refusalで、event.nameはapi_refusalです。全イベント共通の標準属性(session.idなど)に加えて、次の属性が付きます。
| 属性 | 中身 |
|---|---|
model | 中身リクエストのモデル識別子 |
request_id | 中身サーバーが振ったAPIリクエストID(req_011...形式) |
query_source | 中身呼び出し元のサブシステム。repl_main_thread、compact、サブエージェント名など |
attempt | 中身再試行の通し番号。初回が1 |
speed | 中身Fast modeならfast、それ以外はnormal |
effort | 中身適用された思考の努力レベル。指定なしのモデルでは欠落 |
このほかに、フォールバックとカテゴリを表す属性が4つあります(server_fallback_hop、has_category、has_explanation、category)。agent.name、skill.name、plugin.name、marketplace.name、mcp_server.name、mcp_tool.nameも付き、どのスキルやMCPツール経由のリクエストが拒否されたかで切れます。
query_sourceが効くのは、拒否がメインの会話だけで起きるとは限らないからです。query_sourceの定義にはcompactやサブエージェント名が含まれます。メインの会話以外のリクエストも同じ枠で識別できるので、発生源ごとに分けて数えます。
server_fallback_hopで「見えなかった拒否」を分ける
この属性が、拒否の数え方を決めます。
true: APIのサーバー側モデルフォールバックが、この拒否を別のモデルで再試行済み。ユーザーはこの拒否を見ていないfalse: リクエストが拒否で終わった。ユーザーに見えた拒否
1ターンで両方が出ることもあります。フォールバック先のモデルも拒否した場合、trueのホップイベントの後に、falseの最終イベントが来ます。
数え方は目的で変わります。
- ユーザーが実際に止められた回数を見たいなら、
server_fallback_hop=falseだけを数える - モデルが拒否した回数そのもの(フォールバックで救われた分を含む)を見たいなら、両方を数える
- 「フォールバックで救われた率」を見たいなら、
trueの件数をfalseと並べる
フォールバックで隠れた拒否は、ユーザー体験には出ません。ただ、特定のモデルやquery_sourceでtrueが増えていれば、そこで拒否が起きている事実は数字に残ります。
フォールバックの仕組み自体はAPI側の機能です。リトライを自前で組む場合の考え方はClaude APIのrefusal stop_reasonを検出してリセットする方法が扱っています。
has_categoryとcategoryの読み方
拒否の理由を知る属性は3つあり、出方に条件があります。
| 属性 | 出る条件 |
|---|---|
has_category | 出る条件APIのstop_details.categoryがcyber / bio / frontier_llm / reasoning_extractionのいずれかならtrue。カテゴリなし、または範囲外の値ならfalse |
has_explanation | 出る条件stop_details.explanationがあればtrue |
category | 出る条件上記4値のいずれか。OTEL_LOG_TOOL_DETAILS=1で、かつhas_categoryがtrueのときだけ |
server_fallback_hopがtrueのイベントには、has_categoryとhas_explanationが付きません。ホップのブロックはstop_detailsを持たないためです。カテゴリ別の集計は、server_fallback_hop=falseのイベントに限られます。
ここが設計上の分かれ目です。既定ではcategoryの値そのものは出ず、has_categoryの真偽だけが出ます。カテゴリ別に数えるにはOTEL_LOG_TOOL_DETAILS=1が要ります。
この変数は拒否だけのスイッチではありません。公式の説明では、Bashコマンド、MCPサーバー名とツール名、スキル名、ツール入力といったツール詳細をイベントに載せる設定です。組織のテレメトリ方針に照らして、拒否のカテゴリ分けのためだけに有効化してよいかを先に決めておく必要があります。
カテゴリ分けが不要なら、has_categoryとserver_fallback_hopだけで、拒否の頻度と発生源(model、query_source、agent.name)までは追えます。
categoryの4値は、APIが返すstop_details.categoryのうちClaude Codeが受け付ける範囲です。API側では、名前付きカテゴリに当たらない拒否でcategoryがnullになり、4値の外にもgeneral_harmsがあります。Claude Codeは4値以外をhas_category=falseに落とします。general_harmsの拒否も、このイベントでは「カテゴリなし」に混ざります。falseの件数が増えたときは、request_idでAPI側の応答と突き合わせると、nullとgeneral_harmsのどちらが多いかを切り分けられます。
4値とAPI側のgeneral_harmsの意味は、APIの拒否カテゴリ表に次のとおり書かれています。
category | 意味 |
|---|---|
cyber | 意味マルウェアやエクスプロイト開発など、サイバー被害につながりうる依頼。無害なセキュリティ作業でも該当することがある |
bio | 意味危険な実験手法など、生物学的被害につながりうる依頼。有益なライフサイエンス作業でも該当することがある |
frontier_llm | 意味競合するAIモデルの開発を助けうる依頼(Anthropicの商用規約で制限)。無害な機械学習の作業でも該当することがある |
reasoning_extraction | 意味モデルの内部推論を応答テキストに再現させる依頼。構造化された推論が欲しければ適応的思考(adaptive thinking)を使う |
general_harms | 意味上の4つ以外の利用ポリシー領域。Claude Codeではhas_category=falseになる |
cyber、bio、frontier_llmの3つは、無害な依頼が誤って当たる場合があります。explanationについても、Claude Codeが記録するのはhas_explanationの真偽だけです。説明文の本文はイベントに入りません。
拒否の再試行は、Claude Fable 5.1、Claude Fable 5、Claude Opus 5.5、Claude Opus 5、Claude Sonnet 5.5で使えます。拒否されたリクエストは、別のClaudeモデルで再試行すれば多くの場合処理できます(APIのstop reasonsのページに同旨の記載があります)。サーバー側では、APIのfallbacksパラメーターに"default"を指定し(ベータ機能。ヘッダーanthropic-beta: server-side-fallback-2026-07-01が要ります)、拒否カテゴリごとに推奨されるフォールバック先モデルで再試行します。推奨先のないカテゴリでは、拒否がそのまま返ります。クライアント側でも組めます。server_fallback_hop=trueが付くのは、サーバー側で処理された分です。
設定と動作確認
api_refusalはclaude_code.で始まるログイベントなので、ログのエクスポーターが必要です。最小構成は次のとおりです。
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# 拒否のカテゴリ名まで取りたい場合のみ(ツール詳細も出る)
export OTEL_LOG_TOOL_DETAILS=1
claudeClaude Codeには既定のOTLPプロトコルがないため、OTEL_EXPORTER_OTLP_PROTOCOLか信号別の変数は必須です。組織全体に配るなら、管理設定のenvブロックに同じキーを置けます。
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example.com:4317"
}
}配布先の設計で押さえておく点が3つあります。第一に、リポジトリの.claude/settings.jsonと.claude/settings.local.jsonに書いたOpenTelemetryのエクスポーター変数は無視されます。拒否の計測を有効にできるのは、管理設定か各開発者のシェル・~/.claude/settings.jsonです。第二に、管理設定でOTEL_EXPORTER_OTLP_ENDPOINTを置くと、開発者が個別に指定した信号別のエンドポイントは起動時に取り除かれ、送り先が1つのコレクターに揃います。第三に、OTEL_LOG_TOOL_DETAILS=1をこの配布に含めるかどうかで、全員分のツール詳細が集まるかが決まります。
エクスポート間隔の既定は、ログが5000ミリ秒(OTEL_LOGS_EXPORT_INTERVAL)です。拒否が起きてからバックエンドで見えるまでに数秒かかるのは、この間隔によります。
手元で確認するには、コンソールエクスポーター(OTEL_LOGS_EXPORTER=console)で流して、event.nameがapi_refusalの行を探します。拒否を意図的に起こすのは難しいので、まずは配線の確認としてuser_promptイベントが届くかを見るのが現実的です。届かなければclaude --debug-file <path>で、[3P telemetry]のエラーを確認します。
集計クエリの考え方
バックエンドの言語はさまざまなので、ここでは擬似的な条件で示します。実際のクエリはお使いの基盤の構文に読み替えてください(例示であり、特定の基盤で検証した式ではありません)。
# ユーザーに見えた拒否(最終)
event.name = "api_refusal" AND server_fallback_hop = false
# フォールバックで救われた拒否
event.name = "api_refusal" AND server_fallback_hop = true
# 発生源の切り分け(グループ化キー)
model, query_source, agent.name, skill.name, mcp_server.name目的ごとに、使う条件と前提を並べると次のようになります。
| 知りたいこと | 条件 | 前提 |
|---|---|---|
| ユーザーが止められた回数 | 条件server_fallback_hop=false | 前提追加設定なし |
| モデルが拒否した総数 | 条件api_refusalの全件 | 前提追加設定なし |
| フォールバックで救われた分 | 条件server_fallback_hop=true | 前提追加設定なし |
| カテゴリ別の内訳 | 条件falseのうちcategoryでグループ化 | 前提OTEL_LOG_TOOL_DETAILS=1 |
| 拒否の多い経路 | 条件model、query_source、agent.nameでグループ化 | 前提agent.nameの実名はOTEL_LOG_TOOL_DETAILS=1が要る |
カテゴリ別の集計はtrueのホップを含められない点が落とし穴です。ホップのイベントにcategoryが付かないため、総数の内訳をカテゴリで割ると合計が合いません。内訳を出すのはfalseに限ります。
拒否率を出したくなりますが、分母は要注意です。api_requestの件数を分母に使えるかどうかは、公式の説明からは判断できません。拒否したリクエストがapi_requestにも記録されるのかが書かれていないためです。分母に使う前に、自環境のイベントで件数を突き合わせておく方法があります。
アラートは、まず「falseの件数が閾値を超えた」を条件にするとシンプルです。request_idが付くので、アラートから該当リクエストのIDを取り出し、API側の記録と突き合わせることもできます。request_idはapi_requestやapi_errorにも同じ名前で入り、Bedrockのようにrequest-idヘッダーがない応答ではx-amzn-requestidヘッダーの値が入ります(後者はv2.1.282以降)。
api_errorと並べて使う
拒否とエラーは別の事象で、別の対応が要ります。
api_errorが増える: 接続・認証・レート制限などの基盤側の問題。attemptが上限の11に達していれば再試行が尽きた状態api_refusalが増える: モデルが応答を拒否している。HTTPレベルの失敗ではない
アラートも別々に設計します。api_errorは再試行を尽くした後に1件だけ出るので、そのイベント自体が「そのリクエストは失敗した」という終端の合図になります。attemptが既定の上限を使い切った11なら、一時的な障害が続いた状態です。400のような再試行しても直らないエラーでは、それより小さい値で出ます。一方のapi_refusalは、再試行が尽きたかどうかを問う対象ではありません。見るのはfalseの件数の増え方と、どのmodelやquery_sourceに偏っているかです。
両方が同時に出るセッションもあります。session.idとprompt.idでイベントを束ねると、同じプロンプトから何が起きたかを追えます。prompt.idは、1回のユーザー入力で生じたイベントをまとめて結ぶ識別子です。
ユーザー向けの拒否表示の意味と対処は「Usage Policy refusal」とはにまとめてあります。計測側で数えた拒否の中身を人間が確かめるときは、こちらが参照先になります。
導入前に押さえる条件
- 名前付き属性の扱いが変わった版があります。v2.1.273より前は、コストとトークンのカウンターや
api_request、api_error、api_refusalの各イベントが、OTEL_LOG_TOOL_DETAILS=1でも伏せ字の値を持っていました。古い版の集計と混ぜると、agent.nameなどの値が食い違います agent.name、skill.name、plugin.name、mcp_server.name、mcp_tool.nameは、既定では一部がcustomやthird-partyのプレースホルダーに置き換わります。名前で切りたいときはOTEL_LOG_TOOL_DETAILS=1が要りますevent.sequenceは、セッション単位ではなくプロセス単位のカウンターです。/clearをまたいでも数え続けるので、順序付けにはsession.idとの組み合わせで使います
イベントを組織の監査基盤へ流す手順はClaude Code SIEM連携でOTELイベントを監査基盤に送る、他のイベントとの対応はClaude Codeセキュリティ監査とOTELイベントの対応表が扱っています。
まとめ
拒否はstop_reason: "refusal"の成功応答なので、api_errorは鳴りません。数えるのはclaude_code.api_refusalです。
ユーザーが実際に止められた回数はserver_fallback_hop=false、モデルの拒否そのものは両方、と目的で数え方を変えます。カテゴリの中身はOTEL_LOG_TOOL_DETAILS=1のときだけ見えますが、その変数はツール詳細も出すので、方針を決めてから有効にします。has_categoryだけでも、拒否の頻度と発生源は十分追えます。