Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
22 changes: 22 additions & 0 deletions daemon/aicode_otel_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down
49 changes: 49 additions & 0 deletions daemon/aicode_otel_processor_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions daemon/main_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
19 changes: 19 additions & 0 deletions model/aicode_cloud_session.go
Original file line number Diff line number Diff line change
@@ -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"))
}
27 changes: 27 additions & 0 deletions model/aicode_cloud_session_test.go
Original file line number Diff line number Diff line change
@@ -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] }))
})
}
}
Loading