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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ func main() {
commands.DoctorCommand,
commands.QueryCommand,
commands.CCCommand,
commands.CodexCommand,
commands.SchemaCommand,
}
err = app.Run(os.Args)
Expand Down
2 changes: 2 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
61 changes: 61 additions & 0 deletions commands/codex.go
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Following the recommended change to NewCodexOtelConfigService to return an error, this call needs to be updated to handle the potential error. This ensures that if the service cannot be initialized (e.g., because the home directory is not found), the command fails gracefully instead of proceeding with an invalid configuration.

	service, err := model.NewCodexOtelConfigService()
	if err != nil {
		color.Red.Printf("Failed to initialize Codex OTEL config service: %v\n", err)
		return err
	}


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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the install command, the error from the updated NewCodexOtelConfigService must be handled here to ensure the command fails gracefully if service initialization fails.

	service, err := model.NewCodexOtelConfigService()
	if err != nil {
		color.Red.Printf("Failed to initialize Codex OTEL config service: %v\n", err)
		return err
	}


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
}
87 changes: 81 additions & 6 deletions daemon/aicode_otel_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -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
}
}
Expand All @@ -226,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()
Expand Down Expand Up @@ -268,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
Expand All @@ -291,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
Expand Down Expand Up @@ -358,6 +363,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)
Expand All @@ -374,6 +380,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)
Expand All @@ -393,6 +400,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
Expand Down Expand Up @@ -446,17 +459,61 @@ 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
case "reasoning_tokens":
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()
Expand All @@ -468,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()
Expand Down Expand Up @@ -550,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
}
Expand Down Expand Up @@ -611,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()
Expand Down
53 changes: 42 additions & 11 deletions model/aicode_otel_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@ 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
// extracted from OTEL resources and embedded into each metric/event
type AICodeOtelResourceAttributes struct {
// Standard resource attributes
SessionID string
ConversationID string // Codex uses conversation.id instead of session.id
UserAccountUUID string
OrganizationID string
TerminalType string
Expand Down Expand Up @@ -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"`
Expand All @@ -83,6 +107,8 @@ 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
Expand All @@ -101,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"`
Expand All @@ -118,6 +145,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
Expand Down Expand Up @@ -148,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
Expand Down
Loading
Loading