From 1e5a0d24476939b929fe5776781828fe7eece59d Mon Sep 17 00:00:00 2001 From: Morgan Westlee Lunt Date: Wed, 22 Apr 2026 20:16:15 +0000 Subject: [PATCH] feat(claude-code): add telemetry input for OTEL export with workspace attribution --- registry/coder/modules/claude-code/README.md | 28 +++++++++ .../coder/modules/claude-code/main.test.ts | 34 +++++++++++ registry/coder/modules/claude-code/main.tf | 57 +++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 48b291bb0..ca40bc309 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -182,6 +182,34 @@ module "claude-code" { > > The `Content-Type` header doesn't matter—both `text/plain` and `application/json` work fine. +### Telemetry export (OpenTelemetry) + +Claude Code can emit OpenTelemetry metrics and events covering token usage, tool calls, session lifecycle, and errors. 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 = "4.9.2" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + + telemetry = { + enabled = true + otlp_endpoint = "http://otel-collector.observability:4317" + otlp_protocol = "grpc" + otlp_headers = { + authorization = "Bearer ${var.otel_collector_token}" + } + resource_attributes = { + "service.name" = "claude-code" + "deployment.cluster" = var.cluster_name + } + } +} +``` + +See the [Claude Code monitoring documentation](https://docs.anthropic.com/en/docs/claude-code/monitoring-usage) for the full list of exported metrics and events. + ### Standalone Mode Run and configure Claude Code as a standalone CLI in your workspace. diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index b01e88327..ef263a54f 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -213,6 +213,40 @@ describe("claude-code", async () => { expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model); }); + test("telemetry-otel", async () => { + const { coderEnvVars } = await setup({ + moduleVariables: { + telemetry: JSON.stringify({ + enabled: true, + otlp_endpoint: "http://collector:4317", + otlp_headers: { authorization: "Bearer xyz" }, + resource_attributes: { "service.name": "claude-code" }, + }), + }, + }); + + expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBe("1"); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe( + "http://collector:4317", + ); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBe("http/protobuf"); + expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBe( + "authorization=Bearer xyz", + ); + const attrs = coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"]; + expect(attrs).toContain("coder.workspace_id="); + 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_RESOURCE_ATTRIBUTES"]).toBeUndefined(); + }); + test("claude-continue-resume-task-session", async () => { const { id } = await setup({ moduleVariables: { diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index db234c052..1f842915e 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -273,6 +273,18 @@ variable "enable_state_persistence" { default = true } +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), {}) + }) + 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." + default = {} +} + resource "coder_env" "claude_code_md_path" { count = var.claude_md_path == "" ? 0 : 1 agent_id = var.agent_id @@ -322,6 +334,41 @@ resource "coder_env" "anthropic_base_url" { value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" } +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 != null ? 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 { # we have to trim the slash because otherwise coder exp mcp will # set up an invalid claude config @@ -334,6 +381,16 @@ locals { coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "") claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key + 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.template_name" = data.coder_workspace.me.template_name + }, + ) + # Required prompts for the module to properly report task status to Coder report_tasks_system_prompt = <<-EOT -- Tool Selection --