Claude CodeとCoworkの会話ログをOpenTelemetryでGoogle Cloudに集約する構成をTerraformで構築してみた
はじめに
クラウド事業統括本部の浅野です。
Claude CodeとCoworkはOpenTelemetry(OTel)によるテレメトリ送信に対応しており、メトリクス(コスト・トークン使用量・セッション数等)とログ(プロンプト・応答・ツール実行の記録)を外部のコレクターに集約できます。今回はこのテレメトリをGoogle Cloud上で受信・可視化する基盤をTerraformで構築し、実際にどんなデータがどんな粒度で取得できるのかを検証しました。
実現したいこと
OTelテレメトリの対象はメトリクスとログの2種類です。送信元はClaude Code(CLI)とCowork(Claude Desktop経由)で、Claude Chat(Web UI)はOTelの送信対象に含まれません。
これらをGoogle Cloud上に集約し、以下を実現します。
- プロンプト・応答の保管: 誰がいつ何を聞いて、何が返ってきたかを全文で記録する
- ツール実行の追跡: 実行されたコマンド・ファイル操作・MCP呼び出しの引数を記録する
- 利用状況の可視化: コスト・トークン使用量・セッション数をダッシュボードで確認する
- 会話ログの検索: ユーザー・セッション・イベント種別で絞り込んで会話を追跡する
構成
構成図
OTel Collectorの仕組み
OpenTelemetry Collector(OTel Collector)は、テレメトリデータの受信・加工・転送を担うプロキシです。Claude CodeやCoworkから送られてくるOTLPデータを受け取り、GCPのサービス(Cloud Logging、Cloud Monitoring)に送信します。
設定は receivers → processors → exporters のパイプラインで構成されます。今回はGoogleが公式に提供しているGoogle-Built OpenTelemetry Collectorをベースにし、config.yamlで独自のパイプライン設定を追加しています。
処理の流れ
ログパイプラインのプロセッサは以下の順で実行されます。transform以外はGoogle-Built Collectorが標準で提供しているコンポーネントで、config.yamlで有効化するだけで使えます。
| # | プロセッサ | 提供元 | 役割 |
|---|---|---|---|
| 1 | memory_limiter |
Google Collector標準 | メモリ使用率を監視し、閾値(デフォルト65%)を超えるとデータ受信を一時停止してOOMを防ぐ。パイプラインの先頭に置くのがベストプラクティス |
| 2 | transform |
独自設定 | 会話内容(prompt, response, tool_input等)をOTelのattributesからbodyに移動する |
| 3 | resourcedetection |
Google Collector標準 | Cloud Runのメタデータサーバーからcloud.provider、cloud.region等のリソース属性を自動検出して付与する |
| 4 | batch |
Google Collector標準 | 個別のレコードをまとめてからバックエンドに送ることで、API呼び出し回数を削減する。エクスポーターの直前に置く |
transformカスタム実装の仕組み
OTelのgooglecloudエクスポーターはログの属性(attributes)をCloud LoggingのLogEntryのlabelsにマッピングします。しかしCloud Loggingのlabelsには以下の制限があります。
| 制限項目 | 上限 |
|---|---|
| ラベル値1つあたりのサイズ | 64KiB(超過分は切り捨て) |
| ラベルのキー数 | 64個/LogEntry |
| LogEntry全体のサイズ | 256KiB |
Claude Codeのプロンプトや応答は1件で数十KBに達することがあり、prompt + responseの両方がlabelsに入ると合計で64KiBを超えて切り捨てが発生します。またlabelsはメタデータ用の領域であり、全文検索やダッシュボードでの閲覧には適していません。
transformプロセッサで会話内容をattributes(→labels)からbodyに移動すると、bodyに構造化データ(map)を入れた場合はCloud LoggingのjsonPayloadとして格納されます。jsonPayloadはLabelsの64KiB制限に縛られず、LogEntry全体の256KiB枠内であれば長文でも安全に保持でき、ダッシュボードのログパネルでも読みやすくなります。
リクエストフロー
Claude Code / Coworkからのテレメトリは以下の順で処理されます。
- クライアントがOTLP/HTTPでエンドポイント(
https://otel.example.com)にリクエストを送信 - Global External ALBがTLSを終端し、Backend Serviceに転送
- Backend Serviceに紐付いたCloud ArmorポリシーがGoogleのネットワークエッジで
x-api-keyヘッダーを検証。一致しないリクエストは403で拒否され、Cloud Runに到達しない - 認証を通過したリクエストはServerless NEG経由でCloud Run上のOTel Collectorに到達
- OTel Collectorがパイプライン(transform → batch等)で処理し、Cloud LoggingとCloud Monitoringに送信
Terraformファイル構成
claude-otel-gcp-sample/
├── DESIGN.md # 詳細設計(mermaid図付き)
├── otel-config/
│ └── config.yaml.tftpl # OTel Collector設定テンプレート
└── terraform/
├── main.tf # Provider・locals
├── variables.tf # 入力変数
├── outputs.tf # 出力値(クライアント設定等)
├── apis.tf # GCP API有効化
├── iam.tf # Service Account・IAM
├── cloud_run.tf # OTel Collector サービス
├── load_balancer.tf # Global ALB・SSL・Serverless NEG
├── security.tf # Cloud Armor ポリシー
├── secret_manager.tf # シークレット管理
├── artifact_registry.tf # Docker リポジトリ(カスタムイメージ用)
├── monitoring_overview.tf # ダッシュボード・ログベースメトリクス
└── terraform.tfvars.example # 変数ファイルのサンプル
以下、各ファイルの内容とポイントを解説します。
main.tf — Provider・locals
OTel Collector設定YAMLをTerraformテンプレート(.tftpl)から生成している点がポイントです。.tftplはTerraformのtemplatefile()関数で変数を埋め込むためのテンプレートファイルで、${変数名}で変数補間、%{ if ... }で条件分岐が書けます。
生成したYAMLはSecret Managerに格納し、Cloud Runからボリュームマウントで読み込みます。Terraform変数を変更してapplyするだけでCollector設定が更新される仕組みです。
terraform {
required_version = ">= 1.10.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}
locals {
name_prefix = var.name_prefix
otel_collector_image = var.otel_collector_image
# Cloud Logging上のログ名(メトリクス・ダッシュボードのフィルタで共通利用)
otel_log_path = "projects/${var.project_id}/logs/${var.otel_log_name}"
# OTel Collector設定YAML(変数を反映してテンプレートから生成)
otel_config_yaml = templatefile("${path.module}/../otel-config/config.yaml.tftpl", {
memory_limit_percentage = var.otel_memory_limit_percentage
memory_spike_limit_percentage = var.otel_memory_spike_limit_percentage
batch_send_size = var.otel_batch_send_size
batch_max_size = var.otel_batch_max_size
batch_timeout = var.otel_batch_timeout
log_name = var.otel_log_name
collector_log_level = var.otel_collector_log_level
enable_conversation_body_transform = var.enable_conversation_body_transform
})
}
otel-config/config.yaml.tftpl — OTel Collector設定テンプレート
このファイルはTerraformの.tftplテンプレートで、${変数名}の部分がterraform apply時にTerraform変数の値で置換されます。%{ if ... ~}はTerraformの条件分岐構文で、変数enable_conversation_body_transformがfalseのときはtransformプロセッサのブロックごとスキップされます。
ポイント:
receivers.otlp: ポート4317(gRPC)と4318(HTTP)はOTLP仕様の標準ポート。Claude Code/Coworkは4318(HTTP)を使用するprocessors.memory_limiter:limit_percentageとspike_limit_percentageはTerraform変数で外から調整可能。パイプラインの先頭に置くのがベストプラクティスで、後ろに置くとリミッターが効く前にメモリが溢れるprocessors.transform/conversation_to_body: この構成の肝。OTTL(OpenTelemetry Transformation Language)のcacheパターンで、attributesの各フィールドを一旦cacheに集約し、set(body, cache)でbodyに一括移動する。移動後はdelete_keyでattributesから削除し、labelsへの二重書き込みを防ぐprocessors.batch:send_batch_size件溜まるかtimeout秒経過したらバックエンドに送信する。エクスポーターの直前に置くservice.pipelines:metricsパイプラインとlogsパイプラインを分離しており、transformはlogsパイプラインのみに適用している。メトリクスにはtransformが不要なためextensions.health_check: OTel Collectorが正常に起動してデータを受け付けられる状態かを外部から確認するためのHTTPエンドポイント。service.extensionsに登録するとCollector起動時にヘルスチェック用のHTTPサーバーが立ち上がる。ポート13133はOTel Collectorの慣習的なヘルスチェックポートで、Cloud Runのstartup probeとliveness probeからこのエンドポイントを参照する
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_percentage: ${memory_limit_percentage}
spike_limit_percentage: ${memory_spike_limit_percentage}
%{ if enable_conversation_body_transform ~}
transform/conversation_to_body:
error_mode: ignore
log_statements:
- context: log
statements:
- set(cache["event_name"], body) where body != nil
- set(cache["event_name"], attributes["event.name"]) where attributes["event.name"] != nil
- set(cache["prompt"], attributes["prompt"]) where attributes["prompt"] != nil
- set(cache["response"], attributes["response"]) where attributes["response"] != nil
- set(cache["tool_name"], attributes["tool_name"]) where attributes["tool_name"] != nil
- set(cache["tool_input"], attributes["tool_input"]) where attributes["tool_input"] != nil
- set(cache["tool_parameters"], attributes["tool_parameters"]) where attributes["tool_parameters"] != nil
- set(cache["decision"], attributes["decision"]) where attributes["decision"] != nil
- set(cache["success"], attributes["success"]) where attributes["success"] != nil
- set(cache["user_email"], attributes["user.email"]) where attributes["user.email"] != nil
- set(cache["session_id"], attributes["session.id"]) where attributes["session.id"] != nil
- set(cache["service_name"], resource.attributes["service.name"]) where resource.attributes["service.name"] != nil
- set(cache["prompt_length"], attributes["prompt_length"]) where attributes["prompt_length"] != nil
- set(cache["response_length"], attributes["response_length"]) where attributes["response_length"] != nil
- set(body, cache)
- delete_key(attributes, "prompt")
- delete_key(attributes, "response")
- delete_key(attributes, "tool_input")
- delete_key(attributes, "tool_parameters")
%{ endif ~}
resourcedetection:
detectors: [gcp]
timeout: 10s
batch:
send_batch_size: ${batch_send_size}
send_batch_max_size: ${batch_max_size}
timeout: ${batch_timeout}
exporters:
googlecloud:
log:
default_log_name: ${log_name}
googlemanagedprometheus: {}
extensions:
health_check:
endpoint: 0.0.0.0:13133
service:
extensions: [health_check]
pipelines:
metrics:
receivers: [otlp]
processors: [memory_limiter, resourcedetection, batch]
exporters: [googlemanagedprometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, %{ if enable_conversation_body_transform ~}transform/conversation_to_body, %{ endif ~}resourcedetection, batch]
exporters: [googlecloud]
telemetry:
logs:
level: ${collector_log_level}
apis.tf — GCP API有効化
resource "google_project_service" "apis" {
for_each = toset([
"compute.googleapis.com",
"run.googleapis.com",
"logging.googleapis.com",
"secretmanager.googleapis.com",
"artifactregistry.googleapis.com",
"monitoring.googleapis.com",
"iam.googleapis.com",
"cloudresourcemanager.googleapis.com",
])
project = var.project_id
service = each.value
disable_on_destroy = false
}
security.tf — Cloud Armorポリシー
ポイント:
Cloud Armorのセキュリティポリシーをbackend serviceに紐付けることで、Googleのネットワークエッジでリクエストを評価します。不正なリクエストはCloud Runに到達する前に拒否されます。
CEL式でx-api-keyヘッダーの値を検証しています。ヘッダーの値が一致するリクエストのみallow(優先度1000)で許可し、それ以外はデフォルトルール(優先度2147483647 = 最低優先度)で403を返します。
resource "google_compute_security_policy" "otel" {
count = 1
name = "${local.name_prefix}-armor-policy"
description = "Cloud Armor policy for OTel Collector: API key header validation"
rule {
action = "allow"
priority = 1000
description = "Allow requests with valid x-api-key header"
match {
expr {
expression = "request.headers['x-api-key'] == '${var.otel_api_key}'"
}
}
}
rule {
action = "deny(403)"
priority = 2147483647
description = "Default deny all"
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = ["*"]
}
}
}
}
load_balancer.tf — Global ALB・SSL・Serverless NEG
リクエストは以下の順で流れます。
Static IP → Forwarding Rule → Target HTTPS Proxy(TLS終端)→ URL Map → Backend Service → Serverless NEG → Cloud Run
ポイント:
Cloud RunにはIPアドレスがないため、LBから直接ターゲットにできません。Serverless NEG(Network Endpoint Group)がCloud Runのサービス名で参照することで、LBとCloud Runを橋渡ししています。
load_balancing_schemeには"EXTERNAL_MANAGED"を指定しています。"EXTERNAL"(クラシック)でも動作しますが、Cloud ArmorのCEL式評価など高度な機能を使うために現行の推奨タイプを選択しています。
Google-managed SSL証明書はDNSのAレコードがLBの静的IPを向いていないと発行されないため、terraform apply後にDNSを設定し、最大60分程度待つ必要があります。
HTTP(ポート80)のURL MapはHTTPSへの301リダイレクト専用で、バックエンドは持たせていません。
resource "google_compute_global_address" "lb_ip" {
count = 1
name = "${local.name_prefix}-lb-ip"
}
resource "google_compute_managed_ssl_certificate" "otel" {
count = 1
name = "${local.name_prefix}-ssl-cert"
managed {
domains = [var.domain_name]
}
lifecycle {
create_before_destroy = true
}
}
resource "google_compute_region_network_endpoint_group" "otel_neg" {
count = 1
name = "${local.name_prefix}-neg"
network_endpoint_type = "SERVERLESS"
region = var.region
cloud_run {
service = google_cloud_run_v2_service.otel_collector.name
}
}
resource "google_compute_backend_service" "otel" {
count = 1
name = "${local.name_prefix}-backend"
load_balancing_scheme = "EXTERNAL_MANAGED"
security_policy = google_compute_security_policy.otel[0].self_link
backend {
group = google_compute_region_network_endpoint_group.otel_neg[0].id
}
log_config {
enable = true
sample_rate = 1.0
}
}
resource "google_compute_url_map" "otel" {
count = 1
name = "${local.name_prefix}-url-map"
default_service = google_compute_backend_service.otel[0].id
}
resource "google_compute_url_map" "http_redirect" {
count = var.enable_http_redirect ? 1 : 0
name = "${local.name_prefix}-http-redirect"
default_url_redirect {
https_redirect = true
redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
strip_query = false
}
}
resource "google_compute_target_https_proxy" "otel" {
count = 1
name = "${local.name_prefix}-https-proxy"
url_map = google_compute_url_map.otel[0].id
ssl_certificates = [google_compute_managed_ssl_certificate.otel[0].id]
}
resource "google_compute_target_http_proxy" "http_redirect" {
count = var.enable_http_redirect ? 1 : 0
name = "${local.name_prefix}-http-proxy"
url_map = google_compute_url_map.http_redirect[0].id
}
resource "google_compute_global_forwarding_rule" "otel_https" {
count = 1
name = "${local.name_prefix}-https-forwarding-rule"
ip_protocol = "TCP"
load_balancing_scheme = "EXTERNAL_MANAGED"
port_range = "443"
target = google_compute_target_https_proxy.otel[0].id
ip_address = google_compute_global_address.lb_ip[0].id
}
resource "google_compute_global_forwarding_rule" "http_redirect" {
count = var.enable_http_redirect ? 1 : 0
name = "${local.name_prefix}-http-forwarding-rule"
ip_protocol = "TCP"
load_balancing_scheme = "EXTERNAL_MANAGED"
port_range = "80"
target = google_compute_target_http_proxy.http_redirect[0].id
ip_address = google_compute_global_address.lb_ip[0].id
}
cloud_run.tf — OTel Collectorサービス
ポイント:
ingressを"INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"に制限しているため、Cloud RunのURLに直接アクセスしてもリクエストは拒否されます。前述のCloud Armor → LB経由でのみアクセスできる構成のため、APIキー認証をバイパスする経路がありません。
cpu_idle = falseでCPUを常時割当にしています。true(リクエスト処理中のみCPU割当)だと、リクエスト間のアイドル時間にbatchプロセッサのタイマーフラッシュが動かない可能性があるためです。
min_instance_count = 1でコールドスタートを回避しています。0にすると初回リクエスト時に数秒のレイテンシが発生し、テレメトリを取りこぼす可能性があります。
Collector設定はSecret Managerからボリュームマウントで読み込んでいます。version = "latest"を指定しているため、Secret Managerのバージョンを更新してCloud Runの新リビジョンをデプロイすれば設定が反映されます。
ヘルスチェックにはOTel Collectorのhealth_checkエクステンションが提供するポート13133のHTTPエンドポイントを使用しています。アプリケーションポート(4318)ではなくヘルスチェック専用ポートを使うことで、OTLPデータの受信とは独立してCollectorの正常性を判定できます。Cloud Runのstartup probe(起動完了の確認)とliveness probe(稼働中の死活監視)の両方でこのポートを参照しています。
resource "google_cloud_run_v2_service" "otel_collector" {
name = "${local.name_prefix}-collector"
location = var.region
ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
deletion_protection = false
template {
service_account = google_service_account.otel_collector.email
scaling {
min_instance_count = 1
max_instance_count = var.cloud_run_max_instances
}
containers {
name = "otel-collector"
image = local.otel_collector_image
args = ["--config=/etc/otelcol-google/config.yaml"]
ports {
name = "http1"
container_port = 4318
}
resources {
limits = {
cpu = var.cloud_run_cpu
memory = var.cloud_run_memory
}
cpu_idle = false
startup_cpu_boost = var.cloud_run_startup_cpu_boost
}
volume_mounts {
name = "otel-config"
mount_path = "/etc/otelcol-google"
}
startup_probe {
http_get {
path = "/"
port = 13133
}
initial_delay_seconds = 10
period_seconds = 10
timeout_seconds = 5
failure_threshold = 5
}
liveness_probe {
http_get {
path = "/"
port = 13133
}
initial_delay_seconds = 30
period_seconds = 30
timeout_seconds = 5
failure_threshold = 3
}
}
volumes {
name = "otel-config"
secret {
secret = google_secret_manager_secret.otel_config.secret_id
items {
version = "latest"
path = "config.yaml"
mode = 292 # 0444 (read-only)
}
}
}
}
depends_on = [
google_project_service.apis,
google_secret_manager_secret_version.otel_config,
google_project_iam_member.otel_secret_accessor,
]
}
iam.tf — Service Account・IAM
ポイント:
allUsersにroles/run.invokerを付与しているが、Cloud RunのingressをINTERNAL_LOAD_BALANCERに制限しているため、LB経由以外のリクエストはCloud Runに到達しない。 さらにLBにはCloud Armorが適用されているため、APIキー認証を通過したリクエストのみが処理される- 最小権限の原則に従い、必要なロールのみ付与。 logging.logWriter、monitoring.metricWriter、secretmanager.secretAccessorの3つ
resource "google_service_account" "otel_collector" {
account_id = "otel-collector-sa"
display_name = "OTel Collector Service Account"
description = "Service account for the OpenTelemetry Collector running on Cloud Run"
project = var.project_id
depends_on = [google_project_service.apis]
}
resource "google_project_iam_member" "otel_logging_writer" {
project = var.project_id
role = "roles/logging.logWriter"
member = "serviceAccount:${google_service_account.otel_collector.email}"
}
resource "google_project_iam_member" "otel_monitoring_writer" {
project = var.project_id
role = "roles/monitoring.metricWriter"
member = "serviceAccount:${google_service_account.otel_collector.email}"
}
resource "google_project_iam_member" "otel_secret_accessor" {
project = var.project_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.otel_collector.email}"
}
resource "google_cloud_run_v2_service_iam_member" "all_users_invoker" {
project = var.project_id
location = var.region
name = google_cloud_run_v2_service.otel_collector.name
role = "roles/run.invoker"
member = "allUsers"
}
secret_manager.tf — シークレット管理
Collector設定YAMLとAPIキーの2つのシークレットを管理します。
resource "google_secret_manager_secret" "otel_config" {
secret_id = "${local.name_prefix}-collector-config"
project = var.project_id
replication {
user_managed {
replicas {
location = var.region
}
}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "otel_config" {
secret = google_secret_manager_secret.otel_config.id
secret_data = local.otel_config_yaml
}
resource "google_secret_manager_secret" "otel_api_key" {
secret_id = "${local.name_prefix}-api-key"
project = var.project_id
replication {
user_managed {
replicas {
location = var.region
}
}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "otel_api_key" {
secret = google_secret_manager_secret.otel_api_key.id
secret_data = var.otel_api_key
}
outputs.tf — 出力値
terraform output -raw client_env_configでClaude Codeに設定する環境変数一式が出力されます。
locals {
otel_endpoint = "https://${var.domain_name}"
otel_headers_line = "export OTEL_EXPORTER_OTLP_HEADERS=\"x-api-key=${var.otel_api_key}\"\n"
}
output "lb_ip_address" {
description = "Global Load Balancer IP address. DNS A レコードを domain_name → この IP に設定する"
value = google_compute_global_address.lb_ip[0].address
}
output "otel_endpoint" {
description = "OTel OTLP/HTTP endpoint for OTEL_EXPORTER_OTLP_ENDPOINT"
value = local.otel_endpoint
}
output "artifact_registry_url" {
description = "Artifact Registry Docker repository URL"
value = var.enable_artifact_registry ? "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.docker[0].repository_id}" : null
}
output "cloud_run_service_url" {
description = "Cloud Run service URL"
value = google_cloud_run_v2_service.otel_collector.uri
}
output "dashboard_overview_url" {
description = "Cloud Monitoring dashboard URL (Overview)"
value = "https://console.cloud.google.com/monitoring/dashboards/custom/${split("/", google_monitoring_dashboard.claude_code_overview[0].id)[3]}?project=${var.project_id}"
}
output "logs_explorer_url" {
description = "Cloud Logging のログエクスプローラ URL"
value = "https://console.cloud.google.com/logs/query;query=${urlencode("logName=\"${local.otel_log_path}\"")}?project=${var.project_id}"
}
output "client_env_config" {
description = "Environment variables to configure on each Claude Code client"
sensitive = true
value = <<-EOT
# ---- 接続設定 ----
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_EXPORTER_OTLP_ENDPOINT=${local.otel_endpoint}
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
${local.otel_headers_line}export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_SERVICE_NAME=claude-code
# ---- 会話内容の収集----
export OTEL_LOG_USER_PROMPTS=1
export OTEL_LOG_ASSISTANT_RESPONSES=1
export OTEL_LOG_TOOL_DETAILS=1
EOT
}
output "cowork_setup_instructions" {
description = "Claude Desktop で Cowork の OTel を設定する手順"
value = <<-EOT
Claude Desktop アプリ > Admin settings > Cowork で以下を設定してください。
OTLP エンドポイント : ${local.otel_endpoint}
プロトコル : HTTP/protobuf
認証ヘッダー : x-api-key: <otel_api_key の値>
※ エンドポイントを設定した時点で、組織全体の会話内容がデフォルトで送信されます。
※ コンテンツ取得を個別に制御する設定項目は管理画面に存在しません。
EOT
}
monitoring_overview.tf — ダッシュボード・ログベースメトリクス
ログベースメトリクスの定義とダッシュボードのウィジェット定義(JSON)の全文です。
# =====================================================
# Log-based metrics
# =====================================================
resource "google_logging_metric" "claude_code_event_count" {
project = var.project_id
name = "claude_code/event_count"
description = "Claude Code テレメトリログのイベント数 (event.name 別)"
filter = "logName=\"${local.otel_log_path}\""
metric_descriptor {
metric_kind = "DELTA"
value_type = "INT64"
display_name = "Claude Code Event Count"
labels {
key = "event_name"
value_type = "STRING"
description = "OTel イベント名 (event.name ラベル)"
}
labels {
key = "user_email"
value_type = "STRING"
description = "ユーザーのメールアドレス"
}
}
label_extractors = {
event_name = "EXTRACT(labels.\"event.name\")"
user_email = "EXTRACT(labels.\"user.email\")"
}
depends_on = [google_project_service.apis]
}
# =====================================================
# Overview Dashboard
#
# 使用メトリクス名 (OTel → GMP Prometheus 変換後):
# claude_code_cost_usage_USD_total labels: model
# claude_code_token_usage_tokens_total labels: type (input/output/cacheRead/cacheCreation)
# claude_code_session_count_total
# claude_code_lines_of_code_count_total labels: type (added/removed)
# claude_code_edit_tool_decision_total labels: tool_name
# claude_code_session_duration_ms_total ※存在しない場合は Active Time に "no data" が表示されます
#
# 実際の名前は terraform apply 後に Metrics Explorer (prometheus.googleapis.com/*) で確認してください。
# =====================================================
resource "google_monitoring_dashboard" "claude_code_overview" {
count = 1
project = var.project_id
dashboard_json = jsonencode({
displayName = "Claude Code / Cowork Overview"
mosaicLayout = {
columns = 12
tiles = [
# ==========================================
# Row 1 (y=0, h=3): Overview Scorecards
# ==========================================
{
xPos = 0
yPos = 0
width = 2
height = 3
widget = {
title = "Total Cost"
scorecard = {
timeSeriesQuery = {
prometheusQuery = "sum(increase(claude_code_cost_usage_USD_total[24h]))"
outputFullDuration = true
}
sparkChartView = { sparkChartType = "SPARK_LINE" }
}
}
},
{
xPos = 2
yPos = 0
width = 2
height = 3
widget = {
title = "Cost / Session"
scorecard = {
timeSeriesQuery = {
prometheusQuery = "sum(increase(claude_code_cost_usage_USD_total[24h])) / clamp_min(sum(increase(claude_code_session_count_total[24h])), 1)"
outputFullDuration = true
}
sparkChartView = { sparkChartType = "SPARK_LINE" }
}
}
},
{
xPos = 4
yPos = 0
width = 3
height = 3
widget = {
title = "Lines Changed"
scorecard = {
timeSeriesQuery = {
prometheusQuery = "sum(increase(claude_code_lines_of_code_count_total[24h]))"
outputFullDuration = true
}
sparkChartView = { sparkChartType = "SPARK_LINE" }
}
}
},
{
xPos = 7
yPos = 0
width = 3
height = 3
widget = {
title = "Sessions"
scorecard = {
timeSeriesQuery = {
prometheusQuery = "sum(increase(claude_code_session_count_total[24h]))"
outputFullDuration = true
}
sparkChartView = { sparkChartType = "SPARK_LINE" }
}
}
},
{
xPos = 10
yPos = 0
width = 2
height = 3
widget = {
title = "Tokens"
scorecard = {
timeSeriesQuery = {
prometheusQuery = "sum(increase(claude_code_token_usage_tokens_total[24h]))"
outputFullDuration = true
}
sparkChartView = { sparkChartType = "SPARK_LINE" }
}
}
},
# ==========================================
# Row 2 (y=3, h=4): Cost Analysis
# ==========================================
{
xPos = 0
yPos = 3
width = 8
height = 4
widget = {
title = "Cost Trend"
xyChart = {
dataSets = [
{
timeSeriesQuery = {
prometheusQuery = "sum(increase(claude_code_cost_usage_USD_total[1h]))"
}
plotType = "LINE"
legendTemplate = "Session Cost"
},
{
timeSeriesQuery = {
prometheusQuery = "sum(claude_code_cost_usage_USD_total)"
}
plotType = "LINE"
legendTemplate = "Cumulative"
}
]
yAxis = {
label = "USD"
scale = "LINEAR"
}
}
}
},
{
xPos = 8
yPos = 3
width = 4
height = 4
widget = {
title = "Cost by Model"
pieChart = {
chartType = "DONUT"
dataSets = [{
timeSeriesQuery = {
prometheusQuery = "sum by (model) (increase(claude_code_cost_usage_USD_total[24h]))"
outputFullDuration = true
}
sliceNameTemplate = "$${labels.model}"
}]
}
}
},
# ==========================================
# Row 3 (y=7, h=4): Token Analysis
# ==========================================
{
xPos = 0
yPos = 7
width = 8
height = 4
widget = {
title = "Token Usage by Type"
xyChart = {
dataSets = [{
timeSeriesQuery = {
prometheusQuery = "sum by (type) (increase(claude_code_token_usage_tokens_total[1h]))"
}
plotType = "STACKED_AREA"
legendTemplate = "$${labels.type}"
}]
yAxis = {
label = "トークン数"
scale = "LINEAR"
}
}
}
},
{
xPos = 8
yPos = 7
width = 4
height = 4
widget = {
title = "Token Distribution"
pieChart = {
chartType = "DONUT"
dataSets = [{
timeSeriesQuery = {
prometheusQuery = "sum by (type) (increase(claude_code_token_usage_tokens_total[24h]))"
outputFullDuration = true
}
sliceNameTemplate = "$${labels.type}"
}]
}
}
},
# ==========================================
# Row 4 (y=11, h=4): Activity
# ==========================================
{
xPos = 0
yPos = 11
width = 6
height = 4
widget = {
title = "コード変更行数 (追加/削除)"
xyChart = {
dataSets = [{
timeSeriesQuery = {
prometheusQuery = "sum by (type) (increase(claude_code_lines_of_code_count_total[1h]))"
}
plotType = "STACKED_BAR"
legendTemplate = "$${labels.type}"
}]
yAxis = {
label = "行数"
scale = "LINEAR"
}
}
}
},
{
xPos = 6
yPos = 11
width = 6
height = 4
widget = {
title = "ツール使用割合 (上位10件)"
pieChart = {
chartType = "DONUT"
dataSets = [{
timeSeriesQuery = {
prometheusQuery = "topk(10, sum by (tool_name) (increase(claude_code_code_edit_tool_decision_total[24h])))"
outputFullDuration = true
}
sliceNameTemplate = "$${labels.tool_name}"
}]
}
}
},
# ==========================================
# Row 5 (y=15, h=4): Event Count by Type
# ==========================================
{
xPos = 0
yPos = 15
width = 12
height = 4
widget = {
title = "イベント数 (event.name 別)"
xyChart = {
dataSets = [{
timeSeriesQuery = {
timeSeriesFilter = {
filter = "metric.type=\"logging.googleapis.com/user/claude_code/event_count\""
aggregation = {
alignmentPeriod = "3600s"
perSeriesAligner = "ALIGN_SUM"
crossSeriesReducer = "REDUCE_SUM"
groupByFields = ["metric.labels.event_name"]
}
}
}
plotType = "STACKED_BAR"
legendTemplate = "$${metric.labels.event_name}"
}]
yAxis = {
label = "件数"
scale = "LINEAR"
}
}
}
},
# ==========================================
# Row 6 (y=19, h=4): Cost by User
# ==========================================
{
xPos = 0
yPos = 19
width = 12
height = 4
widget = {
title = "コスト累積 (ユーザー別, USD)"
xyChart = {
dataSets = [{
timeSeriesQuery = {
prometheusQuery = "sum by (user_email) (increase(claude_code_cost_usage_USD_total[1h]))"
}
plotType = "STACKED_AREA"
legendTemplate = "$${labels.user_email}"
}]
yAxis = {
label = "コスト (USD)"
scale = "LINEAR"
}
}
}
},
# ==========================================
# Row 7 (y=23, h=5): 会話ログ セクション見出し
# ==========================================
{
xPos = 0
yPos = 23
width = 12
height = 5
widget = {
title = "会話履歴の確認"
text = {
content = <<-EOT
プロンプト・応答・ツール実行の内容を下のログパネルで確認できます。パネル上部の検索窓でフィルタを追加すると絞り込みができます。
**よく使うフィルタ:**
| 目的 | フィルタ |
|---|---|
| プロンプトを全文検索 | `jsonPayload.prompt:"検索したい文字列"` |
| 特定ユーザーの会話 | `jsonPayload.user_email="someone@example.com"` |
| 特定セッションを追跡 | `jsonPayload.session_id="<SESSION_ID>"` |
| Cowork のみ / Code のみ | `jsonPayload.service_name="cowork"` / `="claude-code"` |
| ツール実行のみ | `jsonPayload.event_name="tool_result"` |
| プロンプト・応答のみ | `jsonPayload.event_name="user_prompt" OR jsonPayload.event_name="assistant_response"` |
> プロンプト本文が `<REDACTED>` と表示される場合、クライアント側で `OTEL_LOG_USER_PROMPTS=1` が有効になっていません(Cowork はデフォルトで本文が送信されます)。
EOT
format = "MARKDOWN"
}
}
},
# ==========================================
# Row 8 (y=28, h=14): 会話ログ本体
# ==========================================
{
xPos = 0
yPos = 28
width = 12
height = 14
widget = {
title = "会話ログ (プロンプト / 応答 / ツール実行)"
logsPanel = {
filter = "logName=\"${local.otel_log_path}\""
resourceNames = ["projects/${var.project_id}"]
}
}
},
]
}
})
depends_on = [
google_project_service.apis,
google_logging_metric.claude_code_event_count,
]
}
やってみた
APIキーの作成とデプロイ
まず、Cloud Armorでクライアントを認証するためのAPIキーを生成します。OTelエンドポイントへのリクエストにx-api-keyヘッダーとして付与し、Cloud Armorで値を検証することで不正なアクセスを遮断します。
openssl rand -hex 32
次に、terraform.tfvarsを作成して必要な変数を設定します。
cd terraform
cp terraform.tfvars.example terraform.tfvars
最低限設定が必要な変数は以下の4つです。
# GCPプロジェクトID
project_id = "your-gcp-project-id"
# デプロイ先リージョン
region = "asia-northeast1"
# OTelエンドポイントに使用するドメイン名(事前にDNS管理権限が必要)
domain_name = "otel.example.com"
# 上で生成したAPIキー(Cloud Armorのヘッダー認証に使用)
otel_api_key = "生成した64文字のランダム文字列"
変数を設定したらデプロイします。
terraform init
terraform apply
terraform output lb_ip_addressで出力されるIPアドレスを、domain_nameに指定したドメインのDNS Aレコードに設定します。Google-managed SSL証明書はDNSが反映されてから自動発行されるため、最大60分程度待つ必要があります。
Claude Codeの環境変数を設定する
公式ドキュメント(Monitoring usage)に記載されている環境変数を設定します。terraform output -raw client_env_configでも一式を確認できます。
# 接続設定
export CLAUDE_CODE_ENABLE_TELEMETRY=1 # テレメトリ送信を有効化
export OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com # OTel Collectorのエンドポイント
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # 送信プロトコル(Claude Code/CoworkはHTTPを使用)
export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=<APIキー>" # Cloud Armorで検証する認証ヘッダー
export OTEL_METRICS_EXPORTER=otlp # メトリクスの送信先をOTLPに設定
export OTEL_LOGS_EXPORTER=otlp # ログの送信先をOTLPに設定
export OTEL_SERVICE_NAME=claude-code # サービス名(ログのservice_nameフィールドに反映)
# 会話内容の収集(未設定だとプロンプト・応答は <REDACTED> になる)
export OTEL_LOG_USER_PROMPTS=1 # プロンプト本文の送信を有効化
export OTEL_LOG_ASSISTANT_RESPONSES=1 # 応答本文の送信を有効化
export OTEL_LOG_TOOL_DETAILS=1 # ツール名・引数の送信を有効化
会話内容の収集を制御する環境変数は以下の3つです。今回の検証では3つ全て設定した状態でプロンプト・応答・ツール引数の全文記録を確認しました。未設定の場合、プロンプトと応答は<REDACTED>に置換されます。
| 環境変数 | 効果 |
|---|---|
OTEL_LOG_USER_PROMPTS=1 |
プロンプト本文を送信(未設定で<REDACTED>) |
OTEL_LOG_ASSISTANT_RESPONSES=1 |
応答本文を送信(未設定で<REDACTED>) |
OTEL_LOG_TOOL_DETAILS=1 |
ツール名・引数を送信 |
Claude CodeとCoworkのデフォルト挙動の違い
Claude CodeとCoworkでは会話内容の送信に関するデフォルト挙動が異なります。
| 項目 | Claude Code | Cowork |
|---|---|---|
| デフォルト挙動 | <REDACTED>(本文は伏せられる) |
本文がそのまま送信される |
| コンテンツ取得の有効化 | 上記3つの環境変数を全て設定 | 設定項目が存在しない |
| 送信内容の絞り込み | 環境変数ごとの個別制御は未検証 | 不可(全部送るか、送らないかの二択) |
Coworkの設定をする
Claude DesktopのAdmin settings > Cowork > モニタリングで設定します。

| 項目 | 設定値 |
|---|---|
| OTLPエンドポイント | https://otel.example.com |
| OTLPプロトコル | http/protobuf |
| OTLPヘッダー | x-api-key=<APIキー> |
| リソース属性 | (任意) |
コンテンツ取得を制御する項目は存在しません。エンドポイントを設定した時点で、組織全体の会話内容が送信されます。
GCPリソースの設定を確認する
デプロイが完了したら、まずGCPコンソールでリソースが正しく作成されていることを確認します。
Cloud ArmorのセキュリティポリシーでAPIキーヘッダーを検証しています。x-api-keyが一致するリクエストのみ許可し、それ以外は403で拒否します。

Load BalancerはHTTPS(443)とHTTP→HTTPSリダイレクト(80)の2つの転送ルールを持ち、バックエンドはCloud Run上のOTel CollectorへのServerless NEGです。


OTel Collectorはotelcol-google(Google-Built Collector)の公式イメージをCloud Run上で実行しています。

ダッシュボードを確認する
GCPリソースの確認ができたら、Terraformで作成される「Claude Code / Cowork Overview」ダッシュボードを開きます。メトリクスと会話ログを1画面で確認できます。
ダッシュボード上部には5つのスコアカードが並びます。左から順にTotal Cost、Cost / Session、Lines Changed、Sessions、Tokensで、直近24時間の集計値とスパークラインを表示します。

その下に以下のグラフが続きます。
- Cost Trend: セッションコスト + 累積コストの折れ線グラフ
- Cost by Model: モデル別コスト比率のドーナツチャート
- Token Usage by Type: input / output / cacheRead / cacheCreation別の積み上げ面グラフ
- Token Distribution: トークン種別比率のドーナツチャート
- コード変更行数: 追加/削除の積み上げ棒グラフ
- ツール使用割合: 上位10件のドーナツチャート
- イベント数: event.name別の積み上げ棒グラフ
- コスト累積: ユーザー別の積み上げ面グラフ

ダッシュボード下部にはCloud Loggingのログパネルが埋め込まれており、会話内容を直接検索・確認できます。

会話ログをフィルタして中身を確認する
ログパネルではフィルタを使って会話内容を絞り込めます。よく使うフィルタの例です。
| 目的 | フィルタ |
|---|---|
| プロンプト全文検索 | jsonPayload.prompt:"検索したい文字列" |
| 特定ユーザー | jsonPayload.user_email="someone@example.com" |
| セッション追跡 | jsonPayload.session_id="<SESSION_ID>" |
| Cowork / Code | jsonPayload.service_name="cowork" / ="claude-code" |
| ツール実行のみ | jsonPayload.event_name="tool_result" |
| プロンプト・応答のみ | jsonPayload.event_name="user_prompt" OR jsonPayload.event_name="assistant_response" |
REDACTEDの確認
Claude Codeで環境変数を設定せずにプロンプトを送信し、jsonPayload.prompt="<REDACTED>"でフィルタすると、promptフィールドが<REDACTED>に置換されていることが確認できます。prompt_lengthにはオリジナルの文字数が残るため、長さだけは追跡できます。

環境変数を設定して有効化すると、プロンプト全文がjsonPayload.promptに記録されます。
Coworkのログを確認する
jsonPayload.service_name="cowork"でフィルタすると、Coworkからのイベントのみに絞り込めます。以下はCoworkのReadツール実行に対するtool_decisionイベントで、decision: "accept"(許可)と記録されています。labelsにはOS情報やバージョン等のメタデータも含まれています。

ツール実行の記録を確認する
jsonPayload.event_name:"tool_result"でフィルタすると、ツール実行の記録を確認できます。以下はCoworkからBashコマンドを実行した際のログで、tool_inputに実行コマンドの全文、tool_parametersにMCPサーバー名やツール名が記録されています。

jsonPayloadに含まれる主なフィールドは以下のとおりです。
| フィールド | 内容 |
|---|---|
tool_name |
実行されたツール名(Bash、Read等) |
tool_input |
実行コマンド・引数の全文 |
tool_parameters |
MCPサーバー名(mcp_server_name)やツール名(mcp_tool_name)等の付加情報 |
success |
実行結果(true / false) |
実行コマンドの引数(tool_input)は全文記録されるため「何を実行したか」は追跡できますが、実行結果の中身(コマンドの出力など)はjsonPayloadには残りません。なお、OTelのトレース機能を有効化しOTEL_LOG_TOOL_CONTENT=1を設定すると、ツール実行結果の中身もスパンイベントとして取得できます(今回の構成ではログのみ対象としており、トレースは扱っていません)。
システムプロンプトの記録を確認する
Claude CodeでOTEL_LOG_RAW_API_BODIES=1を設定すると、APIリクエストボディがapi_request_bodyイベントとして記録されます。labels.bodyにAPIリクエストの全文がJSON形式で格納されており、システムプロンプト(CLAUDE.mdの内容等)が確認できます。

長文の記録と切り詰めを確認する
プロンプトは8万文字でも欠落なく全文が記録されることを確認しました。一方、応答(assistant_response)は60KB(61,440文字)で切り詰めが発生します。
以下は意図的に長い応答を生成させた際のログです。申告106,262文字に対し、記録されたresponseフィールドは61,440文字で打ち切られていました。

| 対象 | 切り詰め |
|---|---|
| プロンプト | なし(8万文字でも全文記録) |
| 応答 | 60KB(61,440文字)で切り詰め |
| RAW APIボディ | 60KB(61,388文字)で切り詰め(body_truncated: trueが付与) |
まとめ
Claude CodeとCoworkのOTelテレメトリをGCPに集約する構成をTerraformで構築しました。
取得できるデータ
| データ | Claude Code | Cowork |
|---|---|---|
| プロンプト本文 | 全文(要設定、8万文字でも欠落なし) | 全文(デフォルト) |
| 応答本文 | 全文(要設定、60KB超は切り詰め) | 全文(デフォルト、60KB超は切り詰め) |
| システムプロンプト | ○(OTEL_LOG_RAW_API_BODIES=1有効時) |
× |
| ツール名・引数(実行コマンド・ファイルパス) | 全文 | 全文 |
| ツール実行結果の中身 | サイズのみ(トレース有効化で取得可) | サイズのみ |
| MCPサーバー名・ツール名 | 記録される | 記録される |
| 承認判断(accept / reject) | 記録される | 記録される |
| セッション追跡(session_id) | 記録される | 記録される |
| ユーザー識別(メールアドレス) | 常に記録 | 常に記録 |
| コスト・トークン使用量 | メトリクスで可視化 | メトリクスで可視化 |
ログに記録されないデータ
- ツール実行結果の中身(ログにはサイズのみ。トレース有効化 +
OTEL_LOG_TOOL_CONTENT=1でスパンイベントとして取得可能) - 添付ファイルの中身(ファイル名・パスのみ)
- thinking(思考内容)
- 画像データ
月額想定コスト
本構成をそのまま1ヶ月稼働させた場合の概算です(東京リージョン)。
| 項目 | 月額(USD) | 備考 |
|---|---|---|
| Cloud Run(min-instances=1) | 約 $49 | 1 vCPU + 1 GiB、CPU常時割当(always allocated)のため全秒数に課金 |
| Load Balancer転送ルール | 約 $18 | HTTPS + HTTPの2本(最初の5本はバンドル料金$0.025/hour) |
| Cloud Logging / Monitoring | $0 | 無料枠内(Logging月50GiB、検証実績は1日あたり約2MB) |
| 合計 | 約 $67 |
コストの大半はCloud Runの常時起動(CPU always allocated)です。Cloud Logging / Cloud Monitoringは無料枠内で収まりますが、会話内容の収集を有効にした場合、利用者数とプロンプト長に応じてログ量が増加します(50GiB超過分は$0.50/GiB)。
最後に
OTel CollectorのtransformプロセッサでCloud LoggingのjsonPayloadに会話内容を構造化して格納することで、長文プロンプトも欠落なく保管でき、ダッシュボードからの検索も容易にできました。
会話ログを保管するうえで、Claude CodeとCoworkでは有効化の方法と影響範囲が大きく異なります。
Claude Codeはデフォルトで会話内容が<REDACTED>に伏せられるため、プロンプト・応答を記録するには各端末で環境変数(OTEL_LOG_USER_PROMPTS等)を設定する必要があります。裏を返せば、管理者が環境変数を配布しない限り会話内容は送信されないため、段階的な導入が可能です。
一方Coworkはエンドポイントを設定した時点で、組織全体の会話内容がデフォルトで送信されます。個別にオン/オフを切り替える設定項目は管理画面に存在しないため、導入時は組織内への事前周知が必要です。
今回の構成はCloud Runのmin_instance_count=1で常時1インスタンスを維持しており、数十人規模の利用を想定しています。組織全体で数百人規模の利用やCoworkの全社有効化を行う場合は、Cloud Runのオートスケーリング上限(max_instance_count)の調整、Cloud Loggingの無料枠(50GiB/月)超過によるコスト増、batchプロセッサのバッファサイズとメモリ割当のバランスなど、追加の考慮が必要になります。
本構成ではログとメトリクスのみを対象としていますが、Claude Codeはトレース(分散トレーシング)の送信にも対応しています。トレースを有効化すると、ツール実行結果の中身(コマンド出力やファイル内容)をスパンイベントとして取得できるため、より詳細な実行追跡が可能になります。また、Cloud Loggingのログルーターを使えばBigQueryへのエクスポートも構成でき、長期保存や分析用途にも対応できます。
この記事がどなたかの参考になれば幸いです。今回は以上です。






