diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index d3264256f..b10e72bd0 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id anthropic_api_key = "xxxx-xxxxx-xxxx" } @@ -47,7 +47,7 @@ locals { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = local.claude_workdir anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -78,7 +78,7 @@ resource "coder_app" "claude" { ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_ai_gateway = true @@ -102,7 +102,7 @@ This example shows version pinning, a pre-installed binary path, a custom model, ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -166,7 +166,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -252,7 +252,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -309,7 +309,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" @@ -341,6 +341,34 @@ module "claude-code" { > [!NOTE] > For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai). +### Telemetry export (OpenTelemetry) + +Claude Code can emit OpenTelemetry metrics and events covering token usage, tool calls, session lifecycle, and errors (see the [monitoring docs](https://docs.anthropic.com/en/docs/claude-code/monitoring-usage)). Set `telemetry.enabled = true` and point `otlp_endpoint` at your OTLP collector. + +The module automatically tags every span and metric with `coder.workspace_id`, `coder.workspace_name`, `coder.workspace_owner`, and `coder.template_name` via `OTEL_RESOURCE_ATTRIBUTES`, so Claude Code telemetry can be joined directly against Coder's [audit logs](https://coder.com/docs/admin/security/audit-logs) and `exectrace` records on `workspace_id`. + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.1.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + anthropic_api_key = "xxxx-xxxxx-xxxx" + + telemetry = { + enabled = true + otlp_endpoint = "http://otel-collector.observability:4317" + otlp_protocol = "grpc" + otlp_headers = { + authorization = "Bearer ${var.otel_token}" + } + resource_attributes = { + "service.name" = "claude-code" + } + } +} +``` + ## Troubleshooting If you encounter any issues, check the log files in the `~/.coder-modules/coder/claude-code/logs` directory within your workspace for detailed information. diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 6b1367551..bee682d63 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -435,4 +435,41 @@ describe("claude-code", async () => { ]); expect(resp.stdout.trim()).toBe("ABSENT"); }); + + test("telemetry-otel", async () => { + const { coderEnvVars } = await setup({ + moduleVariables: { + telemetry: JSON.stringify({ + enabled: true, + otlp_endpoint: "http://otel-collector:4317", + otlp_protocol: "grpc", + otlp_headers: { authorization: "Bearer test-token" }, + resource_attributes: { "service.name": "claude-code" }, + }), + }, + }); + expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBe("1"); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe( + "http://otel-collector:4317", + ); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBe("grpc"); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBe( + "authorization=Bearer test-token", + ); + const attrs = coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"]; + expect(attrs).toContain("coder.workspace_id="); + expect(attrs).toContain("coder.workspace_name="); + expect(attrs).toContain("coder.workspace_owner="); + expect(attrs).toContain("coder.template_name="); + expect(attrs).toContain("service.name=claude-code"); + }); + + test("telemetry-disabled-by-default", async () => { + const { coderEnvVars } = await setup(); + expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBeUndefined(); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBeUndefined(); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBeUndefined(); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined(); + expect(coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"]).toBeUndefined(); + }); }); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 346930db9..acfe85387 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -118,6 +118,18 @@ variable "enable_ai_gateway" { } } +variable "telemetry" { + type = object({ + enabled = optional(bool, false) + otlp_endpoint = optional(string, "") + otlp_protocol = optional(string, "http/protobuf") + otlp_headers = optional(map(string), {}) + resource_attributes = optional(map(string), {}) + }) + default = {} + description = "Configure Claude Code OpenTelemetry export. When enabled, sets CLAUDE_CODE_ENABLE_TELEMETRY and the standard OTEL_EXPORTER_OTLP_* environment variables. Coder workspace identifiers (coder.workspace_id, coder.workspace_name, coder.workspace_owner, coder.template_name) are automatically appended to OTEL_RESOURCE_ATTRIBUTES so Claude Code telemetry can be joined with Coder audit and exectrace logs." +} + resource "coder_env" "claude_code_oauth_token" { count = var.claude_code_oauth_token != "" ? 1 : 0 agent_id = var.agent_id @@ -163,6 +175,58 @@ resource "coder_env" "anthropic_base_url" { value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" } +locals { + # Always inject Coder workspace identifiers so OTEL data can be joined with + # Coder's audit log / exectrace on workspace_id without per-template wiring. + otel_resource_attributes = merge( + var.telemetry.resource_attributes, + { + "coder.workspace_id" = data.coder_workspace.me.id + "coder.workspace_name" = data.coder_workspace.me.name + "coder.workspace_owner" = data.coder_workspace_owner.me.name + "coder.workspace_owner_id" = data.coder_workspace_owner.me.id + "coder.template_name" = data.coder_workspace.me.template_name + "coder.template_version" = data.coder_workspace.me.template_version + "coder.access_url" = data.coder_workspace.me.access_url + }, + ) +} + +resource "coder_env" "claude_code_enable_telemetry" { + count = var.telemetry.enabled ? 1 : 0 + agent_id = var.agent_id + name = "CLAUDE_CODE_ENABLE_TELEMETRY" + value = "1" +} + +resource "coder_env" "otel_exporter_otlp_endpoint" { + count = var.telemetry.enabled && var.telemetry.otlp_endpoint != "" ? 1 : 0 + agent_id = var.agent_id + name = "OTEL_EXPORTER_OTLP_ENDPOINT" + value = var.telemetry.otlp_endpoint +} + +resource "coder_env" "otel_exporter_otlp_protocol" { + count = var.telemetry.enabled ? 1 : 0 + agent_id = var.agent_id + name = "OTEL_EXPORTER_OTLP_PROTOCOL" + value = var.telemetry.otlp_protocol +} + +resource "coder_env" "otel_exporter_otlp_headers" { + count = var.telemetry.enabled && length(var.telemetry.otlp_headers) > 0 ? 1 : 0 + agent_id = var.agent_id + name = "OTEL_EXPORTER_OTLP_HEADERS" + value = join(",", [for k, v in var.telemetry.otlp_headers : "${k}=${v}"]) +} + +resource "coder_env" "otel_resource_attributes" { + count = var.telemetry.enabled ? 1 : 0 + agent_id = var.agent_id + name = "OTEL_RESOURCE_ATTRIBUTES" + value = join(",", [for k, v in local.otel_resource_attributes : "${k}=${v}"]) +} + locals { workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {