Skip to content

Commit 7ea24ba

Browse files
authored
Merge pull request #183 from shelltime/feat/codex-otel-integration
feat(cli): add OpenAI Codex OTEL integration support
2 parents e95bd0b + f464cb7 commit 7ea24ba

7 files changed

Lines changed: 342 additions & 20 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: 81 additions & 6 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
}
@@ -226,11 +227,13 @@ func extractResourceAttributes(resource *resourcev1.Resource) *model.AICodeOtelR
226227
// Standard resource attributes
227228
case "session.id":
228229
attrs.SessionID = value.GetStringValue()
230+
case "conversation.id":
231+
attrs.ConversationID = value.GetStringValue()
229232
case "app.version":
230233
attrs.AppVersion = value.GetStringValue()
231234
case "organization.id":
232235
attrs.OrganizationID = value.GetStringValue()
233-
case "user.account_uuid":
236+
case "user.account_uuid", "user.account_id":
234237
attrs.UserAccountUUID = value.GetStringValue()
235238
case "terminal.type":
236239
attrs.TerminalType = value.GetStringValue()
@@ -268,6 +271,7 @@ func extractResourceAttributes(resource *resourcev1.Resource) *model.AICodeOtelR
268271
func applyResourceAttributesToMetric(metric *model.AICodeOtelMetric, attrs *model.AICodeOtelResourceAttributes) {
269272
// Standard resource attributes
270273
metric.SessionID = attrs.SessionID
274+
metric.ConversationID = attrs.ConversationID
271275
metric.UserAccountUUID = attrs.UserAccountUUID
272276
metric.OrganizationID = attrs.OrganizationID
273277
metric.TerminalType = attrs.TerminalType
@@ -291,6 +295,7 @@ func applyResourceAttributesToMetric(metric *model.AICodeOtelMetric, attrs *mode
291295
func applyResourceAttributesToEvent(event *model.AICodeOtelEvent, attrs *model.AICodeOtelResourceAttributes) {
292296
// Standard resource attributes
293297
event.SessionID = attrs.SessionID
298+
event.ConversationID = attrs.ConversationID
294299
event.UserAccountUUID = attrs.UserAccountUUID
295300
event.OrganizationID = attrs.OrganizationID
296301
event.TerminalType = attrs.TerminalType
@@ -358,6 +363,7 @@ func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *mo
358363
MetricType: metricType,
359364
Timestamp: int64(dp.GetTimeUnixNano() / 1e9), // Convert to seconds
360365
Value: getDataPointValue(dp),
366+
ClientType: source,
361367
}
362368
// Apply resource attributes first
363369
applyResourceAttributesToMetric(&metric, resourceAttrs)
@@ -374,6 +380,7 @@ func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, resourceAttrs *mo
374380
MetricType: metricType,
375381
Timestamp: int64(dp.GetTimeUnixNano() / 1e9),
376382
Value: getDataPointValue(dp),
383+
ClientType: source,
377384
}
378385
// Apply resource attributes first
379386
applyResourceAttributesToMetric(&metric, resourceAttrs)
@@ -393,6 +400,12 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs
393400
event := &model.AICodeOtelEvent{
394401
EventID: uuid.New().String(),
395402
Timestamp: int64(lr.GetTimeUnixNano() / 1e9), // Convert to seconds
403+
404+
ClientType: source,
405+
}
406+
407+
if event.Timestamp == 0 {
408+
event.Timestamp = int64(lr.GetObservedTimeUnixNano() / 1e9) // Convert to seconds
396409
}
397410

398411
// Apply resource attributes first
@@ -446,17 +459,61 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs
446459
slog.Debug("AICodeOtel: Failed to parse tool_parameters", "error", err)
447460
}
448461
}
449-
case "status_code":
462+
case "status_code", "http.response.status_code":
450463
event.StatusCode = getIntFromValue(value)
451464
case "attempt":
452465
event.Attempt = getIntFromValue(value)
466+
case "error.message":
467+
event.Error = value.GetStringValue()
453468
case "language":
454469
event.Language = value.GetStringValue()
455470
// Codex-specific fields
456471
case "reasoning_tokens":
457472
event.ReasoningTokens = getIntFromValue(value)
458473
case "provider":
459474
event.Provider = value.GetStringValue()
475+
// Codex-specific fields for tool_decision
476+
case "call_id", "callId":
477+
event.CallID = value.GetStringValue()
478+
// Codex-specific fields for sse_event
479+
case "event_kind", "eventKind":
480+
event.EventKind = value.GetStringValue()
481+
case "tool_tokens", "toolTokens":
482+
event.ToolTokens = getIntFromValue(value)
483+
// Codex-specific fields for conversation_starts
484+
case "auth_mode", "authMode":
485+
event.AuthMode = value.GetStringValue()
486+
case "slug":
487+
event.Slug = value.GetStringValue()
488+
case "context_window", "contextWindow":
489+
event.ContextWindow = getIntFromValue(value)
490+
case "approval_policy", "approvalPolicy":
491+
event.ApprovalPolicy = value.GetStringValue()
492+
case "sandbox_policy", "sandboxPolicy":
493+
event.SandboxPolicy = value.GetStringValue()
494+
case "mcp_servers", "mcpServers":
495+
event.MCPServers = getStringArrayFromValue(value)
496+
case "profile":
497+
event.Profile = value.GetStringValue()
498+
case "reasoning_enabled", "reasoningEnabled":
499+
event.ReasoningEnabled = getBoolFromValue(value)
500+
// Codex-specific fields for tool_result
501+
case "tool_arguments", "toolArguments":
502+
if jsonStr := value.GetStringValue(); jsonStr != "" {
503+
var args map[string]interface{}
504+
if err := json.Unmarshal([]byte(jsonStr), &args); err == nil {
505+
event.ToolArguments = args
506+
} else {
507+
slog.Debug("AICodeOtel: Failed to parse tool_arguments", "error", err)
508+
}
509+
}
510+
case "tool_output", "toolOutput":
511+
event.ToolOutput = value.GetStringValue()
512+
case "prompt_encrypted", "promptEncrypted":
513+
event.PromptEncrypted = getBoolFromValue(value)
514+
// Codex uses conversation.id instead of session.id
515+
case "conversation.id", "conversationId":
516+
event.ConversationID = value.GetStringValue()
460517
// Log record level attributes that override resource attrs
461518
case "user.id":
462519
event.UserID = value.GetStringValue()
@@ -468,7 +525,7 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs
468525
event.AppVersion = value.GetStringValue()
469526
case "organization.id":
470527
event.OrganizationID = value.GetStringValue()
471-
case "user.account_uuid":
528+
case "user.account_uuid", "user.account_id":
472529
event.UserAccountUUID = value.GetStringValue()
473530
case "terminal.type":
474531
event.TerminalType = value.GetStringValue()
@@ -550,6 +607,10 @@ func mapEventName(name string, source string) string {
550607
return model.AICodeEventApiError
551608
case "codex.exec_command":
552609
return model.AICodeEventExecCommand
610+
case "codex.conversation_starts":
611+
return model.AICodeEventConversationStarts
612+
case "codex.sse_event":
613+
return model.AICodeEventSSEEvent
553614
default:
554615
return name // Return as-is if not in our map
555616
}
@@ -611,6 +672,20 @@ func getFloatFromValue(value *commonv1.AnyValue) float64 {
611672
return 0
612673
}
613674

