From a4ee21d99c116c516e23e405c5142c48ab208205 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:28:11 +0000 Subject: [PATCH 1/2] feat(daemon): mark Claude Code cloud session telemetry Claude Code tags every event and metric of a cloud session (claude.ai/code) with ccr.session.id, the VM's CLAUDE_CODE_REMOTE_SESSION_ID. In a cloud VM the daemon is started by the session's SessionStart hook, so it inherits CLAUDE_CODE_REMOTE=true and the session id: it now adds ccr.session.id to Claude Code records that don't carry one, so the server can mark the session as a cloud session. A value Claude Code sent wins, Codex records are left alone, and nothing changes on a local machine. daemon/main_test.go clears both variables so the suite behaves the same inside a cloud session as on CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qinc9uT7wXFFuo7yaKBHKq --- CLAUDE.md | 2 +- daemon/aicode_otel_processor.go | 20 +++++++++++++ daemon/aicode_otel_processor_v2_test.go | 39 +++++++++++++++++++++++++ daemon/main_test.go | 14 +++++++++ model/aicode_cloud_session.go | 19 ++++++++++++ model/aicode_cloud_session_test.go | 26 +++++++++++++++++ 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 daemon/main_test.go create mode 100644 model/aicode_cloud_session.go create mode 100644 model/aicode_cloud_session_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 726a23f..8c8063a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ Optional daemon services (feature-gated via config): - **CCInfoTimerService**: Lazy-fetch background timer for Claude Code statusline data (cost, quota, git info), plus an always-on Anthropic usage sync (10-min interval, only while a `claude` process is running) that pushes quota to `/api/v1/anthropic-usage` without needing statusline activity - **CodexUsageSyncService**: Syncs Codex usage to `/api/v1/codex-usage` every 10 min, only while a `codex`/`chatgpt` process is running (both intervals share the `usageSyncInterval` const in `daemon/usage_sync.go`) - **SyncCircuitBreakerService**: Retry failed syncs with file-based persistence (`sync_pending.log`) and hourly recovery timer -- **AICodeOtelServer**: gRPC OTEL collector for AI coding CLI metrics/logs (Claude Code, Codex) +- **AICodeOtelServer**: gRPC OTEL collector for AI coding CLI metrics/logs (Claude Code, Codex). In a Claude Code cloud session (`CLAUDE_CODE_REMOTE=true`, where the SessionStart hook starts the daemon) it adds the VM's `CLAUDE_CODE_REMOTE_SESSION_ID` as the `ccr.session.id` attribute to Claude Code records that lack it; the server marks those sessions as cloud sessions. `daemon/main_test.go` clears both variables so tests behave the same inside a cloud session - **HeartbeatResyncService**: Periodic resync of failed heartbeats (30-min interval) - **CleanupTimerService**: Periodic log file cleanup (24-hour interval) diff --git a/daemon/aicode_otel_processor.go b/daemon/aicode_otel_processor.go index a28ae66..342c561 100644 --- a/daemon/aicode_otel_processor.go +++ b/daemon/aicode_otel_processor.go @@ -35,6 +35,9 @@ type AICodeOtelProcessor struct { endpoint model.Endpoint hostname string debug bool + // cloudSessionID is the Claude Code cloud session the daemon runs in (from its environment), + // added to Claude Code records that don't carry one. Empty on a local machine. + cloudSessionID string // maxRequestBytes bounds the JSON body of one backend request; larger batches are split. maxRequestBytes int @@ -59,10 +62,24 @@ func NewAICodeOtelProcessor(config model.ShellTimeConfig) *AICodeOtelProcessor { }, hostname: hostname, debug: debug, + cloudSessionID: model.CloudSessionIDFromEnv(os.Getenv), maxRequestBytes: model.AICodeOtelMaxRequestBytes, } } +// fillCloudSessionID adds the daemon's cloud session id to the attributes of a Claude Code +// record that doesn't carry ccr.session.id yet. In a cloud VM the daemon is started by the +// session's SessionStart hook, so every Claude Code record it receives belongs to that session. +func (p *AICodeOtelProcessor) fillCloudSessionID(attrs map[string]any, source string) map[string]any { + if p.cloudSessionID == "" || source != model.AICodeOtelSourceClaudeCode { + return attrs + } + if _, ok := attrs[model.AICodeCloudSessionIDAttribute]; ok { + return attrs + } + return setAttribute(attrs, model.AICodeCloudSessionIDAttribute, p.cloudSessionID) +} + // writeDebugFile appends JSON-formatted data to a debug file func (p *AICodeOtelProcessor) writeDebugFile(filename string, data interface{}) { debugDir := filepath.Join(os.TempDir(), "shelltime") @@ -478,6 +495,7 @@ func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, res *otelResource for _, attr := range dp.GetAttributes() { applyMetricAttribute(&metric, attr, metricType) } + metric.Attributes = p.fillCloudSessionID(metric.Attributes, res.source) metrics = append(metrics, metric) } @@ -562,6 +580,8 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, res *otelReso for _, key := range keys { event.Attributes = setAttribute(event.Attributes, key, res.attrs.Attributes[key]) } + // Seeded the same way: a ccr.session.id on the record itself still wins. + event.Attributes = p.fillCloudSessionID(event.Attributes, res.source) var fallbacks eventFallbacks for _, attr := range lr.GetAttributes() { diff --git a/daemon/aicode_otel_processor_v2_test.go b/daemon/aicode_otel_processor_v2_test.go index 3c0f2f3..62b23ec 100644 --- a/daemon/aicode_otel_processor_v2_test.go +++ b/daemon/aicode_otel_processor_v2_test.go @@ -842,6 +842,45 @@ func TestParseMetric_TypeRoutingAndAttributes(t *testing.T) { assert.Equal(t, "Write", legacyTool.Tool) } +// In a cloud VM the daemon adds its session's ccr.session.id to Claude Code records that lack +// it; a value Claude Code sent wins, and Codex records are left alone. +func TestCloudSessionIDFallback(t *testing.T) { + t.Setenv("CLAUDE_CODE_REMOTE", "true") + t.Setenv("CLAUDE_CODE_REMOTE_SESSION_ID", "cse_01Daemon") + p := NewAICodeOtelProcessor(model.ShellTimeConfig{}) + require.Equal(t, "cse_01Daemon", p.cloudSessionID) + + claude := newOtelResource(claudeTestResource(), model.AICodeOtelSourceClaudeCode) + event := p.parseLogRecord(claudeRecord("user_prompt", kv("prompt", strVal("hi"))), claude, "test-scope") + require.NotNil(t, event) + assert.Equal(t, "cse_01Daemon", event.Attributes[model.AICodeCloudSessionIDAttribute]) + + event = p.parseLogRecord(claudeRecord("user_prompt", kv("ccr.session.id", strVal("cse_01Claude"))), claude, "test-scope") + require.NotNil(t, event) + assert.Equal(t, "cse_01Claude", event.Attributes[model.AICodeCloudSessionIDAttribute], "Claude Code's own value wins") + + delta := metricsv1.AggregationTemporality_AGGREGATION_TEMPORALITY_DELTA + metrics := p.parseMetric(sumMetric("claude_code.active_time.total", delta, 30, kv("type", strVal("cli"))), claude, "com.anthropic.claude_code") + require.Len(t, metrics, 1) + assert.Equal(t, "cse_01Daemon", metrics[0].Attributes[model.AICodeCloudSessionIDAttribute]) + + metrics = p.parseMetric(sumMetric("claude_code.active_time.total", delta, 30, kv("ccr.session.id", strVal("cse_01Claude"))), claude, "com.anthropic.claude_code") + require.Len(t, metrics, 1) + assert.Equal(t, "cse_01Claude", metrics[0].Attributes[model.AICodeCloudSessionIDAttribute]) + + codex := newOtelResource(codexTestResource(), model.AICodeOtelSourceCodex) + event = p.parseLogRecord(codexRecord("user_prompt", kv("prompt", strVal("hi"))), codex, "test-scope") + require.NotNil(t, event) + assert.NotContains(t, event.Attributes, model.AICodeCloudSessionIDAttribute, "Codex isn't a Claude Code cloud session") + + // On a local machine nothing is added. + local := NewAICodeOtelProcessor(model.ShellTimeConfig{}) + local.cloudSessionID = "" + event = local.parseLogRecord(claudeRecord("user_prompt", kv("prompt", strVal("hi"))), claude, "test-scope") + require.NotNil(t, event) + assert.NotContains(t, event.Attributes, model.AICodeCloudSessionIDAttribute) +} + func TestParseMetric_CumulativeTemporalityWarnsOnce(t *testing.T) { var buf bytes.Buffer var mu sync.Mutex diff --git a/daemon/main_test.go b/daemon/main_test.go new file mode 100644 index 0000000..7fc9848 --- /dev/null +++ b/daemon/main_test.go @@ -0,0 +1,14 @@ +package daemon + +import ( + "os" + "testing" +) + +// TestMain clears the Claude Code cloud session environment, so the suite gives the same results +// inside a cloud session (where CLAUDE_CODE_REMOTE=true) as on CI. +func TestMain(m *testing.M) { + os.Unsetenv("CLAUDE_CODE_REMOTE") + os.Unsetenv("CLAUDE_CODE_REMOTE_SESSION_ID") + os.Exit(m.Run()) +} diff --git a/model/aicode_cloud_session.go b/model/aicode_cloud_session.go new file mode 100644 index 0000000..81f70eb --- /dev/null +++ b/model/aicode_cloud_session.go @@ -0,0 +1,19 @@ +package model + +import "strings" + +// AICodeCloudSessionIDAttribute is the standard attribute Claude Code puts on every event and +// metric of a cloud session (claude.ai/code): its CLAUDE_CODE_REMOTE_SESSION_ID, like cse_01AbC. +// The server marks sessions that carry it as cloud sessions. +const AICodeCloudSessionIDAttribute = "ccr.session.id" + +// CloudSessionIDFromEnv returns the Claude Code cloud session the process runs in, or "". A +// cloud VM sets CLAUDE_CODE_REMOTE=true (never true on a local machine) and +// CLAUDE_CODE_REMOTE_SESSION_ID; the daemon inherits both from the SessionStart hook that +// starts it there. +func CloudSessionIDFromEnv(getenv func(string) string) string { + if getenv("CLAUDE_CODE_REMOTE") != "true" { + return "" + } + return strings.TrimSpace(getenv("CLAUDE_CODE_REMOTE_SESSION_ID")) +} diff --git a/model/aicode_cloud_session_test.go b/model/aicode_cloud_session_test.go new file mode 100644 index 0000000..55fecb4 --- /dev/null +++ b/model/aicode_cloud_session_test.go @@ -0,0 +1,26 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCloudSessionIDFromEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + want string + }{ + {name: "local machine", env: map[string]string{"TERM_PROGRAM": "ghostty"}, want: ""}, + {name: "cloud session", env: map[string]string{"CLAUDE_CODE_REMOTE": "true", "CLAUDE_CODE_REMOTE_SESSION_ID": " cse_01AbC\n"}, want: "cse_01AbC"}, + {name: "remote flag off", env: map[string]string{"CLAUDE_CODE_REMOTE": "false", "CLAUDE_CODE_REMOTE_SESSION_ID": "cse_01AbC"}, want: ""}, + {name: "id without the flag", env: map[string]string{"CLAUDE_CODE_REMOTE_SESSION_ID": "cse_01AbC"}, want: ""}, + {name: "flag without an id", env: map[string]string{"CLAUDE_CODE_REMOTE": "true"}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, CloudSessionIDFromEnv(func(k string) string { return tt.env[k] })) + }) + } +} From 854461e713f3f40ca154d119ada7a5d00d7adf4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:43:36 +0000 Subject: [PATCH 2/2] fix(daemon): seed the cloud session id before metric data point attributes Metrics added ccr.session.id after the data point's own attributes, so a data point already at the 64-attribute cap dropped it. It is now seeded before them, like on events; a ccr.session.id on the data point still wins. Also covers a blank CLAUDE_CODE_REMOTE_SESSION_ID. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qinc9uT7wXFFuo7yaKBHKq --- daemon/aicode_otel_processor.go | 4 +++- daemon/aicode_otel_processor_v2_test.go | 10 ++++++++++ model/aicode_cloud_session_test.go | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/daemon/aicode_otel_processor.go b/daemon/aicode_otel_processor.go index 342c561..56fab13 100644 --- a/daemon/aicode_otel_processor.go +++ b/daemon/aicode_otel_processor.go @@ -491,11 +491,13 @@ func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, res *otelResource } // Apply resource attributes first applyResourceAttributesToMetric(&metric, res.attrs) + // Seeded before the data point's attributes, so they can't crowd it out; a ccr.session.id + // on the data point still wins. + metric.Attributes = p.fillCloudSessionID(metric.Attributes, res.source) // Then extract data point attributes (can override resource attrs) for _, attr := range dp.GetAttributes() { applyMetricAttribute(&metric, attr, metricType) } - metric.Attributes = p.fillCloudSessionID(metric.Attributes, res.source) metrics = append(metrics, metric) } diff --git a/daemon/aicode_otel_processor_v2_test.go b/daemon/aicode_otel_processor_v2_test.go index 62b23ec..1134a2f 100644 --- a/daemon/aicode_otel_processor_v2_test.go +++ b/daemon/aicode_otel_processor_v2_test.go @@ -868,6 +868,16 @@ func TestCloudSessionIDFallback(t *testing.T) { require.Len(t, metrics, 1) assert.Equal(t, "cse_01Claude", metrics[0].Attributes[model.AICodeCloudSessionIDAttribute]) + // Seeded before the data point's attributes, so a data point at the attribute cap can't crowd + // it out. + full := make([]*commonv1.KeyValue, 0, model.AICodeOtelMaxAttributes) + for i := range model.AICodeOtelMaxAttributes { + full = append(full, kv(fmt.Sprintf("attr.%d", i), strVal("v"))) + } + metrics = p.parseMetric(sumMetric("claude_code.active_time.total", delta, 30, full...), claude, "com.anthropic.claude_code") + require.Len(t, metrics, 1) + assert.Equal(t, "cse_01Daemon", metrics[0].Attributes[model.AICodeCloudSessionIDAttribute]) + codex := newOtelResource(codexTestResource(), model.AICodeOtelSourceCodex) event = p.parseLogRecord(codexRecord("user_prompt", kv("prompt", strVal("hi"))), codex, "test-scope") require.NotNil(t, event) diff --git a/model/aicode_cloud_session_test.go b/model/aicode_cloud_session_test.go index 55fecb4..2691a7b 100644 --- a/model/aicode_cloud_session_test.go +++ b/model/aicode_cloud_session_test.go @@ -17,6 +17,7 @@ func TestCloudSessionIDFromEnv(t *testing.T) { {name: "remote flag off", env: map[string]string{"CLAUDE_CODE_REMOTE": "false", "CLAUDE_CODE_REMOTE_SESSION_ID": "cse_01AbC"}, want: ""}, {name: "id without the flag", env: map[string]string{"CLAUDE_CODE_REMOTE_SESSION_ID": "cse_01AbC"}, want: ""}, {name: "flag without an id", env: map[string]string{"CLAUDE_CODE_REMOTE": "true"}, want: ""}, + {name: "blank id", env: map[string]string{"CLAUDE_CODE_REMOTE": "true", "CLAUDE_CODE_REMOTE_SESSION_ID": " "}, want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {