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 9dec387..7fcb156 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -125,16 +125,16 @@ func main() { } } - // Start CCOtel service if enabled (v2 - OTEL gRPC passthrough) - 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 67% rename from daemon/ccotel_processor.go rename to daemon/aicode_otel_processor.go index 308f2b0..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,60 +49,61 @@ 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() { 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("AICodeOtel: 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 + var metrics []model.AICodeOtelMetric 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...) } } @@ -112,18 +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) } } @@ -131,31 +133,32 @@ 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() { 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("AICodeOtel: 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 + var events []model.AICodeOtelEvent 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) } @@ -167,42 +170,49 @@ 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) } } 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.AICodeOtelSourceClaudeCode + case "codex", "codex-cli", "openai-codex": + return model.AICodeOtelSourceCodex + } } } - return false + return "" } // 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 @@ -255,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 @@ -278,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 @@ -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 *AICodeOtelProcessor) 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.AICodeOtelSourceClaudeCode { + if project := os.Getenv("CLAUDE_CODE_PROJECT"); project != "" { + return project + } + } else if source == model.AICodeOtelSourceCodex { + if project := os.Getenv("CODEX_PROJECT"); project != "" { + return project + } } + if pwd := os.Getenv("PWD"); pwd != "" { return pwd } @@ -322,12 +339,12 @@ func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource) string { return "unknown" } -// parseMetric parses an OTEL metric into CCOtelMetric(s) -func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *model.CCOtelResourceAttributes) []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) + metricType := mapMetricName(name, source) if metricType == "" { return metrics // Unknown metric, skip } @@ -336,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 @@ -352,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), @@ -371,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) *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 } @@ -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": @@ -411,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": @@ -432,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": @@ -441,6 +452,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,42 +484,72 @@ 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 + 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.AICodeMetricSessionCount + case "codex.token.usage": + return model.AICodeMetricTokenUsage + case "codex.cost.usage": + return model.AICodeMetricCostUsage + case "codex.lines_of_code.count": + return model.AICodeMetricLinesOfCodeCount + case "codex.commit.count": + return model.AICodeMetricCommitCount + case "codex.pull_request.count": + return model.AICodeMetricPullRequestCount + case "codex.active_time.total": + return model.AICodeMetricActiveTimeTotal 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 + 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.AICodeEventUserPrompt + case "codex.tool_result": + return model.AICodeEventToolResult + case "codex.api_request": + return model.AICodeEventApiRequest + case "codex.api_error": + return model.AICodeEventApiError + case "codex.exec_command": + return model.AICodeEventExecCommand default: return name // Return as-is if not in our map } @@ -566,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 67% rename from model/ccotel_types.go rename to model/aicode_otel_types.go index f7ba7ba..25b4902 100644 --- a/model/ccotel_types.go +++ b/model/aicode_otel_types.go @@ -1,17 +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"` - 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 @@ -35,9 +36,9 @@ type CCOtelResourceAttributes struct { Pwd string // from pwd } -// CCOtelEvent represents an event from Claude Code (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"` @@ -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"` @@ -82,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"` @@ -117,45 +120,52 @@ 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"` Message string `json:"message,omitempty"` } -// Claude Code OTEL metric types +// OTEL source identifiers 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" + AICodeOtelSourceClaudeCode = "claude-code" + AICodeOtelSourceCodex = "codex" ) -// Claude Code OTEL event types +// AI Code OTEL metric types (shared between Claude Code and Codex) const ( - CCEventUserPrompt = "user_prompt" - CCEventToolResult = "tool_result" - CCEventApiRequest = "api_request" - CCEventApiError = "api_error" - CCEventToolDecision = "tool_decision" + 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" ) -// Token types for CCMetricTokenUsage +// AI Code OTEL event types (shared between Claude Code and Codex) const ( - CCTokenTypeInput = "input" - CCTokenTypeOutput = "output" - CCTokenTypeCacheRead = "cacheRead" - CCTokenTypeCacheCreation = "cacheCreation" + AICodeEventUserPrompt = "user_prompt" + AICodeEventToolResult = "tool_result" + AICodeEventApiRequest = "api_request" + AICodeEventApiError = "api_error" + AICodeEventToolDecision = "tool_decision" + AICodeEventExecCommand = "exec_command" // Codex: shell command execution ) -// Lines types for CCMetricLinesOfCodeCount +// Token types for AICodeMetricTokenUsage const ( - CCLinesTypeAdded = "added" - CCLinesTypeRemoved = "removed" + AICodeTokenTypeInput = "input" + AICodeTokenTypeOutput = "output" + AICodeTokenTypeCacheRead = "cacheRead" + AICodeTokenTypeCacheCreation = "cacheCreation" +) + +// Lines types for AICodeMetricLinesOfCodeCount +const ( + 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 e6237af..4421f5e 100644 --- a/model/types.go +++ b/model/types.go @@ -25,8 +25,9 @@ type CCUsage struct { Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` } -// CCOtel configuration for OTEL-based Claude Code tracking (v2) -type CCOtel struct { +// AICodeOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) +// The processor auto-detects the source from service.name attribute +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 @@ -80,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 Claude Code tracking (v2 - gRPC passthrough) - 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"` @@ -117,7 +118,7 @@ var DefaultConfig = ShellTimeConfig{ AI: DefaultAIConfig, Exclude: []string{}, CCUsage: nil, - CCOtel: nil, + AICodeOtel: nil, CodeTracking: nil, LogCleanup: nil,