675+
// getStringArrayFromValue extracts a string array from an OTEL value
676+
func getStringArrayFromValue(value *commonv1.AnyValue) []string {
677+
if arr := value.GetArrayValue(); arr != nil {
678+
var result []string
679+
for _, v := range arr.GetValues() {
680+
if s := v.GetStringValue(); s != "" {
681+
result = append(result, s)
682+
}
683+
}
684+
return result
685+
}
686+
return nil
687+
}
688+
614689
// applyMetricAttribute applies an attribute to a metric
615690
func applyMetricAttribute(metric *model.AICodeOtelMetric, attr *commonv1.KeyValue, metricType string) {
616691
key := attr.GetKey()

model/aicode_otel_types.go

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@ 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
1414
// extracted from OTEL resources and embedded into each metric/event
1515
type AICodeOtelResourceAttributes struct {
1616
// Standard resource attributes
1717
SessionID string
18+
ConversationID string // Codex uses conversation.id instead of session.id
1819
UserAccountUUID string
1920
OrganizationID string
2021
TerminalType string
@@ -58,14 +59,37 @@ type AICodeOtelEvent struct {
5859
Error string `json:"error,omitempty"`
5960
PromptLength int `json:"promptLength,omitempty"`
6061
Prompt string `json:"prompt,omitempty"`
62+
PromptEncrypted bool `json:"promptEncrypted,omitempty"` // Whether prompt is encrypted
6163
ToolParameters map[string]interface{} `json:"toolParameters,omitempty"`
6264
StatusCode int `json:"statusCode,omitempty"`
6365
Attempt int `json:"attempt,omitempty"`
6466
Language string `json:"language,omitempty"`
6567
Provider string `json:"provider,omitempty"` // Codex: provider (e.g., "openai")
6668

69+
// Codex-specific fields for tool_decision
70+
CallID string `json:"callId,omitempty"`
71+
72+
// Codex-specific fields for sse_event
73+
EventKind string `json:"eventKind,omitempty"`
74+
ToolTokens int `json:"toolTokens,omitempty"`
75+
76+
// Codex-specific fields for conversation_starts
77+
AuthMode string `json:"authMode,omitempty"`
78+
Slug string `json:"slug,omitempty"`
79+
ContextWindow int `json:"contextWindow,omitempty"`
80+
ApprovalPolicy string `json:"approvalPolicy,omitempty"`
81+
SandboxPolicy string `json:"sandboxPolicy,omitempty"`
82+
MCPServers []string `json:"mcpServers,omitempty"`
83+
Profile string `json:"profile,omitempty"`
84+
ReasoningEnabled bool `json:"reasoningEnabled,omitempty"`
85+
86+
// Codex-specific fields for tool_result
87+
ToolArguments map[string]interface{} `json:"toolArguments,omitempty"`
88+
ToolOutput string `json:"toolOutput,omitempty"`
89+
6790
// Embedded resource attributes (previously in session)
6891
SessionID string `json:"sessionId,omitempty"`
92+
ConversationID string `json:"conversationId,omitempty"` // Codex uses conversationId instead of sessionId
6993
UserAccountUUID string `json:"userAccountUuid,omitempty"`
7094
OrganizationID string `json:"organizationId,omitempty"`
7195
TerminalType string `json:"terminalType,omitempty"`
@@ -83,6 +107,8 @@ type AICodeOtelEvent struct {
83107
MachineName string `json:"machineName,omitempty"`
84108
TeamID string `json:"teamId,omitempty"`
85109
Pwd string `json:"pwd,omitempty"`
110+
111+
ClientType string `json:"clientType"` // claude_code, codex (defaults to claude_code)
86112
}
87113

88114
// AICodeOtelMetric represents a metric data point from Claude Code or Codex
@@ -101,6 +127,7 @@ type AICodeOtelMetric struct {
101127

102128
// Embedded resource attributes (previously in session)
103129
SessionID string `json:"sessionId,omitempty"`
130+
ConversationID string `json:"conversationId,omitempty"` // Codex uses conversationId instead of sessionId
104131
UserAccountUUID string `json:"userAccountUuid,omitempty"`
105132
OrganizationID string `json:"organizationId,omitempty"`
106133
TerminalType string `json:"terminalType,omitempty"`
@@ -118,6 +145,8 @@ type AICodeOtelMetric struct {
118145
MachineName string `json:"machineName,omitempty"`
119146
TeamID string `json:"teamId,omitempty"`
120147
Pwd string `json:"pwd,omitempty"`
148+
149+
ClientType string `json:"clientType"` // claude_code, codex (defaults to claude_code)
121150
}
122151

123152
// AICodeOtelResponse is the response from POST /api/v1/cc/otel
@@ -148,12 +177,14 @@ const (
148177

149178
// AI Code OTEL event types (shared between Claude Code and Codex)
150179
const (
151-
AICodeEventUserPrompt = "user_prompt"
152-
AICodeEventToolResult = "tool_result"
153-
AICodeEventApiRequest = "api_request"
154-
AICodeEventApiError = "api_error"
155-
AICodeEventToolDecision = "tool_decision"
156-
AICodeEventExecCommand = "exec_command" // Codex: shell command execution
180+
AICodeEventUserPrompt = "user_prompt"
181+
AICodeEventToolResult = "tool_result"
182+
AICodeEventApiRequest = "api_request"
183+
AICodeEventApiError = "api_error"
184+
AICodeEventToolDecision = "tool_decision"
185+
AICodeEventExecCommand = "exec_command" // Codex: shell command execution
186+
AICodeEventConversationStarts = "conversation_starts" // Codex: conversation/session start
187+
AICodeEventSSEEvent = "sse_event" // Codex: SSE streaming event
157188
)
158189

159190
// Token types for AICodeMetricTokenUsage

0 commit comments

Comments
 (0)