Skip to content

Commit c6ec301

Browse files
AnnatarHeclaude
andcommitted
feat(cli): add OpenAI Codex OTEL integration support
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 <noreply@anthropic.com>
1 parent e95bd0b commit c6ec301

7 files changed

Lines changed: 241 additions & 11 deletions

File tree

cmd/cli/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ func main() {
107107
commands.DoctorCommand,
108108
commands.QueryCommand,
109109
commands.CCCommand,
110+
commands.CodexCommand,
110111
commands.SchemaCommand,
111112
}
112113
err = app.Run(os.Args)

cmd/daemon/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ func main() {
5353
return
5454
}
5555

56+
slog.DebugContext(ctx, "daemon.config", slog.Any("config", cfg))
57+
5658
uptraceOptions := []uptrace.Option{
5759
uptrace.WithDSN(uptraceDsn),
5860
uptrace.WithServiceName("cli-daemon"),

commands/codex.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package commands
2+
3+
import (
4+
"github.com/gookit/color"
5+
"github.com/malamtime/cli/model"
6+
"github.com/urfave/cli/v2"
7+
)
8+
9+
var CodexCommand = &cli.Command{
10+
Name: "codex",
11+
Usage: "OpenAI Codex integration commands",
12+
Subcommands: []*cli.Command{
13+
CodexInstallCommand,
14+
CodexUninstallCommand,
15+
},
16+
}
17+
18+
var CodexInstallCommand = &cli.Command{
19+
Name: "install",
20+
Aliases: []string{"i"},
21+
Usage: "Install Codex OTEL configuration to ~/.codex/config.toml",
22+
Action: commandCodexInstall,
23+
}
24+
25+
var CodexUninstallCommand = &cli.Command{
26+
Name: "uninstall",
27+
Aliases: []string{"u"},
28+
Usage: "Remove ShellTime OTEL configuration from ~/.codex/config.toml",
29+
Action: commandCodexUninstall,
30+
}
31+
32+
func commandCodexInstall(c *cli.Context) error {
33+
color.Yellow.Println("Installing Codex OTEL configuration...")
34+
35+
service := model.NewCodexOtelConfigService()
36+
37+
if err := service.Install(); err != nil {
38+
color.Red.Printf("Failed to install Codex OTEL config: %v\n", err)
39+
return err
40+
}
41+
42+
color.Green.Println("Codex OTEL configuration has been installed to ~/.codex/config.toml")
43+
color.Yellow.Println("The Codex CLI will now send telemetry to ShellTime daemon.")
44+
45+
return nil
46+
}
47+
48+
func commandCodexUninstall(c *cli.Context) error {
49+
color.Yellow.Println("Removing Codex OTEL configuration...")
50+
51+
service := model.NewCodexOtelConfigService()
52+
53+
if err := service.Uninstall(); err != nil {
54+
color.Red.Printf("Failed to uninstall Codex OTEL config: %v\n", err)
55+
return err
56+
}
57+
58+
color.Green.Println("Codex OTEL configuration has been removed from ~/.codex/config.toml")
59+
60+
return nil
61+
}

daemon/aicode_otel_processor.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"path/filepath"
1010
"strconv"
11+
"strings"
1112
"time"
1213

1314
"github.com/google/uuid"
@@ -198,10 +199,10 @@ func detectOtelSource(resource *resourcev1.Resource) string {
198199
for _, attr := range resource.GetAttributes() {
199200
if attr.GetKey() == "service.name" {
200201
serviceName := attr.GetValue().GetStringValue()
201-
switch serviceName {
202-
case "claude-code":
202+
if strings.Contains(serviceName, "claude") {
203203
return model.AICodeOtelSourceClaudeCode
204-
case "codex", "codex-cli", "openai-codex":
204+
}
205+
if strings.Contains(serviceName, "codex") {
205206
return model.AICodeOtelSourceCodex
206207
}
207208
}
@@ -358,6 +359,7 @@ func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *mo
358359
MetricType: metricType,
359360
Timestamp: int64(dp.GetTimeUnixNano() / 1e9), // Convert to seconds
360361
Value: getDataPointValue(dp),
362+
ClientType: source,
361363
}
362364
// Apply resource attributes first
363365
applyResourceAttributesToMetric(&metric, resourceAttrs)
@@ -374,6 +376,7 @@ func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *mo
374376
MetricType: metricType,
375377
Timestamp: int64(dp.GetTimeUnixNano() / 1e9),
376378
Value: getDataPointValue(dp),
379+
ClientType: source,
377380
}
378381
// Apply resource attributes first
379382
applyResourceAttributesToMetric(&metric, resourceAttrs)
@@ -393,6 +396,12 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs
393396
event := &model.AICodeOtelEvent{
394397
EventID: uuid.New().String(),
395398
Timestamp: int64(lr.GetTimeUnixNano() / 1e9), // Convert to seconds
399+
400+
ClientType: source,
401+
}
402+
403+
if event.Timestamp == 0 {
404+
event.Timestamp = int64(lr.GetObservedTimeUnixNano() / 1e9) // Convert to seconds
396405
}
397406

398407
// Apply resource attributes first

model/aicode_otel_types.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ package model
33
// AICodeOtelRequest is the main request to POST /api/v1/cc/otel
44
// Flat structure without session - resource attributes are embedded in each metric/event
55
type AICodeOtelRequest struct {
6-
Host string `json:"host"`
7-
Project string `json:"project"`
8-
Source string `json:"source,omitempty"` // "claude-code" or "codex" - identifies the CLI source
9-
Events []AICodeOtelEvent `json:"events,omitempty"`
10-
Metrics []AICodeOtelMetric `json:"metrics,omitempty"`
6+
Host string `json:"host"`
7+
Project string `json:"project"`
8+
Source string `json:"source,omitempty"` // "claude-code" or "codex" - identifies the CLI source
9+
Events []AICodeOtelEvent `json:"events,omitempty"`
10+
Metrics []AICodeOtelMetric `json:"metrics,omitempty"`
1111
}
1212

1313
// AICodeOtelResourceAttributes contains common resource-level attributes
@@ -83,6 +83,9 @@ type AICodeOtelEvent struct {
8383
MachineName string `json:"machineName,omitempty"`
8484
TeamID string `json:"teamId,omitempty"`
8585
Pwd string `json:"pwd,omitempty"`
86+
87+
ClientType string `json:"clientType"` // claude_code, codex (defaults to claude_code)
88+
8689
}
8790

8891
// AICodeOtelMetric represents a metric data point from Claude Code or Codex
@@ -118,6 +121,8 @@ type AICodeOtelMetric struct {
118121
MachineName string `json:"machineName,omitempty"`
119122
TeamID string `json:"teamId,omitempty"`
120123
Pwd string `json:"pwd,omitempty"`
124+
125+
ClientType string `json:"clientType"` // claude_code, codex (defaults to claude_code)
121126
}
122127

123128
// AICodeOtelResponse is the response from POST /api/v1/cc/otel

model/codex_otel_config.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package model
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/pelletier/go-toml/v2"
9+
)
10+
11+
const (
12+
codexConfigDir = ".codex"
13+
codexConfigFile = "config.toml"
14+
)
15+
16+
// CodexOtelConfigService handles Codex OTEL configuration
17+
type CodexOtelConfigService interface {
18+
Install() error
19+
Uninstall() error
20+
Check() (bool, error)
21+
}
22+
23+
type codexOtelConfigService struct {
24+
configPath string
25+
}
26+
27+
// NewCodexOtelConfigService creates a new Codex OTEL config service
28+
func NewCodexOtelConfigService() CodexOtelConfigService {
29+
homeDir, _ := os.UserHomeDir()
30+
configPath := filepath.Join(homeDir, codexConfigDir, codexConfigFile)
31+
return &codexOtelConfigService{
32+
configPath: configPath,
33+
}
34+
}
35+
36+
// Install adds OTEL configuration to ~/.codex/config.toml
37+
func (s *codexOtelConfigService) Install() error {
38+
// Ensure directory exists
39+
dir := filepath.Dir(s.configPath)
40+
if err := os.MkdirAll(dir, 0755); err != nil {
41+
return fmt.Errorf("failed to create config directory: %w", err)
42+
}
43+
44+
// Read existing config or create empty map
45+
config := make(map[string]interface{})
46+
if data, err := os.ReadFile(s.configPath); err == nil && len(data) > 0 {
47+
if err := toml.Unmarshal(data, &config); err != nil {
48+
return fmt.Errorf("failed to parse existing config: %w", err)
49+
}
50+
}
51+
52+
// Add OTEL configuration
53+
// Format: exporter = { otlp-grpc = {endpoint = "..."} }
54+
config["otel"] = map[string]interface{}{
55+
"log_user_prompt": true,
56+
"exporter": map[string]interface{}{
57+
"otlp-grpc": map[string]interface{}{
58+
"endpoint": aiCodeOtelEndpoint,
59+
},
60+
},
61+
}
62+
63+
// Write config back
64+
data, err := toml.Marshal(config)
65+
if err != nil {
66+
return fmt.Errorf("failed to marshal config: %w", err)
67+
}
68+
69+
if err := os.WriteFile(s.configPath, data, 0644); err != nil {
70+
return fmt.Errorf("failed to write config file: %w", err)
71+
}
72+
73+
return nil
74+
}
75+
76+
// Uninstall removes OTEL configuration from ~/.codex/config.toml
77+
func (s *codexOtelConfigService) Uninstall() error {
78+
// Check if config file exists
79+
if _, err := os.Stat(s.configPath); os.IsNotExist(err) {
80+
return nil // Nothing to uninstall
81+
}
82+
83+
// Read existing config
84+
data, err := os.ReadFile(s.configPath)
85+
if err != nil {
86+
return fmt.Errorf("failed to read config file: %w", err)
87+
}
88+
89+
config := make(map[string]interface{})
90+
if len(data) > 0 {
91+
if err := toml.Unmarshal(data, &config); err != nil {
92+
return fmt.Errorf("failed to parse config: %w", err)
93+
}
94+
}
95+
96+
// Remove OTEL configuration
97+
delete(config, "otel")
98+
99+
// Write config back
100+
newData, err := toml.Marshal(config)
101+
if err != nil {
102+
return fmt.Errorf("failed to marshal config: %w", err)
103+
}
104+
105+
if err := os.WriteFile(s.configPath, newData, 0644); err != nil {
106+
return fmt.Errorf("failed to write config file: %w", err)
107+
}
108+
109+
return nil
110+
}
111+
112+
// Check returns true if OTEL is configured in ~/.codex/config.toml
113+
func (s *codexOtelConfigService) Check() (bool, error) {
114+
if _, err := os.Stat(s.configPath); os.IsNotExist(err) {
115+
return false, nil
116+
}
117+
118+
data, err := os.ReadFile(s.configPath)
119+
if err != nil {
120+
return false, fmt.Errorf("failed to read config file: %w", err)
121+
}
122+
123+
config := make(map[string]interface{})
124+
if len(data) > 0 {
125+
if err := toml.Unmarshal(data, &config); err != nil {
126+
return false, fmt.Errorf("failed to parse config: %w", err)
127+
}
128+
}
129+
130+
_, exists := config["otel"]
131+
return exists, nil
132+
}

model/config.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package model
33
import (
44
"context"
55
"fmt"
6+
"log/slog"
67
"os"
78
"path/filepath"
89
"strings"
@@ -43,8 +44,8 @@ func unmarshalConfig(data []byte, format configFormat, config *ShellTimeConfig)
4344

4445
// configFiles represents discovered config files
4546
type configFiles struct {
46-
baseFile string // config.yaml, config.yml, or config.toml
47-
localFile string // config.local.yaml, config.local.yml, or config.local.toml
47+
baseFile string // config.yaml, config.yml, or config.toml
48+
localFile string // config.local.yaml, config.local.yml, or config.local.toml
4849
baseFormat configFormat
4950
localFormat configFormat
5051
}
@@ -199,6 +200,15 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO
199200
// Discover config files with priority
200201
files := findConfigFiles(cs.configDir)
201202

203+
slog.InfoContext(
204+
ctx,
205+
"config.ReadConfigFile discovered config files",
206+
slog.String("base", files.baseFile),
207+
slog.String("local", files.localFile),
208+
slog.String("base_format", string(files.baseFormat)),
209+
slog.String("local_format", string(files.localFormat)),
210+
)
211+
202212
// Read base config file
203213
if files.baseFile == "" {
204214
err = fmt.Errorf("no config file found in %s", cs.configDir)
@@ -227,9 +237,19 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO
227237
if files.localFile != "" {
228238
if localConfig, localErr := os.ReadFile(files.localFile); localErr == nil {
229239
var localSettings ShellTimeConfig
230-
if unmarshalErr := unmarshalConfig(localConfig, files.localFormat, &localSettings); unmarshalErr == nil {
240+
unmarshalErr := unmarshalConfig(localConfig, files.localFormat, &localSettings)
241+
if unmarshalErr != nil {
242+
slog.WarnContext(
243+
ctx,
244+
"failed to parse local config file",
245+
slog.String("file", files.localFile),
246+
slog.Any("err", unmarshalErr),
247+
)
248+
}
249+
if unmarshalErr == nil {
231250
mergeConfig(&config, &localSettings)
232251
}
252+
233253
}
234254
}
235255

0 commit comments

Comments
 (0)