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..56fab13 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") @@ -474,6 +491,9 @@ 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) @@ -562,6 +582,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..1134a2f 100644 --- a/daemon/aicode_otel_processor_v2_test.go +++ b/daemon/aicode_otel_processor_v2_test.go @@ -842,6 +842,55 @@ 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]) + + // 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) + 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..2691a7b --- /dev/null +++ b/model/aicode_cloud_session_test.go @@ -0,0 +1,27 @@ +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: ""}, + {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) { + require.Equal(t, tt.want, CloudSessionIDFromEnv(func(k string) string { return tt.env[k] })) + }) + } +}