0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kongを利用してClaude CodeをGrafanaで確認する

0
Posted at

はじめに

今回は、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
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 部分の抜粋)
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 部分の抜粋)
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 を起動します。

compose.yaml
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 がないレコード(記録しないリクエスト)は捨てます。

fluent-bit.conf
[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 をデータソースとして登録しておきます。

grafana/provisioning/datasources/loki.yaml
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 リクエストに付きます。

Terminal
ANTHROPIC_BASE_URL=http://localhost:8000/claude \
ANTHROPIC_CUSTOM_HEADERS="X-User: Target-User" \
claude

常に Kong を経由させる場合は、~/.claude/settings.json の env に設定します。

~/.claude/settings.json
{
  "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 で取り出した項目を確認できます。

kong-cloude_code-loki.png

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 に、ダッシュボードの読み込み設定を追加します。

grafana/provisioning/dashboards/claude-code.yaml
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
grafana/provisioning/dashboards/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"
      }
    }
  ]
}

ダッシュボードの内容

ダッシュボードには、次のパネルを配置しています。

パネル 内容
ユーザー別の利用量 ユーザーごとのトークン数とリクエスト数
ユーザー別の出力トークン数 / 入力トークン数 ユーザーごとの推移(入力はキャッシュ作成・読み取りを含む)
モデル別の利用量 モデルごとのトークン数、リクエスト数、推定料金
プロンプト一覧 ユーザー、モデル、トークン数つきのプロンプト

kong-cloude_code-grafana.png

上部の「ユーザー(正規表現)」に 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 分キャッシュの価格で計算しています。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?