From c6ec30132b23553263c73a9fcc88c1b7d15a4b97 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Thu, 1 Jan 2026 16:07:36 +0800 Subject: [PATCH 1/2] feat(cli): add OpenAI Codex OTEL integration support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Codex CLI commands for installing/uninstalling OTEL configuration: - `shelltime codex install` - configures ~/.codex/config.toml with OTEL settings - `shelltime codex uninstall` - removes OTEL configuration Improvements to AICodeOtel processor: - Use substring matching for source detection (supports claude-code variants) - Add clientType field to metrics and events for better tracking - Fix timestamp fallback to observed time for log records Add debug logging for daemon configuration and config file discovery. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/cli/main.go | 1 + cmd/daemon/main.go | 2 + commands/codex.go | 61 +++++++++++++++ daemon/aicode_otel_processor.go | 15 +++- model/aicode_otel_types.go | 15 ++-- model/codex_otel_config.go | 132 ++++++++++++++++++++++++++++++++ model/config.go | 26 ++++++- 7 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 commands/codex.go create mode 100644 model/codex_otel_config.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 87aa97a..daede09 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -107,6 +107,7 @@ func main() { commands.DoctorCommand, commands.QueryCommand, commands.CCCommand, + commands.CodexCommand, commands.SchemaCommand, } err = app.Run(os.Args) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 7fcb156..3865837 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -53,6 +53,8 @@ func main() { return } + slog.DebugContext(ctx, "daemon.config", slog.Any("config", cfg)) + uptraceOptions := []uptrace.Option{ uptrace.WithDSN(uptraceDsn), uptrace.WithServiceName("cli-daemon"), diff --git a/commands/codex.go b/commands/codex.go new file mode 100644 index 0000000..3e4f580 --- /dev/null +++ b/commands/codex.go @@ -0,0 +1,61 @@ +package commands + +import ( + "github.com/gookit/color" + "github.com/malamtime/cli/model" + "github.com/urfave/cli/v2" +) + +var CodexCommand = &cli.Command{ + Name: "codex", + Usage: "OpenAI Codex integration commands", + Subcommands: []*cli.Command{ + CodexInstallCommand, + CodexUninstallCommand, + }, +} + +var CodexInstallCommand = &cli.Command{ + Name: "install", + Aliases: []string{"i"}, + Usage: "Install Codex OTEL configuration to ~/.codex/config.toml", + Action: commandCodexInstall, +} + +var CodexUninstallCommand = &cli.Command{ + Name: "uninstall", + Aliases: []string{"u"}, + Usage: "Remove ShellTime OTEL configuration from ~/.codex/config.toml", + Action: commandCodexUninstall, +} + +func commandCodexInstall(c *cli.Context) error { + color.Yellow.Println("Installing Codex OTEL configuration...") + + service := model.NewCodexOtelConfigService() + + if err := service.Install(); err != nil { + color.Red.Printf("Failed to install Codex OTEL config: %v\n", err) + return err + } + + color.Green.Println("Codex OTEL configuration has been installed to ~/.codex/config.toml") + color.Yellow.Println("The Codex CLI will now send telemetry to ShellTime daemon.") + + return nil +} + +func commandCodexUninstall(c *cli.Context) error { + color.Yellow.Println("Removing Codex OTEL configuration...") + + service := model.NewCodexOtelConfigService() + + if err := service.Uninstall(); err != nil { + color.Red.Printf("Failed to uninstall Codex OTEL config: %v\n", err) + return err + } + + color.Green.Println("Codex OTEL configuration has been removed from ~/.codex/config.toml") + + return nil +} diff --git a/daemon/aicode_otel_processor.go b/daemon/aicode_otel_processor.go index e9e1e56..63ab847 100644 --- a/daemon/aicode_otel_processor.go +++ b/daemon/aicode_otel_processor.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "github.com/google/uuid" @@ -198,10 +199,10 @@ func detectOtelSource(resource *resourcev1.Resource) string { for _, attr := range resource.GetAttributes() { if attr.GetKey() == "service.name" { serviceName := attr.GetValue().GetStringValue() - switch serviceName { - case "claude-code": + if strings.Contains(serviceName, "claude") { return model.AICodeOtelSourceClaudeCode - case "codex", "codex-cli", "openai-codex": + } + if strings.Contains(serviceName, "codex") { return model.AICodeOtelSourceCodex } } @@ -358,6 +359,7 @@ func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *mo MetricType: metricType, Timestamp: int64(dp.GetTimeUnixNano() / 1e9), // Convert to seconds Value: getDataPointValue(dp), + ClientType: source, } // Apply resource attributes first applyResourceAttributesToMetric(&metric, resourceAttrs) @@ -374,6 +376,7 @@ func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *mo MetricType: metricType, Timestamp: int64(dp.GetTimeUnixNano() / 1e9), Value: getDataPointValue(dp), + ClientType: source, } // Apply resource attributes first applyResourceAttributesToMetric(&metric, resourceAttrs) @@ -393,6 +396,12 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs event := &model.AICodeOtelEvent{ EventID: uuid.New().String(), Timestamp: int64(lr.GetTimeUnixNano() / 1e9), // Convert to seconds + + ClientType: source, + } + + if event.Timestamp == 0 { + event.Timestamp = int64(lr.GetObservedTimeUnixNano() / 1e9) // Convert to seconds } // Apply resource attributes first diff --git a/model/aicode_otel_types.go b/model/aicode_otel_types.go index 25b4902..e65316f 100644 --- a/model/aicode_otel_types.go +++ b/model/aicode_otel_types.go @@ -3,11 +3,11 @@ package model // AICodeOtelRequest is the main request to POST /api/v1/cc/otel // Flat structure without session - resource attributes are embedded in each metric/event 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"` + 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"` } // AICodeOtelResourceAttributes contains common resource-level attributes @@ -83,6 +83,9 @@ type AICodeOtelEvent struct { MachineName string `json:"machineName,omitempty"` TeamID string `json:"teamId,omitempty"` Pwd string `json:"pwd,omitempty"` + + ClientType string `json:"clientType"` // claude_code, codex (defaults to claude_code) + } // AICodeOtelMetric represents a metric data point from Claude Code or Codex @@ -118,6 +121,8 @@ type AICodeOtelMetric struct { MachineName string `json:"machineName,omitempty"` TeamID string `json:"teamId,omitempty"` Pwd string `json:"pwd,omitempty"` + + ClientType string `json:"clientType"` // claude_code, codex (defaults to claude_code) } // AICodeOtelResponse is the response from POST /api/v1/cc/otel diff --git a/model/codex_otel_config.go b/model/codex_otel_config.go new file mode 100644 index 0000000..052a29a --- /dev/null +++ b/model/codex_otel_config.go @@ -0,0 +1,132 @@ +package model + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/pelletier/go-toml/v2" +) + +const ( + codexConfigDir = ".codex" + codexConfigFile = "config.toml" +) + +// CodexOtelConfigService handles Codex OTEL configuration +type CodexOtelConfigService interface { + Install() error + Uninstall() error + Check() (bool, error) +} + +type codexOtelConfigService struct { + configPath string +} + +// NewCodexOtelConfigService creates a new Codex OTEL config service +func NewCodexOtelConfigService() CodexOtelConfigService { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, codexConfigDir, codexConfigFile) + return &codexOtelConfigService{ + configPath: configPath, + } +} + +// Install adds OTEL configuration to ~/.codex/config.toml +func (s *codexOtelConfigService) Install() error { + // Ensure directory exists + dir := filepath.Dir(s.configPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Read existing config or create empty map + config := make(map[string]interface{}) + if data, err := os.ReadFile(s.configPath); err == nil && len(data) > 0 { + if err := toml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse existing config: %w", err) + } + } + + // Add OTEL configuration + // Format: exporter = { otlp-grpc = {endpoint = "..."} } + config["otel"] = map[string]interface{}{ + "log_user_prompt": true, + "exporter": map[string]interface{}{ + "otlp-grpc": map[string]interface{}{ + "endpoint": aiCodeOtelEndpoint, + }, + }, + } + + // Write config back + data, err := toml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(s.configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// Uninstall removes OTEL configuration from ~/.codex/config.toml +func (s *codexOtelConfigService) Uninstall() error { + // Check if config file exists + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return nil // Nothing to uninstall + } + + // Read existing config + data, err := os.ReadFile(s.configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + config := make(map[string]interface{}) + if len(data) > 0 { + if err := toml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + } + + // Remove OTEL configuration + delete(config, "otel") + + // Write config back + newData, err := toml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(s.configPath, newData, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// Check returns true if OTEL is configured in ~/.codex/config.toml +func (s *codexOtelConfigService) Check() (bool, error) { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return false, nil + } + + data, err := os.ReadFile(s.configPath) + if err != nil { + return false, fmt.Errorf("failed to read config file: %w", err) + } + + config := make(map[string]interface{}) + if len(data) > 0 { + if err := toml.Unmarshal(data, &config); err != nil { + return false, fmt.Errorf("failed to parse config: %w", err) + } + } + + _, exists := config["otel"] + return exists, nil +} diff --git a/model/config.go b/model/config.go index 7e1b436..201d730 100644 --- a/model/config.go +++ b/model/config.go @@ -3,6 +3,7 @@ package model import ( "context" "fmt" + "log/slog" "os" "path/filepath" "strings" @@ -43,8 +44,8 @@ func unmarshalConfig(data []byte, format configFormat, config *ShellTimeConfig) // configFiles represents discovered config files type configFiles struct { - baseFile string // config.yaml, config.yml, or config.toml - localFile string // config.local.yaml, config.local.yml, or config.local.toml + baseFile string // config.yaml, config.yml, or config.toml + localFile string // config.local.yaml, config.local.yml, or config.local.toml baseFormat configFormat localFormat configFormat } @@ -199,6 +200,15 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO // Discover config files with priority files := findConfigFiles(cs.configDir) + slog.InfoContext( + ctx, + "config.ReadConfigFile discovered config files", + slog.String("base", files.baseFile), + slog.String("local", files.localFile), + slog.String("base_format", string(files.baseFormat)), + slog.String("local_format", string(files.localFormat)), + ) + // Read base config file if files.baseFile == "" { err = fmt.Errorf("no config file found in %s", cs.configDir) @@ -227,9 +237,19 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO if files.localFile != "" { if localConfig, localErr := os.ReadFile(files.localFile); localErr == nil { var localSettings ShellTimeConfig - if unmarshalErr := unmarshalConfig(localConfig, files.localFormat, &localSettings); unmarshalErr == nil { + unmarshalErr := unmarshalConfig(localConfig, files.localFormat, &localSettings) + if unmarshalErr != nil { + slog.WarnContext( + ctx, + "failed to parse local config file", + slog.String("file", files.localFile), + slog.Any("err", unmarshalErr), + ) + } + if unmarshalErr == nil { mergeConfig(&config, &localSettings) } + } } From f464cb78356616daa27c500feb53d413d7f0b755 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Thu, 1 Jan 2026 16:26:17 +0800 Subject: [PATCH 2/2] feat(daemon): add complete Codex OTEL field support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for all Codex-specific OTEL fields that the server expects: - ConversationID, CallID, EventKind, ToolTokens for session/tool events - AuthMode, Slug, ContextWindow, ApprovalPolicy, SandboxPolicy for config - MCPServers, Profile, ReasoningEnabled for conversation_starts - ToolArguments, ToolOutput, PromptEncrypted for tool_result Also adds field aliases for Codex OTEL format compatibility: - http.response.status_code -> statusCode - user.account_id -> userAccountUuid - error.message -> error 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- daemon/aicode_otel_processor.go | 72 +++++++++++++++++++++++++++++++-- model/aicode_otel_types.go | 40 ++++++++++++++---- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/daemon/aicode_otel_processor.go b/daemon/aicode_otel_processor.go index 63ab847..9a01681 100644 --- a/daemon/aicode_otel_processor.go +++ b/daemon/aicode_otel_processor.go @@ -227,11 +227,13 @@ func extractResourceAttributes(resource *resourcev1.Resource) *model.AICodeOtelR // Standard resource attributes case "session.id": attrs.SessionID = value.GetStringValue() + case "conversation.id": + attrs.ConversationID = value.GetStringValue() case "app.version": attrs.AppVersion = value.GetStringValue() case "organization.id": attrs.OrganizationID = value.GetStringValue() - case "user.account_uuid": + case "user.account_uuid", "user.account_id": attrs.UserAccountUUID = value.GetStringValue() case "terminal.type": attrs.TerminalType = value.GetStringValue() @@ -269,6 +271,7 @@ func extractResourceAttributes(resource *resourcev1.Resource) *model.AICodeOtelR func applyResourceAttributesToMetric(metric *model.AICodeOtelMetric, attrs *model.AICodeOtelResourceAttributes) { // Standard resource attributes metric.SessionID = attrs.SessionID + metric.ConversationID = attrs.ConversationID metric.UserAccountUUID = attrs.UserAccountUUID metric.OrganizationID = attrs.OrganizationID metric.TerminalType = attrs.TerminalType @@ -292,6 +295,7 @@ func applyResourceAttributesToMetric(metric *model.AICodeOtelMetric, attrs *mode func applyResourceAttributesToEvent(event *model.AICodeOtelEvent, attrs *model.AICodeOtelResourceAttributes) { // Standard resource attributes event.SessionID = attrs.SessionID + event.ConversationID = attrs.ConversationID event.UserAccountUUID = attrs.UserAccountUUID event.OrganizationID = attrs.OrganizationID event.TerminalType = attrs.TerminalType @@ -455,10 +459,12 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs slog.Debug("AICodeOtel: Failed to parse tool_parameters", "error", err) } } - case "status_code": + case "status_code", "http.response.status_code": event.StatusCode = getIntFromValue(value) case "attempt": event.Attempt = getIntFromValue(value) + case "error.message": + event.Error = value.GetStringValue() case "language": event.Language = value.GetStringValue() // Codex-specific fields @@ -466,6 +472,48 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs event.ReasoningTokens = getIntFromValue(value) case "provider": event.Provider = value.GetStringValue() + // Codex-specific fields for tool_decision + case "call_id", "callId": + event.CallID = value.GetStringValue() + // Codex-specific fields for sse_event + case "event_kind", "eventKind": + event.EventKind = value.GetStringValue() + case "tool_tokens", "toolTokens": + event.ToolTokens = getIntFromValue(value) + // Codex-specific fields for conversation_starts + case "auth_mode", "authMode": + event.AuthMode = value.GetStringValue() + case "slug": + event.Slug = value.GetStringValue() + case "context_window", "contextWindow": + event.ContextWindow = getIntFromValue(value) + case "approval_policy", "approvalPolicy": + event.ApprovalPolicy = value.GetStringValue() + case "sandbox_policy", "sandboxPolicy": + event.SandboxPolicy = value.GetStringValue() + case "mcp_servers", "mcpServers": + event.MCPServers = getStringArrayFromValue(value) + case "profile": + event.Profile = value.GetStringValue() + case "reasoning_enabled", "reasoningEnabled": + event.ReasoningEnabled = getBoolFromValue(value) + // Codex-specific fields for tool_result + case "tool_arguments", "toolArguments": + if jsonStr := value.GetStringValue(); jsonStr != "" { + var args map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &args); err == nil { + event.ToolArguments = args + } else { + slog.Debug("AICodeOtel: Failed to parse tool_arguments", "error", err) + } + } + case "tool_output", "toolOutput": + event.ToolOutput = value.GetStringValue() + case "prompt_encrypted", "promptEncrypted": + event.PromptEncrypted = getBoolFromValue(value) + // Codex uses conversation.id instead of session.id + case "conversation.id", "conversationId": + event.ConversationID = value.GetStringValue() // Log record level attributes that override resource attrs case "user.id": event.UserID = value.GetStringValue() @@ -477,7 +525,7 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs event.AppVersion = value.GetStringValue() case "organization.id": event.OrganizationID = value.GetStringValue() - case "user.account_uuid": + case "user.account_uuid", "user.account_id": event.UserAccountUUID = value.GetStringValue() case "terminal.type": event.TerminalType = value.GetStringValue() @@ -559,6 +607,10 @@ func mapEventName(name string, source string) string { return model.AICodeEventApiError case "codex.exec_command": return model.AICodeEventExecCommand + case "codex.conversation_starts": + return model.AICodeEventConversationStarts + case "codex.sse_event": + return model.AICodeEventSSEEvent default: return name // Return as-is if not in our map } @@ -620,6 +672,20 @@ func getFloatFromValue(value *commonv1.AnyValue) float64 { return 0 } +// getStringArrayFromValue extracts a string array from an OTEL value +func getStringArrayFromValue(value *commonv1.AnyValue) []string { + if arr := value.GetArrayValue(); arr != nil { + var result []string + for _, v := range arr.GetValues() { + if s := v.GetStringValue(); s != "" { + result = append(result, s) + } + } + return result + } + return nil +} + // applyMetricAttribute applies an attribute to a metric func applyMetricAttribute(metric *model.AICodeOtelMetric, attr *commonv1.KeyValue, metricType string) { key := attr.GetKey() diff --git a/model/aicode_otel_types.go b/model/aicode_otel_types.go index e65316f..5a672dd 100644 --- a/model/aicode_otel_types.go +++ b/model/aicode_otel_types.go @@ -15,6 +15,7 @@ type AICodeOtelRequest struct { type AICodeOtelResourceAttributes struct { // Standard resource attributes SessionID string + ConversationID string // Codex uses conversation.id instead of session.id UserAccountUUID string OrganizationID string TerminalType string @@ -58,14 +59,37 @@ type AICodeOtelEvent struct { Error string `json:"error,omitempty"` PromptLength int `json:"promptLength,omitempty"` Prompt string `json:"prompt,omitempty"` + PromptEncrypted bool `json:"promptEncrypted,omitempty"` // Whether prompt is encrypted ToolParameters map[string]interface{} `json:"toolParameters,omitempty"` 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") + // Codex-specific fields for tool_decision + CallID string `json:"callId,omitempty"` + + // Codex-specific fields for sse_event + EventKind string `json:"eventKind,omitempty"` + ToolTokens int `json:"toolTokens,omitempty"` + + // Codex-specific fields for conversation_starts + AuthMode string `json:"authMode,omitempty"` + Slug string `json:"slug,omitempty"` + ContextWindow int `json:"contextWindow,omitempty"` + ApprovalPolicy string `json:"approvalPolicy,omitempty"` + SandboxPolicy string `json:"sandboxPolicy,omitempty"` + MCPServers []string `json:"mcpServers,omitempty"` + Profile string `json:"profile,omitempty"` + ReasoningEnabled bool `json:"reasoningEnabled,omitempty"` + + // Codex-specific fields for tool_result + ToolArguments map[string]interface{} `json:"toolArguments,omitempty"` + ToolOutput string `json:"toolOutput,omitempty"` + // Embedded resource attributes (previously in session) SessionID string `json:"sessionId,omitempty"` + ConversationID string `json:"conversationId,omitempty"` // Codex uses conversationId instead of sessionId UserAccountUUID string `json:"userAccountUuid,omitempty"` OrganizationID string `json:"organizationId,omitempty"` TerminalType string `json:"terminalType,omitempty"` @@ -85,7 +109,6 @@ type AICodeOtelEvent struct { Pwd string `json:"pwd,omitempty"` ClientType string `json:"clientType"` // claude_code, codex (defaults to claude_code) - } // AICodeOtelMetric represents a metric data point from Claude Code or Codex @@ -104,6 +127,7 @@ type AICodeOtelMetric struct { // Embedded resource attributes (previously in session) SessionID string `json:"sessionId,omitempty"` + ConversationID string `json:"conversationId,omitempty"` // Codex uses conversationId instead of sessionId UserAccountUUID string `json:"userAccountUuid,omitempty"` OrganizationID string `json:"organizationId,omitempty"` TerminalType string `json:"terminalType,omitempty"` @@ -153,12 +177,14 @@ const ( // AI Code OTEL event types (shared between Claude Code and Codex) const ( - AICodeEventUserPrompt = "user_prompt" - AICodeEventToolResult = "tool_result" - AICodeEventApiRequest = "api_request" - AICodeEventApiError = "api_error" - AICodeEventToolDecision = "tool_decision" - AICodeEventExecCommand = "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 + AICodeEventConversationStarts = "conversation_starts" // Codex: conversation/session start + AICodeEventSSEEvent = "sse_event" // Codex: SSE streaming event ) // Token types for AICodeMetricTokenUsage