はじめに
今回は、Kong Gateway を使用して、Claude Code のプロンプトとトークン数を Grafana で確認できるようにする方法を検証しました。
構成
Claude Code は、環境変数 ANTHROPIC_BASE_URL で API の送信先を変更できます。
送信先を Kong Gateway にすると、Claude Code の通信を Kong が中継します。
そのため、Kong のプラグインでログを取得できます。
Kong Gateway は Kong Konnect の Data Plane として動かし、ログは Fluent Bit 経由で Loki に保存します。
Kong では、次の 2 つのプラグインを使います。
| プラグイン | 役割 |
|---|---|
| Pre-Function | リクエストからプロンプトを、レスポンスからトークン数を取り出す |
| HTTP Log | 取り出した値だけを Fluent Bit へ送る |
設定
Kong Gateway
/claude へのリクエストを api.anthropic.com へ転送する Service と Route を作成し、2 つのプラグインを設定します。
ログに残すのは、最後に入力したプロンプト、モデル名、トークン数(入力、出力、キャッシュ作成、キャッシュ読み取り)だけです。
あわせて、ユーザーを見分けるために、X-User ヘッダーの値、アカウント ID、セッション ID も残します。
X-User はログに残すためだけのヘッダーなので、Kong で削除し、Anthropic API へは送りません。
Claude Code は、ユーザーが入力したプロンプトに <system-reminder> というブロックを付けて送ります。
このブロックはプロンプトから除いています。
また、入力欄の候補表示のために送るリクエストや、トークン数を数えるだけのリクエスト(count_tokens)は記録しません。
kong.yaml
_format_version: "3.0"
services:
- name: claude-code
host: api.anthropic.com
port: 443
protocol: https
plugins:
- name: pre-function
config:
access:
- |
-- X-User はログに残すためだけのヘッダーなので、上流へは送らない
kong.ctx.shared.claude_user = kong.request.get_header("X-User")
kong.service.request.clear_header("X-User")
-- count_tokens などは記録しない
if not string.find(kong.request.get_path(), "/v1/messages$") then
kong.ctx.shared.claude_skip = true
return
end
-- 圧縮されると body_filter で usage を読めないため非圧縮で受け取る
kong.service.request.clear_header("Accept-Encoding")
local body = kong.request.get_body("application/json", nil, 4194304)
if not body then
return
end
kong.ctx.shared.claude_model = body.model
-- metadata.user_id は JSON 文字列のため、cjson を使わずに取り出す
local user_id = body.metadata and body.metadata.user_id or ""
kong.ctx.shared.claude_account = string.match(user_id, '"account_uuid":"([^"]+)"')
kong.ctx.shared.claude_session = string.match(user_id, '"session_id":"([^"]+)"')
-- 末尾に role: system が付く場合があるため、最後の user メッセージを探す
local last
for _, message in ipairs(body.messages or {}) do
if message.role == "user" then
last = message
end
end
if not last then
return
end
local content = last.content
if type(content) == "string" then
content = { { type = "text", text = content } }
end
-- Claude Code が挿入する <system-reminder> を除いた text ブロックをプロンプトとする
local texts = {}
for _, block in ipairs(content) do
if block.type == "text" and not string.find(block.text, "^%s*<system%-reminder>") then
-- 入力候補の生成リクエストは model を消して Fluent Bit で捨てる
if string.find(block.text, "^%s*%[SUGGESTION MODE:") then
kong.ctx.shared.claude_skip = true
kong.ctx.shared.claude_model = nil
return
end
texts[#texts + 1] = block.text
end
end
if #texts > 0 then
kong.ctx.shared.claude_prompt = table.concat(texts, "\n")
end
body_filter:
- |
-- 応答はバッファリングせず、流れるチャンクから usage の数値だけを拾う
local ctx = kong.ctx.shared
if ctx.claude_skip then
return
end
-- チャンクの境目で数値が切れないよう、前のチャンクの末尾をつなげて読む
local data = (ctx.claude_tail or "") .. (ngx.arg[1] or "")
ctx.claude_tail = string.sub(data, -64)
-- 本文中の文字列は \" とエスケープされるため、一致するのは usage の値だけ
-- output_tokens は最後の message_delta で確定するので、後から届いた値で上書きする
local keys = { "input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens" }
ctx.claude_usage = ctx.claude_usage or {}
for _, key in ipairs(keys) do
for value in string.gmatch(data, '"' .. key .. '":(%d+)') do
ctx.claude_usage[key] = tonumber(value)
end
end
- name: http-log
config:
http_endpoint: http://fluent-bit:9880/kong.claude-code
method: POST
content_type: application/json
timeout: 10000
custom_fields_by_lua:
service: |
local service = kong.router.get_service()
return service and service.name
prompt: return kong.ctx.shared.claude_prompt
model: return kong.ctx.shared.claude_model
account: return kong.ctx.shared.claude_account
user: return kong.ctx.shared.claude_user
session: return kong.ctx.shared.claude_session
usage: |
local usage = kong.ctx.shared.claude_usage
if usage and next(usage) then
return usage
end
# 標準の serializer が出力する項目はすべて削除する
request: return nil
response: return nil
upstream_uri: return nil
upstream_status: return nil
latencies: return nil
auth_type: return nil
tries: return nil
authenticated_entity: return nil
route: return nil
consumer: return nil
principal: return nil
client_ip: return nil
workspace: return nil
workspace_name: return nil
started_at: return nil
source: return nil
routes:
- name: claude-code
paths:
- /claude
strip_path: true
# SSE を受け取った順にクライアントへ流す
response_buffering: false
Pre-Function の access(Lua 部分の抜粋)
-- X-User はログに残すためだけのヘッダーなので、上流へは送らない
kong.ctx.shared.claude_user = kong.request.get_header("X-User")
kong.service.request.clear_header("X-User")
-- count_tokens などは記録しない
if not string.find(kong.request.get_path(), "/v1/messages$") then
kong.ctx.shared.claude_skip = true
return
end
-- 圧縮されると body_filter で usage を読めないため非圧縮で受け取る
kong.service.request.clear_header("Accept-Encoding")
local body = kong.request.get_body("application/json", nil, 4194304)
if not body then
return
end
kong.ctx.shared.claude_model = body.model
-- metadata.user_id は JSON 文字列のため、cjson を使わずに取り出す
local user_id = body.metadata and body.metadata.user_id or ""
kong.ctx.shared.claude_account = string.match(user_id, '"account_uuid":"([^"]+)"')
kong.ctx.shared.claude_session = string.match(user_id, '"session_id":"([^"]+)"')
-- 末尾に role: system が付く場合があるため、最後の user メッセージを探す
local last
for _, message in ipairs(body.messages or {}) do
if message.role == "user" then
last = message
end
end
if not last then
return
end
local content = last.content
if type(content) == "string" then
content = { { type = "text", text = content } }
end
-- Claude Code が挿入する <system-reminder> を除いた text ブロックをプロンプトとする
local texts = {}
for _, block in ipairs(content) do
if block.type == "text" and not string.find(block.text, "^%s*<system%-reminder>") then
-- 入力候補の生成リクエストは model を消して Fluent Bit で捨てる
if string.find(block.text, "^%s*%[SUGGESTION MODE:") then
kong.ctx.shared.claude_skip = true
kong.ctx.shared.claude_model = nil
return
end
texts[#texts + 1] = block.text
end
end
if #texts > 0 then
kong.ctx.shared.claude_prompt = table.concat(texts, "\n")
end
Pre-Function の body_filter(Lua 部分の抜粋)
-- 応答はバッファリングせず、流れるチャンクから usage の数値だけを拾う
local ctx = kong.ctx.shared
if ctx.claude_skip then
return
end
-- チャンクの境目で数値が切れないよう、前のチャンクの末尾をつなげて読む
local data = (ctx.claude_tail or "") .. (ngx.arg[1] or "")
ctx.claude_tail = string.sub(data, -64)
-- 本文中の文字列は \" とエスケープされるため、一致するのは usage の値だけ
-- output_tokens は最後の message_delta で確定するので、後から届いた値で上書きする
local keys = { "input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens" }
ctx.claude_usage = ctx.claude_usage or {}
for _, key in ipairs(keys) do
for value in string.gmatch(data, '"' .. key .. '":(%d+)') do
ctx.claude_usage[key] = tonumber(value)
end
end
Fluent Bit / Loki / Grafana
Kong の Data Plane と同じ compose.yaml で、Fluent Bit、Loki、Grafana を起動します。
services:
kong:
省略...
fluent-bit:
image: fluent/fluent-bit:3.2.10
ports:
- "127.0.0.1:9880:9880"
volumes:
- ./fluent-bit/fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro
depends_on:
- loki
loki:
image: grafana/loki:3.4.2
ports:
- "127.0.0.1:3100:3100"
command: ["-config.file=/etc/loki/config.yaml"]
volumes:
- ./loki/config.yaml:/etc/loki/config.yaml:ro
- loki-data:/loki
grafana:
image: grafana/grafana:11.6.0
ports:
- "127.0.0.1:3000:3000"
environment:
# ローカル検証用にログの匿名閲覧を許可する
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
# Viewer では Explore が表示されないため許可する
GF_USERS_VIEWERS_CAN_EDIT: "true"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- grafana-data:/var/lib/grafana
depends_on:
- loki
volumes:
loki-data:
grafana-data:
Fluent Bit は、Kong から受け取ったログを Loki へ送ります。
サービス名は Loki のラベルにして本文から外し、model がないレコード(記録しないリクエスト)は捨てます。
[SERVICE]
Flush 1
Log_Level info
[INPUT]
Name http
Listen 0.0.0.0
Port 9880
Buffer_Max_Size 8M
Buffer_Chunk_Size 512K
Mem_Buf_Limit 64M
Successful_Response_Code 204
[FILTER]
Name grep
Match kong.*
Regex model .+
[OUTPUT]
Name loki
# URL パス由来のタグはハイフンが _ に置き換わるため前方一致にする
Match kong.*
Host loki
Port 3100
Labels job=kong,service=$service
# ラベルにしたサービス名は本文から外し、プロンプトとトークン数だけを残す
Remove_Keys service
Line_Format json
Retry_Limit False
Grafana には、Loki をデータソースとして登録しておきます。
apiVersion: 1
datasources:
- name: Loki
uid: loki
type: loki
access: proxy
url: http://loki:3100
isDefault: true
editable: false
Claude Code
ANTHROPIC_BASE_URL に Kong の Route の URL を指定して、Claude Code を起動します。
あわせて、ログでユーザーを見分けるための名前を、環境変数 ANTHROPIC_CUSTOM_HEADERS で X-User ヘッダーとして付けます。
ANTHROPIC_CUSTOM_HEADERS に 名前: 値 の形式で書いたヘッダーは、Claude Code が送るすべての API リクエストに付きます。
ANTHROPIC_BASE_URL=http://localhost:8000/claude \
ANTHROPIC_CUSTOM_HEADERS="X-User: Target-User" \
claude
常に Kong を経由させる場合は、~/.claude/settings.json の env に設定します。
{
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:8000/claude",
"ANTHROPIC_CUSTOM_HEADERS": "X-User: Target-User"
}
}
ログを確認する
Claude Code でいくつか質問したあと、Grafana(http://localhost:3000)の Explore で次の LogQL を実行します。
{job="kong", service="claude-code"} | json | line_format "{{.model}} in={{.usage_input_tokens}} out={{.usage_output_tokens}} cache_read={{.usage_cache_read_input_tokens}} {{.prompt}}"
1 リクエストが 1 行で表示され、行を開くと | json で取り出した項目を確認できます。
Loki には、1 リクエストごとに次のようなログが保存されます。
{
"prompt": "pythonでランダム文字列を実装する方法",
"session": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"account": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"user": "Target-User",
"usage": {
"input_tokens": 2,
"cache_creation_input_tokens": 42018,
"cache_read_input_tokens": 0,
"output_tokens": 703
},
"model": "claude-opus-5-5"
}
ツールの実行結果を返すリクエストには新しいプロンプトがないため、prompt は出力されず、トークン数だけが記録されます。
また、Claude Code はユーザーの入力以外にも、裏でリクエストを送っています。
これらもトークンを消費するため、候補表示と count_tokens 以外は記録しています。
モデルごとの出力トークン数の合計は、次の LogQL でグラフにできます。
sum by (model) (sum_over_time({job="kong", service="claude-code"} | json | unwrap usage_output_tokens [1h]))
ダッシュボードで確認する
Explore で毎回 LogQL を書く代わりに、ダッシュボードを Grafana に自動登録します。
どのユーザーがどれくらい使っているかも、ここで確認できます。
ユーザーの見分け方
ダッシュボードでは、X-User ヘッダーの値(ログの user)をユーザー名として集計します。
X-User がないリクエストは、アカウント ID で集計します。
Claude Code は、リクエスト本文の metadata.user_id に、アカウント ID(account_uuid)とセッション ID(session_id)を入れて送っています。
Kong ではこの値を account と session としてログに残しています。
なお、metadata.user_id は公開された仕様ではないため、Claude Code の更新で形式が変わる可能性があります。
ダッシュボードを登録する
Grafana の provisioning に、ダッシュボードの読み込み設定を追加します。
apiVersion: 1
providers:
- name: claude-code
type: file
allowUiUpdates: false
options:
path: /etc/grafana/provisioning/dashboards
同じディレクトリにダッシュボードの JSON(claude-code.json)を置き、Grafana を再起動すると「Claude Code 利用状況」が登録されます。
claude-code.json
{
"uid": "claude-code-usage",
"title": "Claude Code 利用状況",
"schemaVersion": 39,
"editable": true,
"time": {
"from": "now-24h",
"to": "now"
},
"refresh": "1m",
"templating": {
"list": [
{
"type": "textbox",
"name": "account",
"label": "ユーザー(正規表現)",
"query": ".*",
"current": {
"text": ".*",
"value": ".*"
}
}
]
},
"panels": [
{
"type": "table",
"title": "モデル別の利用量",
"datasource": {
"type": "loki",
"uid": "loki"
},
"gridPos": {
"x": 0,
"y": 22,
"w": 24,
"h": 6
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (model) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "B",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (model) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.output_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "C",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (model) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.cache_creation_input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "D",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (model) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.cache_read_input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "E",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (model) (count_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\" | model != \"\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "F",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (model) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json model=\"model\", i=\"usage.input_tokens\", o=\"usage.output_tokens\", cw=\"usage.cache_creation_input_tokens\", cr=\"usage.cache_read_input_tokens\" | model != \"\" | label_format cost=`{{ $pi := -1.0 }}{{ $po := 0.0 }}{{ $pw := 0.0 }}{{ $pr := 0.0 }}{{ $prompt := addf (addf .i .cw) .cr }}{{ if eq .model \"claude-fable-5-1\" }}{{ $pi = 10.0 }}{{ $po = 50.0 }}{{ $pw = 12.5 }}{{ $pr = 0.25 }}{{ else if eq .model \"claude-opus-5-5\" }}{{ $pi = 4.0 }}{{ $po = 20.0 }}{{ $pw = 5.0 }}{{ $pr = 0.2 }}{{ else if eq .model \"claude-opus-5\" }}{{ $pi = 5.0 }}{{ $po = 25.0 }}{{ $pw = 6.25 }}{{ $pr = 0.5 }}{{ else if eq .model \"claude-sonnet-5-5\" }}{{ $pi = 2.0 }}{{ $po = 10.0 }}{{ $pw = 2.5 }}{{ $pr = 0.1 }}{{ else if eq .model \"claude-sonnet-5\" }}{{ $pi = 2.0 }}{{ $po = 10.0 }}{{ $pw = 2.5 }}{{ $pr = 0.2 }}{{ else if eq .model \"claude-haiku-5-5\" }}{{ if gt $prompt 100000.0 }}{{ $pi = 0.5 }}{{ $po = 2.5 }}{{ $pw = 0.625 }}{{ $pr = 0.05 }}{{ else }}{{ $pi = 0.1 }}{{ $po = 0.5 }}{{ $pw = 0.125 }}{{ $pr = 0.01 }}{{ end }}{{ end }}{{ if ge $pi 0.0 }}{{ divf (addf (addf (mulf .i $pi) (mulf .o $po)) (addf (mulf .cw $pw) (mulf .cr $pr))) 1000000.0 }}{{ end }}` | unwrap cost | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
}
],
"transformations": [
{
"id": "merge",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true
},
"renameByName": {
"Value #A": "input",
"Value #B": "output",
"Value #C": "cache_creation",
"Value #D": "cache_read",
"Value #E": "requests",
"model": "モデル",
"Value #F": "cost_usd"
}
}
}
],
"fieldConfig": {
"defaults": {
"mappings": []
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "cost_usd"
},
"properties": [
{
"id": "unit",
"value": "currencyUSD"
},
{
"id": "decimals",
"value": 4
}
]
}
]
},
"options": {
"showHeader": true,
"sortBy": [
{
"displayName": "output",
"desc": true
}
]
}
},
{
"type": "table",
"title": "ユーザー別の利用量",
"datasource": {
"type": "loki",
"uid": "loki"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 24,
"h": 10
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (account) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "B",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (account) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.output_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "C",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (account) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.cache_creation_input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "D",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (account) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.cache_read_input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
},
{
"refId": "E",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (account) (count_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\" | model != \"\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" [$__range]))",
"legendFormat": "",
"queryType": "instant",
"instant": true,
"range": false
}
],
"transformations": [
{
"id": "merge",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true
},
"renameByName": {
"Value #A": "input",
"Value #B": "output",
"Value #C": "cache_creation",
"Value #D": "cache_read",
"Value #E": "requests",
"account": "ユーザー"
}
}
}
],
"fieldConfig": {
"defaults": {
"mappings": []
},
"overrides": []
},
"options": {
"showHeader": true,
"sortBy": [
{
"displayName": "output",
"desc": true
}
]
}
},
{
"type": "timeseries",
"title": "ユーザー別の出力トークン数",
"datasource": {
"type": "loki",
"uid": "loki"
},
"gridPos": {
"x": 0,
"y": 14,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum by (account) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.output_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__auto]))",
"legendFormat": "{{account}}",
"queryType": "range",
"instant": false,
"range": true
}
],
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 80,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
}
},
{
"type": "timeseries",
"title": "ユーザー別の入力トークン数(キャッシュを含む)",
"datasource": {
"type": "loki",
"uid": "loki"
},
"gridPos": {
"x": 12,
"y": 14,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "(sum by (account) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__auto]))) + (sum by (account) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.cache_creation_input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__auto]))) + (sum by (account) (sum_over_time({job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", v=\"usage.cache_read_input_tokens\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | unwrap v | __error__=\"\" [$__auto])))",
"legendFormat": "{{account}}",
"queryType": "range",
"instant": false,
"range": true
}
],
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 80,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
}
},
{
"type": "logs",
"title": "プロンプト一覧",
"datasource": {
"type": "loki",
"uid": "loki"
},
"gridPos": {
"x": 0,
"y": 28,
"w": 24,
"h": 12
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "loki",
"uid": "loki"
},
"queryType": "range",
"expr": "{job=\"kong\", service=\"claude-code\"} | json account=\"account\", user=\"user\", model=\"model\", prompt=\"prompt\", i=\"usage.input_tokens\", o=\"usage.output_tokens\", cr=\"usage.cache_read_input_tokens\" | label_format prompt=`{{ regexReplaceAll \"(?s)^(?:\\\\s*(?:<local-command-caveat>.*?</local-command-caveat>|<command-name>.*?</command-name>|<command-message>.*?</command-message>|<command-args>.*?</command-args>|<local-command-stdout>.*?</local-command-stdout>|<local-command-stderr>.*?</local-command-stderr>))+\\\\s*\" .prompt \"\" }}` | prompt != \"\" | prompt !~ \"(?s).*CRITICAL: Respond with TEXT ONLY\\\\. Do NOT call any tools\\\\..*REMINDER: Do NOT call any tools\\\\..*\" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | account=~\"$account\" | line_format \"{{.account}} {{.model}} in={{.i}} out={{.o}} cache_read={{.cr}} {{.prompt}}\""
}
],
"options": {
"showTime": true,
"wrapLogMessage": true,
"sortOrder": "Descending"
}
}
]
}
ダッシュボードの内容
ダッシュボードには、次のパネルを配置しています。
| パネル | 内容 |
|---|---|
| ユーザー別の利用量 | ユーザーごとのトークン数とリクエスト数 |
| ユーザー別の出力トークン数 / 入力トークン数 | ユーザーごとの推移(入力はキャッシュ作成・読み取りを含む) |
| モデル別の利用量 | モデルごとのトークン数、リクエスト数、推定料金 |
| プロンプト一覧 | ユーザー、モデル、トークン数つきのプロンプト |
上部の「ユーザー(正規表現)」に X-User の値かアカウント ID を入力すると、そのユーザーだけに絞り込めます。
X-User もアカウント ID もないリクエストは (不明) にまとめて表示します。
各パネルでは、ログの JSON から必要な項目だけを取り出して集計しています。
たとえば、ユーザー別の出力トークン数は次のクエリです。
sum by (account) (sum_over_time({job="kong", service="claude-code"} | json account="account", user="user", v="usage.output_tokens" | label_format account=`{{ if .user }}{{ .user }}{{ else if .account }}{{ .account }}{{ else }}(不明){{ end }}` | unwrap v | __error__="" [$__range]))
推定料金
「モデル別の利用量」の cost_usd は、トークン数に API の定価を掛けた推定料金です。
LogQL の label_format でリクエストごとに料金を計算し、モデルごとに合計しています。
料金表はクエリの中に書いているため、価格が変わったときやモデルが増えたときはクエリを更新します。
サブスクリプションで Claude Code を利用している場合は従量課金ではないため、「API で利用した場合の料金」の目安になります。
また、ログにはキャッシュ書き込みの 5 分 / 1 時間の内訳がないため、キャッシュ書き込みはすべて 5 分キャッシュの価格で計算しています。
参考

