From 22564ae2c57b28d65e485302a288407f2702e476 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 03:47:10 +0000 Subject: [PATCH 1/2] feat(daemon): add OTEL support for OpenAI Codex CLI Extend the existing CCOtel workflow to support both Claude Code and OpenAI Codex CLI by detecting the source from service.name attribute. Changes: - Add detectOtelSource() to identify claude-code vs codex from service.name - Add Source field to CCOtelRequest to identify CLI origin - Extend metric/event name mappings for codex.* prefixes - Add ReasoningTokens and Provider fields for Codex-specific data - Add CCEventExecCommand for Codex shell command execution events - Update detectProject() to check CODEX_PROJECT env var for Codex The single CCOtel gRPC server now handles both: - Claude Code (service.name: "claude-code") - Codex CLI (service.name: "codex", "codex-cli", or "openai-codex") Configuration remains unchanged - just enable ccotel in config.yaml: ccotel: enabled: true grpcPort: 54027 --- cmd/daemon/main.go | 2 +- daemon/ccotel_processor.go | 102 ++++++++++++++++++++++++++++--------- model/ccotel_types.go | 16 ++++-- model/types.go | 5 +- 4 files changed, 94 insertions(+), 31 deletions(-) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 9dec387..1877dff 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -125,7 +125,7 @@ func main() { } } - // Start CCOtel service if enabled (v2 - OTEL gRPC passthrough) + // Start CCOtel service if enabled (OTEL gRPC passthrough for Claude Code, Codex, etc.) var ccOtelServer *daemon.CCOtelServer if cfg.CCOtel != nil && cfg.CCOtel.Enabled != nil && *cfg.CCOtel.Enabled { ccOtelProcessor := daemon.NewCCOtelProcessor(cfg) diff --git a/daemon/ccotel_processor.go b/daemon/ccotel_processor.go index 308f2b0..48ef08d 100644 --- a/daemon/ccotel_processor.go +++ b/daemon/ccotel_processor.go @@ -88,21 +88,22 @@ func (p *CCOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1 for _, rm := range req.GetResourceMetrics() { resource := rm.GetResource() - // Check if this is from Claude Code - if !isClaudeCodeResource(resource) { - slog.Debug("CCOtel: Skipping non-Claude Code resource") + // Check if this is from Claude Code or Codex + source := detectOtelSource(resource) + if source == "" { + slog.Debug("CCOtel: Skipping unknown resource") continue } // Extract resource attributes once for all metrics in this resource resourceAttrs := extractResourceAttributes(resource) - project := p.detectProject(resource) + project := p.detectProject(resource, source) var metrics []model.CCOtelMetric for _, sm := range rm.GetScopeMetrics() { for _, m := range sm.GetMetrics() { - parsedMetrics := p.parseMetric(m, resourceAttrs) + parsedMetrics := p.parseMetric(m, resourceAttrs, source) metrics = append(metrics, parsedMetrics...) } } @@ -115,6 +116,7 @@ func (p *CCOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1 ccReq := &model.CCOtelRequest{ Host: p.hostname, Project: project, + Source: source, Metrics: metrics, } @@ -141,21 +143,22 @@ func (p *CCOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Export for _, rl := range req.GetResourceLogs() { resource := rl.GetResource() - // Check if this is from Claude Code - if !isClaudeCodeResource(resource) { - slog.Debug("CCOtel: Skipping non-Claude Code resource") + // Check if this is from Claude Code or Codex + source := detectOtelSource(resource) + if source == "" { + slog.Debug("CCOtel: Skipping unknown resource") continue } // Extract resource attributes once for all events in this resource resourceAttrs := extractResourceAttributes(resource) - project := p.detectProject(resource) + project := p.detectProject(resource, source) var events []model.CCOtelEvent for _, sl := range rl.GetScopeLogs() { for _, lr := range sl.GetLogRecords() { - event := p.parseLogRecord(lr, resourceAttrs) + event := p.parseLogRecord(lr, resourceAttrs, source) if event != nil { events = append(events, *event) } @@ -170,6 +173,7 @@ func (p *CCOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Export ccReq := &model.CCOtelRequest{ Host: p.hostname, Project: project, + Source: source, Events: events, } @@ -185,18 +189,24 @@ func (p *CCOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Export return &collogsv1.ExportLogsServiceResponse{}, nil } -// isClaudeCodeResource checks if the resource is from Claude Code -func isClaudeCodeResource(resource *resourcev1.Resource) bool { +// detectOtelSource checks the resource and returns the source type (claude-code, codex, or empty if unknown) +func detectOtelSource(resource *resourcev1.Resource) string { if resource == nil { - return false + return "" } for _, attr := range resource.GetAttributes() { if attr.GetKey() == "service.name" { - return attr.GetValue().GetStringValue() == "claude-code" + serviceName := attr.GetValue().GetStringValue() + switch serviceName { + case "claude-code": + return model.CCOtelSourceClaudeCode + case "codex", "codex-cli", "openai-codex": + return model.CCOtelSourceCodex + } } } - return false + return "" } // extractResourceAttributes extracts resource-level attributes from OTEL resource @@ -301,7 +311,7 @@ func applyResourceAttributesToEvent(event *model.CCOtelEvent, attrs *model.CCOte } // detectProject extracts project from resource attributes or environment -func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource) string { +func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource, source string) string { // First check resource attributes if resource != nil { for _, attr := range resource.GetAttributes() { @@ -311,10 +321,17 @@ func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource) string { } } - // Fall back to environment variables - if project := os.Getenv("CLAUDE_CODE_PROJECT"); project != "" { - return project + // Fall back to environment variables based on source + if source == model.CCOtelSourceClaudeCode { + if project := os.Getenv("CLAUDE_CODE_PROJECT"); project != "" { + return project + } + } else if source == model.CCOtelSourceCodex { + if project := os.Getenv("CODEX_PROJECT"); project != "" { + return project + } } + if pwd := os.Getenv("PWD"); pwd != "" { return pwd } @@ -323,11 +340,11 @@ func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource) string { } // parseMetric parses an OTEL metric into CCOtelMetric(s) -func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model.CCOtelResourceAttributes) []model.CCOtelMetric { +func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model.CCOtelResourceAttributes, source string) []model.CCOtelMetric { var metrics []model.CCOtelMetric name := m.GetName() - metricType := mapMetricName(name) + metricType := mapMetricName(name, source) if metricType == "" { return metrics // Unknown metric, skip } @@ -372,7 +389,7 @@ func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model. } // parseLogRecord parses an OTEL log record into a CCOtelEvent -func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *model.CCOtelResourceAttributes) *model.CCOtelEvent { +func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *model.CCOtelResourceAttributes, source string) *model.CCOtelEvent { event := &model.CCOtelEvent{ EventID: uuid.New().String(), Timestamp: int64(lr.GetTimeUnixNano() / 1e9), // Convert to seconds @@ -388,7 +405,7 @@ func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *mo switch key { case "event.name": - event.EventType = mapEventName(value.GetStringValue()) + event.EventType = mapEventName(value.GetStringValue(), source) case "event.timestamp": event.EventTimestamp = value.GetStringValue() case "model": @@ -441,6 +458,11 @@ func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *mo event.Attempt = getIntFromValue(value) case "language": event.Language = value.GetStringValue() + // Codex-specific fields + case "reasoning_tokens": + event.ReasoningTokens = getIntFromValue(value) + case "provider": + event.Provider = value.GetStringValue() // Log record level attributes that override resource attrs case "user.id": event.UserID = value.GetStringValue() @@ -468,8 +490,10 @@ func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *mo } // mapMetricName maps OTEL metric names to our internal types -func mapMetricName(name string) string { +// Supports both Claude Code (claude_code.*) and Codex (codex.*) prefixes +func mapMetricName(name string, source string) string { switch name { + // Claude Code metrics case "claude_code.session.count": return model.CCMetricSessionCount case "claude_code.token.usage": @@ -486,14 +510,31 @@ func mapMetricName(name string) string { return model.CCMetricActiveTimeTotal case "claude_code.code_edit_tool.decision": return model.CCMetricCodeEditToolDecision + // Codex metrics (same internal types, different prefix) + case "codex.session.count": + return model.CCMetricSessionCount + case "codex.token.usage": + return model.CCMetricTokenUsage + case "codex.cost.usage": + return model.CCMetricCostUsage + case "codex.lines_of_code.count": + return model.CCMetricLinesOfCodeCount + case "codex.commit.count": + return model.CCMetricCommitCount + case "codex.pull_request.count": + return model.CCMetricPullRequestCount + case "codex.active_time.total": + return model.CCMetricActiveTimeTotal default: return "" } } // mapEventName maps OTEL event names to our internal types -func mapEventName(name string) string { +// Supports both Claude Code (claude_code.*) and Codex (codex.*) prefixes +func mapEventName(name string, source string) string { switch name { + // Claude Code events case "claude_code.user_prompt": return model.CCEventUserPrompt case "claude_code.tool_result": @@ -504,6 +545,17 @@ func mapEventName(name string) string { return model.CCEventApiError case "claude_code.tool_decision": return model.CCEventToolDecision + // Codex events (same internal types, different prefix) + case "codex.user_prompt": + return model.CCEventUserPrompt + case "codex.tool_result": + return model.CCEventToolResult + case "codex.api_request": + return model.CCEventApiRequest + case "codex.api_error": + return model.CCEventApiError + case "codex.exec_command": + return model.CCEventExecCommand default: return name // Return as-is if not in our map } diff --git a/model/ccotel_types.go b/model/ccotel_types.go index f7ba7ba..d2ba374 100644 --- a/model/ccotel_types.go +++ b/model/ccotel_types.go @@ -5,6 +5,7 @@ package model type CCOtelRequest struct { Host string `json:"host"` Project string `json:"project"` + Source string `json:"source,omitempty"` // "claude-code" or "codex" - identifies the CLI source Events []CCOtelEvent `json:"events,omitempty"` Metrics []CCOtelMetric `json:"metrics,omitempty"` } @@ -35,7 +36,7 @@ type CCOtelResourceAttributes struct { Pwd string // from pwd } -// CCOtelEvent represents an event from Claude Code (api_request, tool_result, etc.) +// CCOtelEvent represents an event from Claude Code or Codex (api_request, tool_result, etc.) // with embedded resource attributes for a flat, session-less structure type CCOtelEvent struct { EventID string `json:"eventId"` @@ -49,6 +50,7 @@ type CCOtelEvent struct { OutputTokens int `json:"outputTokens,omitempty"` CacheReadTokens int `json:"cacheReadTokens,omitempty"` CacheCreationTokens int `json:"cacheCreationTokens,omitempty"` + ReasoningTokens int `json:"reasoningTokens,omitempty"` // Codex: o1 model reasoning tokens ToolName string `json:"toolName,omitempty"` Success bool `json:"success,omitempty"` Decision string `json:"decision,omitempty"` @@ -60,6 +62,7 @@ type CCOtelEvent struct { StatusCode int `json:"statusCode,omitempty"` Attempt int `json:"attempt,omitempty"` Language string `json:"language,omitempty"` + Provider string `json:"provider,omitempty"` // Codex: provider (e.g., "openai") // Embedded resource attributes (previously in session) SessionID string `json:"sessionId,omitempty"` @@ -125,7 +128,13 @@ type CCOtelResponse struct { Message string `json:"message,omitempty"` } -// Claude Code OTEL metric types +// OTEL source identifiers +const ( + CCOtelSourceClaudeCode = "claude-code" + CCOtelSourceCodex = "codex" +) + +// Claude Code / Codex OTEL metric types (shared) const ( CCMetricSessionCount = "session_count" CCMetricLinesOfCodeCount = "lines_of_code_count" @@ -137,13 +146,14 @@ const ( CCMetricActiveTimeTotal = "active_time_total" ) -// Claude Code OTEL event types +// Claude Code / Codex OTEL event types (shared) const ( CCEventUserPrompt = "user_prompt" CCEventToolResult = "tool_result" CCEventApiRequest = "api_request" CCEventApiError = "api_error" CCEventToolDecision = "tool_decision" + CCEventExecCommand = "exec_command" // Codex: shell command execution ) // Token types for CCMetricTokenUsage diff --git a/model/types.go b/model/types.go index e6237af..6bd93c1 100644 --- a/model/types.go +++ b/model/types.go @@ -25,7 +25,8 @@ type CCUsage struct { Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` } -// CCOtel configuration for OTEL-based Claude Code tracking (v2) +// CCOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) +// The processor auto-detects the source from service.name attribute type CCOtel struct { Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` GRPCPort int `toml:"grpcPort,omitempty" yaml:"grpcPort,omitempty" json:"grpcPort,omitempty"` // default: 54027 @@ -80,7 +81,7 @@ type ShellTimeConfig struct { // CCUsage configuration for Claude Code usage tracking (v1 - ccusage CLI based) CCUsage *CCUsage `toml:"ccusage" yaml:"ccusage" json:"ccusage"` - // CCOtel configuration for OTEL-based Claude Code tracking (v2 - gRPC passthrough) + // CCOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) CCOtel *CCOtel `toml:"ccotel" yaml:"ccotel" json:"ccotel"` // CodeTracking configuration for coding activity heartbeat tracking From d763a94769352a0a369a1f6f60dfcf8402f13bdd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 06:35:52 +0000 Subject: [PATCH 2/2] refactor(daemon): rename CCOtel to AICodeOtel for improved clarity Rename all CCOtel references to AICodeOtel to better reflect that the OTEL processor supports multiple AI coding CLIs (Claude Code, Codex, etc.) rather than just Claude Code. Changes: - Rename config type CCOtel -> AICodeOtel in model/types.go - Rename files: ccotel_*.go -> aicode_otel_*.go - Update all type names: CCOtel* -> AICodeOtel* - Update configuration keys: ccotel.* -> aiCodeOtel.* - Update documentation (CLAUDE.md, CONFIG.md) --- CLAUDE.md | 10 +- cmd/daemon/main.go | 18 +- commands/cc.go | 12 +- commands/daemon.status.go | 8 +- ..._processor.go => aicode_otel_processor.go} | 156 +++++++++--------- ...ccotel_server.go => aicode_otel_server.go} | 26 +-- docs/CONFIG.md | 28 ++-- model/{ccotel_env.go => aicode_otel_env.go} | 98 +++++------ .../{ccotel_types.go => aicode_otel_types.go} | 82 ++++----- model/api_aicode_otel.go | 31 ++++ model/api_ccotel.go | 31 ---- model/config.go | 14 +- model/types.go | 10 +- 13 files changed, 260 insertions(+), 264 deletions(-) rename daemon/{ccotel_processor.go => aicode_otel_processor.go} (77%) rename daemon/{ccotel_server.go => aicode_otel_server.go} (73%) rename model/{ccotel_env.go => aicode_otel_env.go} (75%) rename model/{ccotel_types.go => aicode_otel_types.go} (70%) create mode 100644 model/api_aicode_otel.go delete mode 100644 model/api_ccotel.go diff --git a/CLAUDE.md b/CLAUDE.md index d6555aa..6f5b563 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,9 +52,9 @@ go fmt ./... ### Package Structure - **cmd/cli/**: CLI entry point - registers all commands, initializes services via dependency injection -- **cmd/daemon/**: Daemon entry point - sets up pub/sub, socket handler, and optional CCOtel gRPC server +- **cmd/daemon/**: Daemon entry point - sets up pub/sub, socket handler, and optional AICodeOtel gRPC server - **commands/**: CLI command implementations - each command in its own file, `base.go` holds injected services -- **daemon/**: Daemon internals - socket handler, Watermill pub/sub channel, CCOtel gRPC server/processor +- **daemon/**: Daemon internals - socket handler, Watermill pub/sub channel, AICodeOtel gRPC server/processor - **model/**: Business logic - API clients, config, crypto, shell hooks, service installers, dotfile handlers ### Service Interfaces (model package) @@ -69,7 +69,7 @@ Injection happens in `cmd/*/main.go` via `commands.InjectVar()` and `commands.In 1. **SocketHandler**: Unix domain socket server accepting JSON messages from CLI 2. **GoChannel**: Watermill pub/sub for decoupled message processing 3. **SocketTopicProcessor**: Consumes messages and routes to appropriate handlers -4. **CCOtelServer** (optional): gRPC server implementing OTEL collector for Claude Code metrics/logs passthrough +4. **AICodeOtelServer** (optional): gRPC server implementing OTEL collector for AI coding CLI metrics/logs passthrough (Claude Code, Codex, etc.) ### Data Flow 1. Shell hooks capture commands → CLI stores locally (file-based buffer) @@ -81,7 +81,7 @@ Injection happens in `cmd/*/main.go` via `commands.InjectVar()` and `commands.In - Main config: `$HOME/.shelltime/config.toml` - Local overrides: `$HOME/.shelltime/config.local.toml` (merged, gitignored) - Daemon socket: `/tmp/shelltime.sock` (configurable via `socketPath`) -- CCOtel gRPC port: configurable via `ccotel.grpcPort` (default: 4317) +- AICodeOtel gRPC port: configurable via `aiCodeOtel.grpcPort` (default: 54027) ## Commit Rules @@ -92,4 +92,4 @@ Follow Conventional Commits with scope: `fix(daemon): ...`, `feat(cli): ...`, `r - Daemon is optional but recommended (<8ms latency vs ~100ms+ direct) - Encryption requires daemon mode and a token with encryption capability - Shell hooks are platform-specific (bash, zsh, fish) - test on target shells -- CCOtel feature enables Claude Code metrics/logs passthrough via gRPC (port 4317) \ No newline at end of file +- AICodeOtel feature enables AI coding CLI metrics/logs passthrough via gRPC (port 54027) - supports Claude Code, Codex, and other OTEL-compatible CLIs \ No newline at end of file diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 1877dff..7fcb156 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -125,16 +125,16 @@ func main() { } } - // Start CCOtel service if enabled (OTEL gRPC passthrough for Claude Code, Codex, etc.) - var ccOtelServer *daemon.CCOtelServer - if cfg.CCOtel != nil && cfg.CCOtel.Enabled != nil && *cfg.CCOtel.Enabled { - ccOtelProcessor := daemon.NewCCOtelProcessor(cfg) - ccOtelServer = daemon.NewCCOtelServer(cfg.CCOtel.GRPCPort, ccOtelProcessor) - if err := ccOtelServer.Start(); err != nil { - slog.Error("Failed to start CCOtel gRPC server", slog.Any("err", err)) + // Start AICodeOtel service if enabled (OTEL gRPC passthrough for Claude Code, Codex, etc.) + var aiCodeOtelServer *daemon.AICodeOtelServer + if cfg.AICodeOtel != nil && cfg.AICodeOtel.Enabled != nil && *cfg.AICodeOtel.Enabled { + aiCodeOtelProcessor := daemon.NewAICodeOtelProcessor(cfg) + aiCodeOtelServer = daemon.NewAICodeOtelServer(cfg.AICodeOtel.GRPCPort, aiCodeOtelProcessor) + if err := aiCodeOtelServer.Start(); err != nil { + slog.Error("Failed to start AICodeOtel gRPC server", slog.Any("err", err)) } else { - slog.Info("CCOtel gRPC server started", slog.Int("port", cfg.CCOtel.GRPCPort)) - defer ccOtelServer.Stop() + slog.Info("AICodeOtel gRPC server started", slog.Int("port", cfg.AICodeOtel.GRPCPort)) + defer aiCodeOtelServer.Stop() } } diff --git a/commands/cc.go b/commands/cc.go index a3691d8..381f7b8 100644 --- a/commands/cc.go +++ b/commands/cc.go @@ -33,9 +33,9 @@ func commandCCInstall(c *cli.Context) error { color.Yellow.Println("Installing Claude Code OTEL configuration...") // Create shell services - zshService := model.NewZshCCOtelEnvService() - fishService := model.NewFishCCOtelEnvService() - bashService := model.NewBashCCOtelEnvService() + zshService := model.NewZshAICodeOtelEnvService() + fishService := model.NewFishAICodeOtelEnvService() + bashService := model.NewBashAICodeOtelEnvService() // Install for all shells (non-blocking failures) if err := zshService.Install(); err != nil { @@ -60,9 +60,9 @@ func commandCCUninstall(c *cli.Context) error { color.Yellow.Println("Removing Claude Code OTEL configuration...") // Create shell services - zshService := model.NewZshCCOtelEnvService() - fishService := model.NewFishCCOtelEnvService() - bashService := model.NewBashCCOtelEnvService() + zshService := model.NewZshAICodeOtelEnvService() + fishService := model.NewFishAICodeOtelEnvService() + bashService := model.NewBashAICodeOtelEnvService() // Uninstall from all shells if err := zshService.Uninstall(); err != nil { diff --git a/commands/daemon.status.go b/commands/daemon.status.go index 13f35a5..9231017 100644 --- a/commands/daemon.status.go +++ b/commands/daemon.status.go @@ -75,14 +75,14 @@ func commandDaemonStatus(c *cli.Context) error { printSectionHeader("Configuration") fmt.Printf(" Socket Path: %s\n", socketPath) - if cfg.CCOtel != nil && cfg.CCOtel.Enabled != nil && *cfg.CCOtel.Enabled { + if cfg.AICodeOtel != nil && cfg.AICodeOtel.Enabled != nil && *cfg.AICodeOtel.Enabled { debugStatus := "off" - if cfg.CCOtel.Debug != nil && *cfg.CCOtel.Debug { + if cfg.AICodeOtel.Debug != nil && *cfg.AICodeOtel.Debug { debugStatus = "on" } - fmt.Printf(" CCOtel: enabled (port %d, debug %s)\n", cfg.CCOtel.GRPCPort, debugStatus) + fmt.Printf(" AICodeOtel: enabled (port %d, debug %s)\n", cfg.AICodeOtel.GRPCPort, debugStatus) } else { - fmt.Println(" CCOtel: disabled") + fmt.Println(" AICodeOtel: disabled") } if cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled { diff --git a/daemon/ccotel_processor.go b/daemon/aicode_otel_processor.go similarity index 77% rename from daemon/ccotel_processor.go rename to daemon/aicode_otel_processor.go index 48ef08d..e9e1e56 100644 --- a/daemon/ccotel_processor.go +++ b/daemon/aicode_otel_processor.go @@ -20,24 +20,24 @@ import ( resourcev1 "go.opentelemetry.io/proto/otlp/resource/v1" ) -// CCOtelProcessor handles OTEL data parsing and forwarding to the backend -type CCOtelProcessor struct { +// AICodeOtelProcessor handles OTEL data parsing and forwarding to the backend +type AICodeOtelProcessor struct { config model.ShellTimeConfig endpoint model.Endpoint hostname string debug bool } -// NewCCOtelProcessor creates a new CCOtel processor -func NewCCOtelProcessor(config model.ShellTimeConfig) *CCOtelProcessor { +// NewAICodeOtelProcessor creates a new AICodeOtel processor +func NewAICodeOtelProcessor(config model.ShellTimeConfig) *AICodeOtelProcessor { hostname, _ := os.Hostname() if hostname == "" { hostname = "unknown" } - debug := config.CCOtel != nil && config.CCOtel.Debug != nil && *config.CCOtel.Debug + debug := config.AICodeOtel != nil && config.AICodeOtel.Debug != nil && *config.AICodeOtel.Debug - return &CCOtelProcessor{ + return &AICodeOtelProcessor{ config: config, endpoint: model.Endpoint{ Token: config.Token, @@ -49,40 +49,40 @@ func NewCCOtelProcessor(config model.ShellTimeConfig) *CCOtelProcessor { } // writeDebugFile appends JSON-formatted data to a debug file -func (p *CCOtelProcessor) writeDebugFile(filename string, data interface{}) { +func (p *AICodeOtelProcessor) writeDebugFile(filename string, data interface{}) { debugDir := filepath.Join(os.TempDir(), "shelltime") if err := os.MkdirAll(debugDir, 0755); err != nil { - slog.Error("CCOtel: Failed to create debug directory", "error", err) + slog.Error("AICodeOtel: Failed to create debug directory", "error", err) return } filePath := filepath.Join(debugDir, filename) f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - slog.Error("CCOtel: Failed to open debug file", "error", err, "path", filePath) + slog.Error("AICodeOtel: Failed to open debug file", "error", err, "path", filePath) return } defer f.Close() jsonData, err := json.MarshalIndent(data, "", " ") if err != nil { - slog.Error("CCOtel: Failed to marshal debug data", "error", err) + slog.Error("AICodeOtel: Failed to marshal debug data", "error", err) return } timestamp := time.Now().Format(time.RFC3339) if _, err := f.WriteString(fmt.Sprintf("\n--- %s ---\n%s\n", timestamp, jsonData)); err != nil { - slog.Error("CCOtel: Failed to write debug data", "error", err) + slog.Error("AICodeOtel: Failed to write debug data", "error", err) } - slog.Debug("CCOtel: Wrote debug data", "path", filePath) + slog.Debug("AICodeOtel: Wrote debug data", "path", filePath) } // ProcessMetrics receives OTEL metrics and forwards to backend immediately -func (p *CCOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1.ExportMetricsServiceRequest) (*collmetricsv1.ExportMetricsServiceResponse, error) { - slog.Debug("CCOtel: Processing metrics request", "resourceMetricsCount", len(req.GetResourceMetrics())) +func (p *AICodeOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1.ExportMetricsServiceRequest) (*collmetricsv1.ExportMetricsServiceResponse, error) { + slog.Debug("AICodeOtel: Processing metrics request", "resourceMetricsCount", len(req.GetResourceMetrics())) if p.debug { - p.writeDebugFile("ccotel-debug-metrics.txt", req) + p.writeDebugFile("aicode-otel-debug-metrics.txt", req) } for _, rm := range req.GetResourceMetrics() { @@ -91,7 +91,7 @@ func (p *CCOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1 // Check if this is from Claude Code or Codex source := detectOtelSource(resource) if source == "" { - slog.Debug("CCOtel: Skipping unknown resource") + slog.Debug("AICodeOtel: Skipping unknown resource") continue } @@ -99,7 +99,7 @@ func (p *CCOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1 resourceAttrs := extractResourceAttributes(resource) project := p.detectProject(resource, source) - var metrics []model.CCOtelMetric + var metrics []model.AICodeOtelMetric for _, sm := range rm.GetScopeMetrics() { for _, m := range sm.GetMetrics() { @@ -113,19 +113,19 @@ func (p *CCOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1 } // Build and send request immediately - flat structure without session - ccReq := &model.CCOtelRequest{ + aiCodeReq := &model.AICodeOtelRequest{ Host: p.hostname, Project: project, Source: source, Metrics: metrics, } - resp, err := model.SendCCOtelData(ctx, ccReq, p.endpoint) + resp, err := model.SendAICodeOtelData(ctx, aiCodeReq, p.endpoint) if err != nil { - slog.Error("CCOtel: Failed to send metrics to backend", "error", err) + slog.Error("AICodeOtel: Failed to send metrics to backend", "error", err) // Continue processing - passthrough mode, we don't retry } else { - slog.Debug("CCOtel: Metrics sent to backend", "metricsProcessed", resp.MetricsProcessed) + slog.Debug("AICodeOtel: Metrics sent to backend", "metricsProcessed", resp.MetricsProcessed) } } @@ -133,11 +133,11 @@ func (p *CCOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1 } // ProcessLogs receives OTEL logs/events and forwards to backend immediately -func (p *CCOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.ExportLogsServiceRequest) (*collogsv1.ExportLogsServiceResponse, error) { - slog.Debug("CCOtel: Processing logs request", "resourceLogsCount", len(req.GetResourceLogs()), slog.Bool("debug", p.debug)) +func (p *AICodeOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.ExportLogsServiceRequest) (*collogsv1.ExportLogsServiceResponse, error) { + slog.Debug("AICodeOtel: Processing logs request", "resourceLogsCount", len(req.GetResourceLogs()), slog.Bool("debug", p.debug)) if p.debug { - p.writeDebugFile("ccotel-debug-logs.txt", req) + p.writeDebugFile("aicode-otel-debug-logs.txt", req) } for _, rl := range req.GetResourceLogs() { @@ -146,7 +146,7 @@ func (p *CCOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Export // Check if this is from Claude Code or Codex source := detectOtelSource(resource) if source == "" { - slog.Debug("CCOtel: Skipping unknown resource") + slog.Debug("AICodeOtel: Skipping unknown resource") continue } @@ -154,7 +154,7 @@ func (p *CCOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Export resourceAttrs := extractResourceAttributes(resource) project := p.detectProject(resource, source) - var events []model.CCOtelEvent + var events []model.AICodeOtelEvent for _, sl := range rl.GetScopeLogs() { for _, lr := range sl.GetLogRecords() { @@ -170,19 +170,19 @@ func (p *CCOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Export } // Build and send request immediately - flat structure without session - ccReq := &model.CCOtelRequest{ + aiCodeReq := &model.AICodeOtelRequest{ Host: p.hostname, Project: project, Source: source, Events: events, } - resp, err := model.SendCCOtelData(ctx, ccReq, p.endpoint) + resp, err := model.SendAICodeOtelData(ctx, aiCodeReq, p.endpoint) if err != nil { - slog.Error("CCOtel: Failed to send events to backend", "error", err) + slog.Error("AICodeOtel: Failed to send events to backend", "error", err) // Continue processing - passthrough mode, we don't retry } else { - slog.Debug("CCOtel: Events sent to backend", "eventsProcessed", resp.EventsProcessed) + slog.Debug("AICodeOtel: Events sent to backend", "eventsProcessed", resp.EventsProcessed) } } @@ -200,9 +200,9 @@ func detectOtelSource(resource *resourcev1.Resource) string { serviceName := attr.GetValue().GetStringValue() switch serviceName { case "claude-code": - return model.CCOtelSourceClaudeCode + return model.AICodeOtelSourceClaudeCode case "codex", "codex-cli", "openai-codex": - return model.CCOtelSourceCodex + return model.AICodeOtelSourceCodex } } } @@ -211,8 +211,8 @@ func detectOtelSource(resource *resourcev1.Resource) string { // extractResourceAttributes extracts resource-level attributes from OTEL resource // Returns a struct that can be used to populate metrics and events -func extractResourceAttributes(resource *resourcev1.Resource) *model.CCOtelResourceAttributes { - attrs := &model.CCOtelResourceAttributes{} +func extractResourceAttributes(resource *resourcev1.Resource) *model.AICodeOtelResourceAttributes { + attrs := &model.AICodeOtelResourceAttributes{} if resource == nil { return attrs @@ -265,7 +265,7 @@ func extractResourceAttributes(resource *resourcev1.Resource) *model.CCOtelResou } // applyResourceAttributesToMetric copies resource attributes into a metric -func applyResourceAttributesToMetric(metric *model.CCOtelMetric, attrs *model.CCOtelResourceAttributes) { +func applyResourceAttributesToMetric(metric *model.AICodeOtelMetric, attrs *model.AICodeOtelResourceAttributes) { // Standard resource attributes metric.SessionID = attrs.SessionID metric.UserAccountUUID = attrs.UserAccountUUID @@ -288,7 +288,7 @@ func applyResourceAttributesToMetric(metric *model.CCOtelMetric, attrs *model.CC } // applyResourceAttributesToEvent copies resource attributes into an event -func applyResourceAttributesToEvent(event *model.CCOtelEvent, attrs *model.CCOtelResourceAttributes) { +func applyResourceAttributesToEvent(event *model.AICodeOtelEvent, attrs *model.AICodeOtelResourceAttributes) { // Standard resource attributes event.SessionID = attrs.SessionID event.UserAccountUUID = attrs.UserAccountUUID @@ -311,7 +311,7 @@ func applyResourceAttributesToEvent(event *model.CCOtelEvent, attrs *model.CCOte } // detectProject extracts project from resource attributes or environment -func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource, source string) string { +func (p *AICodeOtelProcessor) detectProject(resource *resourcev1.Resource, source string) string { // First check resource attributes if resource != nil { for _, attr := range resource.GetAttributes() { @@ -322,11 +322,11 @@ func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource, source st } // Fall back to environment variables based on source - if source == model.CCOtelSourceClaudeCode { + if source == model.AICodeOtelSourceClaudeCode { if project := os.Getenv("CLAUDE_CODE_PROJECT"); project != "" { return project } - } else if source == model.CCOtelSourceCodex { + } else if source == model.AICodeOtelSourceCodex { if project := os.Getenv("CODEX_PROJECT"); project != "" { return project } @@ -339,9 +339,9 @@ func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource, source st return "unknown" } -// parseMetric parses an OTEL metric into CCOtelMetric(s) -func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model.CCOtelResourceAttributes, source string) []model.CCOtelMetric { - var metrics []model.CCOtelMetric +// parseMetric parses an OTEL metric into AICodeOtelMetric(s) +func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model.AICodeOtelResourceAttributes, source string) []model.AICodeOtelMetric { + var metrics []model.AICodeOtelMetric name := m.GetName() metricType := mapMetricName(name, source) @@ -353,7 +353,7 @@ func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model. switch data := m.GetData().(type) { case *metricsv1.Metric_Sum: for _, dp := range data.Sum.GetDataPoints() { - metric := model.CCOtelMetric{ + metric := model.AICodeOtelMetric{ MetricID: uuid.New().String(), MetricType: metricType, Timestamp: int64(dp.GetTimeUnixNano() / 1e9), // Convert to seconds @@ -369,7 +369,7 @@ func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model. } case *metricsv1.Metric_Gauge: for _, dp := range data.Gauge.GetDataPoints() { - metric := model.CCOtelMetric{ + metric := model.AICodeOtelMetric{ MetricID: uuid.New().String(), MetricType: metricType, Timestamp: int64(dp.GetTimeUnixNano() / 1e9), @@ -388,9 +388,9 @@ func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model. return metrics } -// parseLogRecord parses an OTEL log record into a CCOtelEvent -func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *model.CCOtelResourceAttributes, source string) *model.CCOtelEvent { - event := &model.CCOtelEvent{ +// parseLogRecord parses an OTEL log record into a AICodeOtelEvent +func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *model.AICodeOtelResourceAttributes, source string) *model.AICodeOtelEvent { + event := &model.AICodeOtelEvent{ EventID: uuid.New().String(), Timestamp: int64(lr.GetTimeUnixNano() / 1e9), // Convert to seconds } @@ -428,12 +428,6 @@ func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *mo event.Success = getBoolFromValue(value) case "decision": event.Decision = value.GetStringValue() - // case "decision_source": - // event.DecisionSource = value.GetStringValue() - // case "decision_type": - // event.DecisionType = value.GetStringValue() - // case "tool_result_size_bytes": - // event.ToolResultSizeBytes = getIntFromValue(value) case "source": event.Source = value.GetStringValue() case "error": @@ -449,7 +443,7 @@ func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs *mo if err := json.Unmarshal([]byte(jsonStr), ¶ms); err == nil { event.ToolParameters = params } else { - slog.Debug("CCOtel: Failed to parse tool_parameters", "error", err) + slog.Debug("AICodeOtel: Failed to parse tool_parameters", "error", err) } } case "status_code": @@ -495,36 +489,36 @@ func mapMetricName(name string, source string) string { switch name { // Claude Code metrics case "claude_code.session.count": - return model.CCMetricSessionCount + return model.AICodeMetricSessionCount case "claude_code.token.usage": - return model.CCMetricTokenUsage + return model.AICodeMetricTokenUsage case "claude_code.cost.usage": - return model.CCMetricCostUsage + return model.AICodeMetricCostUsage case "claude_code.lines_of_code.count": - return model.CCMetricLinesOfCodeCount + return model.AICodeMetricLinesOfCodeCount case "claude_code.commit.count": - return model.CCMetricCommitCount + return model.AICodeMetricCommitCount case "claude_code.pull_request.count": - return model.CCMetricPullRequestCount + return model.AICodeMetricPullRequestCount case "claude_code.active_time.total": - return model.CCMetricActiveTimeTotal + return model.AICodeMetricActiveTimeTotal case "claude_code.code_edit_tool.decision": - return model.CCMetricCodeEditToolDecision + return model.AICodeMetricCodeEditToolDecision // Codex metrics (same internal types, different prefix) case "codex.session.count": - return model.CCMetricSessionCount + return model.AICodeMetricSessionCount case "codex.token.usage": - return model.CCMetricTokenUsage + return model.AICodeMetricTokenUsage case "codex.cost.usage": - return model.CCMetricCostUsage + return model.AICodeMetricCostUsage case "codex.lines_of_code.count": - return model.CCMetricLinesOfCodeCount + return model.AICodeMetricLinesOfCodeCount case "codex.commit.count": - return model.CCMetricCommitCount + return model.AICodeMetricCommitCount case "codex.pull_request.count": - return model.CCMetricPullRequestCount + return model.AICodeMetricPullRequestCount case "codex.active_time.total": - return model.CCMetricActiveTimeTotal + return model.AICodeMetricActiveTimeTotal default: return "" } @@ -536,26 +530,26 @@ func mapEventName(name string, source string) string { switch name { // Claude Code events case "claude_code.user_prompt": - return model.CCEventUserPrompt + return model.AICodeEventUserPrompt case "claude_code.tool_result": - return model.CCEventToolResult + return model.AICodeEventToolResult case "claude_code.api_request": - return model.CCEventApiRequest + return model.AICodeEventApiRequest case "claude_code.api_error": - return model.CCEventApiError + return model.AICodeEventApiError case "claude_code.tool_decision": - return model.CCEventToolDecision + return model.AICodeEventToolDecision // Codex events (same internal types, different prefix) case "codex.user_prompt": - return model.CCEventUserPrompt + return model.AICodeEventUserPrompt case "codex.tool_result": - return model.CCEventToolResult + return model.AICodeEventToolResult case "codex.api_request": - return model.CCEventApiRequest + return model.AICodeEventApiRequest case "codex.api_error": - return model.CCEventApiError + return model.AICodeEventApiError case "codex.exec_command": - return model.CCEventExecCommand + return model.AICodeEventExecCommand default: return name // Return as-is if not in our map } @@ -618,13 +612,13 @@ func getFloatFromValue(value *commonv1.AnyValue) float64 { } // applyMetricAttribute applies an attribute to a metric -func applyMetricAttribute(metric *model.CCOtelMetric, attr *commonv1.KeyValue, metricType string) { +func applyMetricAttribute(metric *model.AICodeOtelMetric, attr *commonv1.KeyValue, metricType string) { key := attr.GetKey() value := attr.GetValue() switch key { case "type": - if metricType == model.CCMetricLinesOfCodeCount { + if metricType == model.AICodeMetricLinesOfCodeCount { metric.LinesType = value.GetStringValue() } else { metric.TokenType = value.GetStringValue() diff --git a/daemon/ccotel_server.go b/daemon/aicode_otel_server.go similarity index 73% rename from daemon/ccotel_server.go rename to daemon/aicode_otel_server.go index 918c468..6a1f953 100644 --- a/daemon/ccotel_server.go +++ b/daemon/aicode_otel_server.go @@ -11,24 +11,24 @@ import ( "google.golang.org/grpc" ) -// CCOtelServer is the gRPC server for receiving OTEL data from Claude Code -type CCOtelServer struct { +// AICodeOtelServer is the gRPC server for receiving OTEL data from AI coding CLIs (Claude Code, Codex, etc.) +type AICodeOtelServer struct { port int - processor *CCOtelProcessor + processor *AICodeOtelProcessor grpcServer *grpc.Server listener net.Listener } -// NewCCOtelServer creates a new CCOtel gRPC server -func NewCCOtelServer(port int, processor *CCOtelProcessor) *CCOtelServer { - return &CCOtelServer{ +// NewAICodeOtelServer creates a new AICodeOtel gRPC server +func NewAICodeOtelServer(port int, processor *AICodeOtelProcessor) *AICodeOtelServer { + return &AICodeOtelServer{ port: port, processor: processor, } } // Start starts the gRPC server -func (s *CCOtelServer) Start() error { +func (s *AICodeOtelServer) Start() error { addr := fmt.Sprintf(":%d", s.port) listener, err := net.Listen("tcp", addr) if err != nil { @@ -42,12 +42,12 @@ func (s *CCOtelServer) Start() error { collmetricsv1.RegisterMetricsServiceServer(s.grpcServer, &metricsServiceServer{processor: s.processor}) collogsv1.RegisterLogsServiceServer(s.grpcServer, &logsServiceServer{processor: s.processor}) - slog.Info("CCOtel gRPC server starting", "port", s.port) + slog.Info("AICodeOtel gRPC server starting", "port", s.port) // Start serving in a goroutine go func() { if err := s.grpcServer.Serve(listener); err != nil { - slog.Error("CCOtel gRPC server error", "error", err) + slog.Error("AICodeOtel gRPC server error", "error", err) } }() @@ -55,9 +55,9 @@ func (s *CCOtelServer) Start() error { } // Stop gracefully stops the gRPC server -func (s *CCOtelServer) Stop() { +func (s *AICodeOtelServer) Stop() { if s.grpcServer != nil { - slog.Info("CCOtel gRPC server stopping") + slog.Info("AICodeOtel gRPC server stopping") s.grpcServer.GracefulStop() } } @@ -65,7 +65,7 @@ func (s *CCOtelServer) Stop() { // metricsServiceServer implements the OTEL MetricsService type metricsServiceServer struct { collmetricsv1.UnimplementedMetricsServiceServer - processor *CCOtelProcessor + processor *AICodeOtelProcessor } // Export handles incoming metrics export requests @@ -76,7 +76,7 @@ func (s *metricsServiceServer) Export(ctx context.Context, req *collmetricsv1.Ex // logsServiceServer implements the OTEL LogsService type logsServiceServer struct { collogsv1.UnimplementedLogsServiceServer - processor *CCOtelProcessor + processor *AICodeOtelProcessor } // Export handles incoming logs export requests diff --git a/docs/CONFIG.md b/docs/CONFIG.md index fa7d3f7..7309a15 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -247,18 +247,18 @@ ai: ShellTime can track and forward Claude Code metrics for analysis. -### CCOtel (Recommended) +### AICodeOtel (Recommended) -The modern approach using OpenTelemetry gRPC passthrough: +The modern approach using OpenTelemetry gRPC passthrough for AI coding CLIs (Claude Code, Codex, etc.): | Option | Type | Default | Description | |--------|------|---------|-------------| -| `ccotel.enabled` | boolean | `false` | Enable OTEL collection | -| `ccotel.grpcPort` | integer | `54027` | gRPC server port | -| `ccotel.debug` | boolean | `false` | Write debug files | +| `aiCodeOtel.enabled` | boolean | `false` | Enable OTEL collection | +| `aiCodeOtel.grpcPort` | integer | `54027` | gRPC server port | +| `aiCodeOtel.debug` | boolean | `false` | Write debug files | ```yaml -ccotel: +aiCodeOtel: enabled: true grpcPort: 54027 # Default ShellTime OTEL port debug: false # Set true to debug issues @@ -266,8 +266,9 @@ ccotel: **How it works:** 1. Daemon starts gRPC server on configured port -2. Claude Code sends OTEL metrics/logs to this port -3. ShellTime forwards data to shelltime.xyz for analysis +2. AI coding CLIs (Claude Code, Codex) send OTEL metrics/logs to this port +3. ShellTime auto-detects the source from service.name attribute +4. Data is forwarded to shelltime.xyz for analysis ### CCUsage (Legacy) @@ -399,8 +400,8 @@ ai: edit: false delete: false -# --- Claude Code Integration --- -ccotel: +# --- AI Code Integration (Claude Code, Codex, etc.) --- +aiCodeOtel: enabled: false grpcPort: 54027 debug: false @@ -469,13 +470,14 @@ Or unset your token: token: "" ``` -### What's the difference between CCOtel and CCUsage? +### What's the difference between AICodeOtel and CCUsage? -| Feature | CCOtel | CCUsage | -|---------|--------|---------| +| Feature | AICodeOtel | CCUsage | +|---------|------------|---------| | Method | gRPC passthrough | CLI parsing | | Performance | Better | More overhead | | Data richness | Full OTEL data | Basic metrics | +| Sources | Claude Code, Codex, etc. | Claude Code only | | Recommended | Yes | Legacy | ### How do I test my exclude patterns? diff --git a/model/ccotel_env.go b/model/aicode_otel_env.go similarity index 75% rename from model/ccotel_env.go rename to model/aicode_otel_env.go index ae3df15..8ffb796 100644 --- a/model/ccotel_env.go +++ b/model/aicode_otel_env.go @@ -10,13 +10,13 @@ import ( ) const ( - ccOtelMarkerStart = "# >>> shelltime cc otel >>>" - ccOtelMarkerEnd = "# <<< shelltime cc otel <<<" - ccOtelEndpoint = "http://localhost:54027" + aiCodeOtelMarkerStart = "# >>> shelltime cc otel >>>" + aiCodeOtelMarkerEnd = "# <<< shelltime cc otel <<<" + aiCodeOtelEndpoint = "http://localhost:54027" ) -// CCOtelEnvService interface for shell-specific env var setup -type CCOtelEnvService interface { +// AICodeOtelEnvService interface for shell-specific env var setup +type AICodeOtelEnvService interface { Match(shellName string) bool Install() error Check() error @@ -24,10 +24,10 @@ type CCOtelEnvService interface { ShellName() string } -// baseCCOtelEnvService provides common functionality -type baseCCOtelEnvService struct{} +// baseAICodeOtelEnvService provides common functionality +type baseAICodeOtelEnvService struct{} -func (b *baseCCOtelEnvService) addEnvLines(filePath string, envLines []string) error { +func (b *baseAICodeOtelEnvService) addEnvLines(filePath string, envLines []string) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file: %w", err) @@ -51,7 +51,7 @@ func (b *baseCCOtelEnvService) addEnvLines(filePath string, envLines []string) e return nil } -func (b *baseCCOtelEnvService) removeEnvLines(filePath string) error { +func (b *baseAICodeOtelEnvService) removeEnvLines(filePath string) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file: %w", err) @@ -64,11 +64,11 @@ func (b *baseCCOtelEnvService) removeEnvLines(filePath string) error { for scanner.Scan() { line := scanner.Text() - if strings.Contains(line, ccOtelMarkerStart) { + if strings.Contains(line, aiCodeOtelMarkerStart) { inBlock = true continue } - if strings.Contains(line, ccOtelMarkerEnd) { + if strings.Contains(line, aiCodeOtelMarkerEnd) { inBlock = false continue } @@ -85,36 +85,36 @@ func (b *baseCCOtelEnvService) removeEnvLines(filePath string) error { return nil } -func (b *baseCCOtelEnvService) checkEnvLines(filePath string) (bool, error) { +func (b *baseAICodeOtelEnvService) checkEnvLines(filePath string) (bool, error) { content, err := os.ReadFile(filePath) if err != nil { return false, fmt.Errorf("failed to read file: %w", err) } - return strings.Contains(string(content), ccOtelMarkerStart), nil + return strings.Contains(string(content), aiCodeOtelMarkerStart), nil } // ============================================================================= // Bash Implementation // ============================================================================= -type BashCCOtelEnvService struct { - baseCCOtelEnvService +type BashAICodeOtelEnvService struct { + baseAICodeOtelEnvService shellName string configPath string envLines []string } -func NewBashCCOtelEnvService() CCOtelEnvService { +func NewBashAICodeOtelEnvService() AICodeOtelEnvService { configPath := os.ExpandEnv("$HOME/.bashrc") envLines := []string{ "", - ccOtelMarkerStart, + aiCodeOtelMarkerStart, "export CLAUDE_CODE_ENABLE_TELEMETRY=1", "export OTEL_METRICS_EXPORTER=otlp", "export OTEL_LOGS_EXPORTER=otlp", "export OTEL_EXPORTER_OTLP_PROTOCOL=grpc", - "export OTEL_EXPORTER_OTLP_ENDPOINT=" + ccOtelEndpoint, + "export OTEL_EXPORTER_OTLP_ENDPOINT=" + aiCodeOtelEndpoint, "export OTEL_METRIC_EXPORT_INTERVAL=10000", "export OTEL_LOGS_EXPORT_INTERVAL=5000", "export OTEL_LOG_USER_PROMPTS=1", @@ -122,25 +122,25 @@ func NewBashCCOtelEnvService() CCOtelEnvService { "export OTEL_METRICS_INCLUDE_VERSION=true", "export OTEL_METRICS_INCLUDE_ACCOUNT_UUID=true", "export OTEL_RESOURCE_ATTRIBUTES=\"user.name=$(whoami),machine.name=$(hostname),team.id=shelltime,pwd=$(pwd)\"", - ccOtelMarkerEnd, + aiCodeOtelMarkerEnd, } - return &BashCCOtelEnvService{ + return &BashAICodeOtelEnvService{ shellName: "bash", configPath: configPath, envLines: envLines, } } -func (s *BashCCOtelEnvService) Match(shellName string) bool { +func (s *BashAICodeOtelEnvService) Match(shellName string) bool { return strings.Contains(strings.ToLower(shellName), strings.ToLower(s.shellName)) } -func (s *BashCCOtelEnvService) ShellName() string { +func (s *BashAICodeOtelEnvService) ShellName() string { return s.shellName } -func (s *BashCCOtelEnvService) Install() error { +func (s *BashAICodeOtelEnvService) Install() error { // Create config file if it doesn't exist if _, err := os.Stat(s.configPath); os.IsNotExist(err) { if err := os.WriteFile(s.configPath, []byte(""), 0644); err != nil { @@ -162,7 +162,7 @@ func (s *BashCCOtelEnvService) Install() error { return nil } -func (s *BashCCOtelEnvService) Uninstall() error { +func (s *BashAICodeOtelEnvService) Uninstall() error { if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return nil } @@ -170,7 +170,7 @@ func (s *BashCCOtelEnvService) Uninstall() error { return s.removeEnvLines(s.configPath) } -func (s *BashCCOtelEnvService) Check() error { +func (s *BashAICodeOtelEnvService) Check() error { if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return fmt.Errorf("bash config file not found at %s", s.configPath) } @@ -190,23 +190,23 @@ func (s *BashCCOtelEnvService) Check() error { // Zsh Implementation // ============================================================================= -type ZshCCOtelEnvService struct { - baseCCOtelEnvService +type ZshAICodeOtelEnvService struct { + baseAICodeOtelEnvService shellName string configPath string envLines []string } -func NewZshCCOtelEnvService() CCOtelEnvService { +func NewZshAICodeOtelEnvService() AICodeOtelEnvService { configPath := os.ExpandEnv("$HOME/.zshrc") envLines := []string{ "", - ccOtelMarkerStart, + aiCodeOtelMarkerStart, "export CLAUDE_CODE_ENABLE_TELEMETRY=1", "export OTEL_METRICS_EXPORTER=otlp", "export OTEL_LOGS_EXPORTER=otlp", "export OTEL_EXPORTER_OTLP_PROTOCOL=grpc", - "export OTEL_EXPORTER_OTLP_ENDPOINT=" + ccOtelEndpoint, + "export OTEL_EXPORTER_OTLP_ENDPOINT=" + aiCodeOtelEndpoint, "export OTEL_METRIC_EXPORT_INTERVAL=10000", "export OTEL_LOGS_EXPORT_INTERVAL=5000", "export OTEL_LOG_USER_PROMPTS=1", @@ -214,25 +214,25 @@ func NewZshCCOtelEnvService() CCOtelEnvService { "export OTEL_METRICS_INCLUDE_VERSION=true", "export OTEL_METRICS_INCLUDE_ACCOUNT_UUID=true", "export OTEL_RESOURCE_ATTRIBUTES=\"user.name=$(whoami),machine.name=$(hostname),team.id=shelltime,pwd=$(pwd)\"", - ccOtelMarkerEnd, + aiCodeOtelMarkerEnd, } - return &ZshCCOtelEnvService{ + return &ZshAICodeOtelEnvService{ shellName: "zsh", configPath: configPath, envLines: envLines, } } -func (s *ZshCCOtelEnvService) Match(shellName string) bool { +func (s *ZshAICodeOtelEnvService) Match(shellName string) bool { return strings.Contains(strings.ToLower(shellName), strings.ToLower(s.shellName)) } -func (s *ZshCCOtelEnvService) ShellName() string { +func (s *ZshAICodeOtelEnvService) ShellName() string { return s.shellName } -func (s *ZshCCOtelEnvService) Install() error { +func (s *ZshAICodeOtelEnvService) Install() error { if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return fmt.Errorf("zsh config file not found at %s", s.configPath) } @@ -251,7 +251,7 @@ func (s *ZshCCOtelEnvService) Install() error { return nil } -func (s *ZshCCOtelEnvService) Uninstall() error { +func (s *ZshAICodeOtelEnvService) Uninstall() error { if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return nil } @@ -259,7 +259,7 @@ func (s *ZshCCOtelEnvService) Uninstall() error { return s.removeEnvLines(s.configPath) } -func (s *ZshCCOtelEnvService) Check() error { +func (s *ZshAICodeOtelEnvService) Check() error { if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return fmt.Errorf("zsh config file not found at %s", s.configPath) } @@ -279,23 +279,23 @@ func (s *ZshCCOtelEnvService) Check() error { // Fish Implementation // ============================================================================= -type FishCCOtelEnvService struct { - baseCCOtelEnvService +type FishAICodeOtelEnvService struct { + baseAICodeOtelEnvService shellName string configPath string envLines []string } -func NewFishCCOtelEnvService() CCOtelEnvService { +func NewFishAICodeOtelEnvService() AICodeOtelEnvService { configPath := os.ExpandEnv("$HOME/.config/fish/config.fish") envLines := []string{ "", - ccOtelMarkerStart, + aiCodeOtelMarkerStart, "set -gx CLAUDE_CODE_ENABLE_TELEMETRY 1", "set -gx OTEL_METRICS_EXPORTER otlp", "set -gx OTEL_LOGS_EXPORTER otlp", "set -gx OTEL_EXPORTER_OTLP_PROTOCOL grpc", - "set -gx OTEL_EXPORTER_OTLP_ENDPOINT " + ccOtelEndpoint, + "set -gx OTEL_EXPORTER_OTLP_ENDPOINT " + aiCodeOtelEndpoint, "set -gx OTEL_METRIC_EXPORT_INTERVAL 10000", "set -gx OTEL_LOGS_EXPORT_INTERVAL 5000", "set -gx OTEL_LOG_USER_PROMPTS 1", @@ -303,25 +303,25 @@ func NewFishCCOtelEnvService() CCOtelEnvService { "set -gx OTEL_METRICS_INCLUDE_VERSION true", "set -gx OTEL_METRICS_INCLUDE_ACCOUNT_UUID true", "set -gx OTEL_RESOURCE_ATTRIBUTES \"user.name=$(whoami),machine.name=$(hostname),team.id=shelltime,pwd=$(pwd)\"", - ccOtelMarkerEnd, + aiCodeOtelMarkerEnd, } - return &FishCCOtelEnvService{ + return &FishAICodeOtelEnvService{ shellName: "fish", configPath: configPath, envLines: envLines, } } -func (s *FishCCOtelEnvService) Match(shellName string) bool { +func (s *FishAICodeOtelEnvService) Match(shellName string) bool { return strings.Contains(strings.ToLower(shellName), strings.ToLower(s.shellName)) } -func (s *FishCCOtelEnvService) ShellName() string { +func (s *FishAICodeOtelEnvService) ShellName() string { return s.shellName } -func (s *FishCCOtelEnvService) Install() error { +func (s *FishAICodeOtelEnvService) Install() error { if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return fmt.Errorf("fish config file not found at %s", s.configPath) } @@ -340,7 +340,7 @@ func (s *FishCCOtelEnvService) Install() error { return nil } -func (s *FishCCOtelEnvService) Uninstall() error { +func (s *FishAICodeOtelEnvService) Uninstall() error { if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return nil } @@ -348,7 +348,7 @@ func (s *FishCCOtelEnvService) Uninstall() error { return s.removeEnvLines(s.configPath) } -func (s *FishCCOtelEnvService) Check() error { +func (s *FishAICodeOtelEnvService) Check() error { if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return fmt.Errorf("fish config file not found at %s", s.configPath) } diff --git a/model/ccotel_types.go b/model/aicode_otel_types.go similarity index 70% rename from model/ccotel_types.go rename to model/aicode_otel_types.go index d2ba374..25b4902 100644 --- a/model/ccotel_types.go +++ b/model/aicode_otel_types.go @@ -1,18 +1,18 @@ package model -// CCOtelRequest is the main request to POST /api/v1/cc/otel +// AICodeOtelRequest is the main request to POST /api/v1/cc/otel // Flat structure without session - resource attributes are embedded in each metric/event -type CCOtelRequest struct { - Host string `json:"host"` - Project string `json:"project"` - Source string `json:"source,omitempty"` // "claude-code" or "codex" - identifies the CLI source - Events []CCOtelEvent `json:"events,omitempty"` - Metrics []CCOtelMetric `json:"metrics,omitempty"` +type AICodeOtelRequest struct { + Host string `json:"host"` + Project string `json:"project"` + Source string `json:"source,omitempty"` // "claude-code" or "codex" - identifies the CLI source + Events []AICodeOtelEvent `json:"events,omitempty"` + Metrics []AICodeOtelMetric `json:"metrics,omitempty"` } -// CCOtelResourceAttributes contains common resource-level attributes +// AICodeOtelResourceAttributes contains common resource-level attributes // extracted from OTEL resources and embedded into each metric/event -type CCOtelResourceAttributes struct { +type AICodeOtelResourceAttributes struct { // Standard resource attributes SessionID string UserAccountUUID string @@ -36,9 +36,9 @@ type CCOtelResourceAttributes struct { Pwd string // from pwd } -// CCOtelEvent represents an event from Claude Code or Codex (api_request, tool_result, etc.) +// AICodeOtelEvent represents an event from Claude Code or Codex (api_request, tool_result, etc.) // with embedded resource attributes for a flat, session-less structure -type CCOtelEvent struct { +type AICodeOtelEvent struct { EventID string `json:"eventId"` EventType string `json:"eventType"` Timestamp int64 `json:"timestamp"` @@ -85,9 +85,9 @@ type CCOtelEvent struct { Pwd string `json:"pwd,omitempty"` } -// CCOtelMetric represents a metric data point from Claude Code +// AICodeOtelMetric represents a metric data point from Claude Code or Codex // with embedded resource attributes for a flat, session-less structure -type CCOtelMetric struct { +type AICodeOtelMetric struct { MetricID string `json:"metricId"` MetricType string `json:"metricType"` Timestamp int64 `json:"timestamp"` @@ -120,8 +120,8 @@ type CCOtelMetric struct { Pwd string `json:"pwd,omitempty"` } -// CCOtelResponse is the response from POST /api/v1/cc/otel -type CCOtelResponse struct { +// AICodeOtelResponse is the response from POST /api/v1/cc/otel +type AICodeOtelResponse struct { Success bool `json:"success"` EventsProcessed int `json:"eventsProcessed"` MetricsProcessed int `json:"metricsProcessed"` @@ -130,42 +130,42 @@ type CCOtelResponse struct { // OTEL source identifiers const ( - CCOtelSourceClaudeCode = "claude-code" - CCOtelSourceCodex = "codex" + AICodeOtelSourceClaudeCode = "claude-code" + AICodeOtelSourceCodex = "codex" ) -// Claude Code / Codex OTEL metric types (shared) +// AI Code OTEL metric types (shared between Claude Code and Codex) const ( - CCMetricSessionCount = "session_count" - CCMetricLinesOfCodeCount = "lines_of_code_count" - CCMetricPullRequestCount = "pull_request_count" - CCMetricCommitCount = "commit_count" - CCMetricCostUsage = "cost_usage" - CCMetricTokenUsage = "token_usage" - CCMetricCodeEditToolDecision = "code_edit_tool_decision" - CCMetricActiveTimeTotal = "active_time_total" + AICodeMetricSessionCount = "session_count" + AICodeMetricLinesOfCodeCount = "lines_of_code_count" + AICodeMetricPullRequestCount = "pull_request_count" + AICodeMetricCommitCount = "commit_count" + AICodeMetricCostUsage = "cost_usage" + AICodeMetricTokenUsage = "token_usage" + AICodeMetricCodeEditToolDecision = "code_edit_tool_decision" + AICodeMetricActiveTimeTotal = "active_time_total" ) -// Claude Code / Codex OTEL event types (shared) +// AI Code OTEL event types (shared between Claude Code and Codex) const ( - CCEventUserPrompt = "user_prompt" - CCEventToolResult = "tool_result" - CCEventApiRequest = "api_request" - CCEventApiError = "api_error" - CCEventToolDecision = "tool_decision" - CCEventExecCommand = "exec_command" // Codex: shell command execution + AICodeEventUserPrompt = "user_prompt" + AICodeEventToolResult = "tool_result" + AICodeEventApiRequest = "api_request" + AICodeEventApiError = "api_error" + AICodeEventToolDecision = "tool_decision" + AICodeEventExecCommand = "exec_command" // Codex: shell command execution ) -// Token types for CCMetricTokenUsage +// Token types for AICodeMetricTokenUsage const ( - CCTokenTypeInput = "input" - CCTokenTypeOutput = "output" - CCTokenTypeCacheRead = "cacheRead" - CCTokenTypeCacheCreation = "cacheCreation" + AICodeTokenTypeInput = "input" + AICodeTokenTypeOutput = "output" + AICodeTokenTypeCacheRead = "cacheRead" + AICodeTokenTypeCacheCreation = "cacheCreation" ) -// Lines types for CCMetricLinesOfCodeCount +// Lines types for AICodeMetricLinesOfCodeCount const ( - CCLinesTypeAdded = "added" - CCLinesTypeRemoved = "removed" + AICodeLinesTypeAdded = "added" + AICodeLinesTypeRemoved = "removed" ) diff --git a/model/api_aicode_otel.go b/model/api_aicode_otel.go new file mode 100644 index 0000000..852a4c0 --- /dev/null +++ b/model/api_aicode_otel.go @@ -0,0 +1,31 @@ +package model + +import ( + "context" + "net/http" + "time" +) + +// SendAICodeOtelData sends OTEL data to the backend immediately +// POST /api/v1/cc/otel +func SendAICodeOtelData(ctx context.Context, req *AICodeOtelRequest, endpoint Endpoint) (*AICodeOtelResponse, error) { + ctx, span := modelTracer.Start(ctx, "aicode_otel.send") + defer span.End() + + var resp AICodeOtelResponse + err := SendHTTPRequestJSON(HTTPRequestOptions[*AICodeOtelRequest, AICodeOtelResponse]{ + Context: ctx, + Endpoint: endpoint, + Method: http.MethodPost, + Path: "/api/v1/cc/otel", + Payload: req, + Response: &resp, + Timeout: 30 * time.Second, + }) + + if err != nil { + return nil, err + } + + return &resp, nil +} diff --git a/model/api_ccotel.go b/model/api_ccotel.go deleted file mode 100644 index 84ce51c..0000000 --- a/model/api_ccotel.go +++ /dev/null @@ -1,31 +0,0 @@ -package model - -import ( - "context" - "net/http" - "time" -) - -// SendCCOtelData sends OTEL data to the backend immediately -// POST /api/v1/cc/otel -func SendCCOtelData(ctx context.Context, req *CCOtelRequest, endpoint Endpoint) (*CCOtelResponse, error) { - ctx, span := modelTracer.Start(ctx, "ccotel.send") - defer span.End() - - var resp CCOtelResponse - err := SendHTTPRequestJSON(HTTPRequestOptions[*CCOtelRequest, CCOtelResponse]{ - Context: ctx, - Endpoint: endpoint, - Method: http.MethodPost, - Path: "/api/v1/cc/otel", - Payload: req, - Response: &resp, - Timeout: 30 * time.Second, - }) - - if err != nil { - return nil, err - } - - return &resp, nil -} diff --git a/model/config.go b/model/config.go index c8b9428..83ef596 100644 --- a/model/config.go +++ b/model/config.go @@ -154,8 +154,8 @@ func mergeConfig(base, local *ShellTimeConfig) { if local.CCUsage != nil { base.CCUsage = local.CCUsage } - if local.CCOtel != nil { - base.CCOtel = local.CCOtel + if local.AICodeOtel != nil { + base.AICodeOtel = local.AICodeOtel } if local.LogCleanup != nil { base.LogCleanup = local.LogCleanup @@ -249,13 +249,13 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO config.AI = DefaultAIConfig } - // Initialize CCOtel config with default port if enabled but port not set - if config.CCOtel != nil && config.CCOtel.GRPCPort == 0 { - config.CCOtel.GRPCPort = 54027 // default OTEL gRPC port + // Initialize AICodeOtel config with default port if enabled but port not set + if config.AICodeOtel != nil && config.AICodeOtel.GRPCPort == 0 { + config.AICodeOtel.GRPCPort = 54027 // default OTEL gRPC port } - if config.CCOtel != nil && config.CCOtel.Debug != nil && *config.CCOtel.Debug { - config.CCOtel.Debug = &truthy + if config.AICodeOtel != nil && config.AICodeOtel.Debug != nil && *config.AICodeOtel.Debug { + config.AICodeOtel.Debug = &truthy } if config.SocketPath == "" { config.SocketPath = DefaultSocketPath diff --git a/model/types.go b/model/types.go index 6bd93c1..4421f5e 100644 --- a/model/types.go +++ b/model/types.go @@ -25,9 +25,9 @@ type CCUsage struct { Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` } -// CCOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) +// AICodeOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) // The processor auto-detects the source from service.name attribute -type CCOtel struct { +type AICodeOtel struct { Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` GRPCPort int `toml:"grpcPort,omitempty" yaml:"grpcPort,omitempty" json:"grpcPort,omitempty"` // default: 54027 Debug *bool `toml:"debug" yaml:"debug" json:"debug"` // write raw JSON to debug files @@ -81,8 +81,8 @@ type ShellTimeConfig struct { // CCUsage configuration for Claude Code usage tracking (v1 - ccusage CLI based) CCUsage *CCUsage `toml:"ccusage" yaml:"ccusage" json:"ccusage"` - // CCOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) - CCOtel *CCOtel `toml:"ccotel" yaml:"ccotel" json:"ccotel"` + // AICodeOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) + AICodeOtel *AICodeOtel `toml:"aiCodeOtel" yaml:"aiCodeOtel" json:"aiCodeOtel"` // CodeTracking configuration for coding activity heartbeat tracking CodeTracking *CodeTracking `toml:"codeTracking" yaml:"codeTracking" json:"codeTracking"` @@ -118,7 +118,7 @@ var DefaultConfig = ShellTimeConfig{ AI: DefaultAIConfig, Exclude: []string{}, CCUsage: nil, - CCOtel: nil, + AICodeOtel: nil, CodeTracking: nil, LogCleanup: nil,