mirror of
https://github.com/coder/registry.git
synced 2026-06-02 20:48:14 +00:00
feat(claude-code): add telemetry input for OTEL export with workspace attribution (#862)
## Problem Claude Code ships an OpenTelemetry exporter for token usage, tool calls, session lifecycle and errors (https://docs.anthropic.com/en/docs/claude-code/monitoring-usage), but the module exposes no first-class wiring for it. Template authors who want telemetry have to know the env var names (`CLAUDE_CODE_ENABLE_TELEMETRY`, the `OTEL_EXPORTER_OTLP_*` family) and write their own `coder_env` blocks. More importantly there is no convention for how to correlate Claude Code telemetry with Coder's own audit logs and `exectrace` records, so even when both are exported they end up as two unjoined datasets. ## Change Adds a `telemetry` input that turns on `CLAUDE_CODE_ENABLE_TELEMETRY` and the standard OTLP exporter env vars in one place: ```tf telemetry = { enabled = true otlp_endpoint = "http://otel-collector.observability:4317" otlp_protocol = "grpc" otlp_headers = { authorization = "Bearer ..." } resource_attributes = { "service.name" = "claude-code" } } ``` When enabled, the module automatically appends `coder.workspace_id`, `coder.workspace_name`, `coder.workspace_owner` and `coder.template_name` to `OTEL_RESOURCE_ATTRIBUTES`. This gives a stable join key between Claude Code spans/metrics and Coder's audit log and exectrace events on `workspace_id`, so a platform team can answer "show me every shell command Claude executed in workspace X alongside the token spend for that session" without custom plumbing. This is purely additive (`coder_env` resources behind `count`), defaults to disabled, and is independent of how Claude is launched, so it composes cleanly with the install-only direction in #861. ## Validation - `terraform fmt`, `terraform validate`, `terraform test` (19/19) pass - `bun test -t telemetry` (2/2) pass: env vars are set with the expected values when enabled, and absent when the input is omitted Disclosure: I work at Anthropic on the Claude Code team. --------- Co-authored-by: DevCats <chris@dualriver.com> Co-authored-by: Atif Ali <me@matifali.dev>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user