diff --git a/commands/cc.go b/commands/cc.go index dd1d56d..05a5daa 100644 --- a/commands/cc.go +++ b/commands/cc.go @@ -67,10 +67,19 @@ func commandCCInstall(c *cli.Context) error { color.Green.Println("Claude Code OTEL configuration has been installed!") color.Yellow.Println("Please restart your Claude Code sessions (terminal, desktop app, IDE) to apply changes.") + printCCPrivacyNote(settingsService.SettingsPath()) return nil } +// printCCPrivacyNote says what the installed config sends beyond token and cost counts. +func printCCPrivacyNote(settingsPath string) { + color.Gray.Println("Privacy: besides usage and cost, Claude Code now sends your prompts, tool details (Bash") + color.Gray.Println("commands, file paths, truncated tool input, tool errors) and assistant responses to your") + color.Gray.Printf("ShellTime account. To keep some of them out, set OTEL_LOG_USER_PROMPTS, OTEL_LOG_TOOL_DETAILS or\n") + color.Gray.Printf("OTEL_LOG_ASSISTANT_RESPONSES to 0 in the env of %s (re-running install resets them).\n", settingsPath) +} + func commandCCUninstall(c *cli.Context) error { color.Yellow.Println("Removing Claude Code OTEL configuration...") diff --git a/commands/cc_test.go b/commands/cc_test.go index 45e5ca5..5993293 100644 --- a/commands/cc_test.go +++ b/commands/cc_test.go @@ -1,12 +1,14 @@ package commands import ( + "bytes" "encoding/json" "os" "path/filepath" "strings" "testing" + "github.com/gookit/color" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" @@ -135,3 +137,22 @@ func TestCCInstall_IdempotentNoDuplicateKeys(t *testing.T) { assert.Equal(t, string(first), string(second), "install should be idempotent") assert.Equal(t, 1, strings.Count(string(second), "OTEL_EXPORTER_OTLP_ENDPOINT")) } + +func TestInstallPrivacyNotes(t *testing.T) { + var buf bytes.Buffer + color.SetOutput(&buf) + t.Cleanup(color.ResetOutput) + + printCCPrivacyNote("/home/u/.claude/settings.json") + cc := buf.String() + for _, want := range []string{"Bash", "file paths", "tool input", "tool errors", "assistant responses", "OTEL_LOG_TOOL_DETAILS", "OTEL_LOG_ASSISTANT_RESPONSES", "/home/u/.claude/settings.json"} { + assert.Contains(t, cc, want) + } + + buf.Reset() + printCodexPrivacyNote() + codex := buf.String() + for _, want := range []string{"shell commands", "tool output", "responses", "otel.log_agent_responses", "~/.codex/config.toml"} { + assert.Contains(t, codex, want) + } +} diff --git a/commands/codex.go b/commands/codex.go index 1647242..2982d6d 100644 --- a/commands/codex.go +++ b/commands/codex.go @@ -42,10 +42,19 @@ func commandCodexInstall(c *cli.Context) error { 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.") + printCodexPrivacyNote() return nil } +// printCodexPrivacyNote says what the installed config sends beyond token counts. +func printCodexPrivacyNote() { + color.Gray.Println("Privacy: besides usage, Codex now sends your prompts, tool details (shell commands, patches") + color.Gray.Println("and other tool arguments, truncated tool output, tool errors) and its final responses to your") + color.Gray.Println("ShellTime account. To keep prompts or responses out, set otel.log_user_prompt or") + color.Gray.Println("otel.log_agent_responses to false in ~/.codex/config.toml (re-running install resets them).") +} + func commandCodexUninstall(c *cli.Context) error { color.Yellow.Println("Removing Codex OTEL configuration...") diff --git a/commands/doctor_checks.go b/commands/doctor_checks.go index 54b511b..1879360 100644 --- a/commands/doctor_checks.go +++ b/commands/doctor_checks.go @@ -751,6 +751,18 @@ func doctorCheckClaude(env *doctorEnv) []doctorResult { } else { env.claudeOtel = true results = append(results, doctorResult{ID: "claude.otel", Status: doctorOK, Message: fmt.Sprintf("Claude Code reports usage to shelltime (%s).", svc.SettingsPath())}) + + // Settings written by an older `cc install` lack the newer keys (tool details, + // assistant responses, delta temporality, ...). + if missing, err := svc.MissingManagedKeys(); err == nil && len(missing) > 0 { + results = append(results, doctorResult{ + ID: "claude.otel_keys", + Status: doctorWarn, + Message: fmt.Sprintf("Claude Code's shelltime OTEL config is out of date (missing %s), so some session details aren't reported.", strings.Join(missing, ", ")), + Fix: "Run `shelltime cc install` again, then restart Claude Code.", + AutoFix: fix, + }) + } } var legacyShells []string @@ -839,6 +851,19 @@ func doctorCheckCodex(env *doctorEnv) []doctorResult { } results := []doctorResult{result} + // A config written by an older `codex install` lacks log_agent_responses. + if env.codexOtel { + if missing, err := model.NewCodexOtelConfigService().MissingManagedKeys(); err == nil && len(missing) > 0 { + results = append(results, doctorResult{ + ID: "codex.otel_keys", + Status: doctorWarn, + Message: fmt.Sprintf("Codex's shelltime OTEL config is out of date (missing otel.%s), so some session details aren't reported.", strings.Join(missing, ", otel.")), + Fix: "Run `shelltime codex install` again, then restart Codex.", + AutoFix: fix, + }) + } + } + if ok, _ := doctorCodexInstallationStatus(); !ok { results = append(results, doctorResult{ ID: "codex.auth", diff --git a/commands/doctor_test.go b/commands/doctor_test.go index 9138f2f..2642b44 100644 --- a/commands/doctor_test.go +++ b/commands/doctor_test.go @@ -517,6 +517,19 @@ func TestDoctorCheckClaude(t *testing.T) { assert.True(t, env.claudeOtel) assert.False(t, hasDoctorResult(results, "claude.legacy_env")) + // Settings from an older `cc install` lack the newer keys: warn and offer to re-run install. + outdated := findDoctorResult(t, results, "claude.otel_keys") + assert.Equal(t, doctorWarn, outdated.Status) + assert.Contains(t, outdated.Message, "OTEL_LOG_TOOL_DETAILS") + assert.Contains(t, outdated.Fix, "shelltime cc install") + require.NotNil(t, outdated.AutoFix) + assert.Equal(t, "claude.install", outdated.AutoFix.Key) + + require.NoError(t, model.NewClaudeSettingsAICodeOtelEnvService().Install()) + results = doctorCheckClaude(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "claude.otel").Status) + assert.False(t, hasDoctorResult(results, "claude.otel_keys"), "a current install has every key") + // An OTEL block written to ~/.zshrc by older versions is flagged for migration. require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), nil, 0644)) require.NoError(t, model.NewZshAICodeOtelEnvService().Install()) @@ -557,12 +570,25 @@ func TestDoctorCheckCodex(t *testing.T) { assert.Equal(t, doctorFail, otelResult.Status) assert.Nil(t, otelResult.AutoFix, "codex install can't fix invalid TOML") + // A config from an older `codex install` reports usage but lacks log_agent_responses. + older := "[otel]\nlog_user_prompt = true\n\n[otel.exporter.otlp-grpc]\nendpoint = \"" + model.AICodeOtelEndpoint + "\"\n" + require.NoError(t, os.WriteFile(configPath, []byte(older), 0644)) + results := doctorCheckCodex(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "codex.otel").Status) + outdated := findDoctorResult(t, results, "codex.otel_keys") + assert.Equal(t, doctorWarn, outdated.Status) + assert.Contains(t, outdated.Message, "otel.log_agent_responses") + require.NotNil(t, outdated.AutoFix) + assert.Equal(t, "codex.install", outdated.AutoFix.Key) + require.NoError(t, os.Remove(configPath)) + env.codexOtel = false require.NoError(t, model.NewCodexOtelConfigService().Install()) - results := doctorCheckCodex(env) + results = doctorCheckCodex(env) assert.Equal(t, doctorOK, findDoctorResult(t, results, "codex.otel").Status) assert.True(t, env.codexOtel) assert.False(t, hasDoctorResult(results, "codex.auth")) + assert.False(t, hasDoctorResult(results, "codex.otel_keys")) doctorCodexInstallationStatus = daemon.CodexInstallationStatus assert.Equal(t, doctorInfo, findDoctorResult(t, doctorCheckCodex(env), "codex.auth").Status, "no auth.json") diff --git a/daemon/aicode_otel_processor.go b/daemon/aicode_otel_processor.go index bef5429..a28ae66 100644 --- a/daemon/aicode_otel_processor.go +++ b/daemon/aicode_otel_processor.go @@ -2,13 +2,20 @@ package daemon import ( "context" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" "encoding/json" "fmt" "log/slog" + "math" "os" "path/filepath" + "sort" "strconv" "strings" + "sync" "time" "github.com/google/uuid" @@ -19,6 +26,7 @@ import ( logsv1 "go.opentelemetry.io/proto/otlp/logs/v1" metricsv1 "go.opentelemetry.io/proto/otlp/metrics/v1" resourcev1 "go.opentelemetry.io/proto/otlp/resource/v1" + "google.golang.org/protobuf/proto" ) // AICodeOtelProcessor handles OTEL data parsing and forwarding to the backend @@ -27,6 +35,11 @@ type AICodeOtelProcessor struct { endpoint model.Endpoint hostname string debug bool + + // maxRequestBytes bounds the JSON body of one backend request; larger batches are split. + maxRequestBytes int + // cumulativeWarning makes sure the cumulative-temporality warning is logged once. + cumulativeWarning sync.Once } // NewAICodeOtelProcessor creates a new AICodeOtel processor @@ -44,8 +57,9 @@ func NewAICodeOtelProcessor(config model.ShellTimeConfig) *AICodeOtelProcessor { Token: config.Token, APIEndpoint: config.APIEndpoint, }, - hostname: hostname, - debug: debug, + hostname: hostname, + debug: debug, + maxRequestBytes: model.AICodeOtelMaxRequestBytes, } } @@ -78,9 +92,63 @@ func (p *AICodeOtelProcessor) writeDebugFile(filename string, data interface{}) slog.Debug("AICodeOtel: Wrote debug data", "path", filePath) } +// otelResource is one OTLP resource together with what every record under it shares. +type otelResource struct { + source string + attrs *model.AICodeOtelResourceAttributes + // digest identifies the resource in stable ids. It is computed once per resource. + digest [sha256.Size]byte +} + +// deterministicMarshal encodes OTLP messages byte-for-byte reproducibly, so an exporter retry of +// the same record hashes to the same id. +var deterministicMarshal = proto.MarshalOptions{Deterministic: true} + +func newOtelResource(resource *resourcev1.Resource, source string) *otelResource { + res := &otelResource{source: source, attrs: extractResourceAttributes(resource)} + if resource != nil { + if buf, err := deterministicMarshal.Marshal(resource); err == nil { + res.digest = sha256.Sum256(buf) + } + } + return res +} + +// stableOtelID derives an event or metric id from its OTLP payload: the source, the resource, +// the instrumentation scope and the encoded record (or metric name and data point). Each part +// is length-prefixed so different splits never collide. +func stableOtelID(source string, resourceDigest [sha256.Size]byte, scope string, parts ...[]byte) string { + h := sha256.New() + write := func(b []byte) { + var n [8]byte + binary.BigEndian.PutUint64(n[:], uint64(len(b))) + h.Write(n[:]) + h.Write(b) + } + write([]byte(source)) + write(resourceDigest[:]) + write([]byte(scope)) + for _, part := range parts { + write(part) + } + sum := h.Sum(nil) + return model.AICodeOtelIDPrefix + hex.EncodeToString(sum[:20]) +} + +// stableMessageID is stableOtelID over a proto message. If the message can't be encoded, which +// doesn't happen for valid OTLP, it falls back to a random id. +func stableMessageID(res *otelResource, scope string, prefix string, msg proto.Message) string { + buf, err := deterministicMarshal.Marshal(msg) + if err != nil { + slog.Debug("AICodeOtel: Failed to encode record for its id", "error", err) + return uuid.New().String() + } + return stableOtelID(res.source, res.digest, scope, []byte(prefix), buf) +} + // ProcessMetrics receives OTEL metrics and forwards to backend immediately func (p *AICodeOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1.ExportMetricsServiceRequest) (*collmetricsv1.ExportMetricsServiceResponse, error) { - slog.Debug("AICodeOtel: Processing metrics request", "resourceMetricsCount", slog.Int("len", len(req.GetResourceMetrics())), slog.Bool("debug", p.debug)) + slog.Debug("AICodeOtel: Processing metrics request", "resourceMetricsCount", len(req.GetResourceMetrics()), slog.Bool("debug", p.debug)) if p.debug { p.writeDebugFile("aicode-otel-debug-metrics.txt", req) @@ -97,15 +165,14 @@ func (p *AICodeOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetri } // Extract resource attributes once for all metrics in this resource - resourceAttrs := extractResourceAttributes(resource) + res := newOtelResource(resource, source) project := p.detectProject(resource, source) var metrics []model.AICodeOtelMetric - for _, sm := range rm.GetScopeMetrics() { + scope := sm.GetScope().GetName() for _, m := range sm.GetMetrics() { - parsedMetrics := p.parseMetric(m, resourceAttrs, source) - metrics = append(metrics, parsedMetrics...) + metrics = append(metrics, p.parseMetric(m, res, scope)...) } } @@ -114,20 +181,12 @@ func (p *AICodeOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetri } // Build and send request immediately - flat structure without session - aiCodeReq := &model.AICodeOtelRequest{ + p.send(ctx, &model.AICodeOtelRequest{ Host: p.hostname, Project: project, Source: source, Metrics: metrics, - } - - resp, err := model.SendAICodeOtelData(ctx, aiCodeReq, p.endpoint) - if err != nil { - slog.Error("AICodeOtel: Failed to send metrics to backend", "error", err) - // Continue processing - passthrough mode, we don't retry - } else { - slog.Debug("AICodeOtel: Metrics sent to backend", "metricsProcessed", resp.MetricsProcessed) - } + }) } return &collmetricsv1.ExportMetricsServiceResponse{}, nil @@ -152,15 +211,14 @@ func (p *AICodeOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Ex } // Extract resource attributes once for all events in this resource - resourceAttrs := extractResourceAttributes(resource) + res := newOtelResource(resource, source) project := p.detectProject(resource, source) var events []model.AICodeOtelEvent - for _, sl := range rl.GetScopeLogs() { + scope := sl.GetScope().GetName() for _, lr := range sl.GetLogRecords() { - event := p.parseLogRecord(lr, resourceAttrs, source) - if event != nil { + if event := p.parseLogRecord(lr, res, scope); event != nil { events = append(events, *event) } } @@ -171,23 +229,32 @@ func (p *AICodeOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Ex } // Build and send request immediately - flat structure without session - aiCodeReq := &model.AICodeOtelRequest{ + p.send(ctx, &model.AICodeOtelRequest{ Host: p.hostname, Project: project, Source: source, Events: events, - } + }) + } - resp, err := model.SendAICodeOtelData(ctx, aiCodeReq, p.endpoint) + return &collogsv1.ExportLogsServiceResponse{}, nil +} + +// send forwards a request to the backend, split so that each body stays within maxRequestBytes. +// Passthrough mode: failures are logged, not retried. +func (p *AICodeOtelProcessor) send(ctx context.Context, req *model.AICodeOtelRequest) { + maxBytes := p.maxRequestBytes + if maxBytes <= 0 { + maxBytes = model.AICodeOtelMaxRequestBytes + } + for _, chunk := range model.SplitAICodeOtelRequest(req, maxBytes) { + resp, err := model.SendAICodeOtelData(ctx, chunk, p.endpoint) if err != nil { - slog.Error("AICodeOtel: Failed to send events to backend", "error", err) - // Continue processing - passthrough mode, we don't retry - } else { - slog.Debug("AICodeOtel: Events sent to backend", "eventsProcessed", resp.EventsProcessed) + slog.Error("AICodeOtel: Failed to send data to backend", "error", err, "events", len(chunk.Events), "metrics", len(chunk.Metrics)) + continue } + slog.Debug("AICodeOtel: Data sent to backend", "eventsProcessed", resp.EventsProcessed, "metricsProcessed", resp.MetricsProcessed) } - - return &collogsv1.ExportLogsServiceResponse{}, nil } // detectOtelSource checks the resource and returns the source type (claude-code, codex, or empty if unknown) @@ -210,8 +277,8 @@ func detectOtelSource(resource *resourcev1.Resource) string { return "" } -// extractResourceAttributes extracts resource-level attributes from OTEL resource -// Returns a struct that can be used to populate metrics and events +// extractResourceAttributes extracts resource-level attributes from OTEL resource. +// Attributes without a dedicated field are kept in Attributes for the events. func extractResourceAttributes(resource *resourcev1.Resource) *model.AICodeOtelResourceAttributes { attrs := &model.AICodeOtelResourceAttributes{} @@ -219,6 +286,7 @@ func extractResourceAttributes(resource *resourcev1.Resource) *model.AICodeOtelR return attrs } + var accountID string for _, attr := range resource.GetAttributes() { key := attr.GetKey() value := attr.GetValue() @@ -226,45 +294,68 @@ func extractResourceAttributes(resource *resourcev1.Resource) *model.AICodeOtelR switch key { // Standard resource attributes case "session.id": - attrs.SessionID = value.GetStringValue() + attrs.SessionID = anyString(value) case "event.kind": - attrs.EventKind = value.GetStringValue() + attrs.EventKind = anyString(value) case "conversation.id": - attrs.ConversationID = value.GetStringValue() + attrs.ConversationID = anyString(value) case "app.version": - attrs.AppVersion = value.GetStringValue() + attrs.AppVersion = anyString(value) + case "app.entrypoint": + attrs.Entrypoint = anyString(value) case "organization.id": - attrs.OrganizationID = value.GetStringValue() - case "user.account_uuid", "user.account_id": - attrs.UserAccountUUID = value.GetStringValue() + attrs.OrganizationID = anyString(value) + case "user.account_uuid": + attrs.UserAccountUUID = anyString(value) + case "user.account_id": + accountID = anyString(value) case "terminal.type": - attrs.TerminalType = value.GetStringValue() - case "service.version": - attrs.ServiceVersion = value.GetStringValue() + attrs.TerminalType = anyString(value) case "os.type": - attrs.OSType = value.GetStringValue() + attrs.OSType = anyString(value) case "os.version": - attrs.OSVersion = value.GetStringValue() + attrs.OSVersion = anyString(value) case "host.arch": - attrs.HostArch = value.GetStringValue() - case "wsl.version": - attrs.WSLVersion = value.GetStringValue() + attrs.HostArch = anyString(value) // Additional identifiers case "user.id": - attrs.UserID = value.GetStringValue() + attrs.UserID = anyString(value) case "user.email": - attrs.UserEmail = value.GetStringValue() + attrs.UserEmail = anyString(value) // Custom resource attributes (from OTEL_RESOURCE_ATTRIBUTES) case "user.name": - attrs.UserName = value.GetStringValue() + attrs.UserName = anyString(value) case "machine.name": - attrs.MachineName = value.GetStringValue() + attrs.MachineName = anyString(value) case "team.id": - attrs.TeamID = value.GetStringValue() + attrs.TeamID = anyString(value) case "pwd": - attrs.Pwd = value.GetStringValue() + attrs.Pwd = anyString(value) + case "project", "project.path": + // The request-level project, see detectProject. + + // Kept as fields and also forwarded in the attributes. + case "service.name": + attrs.ServiceName = anyString(value) + attrs.Attributes = setAttribute(attrs.Attributes, key, anyValueToGo(value)) + case "service.version": + attrs.ServiceVersion = anyString(value) + attrs.Attributes = setAttribute(attrs.Attributes, key, anyValueToGo(value)) + case "wsl.version": + attrs.WSLVersion = anyString(value) + attrs.Attributes = setAttribute(attrs.Attributes, key, anyValueToGo(value)) + case "host.name": + attrs.HostName = anyString(value) + attrs.Attributes = setAttribute(attrs.Attributes, key, anyValueToGo(value)) + default: + // env, vcs.*, custom OTEL_RESOURCE_ATTRIBUTES keys, ... + attrs.Attributes = setAttribute(attrs.Attributes, key, anyValueToGo(value)) } } + // Claude sends both user.account_uuid and the tagged user.account_id; Codex only the latter. + if attrs.UserAccountUUID == "" { + attrs.UserAccountUUID = accountID + } return attrs } @@ -288,7 +379,7 @@ func applyResourceAttributesToMetric(metric *model.AICodeOtelMetric, attrs *mode // Custom resource attributes metric.UserName = attrs.UserName - metric.MachineName = attrs.MachineName + metric.MachineName = firstNonEmpty(attrs.MachineName, attrs.HostName) metric.TeamID = attrs.TeamID metric.Pwd = attrs.Pwd } @@ -313,7 +404,7 @@ func applyResourceAttributesToEvent(event *model.AICodeOtelEvent, attrs *model.A // Custom resource attributes event.UserName = attrs.UserName - event.MachineName = attrs.MachineName + event.MachineName = firstNonEmpty(attrs.MachineName, attrs.HostName) event.TeamID = attrs.TeamID event.Pwd = attrs.Pwd } @@ -348,207 +439,135 @@ func (p *AICodeOtelProcessor) detectProject(resource *resourcev1.Resource, sourc } // 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 - +func (p *AICodeOtelProcessor) parseMetric(m *metricsv1.Metric, res *otelResource, scope string) []model.AICodeOtelMetric { name := m.GetName() - metricType := mapMetricName(name, source) + metricType := mapMetricName(name) if metricType == "" { - return metrics // Unknown metric, skip + return nil // Unknown metric, skip } - // Handle different metric data types + var dataPoints []*metricsv1.NumberDataPoint switch data := m.GetData().(type) { case *metricsv1.Metric_Sum: - for _, dp := range data.Sum.GetDataPoints() { - metric := model.AICodeOtelMetric{ - MetricID: uuid.New().String(), - MetricType: metricType, - Timestamp: int64(dp.GetTimeUnixNano() / 1e9), // Convert to seconds - Value: getDataPointValue(dp), - ClientType: source, - } - // Apply resource attributes first - applyResourceAttributesToMetric(&metric, resourceAttrs) - // Then extract data point attributes (can override resource attrs) - for _, attr := range dp.GetAttributes() { - applyMetricAttribute(&metric, attr, metricType) - } - metrics = append(metrics, metric) + if data.Sum.GetAggregationTemporality() == metricsv1.AggregationTemporality_AGGREGATION_TEMPORALITY_CUMULATIVE { + p.warnCumulative(name) } + dataPoints = data.Sum.GetDataPoints() case *metricsv1.Metric_Gauge: - for _, dp := range data.Gauge.GetDataPoints() { - metric := model.AICodeOtelMetric{ - MetricID: uuid.New().String(), - MetricType: metricType, - Timestamp: int64(dp.GetTimeUnixNano() / 1e9), - Value: getDataPointValue(dp), - ClientType: source, - } - // Apply resource attributes first - applyResourceAttributesToMetric(&metric, resourceAttrs) - // Then extract data point attributes (can override resource attrs) - for _, attr := range dp.GetAttributes() { - applyMetricAttribute(&metric, attr, metricType) - } - metrics = append(metrics, metric) + dataPoints = data.Gauge.GetDataPoints() + } + + metrics := make([]model.AICodeOtelMetric, 0, len(dataPoints)) + for _, dp := range dataPoints { + value := getDataPointValue(dp) + if math.IsNaN(value) || math.IsInf(value, 0) { + continue // not representable in JSON } + ts := dp.GetTimeUnixNano() + metric := model.AICodeOtelMetric{ + MetricID: stableMessageID(res, scope, name, dp), + MetricType: metricType, + Timestamp: int64(ts / 1e9), + TimestampMs: int64(ts / 1e6), + Value: value, + ClientType: res.source, + } + // Apply resource attributes first + applyResourceAttributesToMetric(&metric, res.attrs) + // Then extract data point attributes (can override resource attrs) + for _, attr := range dp.GetAttributes() { + applyMetricAttribute(&metric, attr, metricType) + } + metrics = append(metrics, metric) } return metrics } -// 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 +// warnCumulative logs once that a metric arrived with cumulative temporality. The server sums +// data points, so cumulative values are counted again on every export. +func (p *AICodeOtelProcessor) warnCumulative(metricName string) { + p.cumulativeWarning.Do(func() { + slog.Warn("AICodeOtel: Received cumulative metrics; ShellTime expects delta temporality, so totals will be inflated. Set OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta (shelltime cc install does this).", "metric", metricName) + }) +} - ClientType: source, +// eventFallbacks collects attributes that only apply when a preferred one is absent, since OTLP +// attributes arrive in no particular order. +type eventFallbacks struct { + effort string + modelReasoningEffort string + reasoningEffort string + costMicros *float64 + err string + errMessage string + errType string + promptText string + callID string + toolUseID string + appEntrypoint string + originator string + toolInput string + argumentsInput string +} + +func (f *eventFallbacks) apply(event *model.AICodeOtelEvent, res *model.AICodeOtelResourceAttributes) { + if effort := firstNonEmpty(f.effort, f.modelReasoningEffort, f.reasoningEffort); effort != "" { + event.ReasoningEffort = effort + } + if event.CostUSD == nil && f.costMicros != nil { + event.CostUSD = model.Float64Ref(*f.costMicros / 1e6) + } + event.Error = firstNonEmpty(f.err, f.errMessage, f.errType) + if event.Prompt == "" { + event.Prompt = f.promptText } + event.CallID = firstNonEmpty(f.callID, f.toolUseID) + if input := firstNonEmpty(f.toolInput, f.argumentsInput); input != "" { + event.ToolInput = input + } + event.Entrypoint = firstNonEmpty(f.appEntrypoint, res.Entrypoint, f.originator, res.ServiceName) +} - if event.Timestamp == 0 { - event.Timestamp = int64(lr.GetObservedTimeUnixNano() / 1e9) // Convert to seconds +// parseLogRecord parses an OTEL log record into a AICodeOtelEvent. It returns nil for records +// that aren't events and for dropped events. +func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, res *otelResource, scope string) *model.AICodeOtelEvent { + eventType := normalizeOtelEventName(resolveEventName(lr)) + if eventType == "" { + return nil } - // Apply resource attributes first - applyResourceAttributesToEvent(event, resourceAttrs) + ts := lr.GetTimeUnixNano() + if ts == 0 { + ts = lr.GetObservedTimeUnixNano() + } + event := &model.AICodeOtelEvent{ + EventID: stableMessageID(res, scope, "", lr), + EventType: eventType, + Timestamp: int64(ts / 1e9), + TimestampMs: int64(ts / 1e6), + ClientType: res.source, + } - // Extract event type and other attributes from log record - for _, attr := range lr.GetAttributes() { - key := attr.GetKey() - value := attr.GetValue() + // Apply resource attributes first + applyResourceAttributesToEvent(event, res.attrs) - switch key { - case "event.name": - event.EventType = mapEventName(value.GetStringValue(), source) - case "event.kind": - event.EventKind = value.GetStringValue() - case "event.timestamp": - event.EventTimestamp = value.GetStringValue() - case "model": - event.Model = value.GetStringValue() - case "cost_usd": - event.CostUSD = getFloatFromValue(value) - case "duration_ms": - event.DurationMs = getIntFromValue(value) - case "input_tokens", "input_token_count": - event.InputTokens = getIntFromValue(value) - case "output_tokens", "output_token_count": - event.OutputTokens = getIntFromValue(value) - case "cache_read_tokens", "cache_token_count", "cached_token_count", "cachedTokenCount": - event.CacheReadTokens = getIntFromValue(value) - case "cache_creation_tokens": - event.CacheCreationTokens = getIntFromValue(value) - case "tool_name": - event.ToolName = value.GetStringValue() - case "success": - event.Success = getBoolFromValue(value) - case "decision": - event.Decision = value.GetStringValue() - case "source": - event.Source = value.GetStringValue() - case "error": - event.Error = value.GetStringValue() - case "prompt_length": - event.PromptLength = getIntFromValue(value) - case "prompt": - event.Prompt = value.GetStringValue() - case "tool_parameters": - // tool_parameters comes as a JSON string, parse it into map - if jsonStr := value.GetStringValue(); jsonStr != "" { - var params map[string]interface{} - if err := json.Unmarshal([]byte(jsonStr), ¶ms); err == nil { - event.ToolParameters = params - } else { - slog.Debug("AICodeOtel: Failed to parse tool_parameters", "error", err) - } - } - 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", "reasoning_token_count", "reasoningTokenCount": - event.ReasoningTokens = getIntFromValue(value) - case "provider", "provider_name", "providerName": - 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", "tool_token_count", "toolTokenCount": - 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", "active_profile", "activeProfile": - event.Profile = value.GetStringValue() - case "reasoning_enabled", "reasoningEnabled": - event.ReasoningEnabled = getBoolFromValue(value) - case "reasoning_effort", "reasoningEffort": - event.ReasoningEffort = value.GetStringValue() - case "reasoning_summary", "reasoningSummary": - event.ReasoningSummary = value.GetStringValue() - case "max_output_tokens", "maxOutputTokens": - event.MaxOutputTokens = getIntFromValue(value) - case "auto_compact_token_limit", "autoCompactTokenLimit": - event.AutoCompactTokenLimit = getIntFromValue(value) - // Codex-specific fields for tool_result - case "tool_arguments", "toolArguments", "arguments": - 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() - case "user.email": - event.UserEmail = value.GetStringValue() - case "session.id": - event.SessionID = value.GetStringValue() - case "app.version": - event.AppVersion = value.GetStringValue() - case "organization.id": - event.OrganizationID = value.GetStringValue() - case "user.account_uuid", "user.account_id": - event.UserAccountUUID = value.GetStringValue() - case "terminal.type": - event.TerminalType = value.GetStringValue() - } + // Resource attributes without a field (service.*, env, vcs.*, ...) are seeded first, so a + // record with many attributes can't crowd them out; the record's own values override them. + keys := make([]string, 0, len(res.attrs.Attributes)) + for key := range res.attrs.Attributes { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + event.Attributes = setAttribute(event.Attributes, key, res.attrs.Attributes[key]) } - // Skip if no event type was extracted - if event.EventType == "" { - return nil + var fallbacks eventFallbacks + for _, attr := range lr.GetAttributes() { + applyEventAttribute(event, &fallbacks, attr.GetKey(), attr.GetValue()) } + fallbacks.apply(event, res.attrs) if event.SessionID == "" && event.ConversationID != "" { event.SessionID = event.ConversationID @@ -557,11 +576,246 @@ func (p *AICodeOtelProcessor) parseLogRecord(lr *logsv1.LogRecord, resourceAttrs return event } -// mapMetricName maps OTEL metric names to our internal types -// Supports both Claude Code (claude_code.*) and Codex (codex.*) prefixes -func mapMetricName(name string, source string) string { +// applyEventAttribute maps one log record attribute onto the event. Attributes without a field +// go to the attributes catch-all. +func applyEventAttribute(event *model.AICodeOtelEvent, f *eventFallbacks, key string, value *commonv1.AnyValue) { + switch key { + case "event.name": + // Resolved before the attributes, see resolveEventName. + case "event.kind", "event_kind", "eventKind": + event.EventKind = anyString(value) + case "event.timestamp": + event.EventTimestamp = anyString(value) + case "event.sequence": + event.Sequence = optInt64Ref(value) + case "prompt.id": + event.PromptID = anyString(value) + case "request_id": + event.RequestID = anyString(value) + case "speed": + event.Speed = anyString(value) + case "query_source": + event.QuerySource = anyString(value) + case "effort": + f.effort = anyString(value) + case "model_reasoning_effort": + f.modelReasoningEffort = anyString(value) + case "reasoning_effort", "reasoningEffort": + f.reasoningEffort = anyString(value) + case "model": + event.Model = anyString(value) + + // Cost, duration and tokens + case "cost_usd": + event.CostUSD = optFloat(value) + case "cost_usd_micros": + f.costMicros = optFloat(value) + case "duration_ms": + event.DurationMs = optInt(value) + case "input_tokens", "input_token_count": + event.InputTokens = optInt(value) + case "output_tokens", "output_token_count": + event.OutputTokens = optInt(value) + case "cache_read_tokens", "cache_token_count", "cached_token_count", "cachedTokenCount": + event.CacheReadTokens = optInt(value) + case "cache_creation_tokens", "cache_write_token_count", "cacheWriteTokenCount": + event.CacheCreationTokens = optInt(value) + case "reasoning_tokens", "reasoning_token_count", "reasoningTokenCount": + event.ReasoningTokens = optInt(value) + case "tool_tokens", "toolTokens": + event.ToolTokens = optInt(value) + case "tool_token_count", "toolTokenCount": + // Codex sends the response's total_tokens under this name; it is not a tool token count. + if n, ok := optInt64(value); ok { + event.Attributes = setAttribute(event.Attributes, "total_token_count", n) + } + + // Tools + case "tool_name": + event.ToolName = anyString(value) + case "success": + event.Success = optBool(value) + case "decision": + event.Decision = anyString(value) + case "source": + event.Source = anyString(value) + case "call_id", "callId": + f.callID = anyString(value) + case "tool_use_id": + f.toolUseID = anyString(value) + case "tool_parameters": + // Claude sends a JSON object encoded as a string. + raw := anyString(value) + var params map[string]interface{} + if err := json.Unmarshal([]byte(raw), ¶ms); err == nil && params != nil { + event.ToolParameters = params + } else { + event.Attributes = setAttribute(event.Attributes, key, raw) + } + case "tool_input": + f.toolInput = model.CapAICodeText(anyJSONString(value), model.AICodeOtelMaxTextBytes) + case "arguments": + // Codex: raw tool arguments, JSON or freeform (apply_patch). + f.argumentsInput = model.CapAICodeText(anyJSONString(value), model.AICodeOtelMaxTextBytes) + case "tool_arguments", "toolArguments": + raw := anyJSONString(value) + var args map[string]interface{} + if err := json.Unmarshal([]byte(raw), &args); err == nil && args != nil { + event.ToolArguments = args + } else if raw != "" { + f.argumentsInput = model.CapAICodeText(raw, model.AICodeOtelMaxTextBytes) + } + case "output", "tool_output", "toolOutput": + event.ToolOutput = model.CapAICodeText(anyString(value), model.AICodeOtelMaxTextBytes) + + // Errors: error wins over error.message, which wins over error_type. + case "error": + f.err = anyString(value) + case "error.message": + f.errMessage = anyString(value) + case "error_type": + f.errType = anyString(value) + event.Attributes = setAttribute(event.Attributes, key, f.errType) + + // Prompts and responses + case "prompt_length": + event.PromptLength = optInt(value) + case "prompt": + if s := anyString(value); !isRedacted(s) { + event.Prompt = s + } + case "prompt_text": + if s := anyString(value); !isRedacted(s) { + f.promptText = s + } + case "prompt_encrypted", "promptEncrypted": + if b := optBool(value); b != nil { + event.PromptEncrypted = *b + } + case "response": + if s := anyString(value); !isRedacted(s) { + event.Response = model.CapAICodeText(s, model.AICodeOtelMaxTextBytes) + } + case "response_length": + event.ResponseLength = optInt(value) + + // API requests + case "status_code", "http.response.status_code": + event.StatusCode = optInt(value) + case "attempt": + event.Attempt = optInt(value) + case "language": + event.Language = anyString(value) + case "provider", "provider_name", "providerName": + event.Provider = anyString(value) + + // Codex conversation_starts + case "auth_mode", "authMode": + event.AuthMode = anyString(value) + case "slug": + event.Slug = anyString(value) + case "context_window", "contextWindow": + event.ContextWindow = optInt(value) + case "approval_policy", "approvalPolicy": + event.ApprovalPolicy = anyString(value) + case "sandbox_policy", "sandboxPolicy": + event.SandboxPolicy = anyString(value) + case "mcp_servers", "mcpServers": + event.MCPServers = stringList(value) + case "profile", "active_profile", "activeProfile": + event.Profile = anyString(value) + case "reasoning_enabled", "reasoningEnabled": + event.ReasoningEnabled = optBool(value) + case "reasoning_summary", "reasoningSummary": + event.ReasoningSummary = anyString(value) + case "max_output_tokens", "maxOutputTokens": + event.MaxOutputTokens = optInt(value) + case "auto_compact_token_limit", "autoCompactTokenLimit": + event.AutoCompactTokenLimit = optInt(value) + + // Entrypoint: app.entrypoint, then Codex originator, then service.name + case "app.entrypoint": + f.appEntrypoint = anyString(value) + case "originator": + f.originator = anyString(value) + + // Identity attributes on the record override the resource's. + case "conversation.id", "conversationId": + event.ConversationID = anyString(value) + case "user.id": + event.UserID = anyString(value) + case "user.email": + event.UserEmail = anyString(value) + case "session.id": + event.SessionID = anyString(value) + case "app.version": + event.AppVersion = anyString(value) + case "organization.id": + event.OrganizationID = anyString(value) + case "user.account_uuid": + event.UserAccountUUID = anyString(value) + case "user.account_id": + if event.UserAccountUUID == "" { + event.UserAccountUUID = anyString(value) + } + case "terminal.type": + event.TerminalType = anyString(value) + case "os.type": + event.OSType = anyString(value) + case "os.version": + event.OSVersion = anyString(value) + case "host.arch": + event.HostArch = anyString(value) + case "user.name": + event.UserName = anyString(value) + case "machine.name": + event.MachineName = anyString(value) + case "team.id": + event.TeamID = anyString(value) + case "pwd": + event.Pwd = anyString(value) + case "project", "project.path": + // The request-level project, see detectProject. + + default: + event.Attributes = setAttribute(event.Attributes, key, anyValueToGo(value)) + } +} + +// resolveEventName returns the event name of a log record: the event.name attribute, then the +// record's EventName field, then a string body that looks like an event name. +func resolveEventName(lr *logsv1.LogRecord) string { + for _, attr := range lr.GetAttributes() { + if attr.GetKey() == "event.name" { + if name := strings.TrimSpace(anyString(attr.GetValue())); name != "" { + return name + } + } + } + if name := strings.TrimSpace(lr.GetEventName()); name != "" { + return name + } + if body := strings.TrimSpace(lr.GetBody().GetStringValue()); body != "" && len(body) <= 128 && !strings.ContainsAny(body, " \t\r\n") { + return body + } + return "" +} + +// normalizeOtelEventName strips the claude_code./codex. prefix and returns "" for dropped events. +func normalizeOtelEventName(name string) string { + name = strings.TrimSpace(name) + name = strings.TrimPrefix(name, "claude_code.") + name = strings.TrimPrefix(name, "codex.") + if model.IsDroppedAICodeOtelEvent(name) { + return "" + } + return name +} + +// mapMetricName maps OTEL metric names to our internal types. Only Claude Code exports OTLP +// metrics that ShellTime reads; Codex metrics carry no session id and stay off. +func mapMetricName(name string) string { switch name { - // Claude Code metrics case "claude_code.session.count": return model.AICodeMetricSessionCount case "claude_code.token.usage": @@ -578,63 +832,11 @@ func mapMetricName(name string, source string) string { return model.AICodeMetricActiveTimeTotal case "claude_code.code_edit_tool.decision": 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 -// 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.AICodeEventUserPrompt - case "claude_code.tool_result": - return model.AICodeEventToolResult - case "claude_code.api_request": - return model.AICodeEventApiRequest - case "claude_code.api_error": - return model.AICodeEventApiError - case "claude_code.tool_decision": - 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.tool_decision": - return model.AICodeEventToolDecision - 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 - } -} - // getDataPointValue extracts the numeric value from a data point func getDataPointValue(dp *metricsv1.NumberDataPoint) float64 { switch v := dp.GetValue().(type) { @@ -647,64 +849,6 @@ func getDataPointValue(dp *metricsv1.NumberDataPoint) float64 { } } -// getIntFromValue extracts an int from an OTEL value, handling both int and string formats -func getIntFromValue(value *commonv1.AnyValue) int { - // First try to get as int - if intVal := value.GetIntValue(); intVal != 0 { - return int(intVal) - } - // Try to parse from string (Claude Code sends some values as strings) - if strVal := value.GetStringValue(); strVal != "" { - if parsed, err := strconv.Atoi(strVal); err == nil { - return parsed - } - } - return 0 -} - -func getBoolFromValue(value *commonv1.AnyValue) bool { - // First try to get as bool - if boolVal := value.GetBoolValue(); boolVal { - return boolVal - } - // Try to parse from string (Claude Code sends some values as strings) - if strVal := value.GetStringValue(); strVal != "" { - if parsed, err := strconv.ParseBool(strVal); err == nil { - return parsed - } - } - return false -} - -// getFloatFromValue extracts a float64 from an OTEL value, handling both double and string formats -func getFloatFromValue(value *commonv1.AnyValue) float64 { - // First try to get as double - if doubleVal := value.GetDoubleValue(); doubleVal != 0 { - return doubleVal - } - // Try to parse from string (Claude Code sends some values as strings) - if strVal := value.GetStringValue(); strVal != "" { - if parsed, err := strconv.ParseFloat(strVal, 64); err == nil { - return parsed - } - } - 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() @@ -712,40 +856,311 @@ func applyMetricAttribute(metric *model.AICodeOtelMetric, attr *commonv1.KeyValu switch key { case "type": - if metricType == model.AICodeMetricLinesOfCodeCount { - metric.LinesType = value.GetStringValue() - } else { - metric.TokenType = value.GetStringValue() + // token_usage: input/output/cacheRead/cacheCreation; lines_of_code: added/removed; + // active_time: user/cli, which is no token type. + switch metricType { + case model.AICodeMetricTokenUsage: + metric.TokenType = anyString(value) + case model.AICodeMetricLinesOfCodeCount: + metric.LinesType = anyString(value) + default: + metric.Attributes = setAttribute(metric.Attributes, key, anyValueToGo(value)) } case "model": - metric.Model = value.GetStringValue() - case "tool": - metric.Tool = value.GetStringValue() + metric.Model = anyString(value) + case "tool", "tool_name": + metric.Tool = anyString(value) case "decision": - metric.Decision = value.GetStringValue() + metric.Decision = anyString(value) case "language": - metric.Language = value.GetStringValue() + metric.Language = anyString(value) // Resource attributes at data point level - apply them (override if already set from resource) case "session.id": - metric.SessionID = value.GetStringValue() + metric.SessionID = anyString(value) + case "conversation.id": + metric.ConversationID = anyString(value) case "user.account_uuid": - metric.UserAccountUUID = value.GetStringValue() + metric.UserAccountUUID = anyString(value) + case "user.account_id": + if metric.UserAccountUUID == "" { + metric.UserAccountUUID = anyString(value) + } case "organization.id": - metric.OrganizationID = value.GetStringValue() + metric.OrganizationID = anyString(value) case "terminal.type": - metric.TerminalType = value.GetStringValue() + metric.TerminalType = anyString(value) case "app.version": - metric.AppVersion = value.GetStringValue() + metric.AppVersion = anyString(value) case "os.type": - metric.OSType = value.GetStringValue() + metric.OSType = anyString(value) case "os.version": - metric.OSVersion = value.GetStringValue() + metric.OSVersion = anyString(value) case "host.arch": - metric.HostArch = value.GetStringValue() + metric.HostArch = anyString(value) // Additional identifiers at data point level case "user.id": - metric.UserID = value.GetStringValue() + metric.UserID = anyString(value) case "user.email": - metric.UserEmail = value.GetStringValue() + metric.UserEmail = anyString(value) + // OTEL_RESOURCE_ATTRIBUTES keys, which Claude Code copies onto data points + case "user.name": + metric.UserName = anyString(value) + case "machine.name": + metric.MachineName = anyString(value) + case "team.id": + metric.TeamID = anyString(value) + case "pwd": + metric.Pwd = anyString(value) + default: + metric.Attributes = setAttribute(metric.Attributes, key, anyValueToGo(value)) + } +} + +// --- OTLP value helpers -------------------------------------------------------- +// +// Claude Code and Codex encode the same logical type differently (Codex formats many numbers +// and flags as strings), so each helper accepts every encoding seen in practice and reports +// whether a value was present, so that 0 and false are kept. + +// optInt64 reads an integer from an int, a double (rounded) or a numeric string. +func optInt64(v *commonv1.AnyValue) (int64, bool) { + switch x := v.GetValue().(type) { + case *commonv1.AnyValue_IntValue: + return x.IntValue, true + case *commonv1.AnyValue_DoubleValue: + return floatToInt64(x.DoubleValue) + case *commonv1.AnyValue_StringValue: + s := strings.TrimSpace(x.StringValue) + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return n, true + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return floatToInt64(f) + } + } + return 0, false +} + +func floatToInt64(f float64) (int64, bool) { + if math.IsNaN(f) || math.IsInf(f, 0) || math.Abs(f) >= math.MaxInt64 { + return 0, false + } + return int64(math.Round(f)), true +} + +func optInt(v *commonv1.AnyValue) *int { + if n, ok := optInt64(v); ok { + return model.IntRef(int(n)) + } + return nil +} + +func optInt64Ref(v *commonv1.AnyValue) *int64 { + if n, ok := optInt64(v); ok { + return model.Int64Ref(n) + } + return nil +} + +// optFloat reads a number from a double, an int or a numeric string. +func optFloat(v *commonv1.AnyValue) *float64 { + var f float64 + switch x := v.GetValue().(type) { + case *commonv1.AnyValue_DoubleValue: + f = x.DoubleValue + case *commonv1.AnyValue_IntValue: + f = float64(x.IntValue) + case *commonv1.AnyValue_StringValue: + parsed, err := strconv.ParseFloat(strings.TrimSpace(x.StringValue), 64) + if err != nil { + return nil + } + f = parsed + default: + return nil + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return nil + } + return model.Float64Ref(f) +} + +// optBool reads a flag from a bool or the strings "true"/"false". +func optBool(v *commonv1.AnyValue) *bool { + switch x := v.GetValue().(type) { + case *commonv1.AnyValue_BoolValue: + return model.BoolRef(x.BoolValue) + case *commonv1.AnyValue_StringValue: + switch strings.ToLower(strings.TrimSpace(x.StringValue)) { + case "true": + return model.BoolRef(true) + case "false": + return model.BoolRef(false) + } + } + return nil +} + +// stringList reads a list from an array or a comma-separated string (Codex joins mcp_servers +// with ", "). +func stringList(v *commonv1.AnyValue) []string { + var items []string + switch x := v.GetValue().(type) { + case *commonv1.AnyValue_ArrayValue: + for _, item := range x.ArrayValue.GetValues() { + items = append(items, anyString(item)) + } + case *commonv1.AnyValue_StringValue: + items = strings.Split(x.StringValue, ",") + } + var out []string + for _, item := range items { + if item = strings.TrimSpace(item); item != "" { + out = append(out, item) + } + } + return out +} + +// anyString reads a scalar as a string. Non-string scalars are formatted; arrays and maps yield "". +func anyString(v *commonv1.AnyValue) string { + switch x := v.GetValue().(type) { + case *commonv1.AnyValue_StringValue: + return x.StringValue + case *commonv1.AnyValue_IntValue: + return strconv.FormatInt(x.IntValue, 10) + case *commonv1.AnyValue_DoubleValue: + return strconv.FormatFloat(x.DoubleValue, 'f', -1, 64) + case *commonv1.AnyValue_BoolValue: + return strconv.FormatBool(x.BoolValue) + } + return "" +} + +// anyJSONString reads a value meant to be text: strings as they are, arrays and maps as JSON. +func anyJSONString(v *commonv1.AnyValue) string { + switch v.GetValue().(type) { + case *commonv1.AnyValue_ArrayValue, *commonv1.AnyValue_KvlistValue: + buf, err := json.Marshal(anyValueToGo(v)) + if err != nil { + return "" + } + return string(buf) + } + return anyString(v) +} + +// anyValueToGo converts any OTLP value to a JSON-encodable Go value, recursing into arrays and +// key/value lists. Non-finite doubles become strings, since JSON can't represent them. +func anyValueToGo(v *commonv1.AnyValue) any { + switch x := v.GetValue().(type) { + case *commonv1.AnyValue_StringValue: + return x.StringValue + case *commonv1.AnyValue_BoolValue: + return x.BoolValue + case *commonv1.AnyValue_IntValue: + return x.IntValue + case *commonv1.AnyValue_DoubleValue: + if math.IsNaN(x.DoubleValue) || math.IsInf(x.DoubleValue, 0) { + return strconv.FormatFloat(x.DoubleValue, 'f', -1, 64) + } + return x.DoubleValue + case *commonv1.AnyValue_ArrayValue: + values := x.ArrayValue.GetValues() + out := make([]any, 0, len(values)) + for _, item := range values { + out = append(out, anyValueToGo(item)) + } + return out + case *commonv1.AnyValue_KvlistValue: + values := x.KvlistValue.GetValues() + out := make(map[string]any, len(values)) + for _, kv := range values { + out[kv.GetKey()] = anyValueToGo(kv.GetValue()) + } + return out + case *commonv1.AnyValue_BytesValue: + return base64.StdEncoding.EncodeToString(x.BytesValue) } + return nil +} + +// setAttribute adds key to the attributes catch-all, enforcing its limits: string values are +// capped at AICodeOtelMaxAttributeBytes and at most AICodeOtelMaxAttributes keys are kept. +// Empty values are skipped. It returns the (possibly new) map. +func setAttribute(attrs map[string]any, key string, value any) map[string]any { + value = capAttributeValue(value, 0) + if value == nil || value == "" || key == "" { + return attrs + } + if _, exists := attrs[key]; !exists && len(attrs) >= model.AICodeOtelMaxAttributes { + return attrs + } + if attrs == nil { + attrs = make(map[string]any) + } + attrs[key] = value + return attrs +} + +// maxAttributeDepth and maxAttributeItems bound nested arrays and maps in attribute values. +const ( + maxAttributeDepth = 4 + maxAttributeItems = model.AICodeOtelMaxAttributes +) + +func capAttributeValue(value any, depth int) any { + switch x := value.(type) { + case string: + return model.CapAICodeText(x, model.AICodeOtelMaxAttributeBytes) + case []any: + if depth >= maxAttributeDepth { + return nil + } + if len(x) > maxAttributeItems { + x = x[:maxAttributeItems] + } + out := make([]any, 0, len(x)) + for _, item := range x { + out = append(out, capAttributeValue(item, depth+1)) + } + return out + case map[string]any: + if depth >= maxAttributeDepth { + return nil + } + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) > maxAttributeItems { + keys = keys[:maxAttributeItems] + } + out := make(map[string]any, len(keys)) + for _, k := range keys { + out[k] = capAttributeValue(x[k], depth+1) + } + return out + } + return value +} + +// isRedacted reports whether a content attribute holds a redaction placeholder instead of text +// (Claude Code sends , Codex [REDACTED]). +func isRedacted(s string) bool { + switch strings.TrimSpace(s) { + case "", "[REDACTED]": + return true + } + return false +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" } diff --git a/daemon/aicode_otel_processor_extra_test.go b/daemon/aicode_otel_processor_extra_test.go index f884819..89f3276 100644 --- a/daemon/aicode_otel_processor_extra_test.go +++ b/daemon/aicode_otel_processor_extra_test.go @@ -49,6 +49,14 @@ func serviceResource(serviceName string, extra ...*commonv1.KeyValue) *resourcev return &resourcev1.Resource{Attributes: attrs} } +// testResource wraps pre-built resource attributes for direct parseLogRecord/parseMetric calls. +func testResource(source string, attrs *model.AICodeOtelResourceAttributes) *otelResource { + if attrs == nil { + attrs = &model.AICodeOtelResourceAttributes{} + } + return &otelResource{source: source, attrs: attrs} +} + // captureProcessor wires a processor to a test HTTP server and records the // AICodeOtelRequest bodies POSTed to /api/v1/cc/otel. type captureProcessor struct { @@ -155,13 +163,14 @@ func TestProcessMetrics_SumAndGauge(t *testing.T) { // Sum metric (token usage) tokenMetric := got.Metrics[0] assert.Equal(t, model.AICodeMetricTokenUsage, tokenMetric.MetricType) - assert.Equal(t, int64(2), tokenMetric.Timestamp) // nanos -> seconds + assert.Equal(t, int64(2), tokenMetric.Timestamp) // nanos -> seconds + assert.Equal(t, int64(2000), tokenMetric.TimestampMs) // nanos -> milliseconds assert.Equal(t, float64(123), tokenMetric.Value) assert.Equal(t, "input", tokenMetric.TokenType) assert.Equal(t, "claude-3", tokenMetric.Model) assert.Equal(t, "sess-1", tokenMetric.SessionID) // from resource attrs assert.Equal(t, model.AICodeOtelSourceClaudeCode, tokenMetric.ClientType) - assert.NotEmpty(t, tokenMetric.MetricID) + assert.True(t, strings.HasPrefix(tokenMetric.MetricID, model.AICodeOtelIDPrefix), tokenMetric.MetricID) // Gauge metric (cost usage) costMetric := got.Metrics[1] @@ -325,16 +334,17 @@ func TestProcessLogs_ClaudeApiRequestEvent(t *testing.T) { ev := reqs[0].Events[0] assert.Equal(t, model.AICodeEventApiRequest, ev.EventType) assert.Equal(t, int64(5), ev.Timestamp) + assert.Equal(t, int64(5000), ev.TimestampMs) assert.Equal(t, "claude-3-5", ev.Model) - assert.Equal(t, 0.01, ev.CostUSD) - assert.Equal(t, 250, ev.DurationMs) - assert.Equal(t, 100, ev.InputTokens) - assert.Equal(t, 50, ev.OutputTokens) - assert.Equal(t, 10, ev.CacheReadTokens) - assert.True(t, ev.Success) - assert.Equal(t, 200, ev.StatusCode) + assert.Equal(t, model.Float64Ref(0.01), ev.CostUSD) + assert.Equal(t, model.IntRef(250), ev.DurationMs) + assert.Equal(t, model.IntRef(100), ev.InputTokens) + assert.Equal(t, model.IntRef(50), ev.OutputTokens) + assert.Equal(t, model.IntRef(10), ev.CacheReadTokens) + assert.Equal(t, model.BoolRef(true), ev.Success) + assert.Equal(t, model.IntRef(200), ev.StatusCode) assert.Equal(t, "e@x.com", ev.UserEmail) // from resource attrs - assert.NotEmpty(t, ev.EventID) + assert.True(t, strings.HasPrefix(ev.EventID, model.AICodeOtelIDPrefix), ev.EventID) } func TestProcessLogs_CodexConversationStartsMapsConvIDToSession(t *testing.T) { @@ -383,9 +393,9 @@ func TestProcessLogs_CodexConversationStartsMapsConvIDToSession(t *testing.T) { assert.Equal(t, "conv-9", ev.SessionID) // sessionID derived from conversationID assert.Equal(t, "apikey", ev.AuthMode) assert.Equal(t, "auto", ev.ApprovalPolicy) - assert.True(t, ev.ReasoningEnabled) + assert.Equal(t, model.BoolRef(true), ev.ReasoningEnabled) assert.Equal(t, "high", ev.ReasoningEffort) - assert.Equal(t, 128000, ev.ContextWindow) + assert.Equal(t, model.IntRef(128000), ev.ContextWindow) assert.Equal(t, []string{"fs", "git"}, ev.MCPServers) } @@ -410,7 +420,7 @@ func TestProcessLogs_ToolParametersJSONParsed(t *testing.T) { }, }, { - // invalid JSON tool_parameters -> ignored, but event still valid + // invalid JSON tool_parameters -> kept raw in attributes, event still valid TimeUnixNano: 1_000_000_000, Attributes: []*commonv1.KeyValue{ kv("event.name", strVal("claude_code.tool_result")), @@ -447,9 +457,12 @@ func TestProcessLogs_ToolParametersJSONParsed(t *testing.T) { require.NotNil(t, first.ToolArguments) assert.Equal(t, "b", first.ToolArguments["a"]) + assert.Equal(t, "noop", first.Attributes["tool_parameters_bad_just_ignored"], "unknown attributes go to the catch-all") + second := reqs[0].Events[1] assert.Equal(t, model.AICodeEventToolResult, second.EventType) - assert.Nil(t, second.ToolParameters) // bad JSON ignored + assert.Nil(t, second.ToolParameters) // bad JSON isn't parsed... + assert.Equal(t, `{not json`, second.Attributes["tool_parameters"]) // ...but kept raw } func TestParseLogRecord_AllAttributeBranches(t *testing.T) { @@ -502,33 +515,33 @@ func TestParseLogRecord_AllAttributeBranches(t *testing.T) { }, } - ev := p.parseLogRecord(lr, resAttrs, model.AICodeOtelSourceCodex) + ev := p.parseLogRecord(lr, testResource(model.AICodeOtelSourceCodex, resAttrs), "") require.NotNil(t, ev) assert.Equal(t, model.AICodeEventApiError, ev.EventType) assert.Equal(t, int64(10), ev.Timestamp) assert.Equal(t, "2025-01-01T00:00:00Z", ev.EventTimestamp) - assert.Equal(t, 7, ev.CacheCreationTokens) + assert.Equal(t, model.IntRef(7), ev.CacheCreationTokens) assert.Equal(t, "reject", ev.Decision) assert.Equal(t, "user", ev.Source) - // error.message overrides error - assert.Equal(t, "overridden-error", ev.Error) - assert.Equal(t, 42, ev.PromptLength) + // error wins over error.message + assert.Equal(t, "boom", ev.Error) + assert.Equal(t, model.IntRef(42), ev.PromptLength) assert.Equal(t, "hello", ev.Prompt) - assert.Equal(t, 2, ev.Attempt) + assert.Equal(t, model.IntRef(2), ev.Attempt) assert.Equal(t, "python", ev.Language) - assert.Equal(t, 99, ev.ReasoningTokens) + assert.Equal(t, model.IntRef(99), ev.ReasoningTokens) assert.Equal(t, "openai", ev.Provider) assert.Equal(t, "call-1", ev.CallID) // event_kind is processed after event.kind, so it wins assert.Equal(t, "ek-override", ev.EventKind) - assert.Equal(t, 3, ev.ToolTokens) + assert.Equal(t, model.IntRef(3), ev.ToolTokens) assert.Equal(t, "gpt-5", ev.Slug) assert.Equal(t, "workspace", ev.SandboxPolicy) assert.Equal(t, []string{"a"}, ev.MCPServers) assert.Equal(t, "default", ev.Profile) assert.Equal(t, "brief", ev.ReasoningSummary) - assert.Equal(t, 1000, ev.MaxOutputTokens) - assert.Equal(t, 2000, ev.AutoCompactTokenLimit) + assert.Equal(t, model.IntRef(1000), ev.MaxOutputTokens) + assert.Equal(t, model.IntRef(2000), ev.AutoCompactTokenLimit) assert.Equal(t, "done", ev.ToolOutput) assert.True(t, ev.PromptEncrypted) // overrides @@ -570,25 +583,25 @@ func TestParseLogRecord_CamelCaseCodexAliases(t *testing.T) { }, } - ev := p.parseLogRecord(lr, &model.AICodeOtelResourceAttributes{}, model.AICodeOtelSourceCodex) + ev := p.parseLogRecord(lr, testResource(model.AICodeOtelSourceCodex, nil), "") require.NotNil(t, ev) - assert.Equal(t, 5, ev.InputTokens) - assert.Equal(t, 6, ev.OutputTokens) - assert.Equal(t, 7, ev.CacheReadTokens) - assert.Equal(t, 8, ev.ReasoningTokens) + assert.Equal(t, model.IntRef(5), ev.InputTokens) + assert.Equal(t, model.IntRef(6), ev.OutputTokens) + assert.Equal(t, model.IntRef(7), ev.CacheReadTokens) + assert.Equal(t, model.IntRef(8), ev.ReasoningTokens) assert.Equal(t, "openai", ev.Provider) assert.Equal(t, "c-2", ev.CallID) - assert.Equal(t, 9, ev.ToolTokens) + assert.Equal(t, model.IntRef(9), ev.ToolTokens) assert.Equal(t, "oauth", ev.AuthMode) - assert.Equal(t, 64000, ev.ContextWindow) + assert.Equal(t, model.IntRef(64000), ev.ContextWindow) assert.Equal(t, "manual", ev.ApprovalPolicy) assert.Equal(t, "none", ev.SandboxPolicy) assert.Equal(t, "p", ev.Profile) - assert.True(t, ev.ReasoningEnabled) + assert.Equal(t, model.BoolRef(true), ev.ReasoningEnabled) assert.Equal(t, "low", ev.ReasoningEffort) assert.Equal(t, "s", ev.ReasoningSummary) - assert.Equal(t, 100, ev.MaxOutputTokens) - assert.Equal(t, 200, ev.AutoCompactTokenLimit) + assert.Equal(t, model.IntRef(100), ev.MaxOutputTokens) + assert.Equal(t, model.IntRef(200), ev.AutoCompactTokenLimit) assert.Equal(t, "ok", ev.ToolOutput) assert.True(t, ev.PromptEncrypted) // conversationId -> ConversationID and, since SessionID empty, -> SessionID @@ -598,15 +611,14 @@ func TestParseLogRecord_CamelCaseCodexAliases(t *testing.T) { func TestParseLogRecord_NilWhenNoEventType(t *testing.T) { p := NewAICodeOtelProcessor(model.ShellTimeConfig{}) - attrs := &model.AICodeOtelResourceAttributes{} lr := &logsv1.LogRecord{Attributes: []*commonv1.KeyValue{kv("model", strVal("x"))}} - assert.Nil(t, p.parseLogRecord(lr, attrs, model.AICodeOtelSourceClaudeCode)) + assert.Nil(t, p.parseLogRecord(lr, testResource(model.AICodeOtelSourceClaudeCode, nil), "")) } func TestParseMetric_UnknownReturnsEmpty(t *testing.T) { p := NewAICodeOtelProcessor(model.ShellTimeConfig{}) m := &metricsv1.Metric{Name: "nope"} - got := p.parseMetric(m, &model.AICodeOtelResourceAttributes{}, model.AICodeOtelSourceClaudeCode) + got := p.parseMetric(m, testResource(model.AICodeOtelSourceClaudeCode, nil), "") assert.Empty(t, got) } diff --git a/daemon/aicode_otel_processor_test.go b/daemon/aicode_otel_processor_test.go index 7f3b99a..a74c3a9 100644 --- a/daemon/aicode_otel_processor_test.go +++ b/daemon/aicode_otel_processor_test.go @@ -1,10 +1,14 @@ package daemon import ( + "math" "testing" "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" commonv1 "go.opentelemetry.io/proto/otlp/common/v1" + logsv1 "go.opentelemetry.io/proto/otlp/logs/v1" resourcev1 "go.opentelemetry.io/proto/otlp/resource/v1" ) @@ -184,251 +188,206 @@ func TestMapMetricName_ClaudeCode(t *testing.T) { for _, tc := range testCases { t.Run(tc.input, func(t *testing.T) { - result := mapMetricName(tc.input, model.AICodeOtelSourceClaudeCode) - if result != tc.expected { - t.Errorf("Expected %s, got %s", tc.expected, result) - } + assert.Equal(t, tc.expected, mapMetricName(tc.input)) }) } } -func TestMapMetricName_Codex(t *testing.T) { - testCases := []struct { - input string - expected string - }{ - {"codex.session.count", model.AICodeMetricSessionCount}, - {"codex.token.usage", model.AICodeMetricTokenUsage}, - {"codex.cost.usage", model.AICodeMetricCostUsage}, - {"codex.lines_of_code.count", model.AICodeMetricLinesOfCodeCount}, - {"codex.commit.count", model.AICodeMetricCommitCount}, - {"codex.pull_request.count", model.AICodeMetricPullRequestCount}, - {"codex.active_time.total", model.AICodeMetricActiveTimeTotal}, - } - - for _, tc := range testCases { - t.Run(tc.input, func(t *testing.T) { - result := mapMetricName(tc.input, model.AICodeOtelSourceCodex) - if result != tc.expected { - t.Errorf("Expected %s, got %s", tc.expected, result) - } - }) +// Codex exports no such metrics (its OTLP metrics carry no session id), so the +// former codex.* aliases are gone. +func TestMapMetricName_CodexNamesAreNotMapped(t *testing.T) { + for _, name := range []string{ + "codex.session.count", "codex.token.usage", "codex.cost.usage", "codex.lines_of_code.count", + "codex.commit.count", "codex.pull_request.count", "codex.active_time.total", "codex.tool.call", + } { + assert.Empty(t, mapMetricName(name), name) } } -func TestMapEventName_ClaudeCode(t *testing.T) { +func TestNormalizeOtelEventName(t *testing.T) { testCases := []struct { input string expected string }{ {"claude_code.user_prompt", model.AICodeEventUserPrompt}, + {"user_prompt", model.AICodeEventUserPrompt}, // Claude's event.name attribute has no prefix {"claude_code.tool_result", model.AICodeEventToolResult}, {"claude_code.api_request", model.AICodeEventApiRequest}, {"claude_code.api_error", model.AICodeEventApiError}, {"claude_code.tool_decision", model.AICodeEventToolDecision}, + {"claude_code.assistant_response", model.AICodeEventAssistantResponse}, + {"claude_code.hook_execution_complete", "hook_execution_complete"}, + {"codex.user_prompt", model.AICodeEventUserPrompt}, + {"codex.tool_result", model.AICodeEventToolResult}, + {"codex.exec_command", model.AICodeEventExecCommand}, + {"codex.conversation_starts", model.AICodeEventConversationStarts}, + {"codex.sse_event", model.AICodeEventSSEEvent}, + {"codex.agent_response", model.AICodeEventAgentResponse}, + {"codex.turn_cost", model.AICodeEventTurnCost}, + {" custom.event ", "custom.event"}, // unknown names pass through for the server's "other" + // dropped + {"claude_code.api_request_body", ""}, + {"claude_code.api_response_body", ""}, + {"claude_code.system_prompt", ""}, + {"api_request_body", ""}, + {"", ""}, } for _, tc := range testCases { t.Run(tc.input, func(t *testing.T) { - result := mapEventName(tc.input, model.AICodeOtelSourceClaudeCode) - if result != tc.expected { - t.Errorf("Expected %s, got %s", tc.expected, result) - } + assert.Equal(t, tc.expected, normalizeOtelEventName(tc.input)) }) } } -func TestMapEventName_Codex(t *testing.T) { +func arrVal(values ...*commonv1.AnyValue) *commonv1.AnyValue { + return &commonv1.AnyValue{Value: &commonv1.AnyValue_ArrayValue{ArrayValue: &commonv1.ArrayValue{Values: values}}} +} + +func TestOptInt(t *testing.T) { testCases := []struct { - input string - expected string + name string + value *commonv1.AnyValue + expected *int }{ - {"codex.user_prompt", model.AICodeEventUserPrompt}, - {"codex.tool_result", model.AICodeEventToolResult}, - {"codex.api_request", model.AICodeEventApiRequest}, - {"codex.api_error", model.AICodeEventApiError}, - {"codex.tool_decision", model.AICodeEventToolDecision}, - {"codex.exec_command", model.AICodeEventExecCommand}, - {"codex.conversation_starts", model.AICodeEventConversationStarts}, - {"codex.sse_event", model.AICodeEventSSEEvent}, + {"int", intVal(42), model.IntRef(42)}, + {"int zero is kept", intVal(0), model.IntRef(0)}, + {"integral double", dblVal(1200), model.IntRef(1200)}, + {"fractional double rounds", dblVal(12.6), model.IntRef(13)}, + {"numeric string", strVal("123"), model.IntRef(123)}, + {"string zero is kept", strVal("0"), model.IntRef(0)}, + {"padded string", strVal(" 7 "), model.IntRef(7)}, + {"float string", strVal("250.0"), model.IntRef(250)}, + {"invalid string", strVal("not-a-number"), nil}, + {"empty string", strVal(""), nil}, + {"bool", boolVal(true), nil}, + {"NaN", dblVal(math.NaN()), nil}, + {"nil", nil, nil}, } for _, tc := range testCases { - t.Run(tc.input, func(t *testing.T) { - result := mapEventName(tc.input, model.AICodeOtelSourceCodex) - if result != tc.expected { - t.Errorf("Expected %s, got %s", tc.expected, result) - } + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, optInt(tc.value)) }) } -} -func TestMapEventName_Unknown(t *testing.T) { - // Unknown events should return as-is - result := mapEventName("custom.event", "") - if result != "custom.event" { - t.Errorf("Unknown events should be returned as-is, got %s", result) - } + n := optInt64Ref(strVal("9007199254740993")) + require.NotNil(t, n) + assert.Equal(t, int64(9007199254740993), *n, "int64 strings keep full precision") } -func TestGetIntFromValue(t *testing.T) { +func TestOptBool(t *testing.T) { testCases := []struct { name string value *commonv1.AnyValue - expected int + expected *bool }{ - { - "int value", - &commonv1.AnyValue{Value: &commonv1.AnyValue_IntValue{IntValue: 42}}, - 42, - }, - { - "string value", - &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: "123"}}, - 123, - }, - { - "invalid string", - &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: "not-a-number"}}, - 0, - }, - { - "empty string", - &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: ""}}, - 0, - }, + {"bool true", boolVal(true), model.BoolRef(true)}, + {"bool false is kept", boolVal(false), model.BoolRef(false)}, + {"string true", strVal("true"), model.BoolRef(true)}, + {"string false is kept", strVal("false"), model.BoolRef(false)}, + {"string TRUE", strVal("TRUE"), model.BoolRef(true)}, + {"invalid string", strVal("maybe"), nil}, + {"int", intVal(1), nil}, + {"nil", nil, nil}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := getIntFromValue(tc.value) - if result != tc.expected { - t.Errorf("Expected %d, got %d", tc.expected, result) - } + assert.Equal(t, tc.expected, optBool(tc.value)) }) } } -func TestGetBoolFromValue(t *testing.T) { +func TestOptFloat(t *testing.T) { testCases := []struct { name string value *commonv1.AnyValue - expected bool + expected *float64 }{ - { - "bool true", - &commonv1.AnyValue{Value: &commonv1.AnyValue_BoolValue{BoolValue: true}}, - true, - }, - { - "bool false", - &commonv1.AnyValue{Value: &commonv1.AnyValue_BoolValue{BoolValue: false}}, - false, - }, - { - "string true", - &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: "true"}}, - true, - }, - { - "string false", - &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: "false"}}, - false, - }, - { - "invalid string", - &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: "maybe"}}, - false, - }, + {"double", dblVal(3.14), model.Float64Ref(3.14)}, + {"double zero is kept", dblVal(0), model.Float64Ref(0)}, + {"int", intVal(1), model.Float64Ref(1)}, + {"int zero is kept", intVal(0), model.Float64Ref(0)}, + {"string", strVal("2.71"), model.Float64Ref(2.71)}, + {"invalid string", strVal("not-a-float"), nil}, + {"infinite", dblVal(math.Inf(1)), nil}, + {"bool", boolVal(true), nil}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := getBoolFromValue(tc.value) - if result != tc.expected { - t.Errorf("Expected %v, got %v", tc.expected, result) - } + assert.Equal(t, tc.expected, optFloat(tc.value)) }) } } -func TestGetFloatFromValue(t *testing.T) { +func TestStringList(t *testing.T) { testCases := []struct { name string value *commonv1.AnyValue - expected float64 + expected []string }{ - { - "double value", - &commonv1.AnyValue{Value: &commonv1.AnyValue_DoubleValue{DoubleValue: 3.14}}, - 3.14, - }, - { - "string value", - &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: "2.71"}}, - 2.71, - }, - { - "invalid string", - &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: "not-a-float"}}, - 0, - }, + {"array", arrVal(strVal("a"), strVal("b"), strVal("c")), []string{"a", "b", "c"}}, + {"array skips empty", arrVal(strVal("a"), strVal(""), strVal(" b ")), []string{"a", "b"}}, + {"codex joined string", strVal("filesystem, github"), []string{"filesystem", "github"}}, + {"comma string", strVal("a,b"), []string{"a", "b"}}, + {"single", strVal("only"), []string{"only"}}, + {"empty string", strVal(""), nil}, + {"empty array", arrVal(), nil}, + {"int", intVal(3), nil}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := getFloatFromValue(tc.value) - if result != tc.expected { - t.Errorf("Expected %f, got %f", tc.expected, result) - } + assert.Equal(t, tc.expected, stringList(tc.value)) }) } } -func TestGetStringArrayFromValue(t *testing.T) { - t.Run("valid array", func(t *testing.T) { - value := &commonv1.AnyValue{ - Value: &commonv1.AnyValue_ArrayValue{ - ArrayValue: &commonv1.ArrayValue{ - Values: []*commonv1.AnyValue{ - {Value: &commonv1.AnyValue_StringValue{StringValue: "a"}}, - {Value: &commonv1.AnyValue_StringValue{StringValue: "b"}}, - {Value: &commonv1.AnyValue_StringValue{StringValue: "c"}}, - }, - }, - }, - } - - result := getStringArrayFromValue(value) - if len(result) != 3 { - t.Errorf("Expected 3 elements, got %d", len(result)) - } - if result[0] != "a" || result[1] != "b" || result[2] != "c" { - t.Errorf("Array content mismatch") - } - }) - - t.Run("nil array", func(t *testing.T) { - value := &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: "not-array"}} - result := getStringArrayFromValue(value) - if result != nil { - t.Error("Expected nil for non-array value") - } - }) - - t.Run("empty array", func(t *testing.T) { - value := &commonv1.AnyValue{ - Value: &commonv1.AnyValue_ArrayValue{ - ArrayValue: &commonv1.ArrayValue{ - Values: []*commonv1.AnyValue{}, - }, +func TestAnyValueToGo(t *testing.T) { + kvlist := &commonv1.AnyValue{Value: &commonv1.AnyValue_KvlistValue{KvlistValue: &commonv1.KeyValueList{Values: []*commonv1.KeyValue{ + kv("name", strVal("x")), + kv("nested", arrVal(intVal(1), boolVal(false), dblVal(1.5))), + }}}} + + assert.Equal(t, "s", anyValueToGo(strVal("s"))) + assert.Equal(t, int64(4), anyValueToGo(intVal(4))) + assert.Equal(t, 2.5, anyValueToGo(dblVal(2.5))) + assert.Equal(t, false, anyValueToGo(boolVal(false))) + assert.Equal(t, "aGk=", anyValueToGo(&commonv1.AnyValue{Value: &commonv1.AnyValue_BytesValue{BytesValue: []byte("hi")}})) + assert.Equal(t, "NaN", anyValueToGo(dblVal(math.NaN())), "JSON can't encode NaN") + assert.Nil(t, anyValueToGo(nil)) + assert.Equal(t, map[string]any{"name": "x", "nested": []any{int64(1), false, 1.5}}, anyValueToGo(kvlist)) +} + +func TestResolveEventName(t *testing.T) { + testCases := []struct { + name string + record *logsv1.LogRecord + expected string + }{ + { + "event.name attribute wins", + &logsv1.LogRecord{ + EventName: "claude_code.other", + Body: strVal("claude_code.body"), + Attributes: []*commonv1.KeyValue{kv("event.name", strVal("user_prompt"))}, }, - } - result := getStringArrayFromValue(value) - if len(result) != 0 { - t.Errorf("Expected 0 elements, got %d", len(result)) - } - }) + "user_prompt", + }, + {"EventName field", &logsv1.LogRecord{EventName: "codex.skill_invocation", Body: strVal("x")}, "codex.skill_invocation"}, + {"string body", &logsv1.LogRecord{Body: strVal("claude_code.api_request")}, "claude_code.api_request"}, + {"free text body is no event name", &logsv1.LogRecord{Body: strVal("something went wrong")}, ""}, + {"empty event.name falls through", &logsv1.LogRecord{EventName: "x.y", Attributes: []*commonv1.KeyValue{kv("event.name", strVal(""))}}, "x.y"}, + {"nothing", &logsv1.LogRecord{}, ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, resolveEventName(tc.record)) + }) + } } func TestApplyResourceAttributesToMetric(t *testing.T) { diff --git a/daemon/aicode_otel_processor_v2_test.go b/daemon/aicode_otel_processor_v2_test.go new file mode 100644 index 0000000..3c0f2f3 --- /dev/null +++ b/daemon/aicode_otel_processor_v2_test.go @@ -0,0 +1,998 @@ +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + collogsv1 "go.opentelemetry.io/proto/otlp/collector/logs/v1" + collmetricsv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + commonv1 "go.opentelemetry.io/proto/otlp/common/v1" + logsv1 "go.opentelemetry.io/proto/otlp/logs/v1" + metricsv1 "go.opentelemetry.io/proto/otlp/metrics/v1" + resourcev1 "go.opentelemetry.io/proto/otlp/resource/v1" + "google.golang.org/protobuf/proto" +) + +// Fixtures shaped after the Claude Code monitoring reference (monitoring-usage.md) and Codex's +// codex-rs/otel event macros: which attributes exist and how each one is encoded. + +const v2TestTimeNano = 1_791_446_400_123_456_789 // 2026-10-08T08:00:00.123456789Z + +func claudeTestResource() *resourcev1.Resource { + return serviceResource("claude-code", + kv("service.version", strVal("2.1.300")), + kv("os.type", strVal("darwin")), + kv("os.version", strVal("25.0.0")), + kv("host.arch", strVal("arm64")), + kv("vcs.repository.url.full", strVal("https://github.com/example-org/example-repo")), + kv("vcs.provider.name", strVal("github")), + kv("user.name", strVal("alice")), + kv("machine.name", strVal("mbp")), + kv("team.id", strVal("shelltime")), + ) +} + +// claudeRecord builds a Claude Code log record. Claude Code puts the unprefixed name in the +// event.name attribute and the prefixed one in the record's event name. +func claudeRecord(name string, attrs ...*commonv1.KeyValue) *logsv1.LogRecord { + base := []*commonv1.KeyValue{ + kv("event.name", strVal(name)), + kv("event.timestamp", strVal("2026-10-08T08:00:00.123Z")), + kv("event.sequence", intVal(41)), + kv("prompt.id", strVal("6f1c1d6e-prompt")), + kv("session.id", strVal("sess-claude")), + kv("app.version", strVal("2.1.300")), + kv("organization.id", strVal("org-1")), + kv("user.account_uuid", strVal("acct-uuid")), + kv("user.account_id", strVal("user_01ABC")), + kv("user.id", strVal("anon-1")), + kv("user.email", strVal("dev@example.com")), + kv("terminal.type", strVal("iTerm.app")), + kv("app.entrypoint", strVal("cli")), + } + return &logsv1.LogRecord{ + TimeUnixNano: v2TestTimeNano, + EventName: "claude_code." + name, + Body: strVal("claude_code." + name), + Attributes: append(base, attrs...), + } +} + +func codexTestResource() *resourcev1.Resource { + return serviceResource("codex_cli_rs", + kv("service.version", strVal("0.50.0")), + kv("env", strVal("prod")), + kv("host.name", strVal("dev-box")), + ) +} + +// codexRecord builds a Codex log record the way log_event! orders it: event.name and the +// event's own fields first, then the shared metadata. +func codexRecord(name string, attrs ...*commonv1.KeyValue) *logsv1.LogRecord { + all := []*commonv1.KeyValue{kv("event.name", strVal("codex."+name))} + all = append(all, attrs...) + all = append(all, + kv("event.timestamp", strVal("2026-10-08T08:00:00.123Z")), + kv("conversation.id", strVal("conv-1")), + kv("app.version", strVal("0.50.0")), + kv("auth_mode", strVal("Chatgpt")), + kv("originator", strVal("codex_cli_rs")), + kv("user.account_id", strVal("acct-codex")), + kv("user.email", strVal("dev@example.com")), + kv("terminal.type", strVal("iTerm.app")), + kv("model", strVal("gpt-5-codex")), + kv("slug", strVal("gpt-5-codex")), + ) + return &logsv1.LogRecord{TimeUnixNano: v2TestTimeNano, Attributes: all} +} + +func parseTestRecord(t *testing.T, resource *resourcev1.Resource, lr *logsv1.LogRecord) *model.AICodeOtelEvent { + t.Helper() + source := detectOtelSource(resource) + require.NotEmpty(t, source) + p := NewAICodeOtelProcessor(model.ShellTimeConfig{}) + return p.parseLogRecord(lr, newOtelResource(resource, source), "test-scope") +} + +func TestParseLogRecord_ClaudeSpecEvents(t *testing.T) { + testCases := []struct { + name string + record *logsv1.LogRecord + check func(t *testing.T, ev *model.AICodeOtelEvent) + }{ + { + name: "user_prompt", + record: claudeRecord("user_prompt", + kv("prompt_length", intVal(18)), + kv("prompt", strVal("fix the failing test")), + kv("prompt_text", strVal("fix the failing test")), + kv("message.uuid", strVal("msg-uuid-1")), + kv("command_name", strVal("compact")), + kv("command_source", strVal("builtin")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventUserPrompt, ev.EventType) + assert.Equal(t, model.IntRef(18), ev.PromptLength) + assert.Equal(t, "fix the failing test", ev.Prompt) + assert.Equal(t, "msg-uuid-1", ev.Attributes["message.uuid"]) + assert.Equal(t, "compact", ev.Attributes["command_name"]) + assert.Equal(t, "builtin", ev.Attributes["command_source"]) + assert.NotContains(t, ev.Attributes, "prompt_text", "mapped content isn't duplicated") + }, + }, + { + name: "user_prompt redacted", + record: claudeRecord("user_prompt", + kv("prompt_length", strVal("18")), + kv("prompt", strVal("")), + kv("prompt_text", strVal("")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Empty(t, ev.Prompt) + assert.Equal(t, model.IntRef(18), ev.PromptLength) + assert.NotContains(t, ev.Attributes, "prompt") + }, + }, + { + name: "user_prompt from prompt_text", + record: claudeRecord("user_prompt", kv("prompt_text", strVal("only in prompt_text"))), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, "only in prompt_text", ev.Prompt) + }, + }, + { + name: "assistant_response", + record: claudeRecord("assistant_response", + kv("response_length", intVal(17)), + kv("response", strVal("Done! I fixed it.")), + kv("model", strVal("claude-sonnet-5")), + kv("request_id", strVal("req_011CXabc")), + kv("message.uuid", strVal("msg-uuid-2")), + kv("query_source", strVal("repl_main_thread")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventAssistantResponse, ev.EventType) + assert.Equal(t, "Done! I fixed it.", ev.Response) + assert.Equal(t, model.IntRef(17), ev.ResponseLength) + assert.Equal(t, "claude-sonnet-5", ev.Model) + assert.Equal(t, "req_011CXabc", ev.RequestID) + assert.Equal(t, "repl_main_thread", ev.QuerySource) + }, + }, + { + name: "assistant_response redacted", + record: claudeRecord("assistant_response", kv("response_length", intVal(17)), kv("response", strVal(""))), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Empty(t, ev.Response) + assert.Equal(t, model.IntRef(17), ev.ResponseLength) + }, + }, + { + name: "tool_result failed", + record: claudeRecord("tool_result", + kv("tool_name", strVal("Bash")), + kv("tool_use_id", strVal("toolu_01")), + kv("success", strVal("false")), + kv("duration_ms", intVal(1234)), + kv("error_type", strVal("ShellError")), + kv("error", strVal("Command failed with exit code 1")), + kv("decision_type", strVal("accept")), + kv("decision_source", strVal("config")), + kv("tool_input_size_bytes", intVal(64)), + kv("tool_result_size_bytes", intVal(512)), + kv("tool_parameters", strVal(`{"bash_command":"go","full_command":"go test ./...","timeout":120000,"description":"Run tests"}`)), + kv("tool_input", strVal(`{"command":"go test ./...","description":"Run tests"}`)), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventToolResult, ev.EventType) + assert.Equal(t, "Bash", ev.ToolName) + assert.Equal(t, "toolu_01", ev.CallID) + assert.Equal(t, model.BoolRef(false), ev.Success) + assert.Equal(t, model.IntRef(1234), ev.DurationMs) + assert.Equal(t, "Command failed with exit code 1", ev.Error, "error wins over error_type") + assert.Equal(t, "ShellError", ev.Attributes["error_type"]) + assert.Equal(t, `{"command":"go test ./...","description":"Run tests"}`, ev.ToolInput) + assert.Nil(t, ev.ToolArguments) + require.NotNil(t, ev.ToolParameters) + assert.Equal(t, "go test ./...", ev.ToolParameters["full_command"]) + assert.Equal(t, "accept", ev.Attributes["decision_type"]) + assert.Equal(t, "config", ev.Attributes["decision_source"]) + assert.Equal(t, int64(64), ev.Attributes["tool_input_size_bytes"]) + assert.Equal(t, int64(512), ev.Attributes["tool_result_size_bytes"]) + }, + }, + { + name: "tool_result error_type only", + record: claudeRecord("tool_result", + kv("tool_name", strVal("Read")), + kv("success", strVal("false")), + kv("error_type", strVal("Error:ENOENT")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, "Error:ENOENT", ev.Error) + assert.Equal(t, "Error:ENOENT", ev.Attributes["error_type"]) + }, + }, + { + name: "tool_decision", + record: claudeRecord("tool_decision", + kv("tool_name", strVal("Edit")), + kv("tool_use_id", strVal("toolu_02")), + kv("decision", strVal("reject")), + kv("source", strVal("user_reject")), + kv("tool_source", strVal("builtin")), + kv("tool_parameters", strVal(`{"file_path":"/repo/main.go"}`)), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventToolDecision, ev.EventType) + assert.Equal(t, "Edit", ev.ToolName) + assert.Equal(t, "toolu_02", ev.CallID) + assert.Equal(t, "reject", ev.Decision) + assert.Equal(t, "user_reject", ev.Source) + assert.Equal(t, "builtin", ev.Attributes["tool_source"]) + assert.Equal(t, "/repo/main.go", ev.ToolParameters["file_path"]) + }, + }, + { + name: "api_request with zero cost", + record: claudeRecord("api_request", + kv("model", strVal("claude-sonnet-5")), + kv("cost_usd", intVal(0)), + kv("cost_usd_micros", intVal(0)), + kv("duration_ms", intVal(2100)), + kv("input_tokens", intVal(0)), + kv("output_tokens", intVal(350)), + kv("cache_read_tokens", intVal(12000)), + kv("cache_creation_tokens", intVal(0)), + kv("request_id", strVal("req_011CXdef")), + kv("client_request_id", strVal("client-uuid")), + kv("speed", strVal("fast")), + kv("query_source", strVal("repl_main_thread")), + kv("effort", strVal("high")), + kv("agent.name", strVal("Explore")), + kv("skill.name", strVal("custom")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventApiRequest, ev.EventType) + assert.Equal(t, model.Float64Ref(0), ev.CostUSD, "an int-encoded 0 is kept") + assert.Equal(t, model.IntRef(0), ev.InputTokens) + assert.Equal(t, model.IntRef(350), ev.OutputTokens) + assert.Equal(t, model.IntRef(12000), ev.CacheReadTokens) + assert.Equal(t, model.IntRef(0), ev.CacheCreationTokens) + assert.Equal(t, model.IntRef(2100), ev.DurationMs) + assert.Equal(t, "req_011CXdef", ev.RequestID) + assert.Equal(t, "fast", ev.Speed) + assert.Equal(t, "repl_main_thread", ev.QuerySource) + assert.Equal(t, "high", ev.ReasoningEffort) + assert.Equal(t, "client-uuid", ev.Attributes["client_request_id"]) + assert.Equal(t, "Explore", ev.Attributes["agent.name"]) + assert.NotContains(t, ev.Attributes, "cost_usd_micros") + }, + }, + { + name: "api_request with int cost", + record: claudeRecord("api_request", kv("cost_usd", intVal(1)), kv("cost_usd_micros", intVal(1_000_000))), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.Float64Ref(1), ev.CostUSD) + }, + }, + { + name: "api_request cost from micros", + record: claudeRecord("api_request", kv("cost_usd_micros", intVal(1_234_567))), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + require.NotNil(t, ev.CostUSD) + assert.InDelta(t, 1.234567, *ev.CostUSD, 1e-12) + }, + }, + { + name: "api_error", + record: claudeRecord("api_error", + kv("model", strVal("claude-sonnet-5")), + kv("error", strVal("Overloaded")), + kv("status_code", intVal(529)), + kv("attempt", intVal(3)), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventApiError, ev.EventType) + assert.Equal(t, "Overloaded", ev.Error) + assert.Equal(t, model.IntRef(529), ev.StatusCode) + assert.Equal(t, model.IntRef(3), ev.Attempt) + }, + }, + { + name: "compaction", + record: claudeRecord("compaction", + kv("trigger", strVal("auto")), + kv("success", strVal("true")), + kv("duration_ms", intVal(5000)), + kv("pre_tokens", intVal(150000)), + kv("post_tokens", intVal(20000)), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, "compaction", ev.EventType) + assert.Equal(t, model.BoolRef(true), ev.Success) + assert.Equal(t, model.IntRef(5000), ev.DurationMs) + assert.Equal(t, "auto", ev.Attributes["trigger"]) + assert.Equal(t, int64(150000), ev.Attributes["pre_tokens"]) + assert.Equal(t, int64(20000), ev.Attributes["post_tokens"]) + }, + }, + { + name: "hook_execution_complete", + record: claudeRecord("hook_execution_complete", + kv("hook_event", strVal("PreToolUse")), + kv("hook_name", strVal("PreToolUse:Write")), + kv("num_hooks", intVal(2)), + kv("num_success", intVal(2)), + kv("num_blocking", intVal(0)), + kv("num_non_blocking_error", intVal(0)), + kv("num_cancelled", intVal(0)), + kv("total_duration_ms", intVal(45)), + kv("managed_only", strVal("false")), + kv("hook_source", strVal("merged")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, "hook_execution_complete", ev.EventType) + assert.Equal(t, "PreToolUse:Write", ev.Attributes["hook_name"]) + assert.Equal(t, int64(2), ev.Attributes["num_success"]) + assert.Equal(t, int64(0), ev.Attributes["num_blocking"], "zero counts are kept") + assert.Equal(t, int64(45), ev.Attributes["total_duration_ms"]) + assert.Equal(t, "false", ev.Attributes["managed_only"]) + }, + }, + { + name: "subagent_completed", + record: claudeRecord("subagent_completed", + kv("agent_type", strVal("Explore")), + kv("agent.source", strVal("built-in")), + kv("is_built_in", boolVal(true)), + kv("is_async", boolVal(false)), + kv("total_tokens", intVal(12345)), + kv("total_tool_uses", intVal(7)), + kv("duration_ms", intVal(60000)), + kv("model", strVal("claude-haiku-5")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, "subagent_completed", ev.EventType) + assert.Equal(t, model.IntRef(60000), ev.DurationMs) + assert.Equal(t, "claude-haiku-5", ev.Model) + assert.Equal(t, "Explore", ev.Attributes["agent_type"]) + assert.Equal(t, true, ev.Attributes["is_built_in"]) + assert.Equal(t, false, ev.Attributes["is_async"], "false flags are kept") + assert.Equal(t, int64(12345), ev.Attributes["total_tokens"]) + assert.Nil(t, ev.InputTokens, "total_tokens isn't a token count of this event") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ev := parseTestRecord(t, claudeTestResource(), tc.record) + require.NotNil(t, ev) + + // Shared by every Claude Code event. + assert.True(t, strings.HasPrefix(ev.EventID, model.AICodeOtelIDPrefix), ev.EventID) + assert.Equal(t, model.AICodeOtelSourceClaudeCode, ev.ClientType) + assert.Equal(t, int64(1_791_446_400), ev.Timestamp) + assert.Equal(t, int64(1_791_446_400_123), ev.TimestampMs) + assert.Equal(t, "2026-10-08T08:00:00.123Z", ev.EventTimestamp) + assert.Equal(t, "6f1c1d6e-prompt", ev.PromptID) + assert.Equal(t, model.Int64Ref(41), ev.Sequence) + assert.Equal(t, "sess-claude", ev.SessionID) + assert.Equal(t, "acct-uuid", ev.UserAccountUUID, "user.account_uuid wins over user.account_id") + assert.Equal(t, "cli", ev.Entrypoint) + assert.Equal(t, "alice", ev.UserName) + assert.Equal(t, "mbp", ev.MachineName) + assert.Equal(t, "darwin", ev.OSType) + // Resource attributes without a field + assert.Equal(t, "claude-code", ev.Attributes["service.name"]) + assert.Equal(t, "2.1.300", ev.Attributes["service.version"]) + assert.Equal(t, "https://github.com/example-org/example-repo", ev.Attributes["vcs.repository.url.full"]) + assert.Equal(t, "github", ev.Attributes["vcs.provider.name"]) + // Identity attributes have fields and stay out of the catch-all. + for _, key := range []string{"session.id", "user.email", "prompt.id", "event.sequence", "event.name", "user.name", "machine.name", "team.id"} { + assert.NotContains(t, ev.Attributes, key) + } + + tc.check(t, ev) + }) + } +} + +func TestParseLogRecord_CodexSpecEvents(t *testing.T) { + testCases := []struct { + name string + record *logsv1.LogRecord + check func(t *testing.T, ev *model.AICodeOtelEvent) + }{ + { + name: "conversation_starts", + record: codexRecord("conversation_starts", + kv("provider_name", strVal("openai")), + kv("auth.env_openai_api_key_present", boolVal(false)), + kv("reasoning_effort", strVal("high")), + kv("reasoning_summary", strVal("auto")), + kv("context_window", intVal(272000)), + kv("auto_compact_token_limit", intVal(244800)), + kv("approval_policy", strVal("on-request")), + kv("sandbox_policy", strVal("workspace-write")), + kv("mcp_servers", strVal("filesystem, github")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventConversationStarts, ev.EventType) + assert.Equal(t, "openai", ev.Provider) + assert.Equal(t, "high", ev.ReasoningEffort) + assert.Equal(t, "auto", ev.ReasoningSummary) + assert.Equal(t, model.IntRef(272000), ev.ContextWindow) + assert.Equal(t, model.IntRef(244800), ev.AutoCompactTokenLimit) + assert.Equal(t, "on-request", ev.ApprovalPolicy) + assert.Equal(t, "workspace-write", ev.SandboxPolicy) + assert.Equal(t, []string{"filesystem", "github"}, ev.MCPServers) + assert.Equal(t, false, ev.Attributes["auth.env_openai_api_key_present"]) + }, + }, + { + name: "sse_event response.completed", + record: codexRecord("sse_event", + kv("event.kind", strVal("response.completed")), + kv("input_token_count", strVal("1000")), + kv("output_token_count", strVal("200")), + kv("cached_token_count", intVal(800)), + kv("cache_write_token_count", intVal(0)), + kv("reasoning_token_count", intVal(64)), + kv("tool_token_count", strVal("1200")), + kv("ttft_ms", intVal(350)), + kv("service_tier", strVal("flex")), + kv("model_reasoning_effort", strVal("medium")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventSSEEvent, ev.EventType) + assert.Equal(t, "response.completed", ev.EventKind) + assert.Equal(t, model.IntRef(1000), ev.InputTokens) + assert.Equal(t, model.IntRef(200), ev.OutputTokens) + assert.Equal(t, model.IntRef(800), ev.CacheReadTokens) + assert.Equal(t, model.IntRef(0), ev.CacheCreationTokens) + assert.Equal(t, model.IntRef(64), ev.ReasoningTokens) + assert.Nil(t, ev.ToolTokens, "tool_token_count is total_tokens, not tool tokens") + assert.Equal(t, int64(1200), ev.Attributes["total_token_count"]) + assert.Equal(t, int64(350), ev.Attributes["ttft_ms"]) + assert.Equal(t, "flex", ev.Attributes["service_tier"]) + assert.Equal(t, "medium", ev.ReasoningEffort) + }, + }, + { + name: "sse_event failed", + record: codexRecord("sse_event", + kv("duration_ms", strVal("12")), + kv("error.message", strVal("stream disconnected before completion")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, "stream disconnected before completion", ev.Error) + assert.Equal(t, model.IntRef(12), ev.DurationMs) + }, + }, + { + name: "tool_result apply_patch", + record: codexRecord("tool_result", + kv("tool_result_seq", intVal(5)), + kv("tool_name", strVal("apply_patch")), + kv("tool_namespace", strVal("functions")), + kv("call_id", strVal("call_abc")), + kv("duration_ms", strVal("37")), + kv("success", strVal("true")), + kv("output_truncated", boolVal(false)), + kv("agent_name", strVal("main")), + kv("arguments", strVal("*** Begin Patch\n*** Update File: main.go\n@@\n-old\n+new\n*** End Patch")), + kv("output", strVal("Success. Updated the following files:\nM main.go")), + kv("mcp_server", strVal("")), + kv("mcp_server_origin", strVal("")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventToolResult, ev.EventType) + assert.Equal(t, "apply_patch", ev.ToolName) + assert.Equal(t, "call_abc", ev.CallID) + assert.Equal(t, model.IntRef(37), ev.DurationMs) + assert.Equal(t, model.BoolRef(true), ev.Success) + assert.Equal(t, "*** Begin Patch\n*** Update File: main.go\n@@\n-old\n+new\n*** End Patch", ev.ToolInput, "freeform arguments are kept raw") + assert.Nil(t, ev.ToolArguments) + assert.Equal(t, "Success. Updated the following files:\nM main.go", ev.ToolOutput) + assert.Equal(t, int64(5), ev.Attributes["tool_result_seq"]) + assert.Equal(t, "functions", ev.Attributes["tool_namespace"]) + assert.Equal(t, false, ev.Attributes["output_truncated"]) + assert.Equal(t, "main", ev.Attributes["agent_name"]) + assert.NotContains(t, ev.Attributes, "mcp_server", "empty values are skipped") + assert.NotContains(t, ev.Attributes, "output") + }, + }, + { + name: "tool_result shell JSON arguments", + record: codexRecord("tool_result", + kv("tool_name", strVal("shell")), + kv("call_id", strVal("call_def")), + kv("success", strVal("false")), + kv("arguments", strVal(`{"command":["bash","-lc","go test ./..."],"workdir":"/repo"}`)), + kv("output", strVal("exit status 1")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.BoolRef(false), ev.Success) + assert.Equal(t, `{"command":["bash","-lc","go test ./..."],"workdir":"/repo"}`, ev.ToolInput) + assert.Nil(t, ev.ToolArguments, "arguments no longer fill toolArguments") + }, + }, + { + name: "tool_decision", + record: codexRecord("tool_decision", + kv("tool_name", strVal("shell")), + kv("tool_namespace", strVal("functions")), + kv("call_id", strVal("call_xyz")), + kv("decision", strVal("approved_for_session")), + kv("source", strVal("User")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventToolDecision, ev.EventType) + assert.Equal(t, "call_xyz", ev.CallID) + assert.Equal(t, "approved_for_session", ev.Decision) + assert.Equal(t, "User", ev.Source) + }, + }, + { + name: "turn_cost", + record: codexRecord("turn_cost", + kv("turn.id", strVal("turn-1")), + kv("usage.estimated_usd", strVal("0.0123")), + kv("turn.interrupted", boolVal(false)), + kv("speed", strVal("fast")), + kv("reasoning_effort", strVal("high")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventTurnCost, ev.EventType) + assert.Nil(t, ev.CostUSD, "the turn's cost is already counted per request") + assert.Equal(t, "0.0123", ev.Attributes["usage.estimated_usd"]) + assert.Equal(t, "turn-1", ev.Attributes["turn.id"]) + assert.Equal(t, false, ev.Attributes["turn.interrupted"]) + assert.Equal(t, "fast", ev.Speed) + assert.Equal(t, "high", ev.ReasoningEffort) + }, + }, + { + name: "agent_response", + record: codexRecord("agent_response", + kv("agent.type", strVal("main")), + kv("turn.id", strVal("turn-2")), + kv("item.id", strVal("msg_1")), + kv("response", strVal("Here is the summary")), + kv("response_length", intVal(19)), + kv("response_truncated", boolVal(false)), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventAgentResponse, ev.EventType) + assert.Equal(t, "Here is the summary", ev.Response) + assert.Equal(t, model.IntRef(19), ev.ResponseLength) + assert.Equal(t, "main", ev.Attributes["agent.type"]) + assert.Equal(t, "msg_1", ev.Attributes["item.id"]) + }, + }, + { + name: "user_prompt redacted", + record: codexRecord("user_prompt", kv("prompt_length", strVal("15")), kv("prompt", strVal("[REDACTED]"))), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.AICodeEventUserPrompt, ev.EventType) + assert.Empty(t, ev.Prompt) + assert.Equal(t, model.IntRef(15), ev.PromptLength) + }, + }, + { + name: "api_request", + record: codexRecord("api_request", + kv("duration_ms", strVal("830")), + kv("http.response.status_code", intVal(200)), + kv("attempt", intVal(1)), + kv("endpoint", strVal("/responses")), + ), + check: func(t *testing.T, ev *model.AICodeOtelEvent) { + assert.Equal(t, model.IntRef(830), ev.DurationMs) + assert.Equal(t, model.IntRef(200), ev.StatusCode) + assert.Equal(t, model.IntRef(1), ev.Attempt) + assert.Equal(t, "/responses", ev.Attributes["endpoint"]) + assert.Empty(t, ev.Error) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ev := parseTestRecord(t, codexTestResource(), tc.record) + require.NotNil(t, ev) + + // Shared by every Codex event. + assert.True(t, strings.HasPrefix(ev.EventID, model.AICodeOtelIDPrefix), ev.EventID) + assert.Equal(t, model.AICodeOtelSourceCodex, ev.ClientType) + assert.Equal(t, int64(1_791_446_400_123), ev.TimestampMs) + assert.Equal(t, "conv-1", ev.ConversationID) + assert.Equal(t, "conv-1", ev.SessionID) + assert.Equal(t, "acct-codex", ev.UserAccountUUID) + assert.Equal(t, "Chatgpt", ev.AuthMode) + assert.Equal(t, "gpt-5-codex", ev.Model) + assert.Equal(t, "codex_cli_rs", ev.Entrypoint, "from originator") + assert.Equal(t, "dev-box", ev.MachineName, "host.name is the machine name fallback") + assert.Equal(t, "prod", ev.Attributes["env"]) + assert.Equal(t, "dev-box", ev.Attributes["host.name"]) + assert.Equal(t, "0.50.0", ev.Attributes["service.version"]) + assert.NotContains(t, ev.Attributes, "originator") + + tc.check(t, ev) + }) + } +} + +func TestParseLogRecord_EntrypointFallsBackToServiceName(t *testing.T) { + resource := serviceResource("claude-code-desktop") + lr := &logsv1.LogRecord{TimeUnixNano: v2TestTimeNano, Attributes: []*commonv1.KeyValue{kv("event.name", strVal("user_prompt"))}} + ev := parseTestRecord(t, resource, lr) + require.NotNil(t, ev) + assert.Equal(t, "claude-code-desktop", ev.Entrypoint) +} + +func TestParseLogRecord_RecordAttributeOverridesResourceAttribute(t *testing.T) { + resource := serviceResource("claude-code", kv("vcs.repository.name", strVal("from-resource")), kv("department", strVal("eng"))) + lr := &logsv1.LogRecord{TimeUnixNano: v2TestTimeNano, Attributes: []*commonv1.KeyValue{ + kv("event.name", strVal("user_prompt")), + kv("vcs.repository.name", strVal("from-record")), + }} + ev := parseTestRecord(t, resource, lr) + require.NotNil(t, ev) + assert.Equal(t, "from-record", ev.Attributes["vcs.repository.name"]) + assert.Equal(t, "eng", ev.Attributes["department"], "custom OTEL_RESOURCE_ATTRIBUTES keys are kept") +} + +func TestParseLogRecord_EventNameAndBodyFallback(t *testing.T) { + p := NewAICodeOtelProcessor(model.ShellTimeConfig{}) + res := newOtelResource(claudeTestResource(), model.AICodeOtelSourceClaudeCode) + + byEventName := &logsv1.LogRecord{TimeUnixNano: v2TestTimeNano, EventName: "claude_code.user_prompt", Attributes: []*commonv1.KeyValue{kv("prompt_length", intVal(3))}} + ev := p.parseLogRecord(byEventName, res, "") + require.NotNil(t, ev) + assert.Equal(t, model.AICodeEventUserPrompt, ev.EventType) + + byBody := &logsv1.LogRecord{TimeUnixNano: v2TestTimeNano, Body: strVal("claude_code.tool_result")} + ev = p.parseLogRecord(byBody, res, "") + require.NotNil(t, ev) + assert.Equal(t, model.AICodeEventToolResult, ev.EventType) + + plainLog := &logsv1.LogRecord{TimeUnixNano: v2TestTimeNano, Body: strVal("an ordinary log line")} + assert.Nil(t, p.parseLogRecord(plainLog, res, "")) +} + +func TestProcessLogs_DropListIsNotForwarded(t *testing.T) { + cp := newCaptureProcessor(t, model.ShellTimeConfig{Token: "tok"}) + + req := &collogsv1.ExportLogsServiceRequest{ResourceLogs: []*logsv1.ResourceLogs{{ + Resource: claudeTestResource(), + ScopeLogs: []*logsv1.ScopeLogs{{LogRecords: []*logsv1.LogRecord{ + claudeRecord("api_request_body", kv("body", strVal(`{"messages":[]}`))), + claudeRecord("api_response_body", kv("body", strVal(`{"content":[]}`))), + claudeRecord("system_prompt", kv("system_prompt", strVal("You are Claude Code"))), + claudeRecord("user_prompt", kv("prompt_length", intVal(1))), + }}}, + }}} + + _, err := cp.processor.ProcessLogs(context.Background(), req) + require.NoError(t, err) + reqs := cp.captured() + require.Len(t, reqs, 1) + require.Len(t, reqs[0].Events, 1) + assert.Equal(t, model.AICodeEventUserPrompt, reqs[0].Events[0].EventType) + + // Only dropped events -> no request at all. + onlyDropped := &collogsv1.ExportLogsServiceRequest{ResourceLogs: []*logsv1.ResourceLogs{{ + Resource: claudeTestResource(), + ScopeLogs: []*logsv1.ScopeLogs{{LogRecords: []*logsv1.LogRecord{claudeRecord("api_request_body")}}}, + }}} + _, err = cp.processor.ProcessLogs(context.Background(), onlyDropped) + require.NoError(t, err) + assert.Len(t, cp.captured(), 1) +} + +func TestStableOtelIDs(t *testing.T) { + logsRequest := func() *collogsv1.ExportLogsServiceRequest { + return &collogsv1.ExportLogsServiceRequest{ResourceLogs: []*logsv1.ResourceLogs{{ + Resource: claudeTestResource(), + ScopeLogs: []*logsv1.ScopeLogs{{ + Scope: &commonv1.InstrumentationScope{Name: "com.anthropic.claude_code.events"}, + LogRecords: []*logsv1.LogRecord{ + claudeRecord("user_prompt", kv("prompt_length", intVal(1))), + claudeRecord("api_request", kv("cost_usd", dblVal(0.5))), + }, + }}, + }}} + } + eventIDs := func(t *testing.T, req *collogsv1.ExportLogsServiceRequest) []string { + cp := newCaptureProcessor(t, model.ShellTimeConfig{Token: "tok"}) + _, err := cp.processor.ProcessLogs(context.Background(), req) + require.NoError(t, err) + reqs := cp.captured() + require.Len(t, reqs, 1) + var ids []string + for _, ev := range reqs[0].Events { + ids = append(ids, ev.EventID) + } + return ids + } + + first := eventIDs(t, logsRequest()) + require.Len(t, first, 2) + assert.NotEqual(t, first[0], first[1], "different records get different ids") + for _, id := range first { + assert.Regexp(t, `^ot1:[0-9a-f]{40}$`, id) + } + + // An exporter retry sends the same payload again: same ids, so the server de-duplicates. + retry := eventIDs(t, proto.Clone(logsRequest()).(*collogsv1.ExportLogsServiceRequest)) + assert.Equal(t, first, retry) + + changedRecord := logsRequest() + changedRecord.ResourceLogs[0].ScopeLogs[0].LogRecords[1].Attributes = append(changedRecord.ResourceLogs[0].ScopeLogs[0].LogRecords[1].Attributes, kv("speed", strVal("fast"))) + changed := eventIDs(t, changedRecord) + assert.Equal(t, first[0], changed[0]) + assert.NotEqual(t, first[1], changed[1], "a different record gets a different id") + + changedResource := logsRequest() + changedResource.ResourceLogs[0].Resource.Attributes = append(changedResource.ResourceLogs[0].Resource.Attributes, kv("host.name", strVal("other"))) + assert.NotEqual(t, first, eventIDs(t, changedResource), "the resource is part of the id") + + changedScope := logsRequest() + changedScope.ResourceLogs[0].ScopeLogs[0].Scope.Name = "other.scope" + assert.NotEqual(t, first, eventIDs(t, changedScope), "the scope is part of the id") + + // Metrics: same data point, same id; a later data point, another id. + p := NewAICodeOtelProcessor(model.ShellTimeConfig{}) + res := newOtelResource(claudeTestResource(), model.AICodeOtelSourceClaudeCode) + metric := func(ts uint64) *metricsv1.Metric { + return &metricsv1.Metric{Name: "claude_code.cost.usage", Data: &metricsv1.Metric_Sum{Sum: &metricsv1.Sum{ + AggregationTemporality: metricsv1.AggregationTemporality_AGGREGATION_TEMPORALITY_DELTA, + DataPoints: []*metricsv1.NumberDataPoint{{TimeUnixNano: ts, Value: &metricsv1.NumberDataPoint_AsDouble{AsDouble: 0.1}}}, + }}} + } + m1 := p.parseMetric(metric(v2TestTimeNano), res, "com.anthropic.claude_code") + m2 := p.parseMetric(metric(v2TestTimeNano), res, "com.anthropic.claude_code") + m3 := p.parseMetric(metric(v2TestTimeNano+10_000_000_000), res, "com.anthropic.claude_code") + require.Len(t, m1, 1) + assert.Regexp(t, `^ot1:[0-9a-f]{40}$`, m1[0].MetricID) + assert.Equal(t, m1[0].MetricID, m2[0].MetricID) + assert.NotEqual(t, m1[0].MetricID, m3[0].MetricID) +} + +func sumMetric(name string, temporality metricsv1.AggregationTemporality, value int64, attrs ...*commonv1.KeyValue) *metricsv1.Metric { + return &metricsv1.Metric{Name: name, Data: &metricsv1.Metric_Sum{Sum: &metricsv1.Sum{ + AggregationTemporality: temporality, + IsMonotonic: true, + DataPoints: []*metricsv1.NumberDataPoint{{ + TimeUnixNano: v2TestTimeNano, + Value: &metricsv1.NumberDataPoint_AsInt{AsInt: value}, + Attributes: attrs, + }}, + }}} +} + +func TestParseMetric_TypeRoutingAndAttributes(t *testing.T) { + p := NewAICodeOtelProcessor(model.ShellTimeConfig{}) + res := newOtelResource(claudeTestResource(), model.AICodeOtelSourceClaudeCode) + delta := metricsv1.AggregationTemporality_AGGREGATION_TEMPORALITY_DELTA + parse := func(m *metricsv1.Metric) model.AICodeOtelMetric { + t.Helper() + got := p.parseMetric(m, res, "com.anthropic.claude_code") + require.Len(t, got, 1) + return got[0] + } + + token := parse(sumMetric("claude_code.token.usage", delta, 1200, + kv("type", strVal("cacheRead")), + kv("model", strVal("claude-sonnet-5")), + kv("query_source", strVal("main")), + kv("effort", strVal("high")), + kv("session.id", strVal("sess-m")), + kv("user.name", strVal("alice")), + kv("machine.name", strVal("mbp")), + kv("team.id", strVal("shelltime")), + )) + assert.Equal(t, model.AICodeMetricTokenUsage, token.MetricType) + assert.Equal(t, "cacheRead", token.TokenType) + assert.Empty(t, token.LinesType) + assert.Equal(t, "claude-sonnet-5", token.Model) + assert.Equal(t, int64(1_791_446_400_123), token.TimestampMs) + assert.Equal(t, "sess-m", token.SessionID) + assert.Equal(t, map[string]any{"query_source": "main", "effort": "high"}, token.Attributes, "identity keys have fields") + + lines := parse(sumMetric("claude_code.lines_of_code.count", delta, 10, kv("type", strVal("added")))) + assert.Equal(t, "added", lines.LinesType) + assert.Empty(t, lines.TokenType) + + active := parse(sumMetric("claude_code.active_time.total", delta, 30, kv("type", strVal("cli")))) + assert.Empty(t, active.TokenType, "active time's type isn't a token type") + assert.Empty(t, active.LinesType) + assert.Equal(t, "cli", active.Attributes["type"]) + + cost := parse(sumMetric("claude_code.cost.usage", delta, 1, kv("speed", strVal("fast")))) + assert.Empty(t, cost.TokenType) + assert.Equal(t, "fast", cost.Attributes["speed"]) + + decision := parse(sumMetric("claude_code.code_edit_tool.decision", delta, 1, + kv("tool_name", strVal("Edit")), + kv("decision", strVal("accept")), + kv("source", strVal("user_temporary")), + kv("language", strVal("Go")), + )) + assert.Equal(t, "Edit", decision.Tool, "tool_name is read") + assert.Equal(t, "accept", decision.Decision) + assert.Equal(t, "Go", decision.Language) + assert.Equal(t, "user_temporary", decision.Attributes["source"]) + + legacyTool := parse(sumMetric("claude_code.code_edit_tool.decision", delta, 1, kv("tool", strVal("Write")))) + assert.Equal(t, "Write", legacyTool.Tool) +} + +func TestParseMetric_CumulativeTemporalityWarnsOnce(t *testing.T) { + var buf bytes.Buffer + var mu sync.Mutex + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&lockedWriter{w: &buf, mu: &mu}, &slog.HandlerOptions{Level: slog.LevelWarn}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + p := NewAICodeOtelProcessor(model.ShellTimeConfig{}) + res := newOtelResource(claudeTestResource(), model.AICodeOtelSourceClaudeCode) + cumulative := metricsv1.AggregationTemporality_AGGREGATION_TEMPORALITY_CUMULATIVE + for i := 0; i < 3; i++ { + got := p.parseMetric(sumMetric("claude_code.session.count", cumulative, 1), res, "") + require.Len(t, got, 1, "cumulative data is still forwarded") + } + p.parseMetric(sumMetric("claude_code.session.count", metricsv1.AggregationTemporality_AGGREGATION_TEMPORALITY_DELTA, 1), res, "") + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 1, strings.Count(buf.String(), "cumulative"), buf.String()) +} + +type lockedWriter struct { + w *bytes.Buffer + mu *sync.Mutex +} + +func (l *lockedWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.w.Write(p) +} + +func TestParseLogRecord_AttributeCaps(t *testing.T) { + attrs := []*commonv1.KeyValue{kv("event.name", strVal("tool_result"))} + for i := 0; i < 100; i++ { + attrs = append(attrs, kv(fmt.Sprintf("custom.attr_%03d", i), intVal(int64(i)))) + } + long := strings.Repeat("é", 4000) // 8000 bytes + attrs = append(attrs, + kv("tool_input", strVal(strings.Repeat("i", 100<<10))), + kv("output", strVal(strings.Repeat("o", 100<<10))), + kv("response", strVal(strings.Repeat("r", 100<<10))), + ) + lr := &logsv1.LogRecord{TimeUnixNano: v2TestTimeNano, Attributes: attrs} + + ev := parseTestRecord(t, serviceResource("codex_cli_rs", kv("vcs.repository.url.full", strVal("https://github.com/o/r"))), lr) + require.NotNil(t, ev) + assert.Len(t, ev.Attributes, model.AICodeOtelMaxAttributes) + assert.Equal(t, "https://github.com/o/r", ev.Attributes["vcs.repository.url.full"], "resource attributes are kept first") + assert.Equal(t, "codex_cli_rs", ev.Attributes["service.name"]) + assert.NotContains(t, ev.Attributes, "custom.attr_099", "attributes past the cap are dropped") + assert.Len(t, ev.ToolInput, model.AICodeOtelMaxTextBytes) + assert.Len(t, ev.ToolOutput, model.AICodeOtelMaxTextBytes) + assert.Len(t, ev.Response, model.AICodeOtelMaxTextBytes) + + // A long value is capped without splitting a UTF-8 character. + lr = &logsv1.LogRecord{TimeUnixNano: v2TestTimeNano, Attributes: []*commonv1.KeyValue{ + kv("event.name", strVal("tool_result")), + kv("hook_definitions", strVal(long)), + kv("workspace.host_paths", arrVal(strVal(long), strVal("/b"))), + kv("tool_parameters", strVal(long)), // not a JSON object: kept raw, capped + }} + ev = parseTestRecord(t, serviceResource("claude-code"), lr) + require.NotNil(t, ev) + capped, _ := ev.Attributes["hook_definitions"].(string) + assert.Len(t, capped, model.AICodeOtelMaxAttributeBytes) + assert.True(t, strings.HasPrefix(long, capped)) + paths, _ := ev.Attributes["workspace.host_paths"].([]any) + require.Len(t, paths, 2) + assert.Len(t, paths[0], model.AICodeOtelMaxAttributeBytes) + assert.Equal(t, "/b", paths[1]) + assert.Len(t, ev.Attributes["tool_parameters"], model.AICodeOtelMaxAttributeBytes) + + // Everything still encodes as JSON. + _, err := json.Marshal(ev) + require.NoError(t, err) +} + +func TestProcessLogs_ChunkedSending(t *testing.T) { + var mu sync.Mutex + var bodies [][]byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + _, _ = buf.ReadFrom(r.Body) + mu.Lock() + bodies = append(bodies, buf.Bytes()) + mu.Unlock() + _ = json.NewEncoder(w).Encode(model.AICodeOtelResponse{Success: true}) + })) + t.Cleanup(server.Close) + + p := NewAICodeOtelProcessor(model.ShellTimeConfig{Token: "tok", APIEndpoint: server.URL}) + const limit = 16 << 10 + p.maxRequestBytes = limit + + var records []*logsv1.LogRecord + for i := 0; i < 40; i++ { + records = append(records, claudeRecord("tool_result", + kv("tool_use_id", strVal(fmt.Sprintf("toolu_%02d", i))), + kv("tool_input", strVal(strings.Repeat("x", 1500))), + )) + } + req := &collogsv1.ExportLogsServiceRequest{ResourceLogs: []*logsv1.ResourceLogs{{ + Resource: claudeTestResource(), + ScopeLogs: []*logsv1.ScopeLogs{{LogRecords: records}}, + }}} + _, err := p.ProcessLogs(context.Background(), req) + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + require.Greater(t, len(bodies), 1, "the batch is split") + var callIDs []string + for _, body := range bodies { + assert.LessOrEqual(t, len(body), limit) + var got model.AICodeOtelRequest + require.NoError(t, json.Unmarshal(body, &got)) + assert.Equal(t, model.AICodeOtelSourceClaudeCode, got.Source) + for _, ev := range got.Events { + callIDs = append(callIDs, ev.CallID) + } + } + require.Len(t, callIDs, 40) + for i, id := range callIDs { + assert.Equal(t, fmt.Sprintf("toolu_%02d", i), id, "order is kept") + } +} + +func TestProcessMetrics_ChunkedSending(t *testing.T) { + cp := newCaptureProcessor(t, model.ShellTimeConfig{Token: "tok"}) + cp.processor.maxRequestBytes = 4 << 10 + + var metrics []*metricsv1.Metric + for i := 0; i < 30; i++ { + metrics = append(metrics, sumMetric("claude_code.token.usage", metricsv1.AggregationTemporality_AGGREGATION_TEMPORALITY_DELTA, int64(i), kv("type", strVal("input")))) + } + req := &collmetricsv1.ExportMetricsServiceRequest{ResourceMetrics: []*metricsv1.ResourceMetrics{{ + Resource: claudeTestResource(), + ScopeMetrics: []*metricsv1.ScopeMetrics{{Metrics: metrics}}, + }}} + _, err := cp.processor.ProcessMetrics(context.Background(), req) + require.NoError(t, err) + + reqs := cp.captured() + require.Greater(t, len(reqs), 1) + total := 0 + for _, r := range reqs { + for _, m := range r.Metrics { + assert.Equal(t, float64(total), m.Value, "order is kept") + total++ + } + } + assert.Equal(t, 30, total) +} diff --git a/daemon/aicode_otel_server.go b/daemon/aicode_otel_server.go index 6a1f953..5db7cba 100644 --- a/daemon/aicode_otel_server.go +++ b/daemon/aicode_otel_server.go @@ -11,6 +11,10 @@ import ( "google.golang.org/grpc" ) +// aiCodeOtelMaxRecvMsgSize raises gRPC's 4 MB default: with tool details and assistant responses +// enabled, one Claude Code or Codex export batch can carry several 60 KB attributes per record. +const aiCodeOtelMaxRecvMsgSize = 32 << 20 + // AICodeOtelServer is the gRPC server for receiving OTEL data from AI coding CLIs (Claude Code, Codex, etc.) type AICodeOtelServer struct { port int @@ -36,7 +40,7 @@ func (s *AICodeOtelServer) Start() error { } s.listener = listener - s.grpcServer = grpc.NewServer() + s.grpcServer = grpc.NewServer(grpc.MaxRecvMsgSize(aiCodeOtelMaxRecvMsgSize)) // Register OTEL collector services collmetricsv1.RegisterMetricsServiceServer(s.grpcServer, &metricsServiceServer{processor: s.processor}) diff --git a/daemon/aicode_otel_server_test.go b/daemon/aicode_otel_server_test.go index a2b13ab..5e0b669 100644 --- a/daemon/aicode_otel_server_test.go +++ b/daemon/aicode_otel_server_test.go @@ -1,9 +1,13 @@ package daemon import ( + "bytes" "context" + "encoding/json" "net/http" "net/http/httptest" + "strings" + "sync" "testing" "time" @@ -12,6 +16,8 @@ import ( "github.com/stretchr/testify/require" collogsv1 "go.opentelemetry.io/proto/otlp/collector/logs/v1" collmetricsv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + logsv1 "go.opentelemetry.io/proto/otlp/logs/v1" + metricsv1 "go.opentelemetry.io/proto/otlp/metrics/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) @@ -84,3 +90,155 @@ func TestAICodeOtelServer_ExportRoundTrip(t *testing.T) { require.NoError(t, err) require.NotNil(t, lResp) } + +// rawBackend records the raw JSON bodies the processor POSTs. +type rawBackend struct { + server *httptest.Server + mu sync.Mutex + bodies []string +} + +func newRawBackend(t *testing.T) *rawBackend { + t.Helper() + b := &rawBackend{} + b.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + _, _ = buf.ReadFrom(r.Body) + b.mu.Lock() + b.bodies = append(b.bodies, buf.String()) + b.mu.Unlock() + _ = json.NewEncoder(w).Encode(model.AICodeOtelResponse{Success: true}) + })) + t.Cleanup(b.server.Close) + return b +} + +func (b *rawBackend) captured() []string { + b.mu.Lock() + defer b.mu.Unlock() + return append([]string(nil), b.bodies...) +} + +func startTestOtelServer(t *testing.T, backendURL string) *grpc.ClientConn { + t.Helper() + proc := NewAICodeOtelProcessor(model.ShellTimeConfig{Token: "t", APIEndpoint: backendURL}) + server := NewAICodeOtelServer(0, proc) + require.NoError(t, server.Start()) + t.Cleanup(server.Stop) + + conn, err := grpc.NewClient(server.listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + return conn +} + +// TestAICodeOtelServer_ExportRoundTripPayload sends spec-shaped Claude Code and Codex exports +// over gRPC and checks the JSON the backend receives. +func TestAICodeOtelServer_ExportRoundTripPayload(t *testing.T) { + backend := newRawBackend(t) + conn := startTestOtelServer(t, backend.server.URL) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + logs := &collogsv1.ExportLogsServiceRequest{ResourceLogs: []*logsv1.ResourceLogs{ + { + Resource: claudeTestResource(), + ScopeLogs: []*logsv1.ScopeLogs{{LogRecords: []*logsv1.LogRecord{ + claudeRecord("tool_result", + kv("tool_name", strVal("Bash")), + kv("tool_use_id", strVal("toolu_01")), + kv("success", strVal("false")), + kv("duration_ms", intVal(0)), + kv("tool_input", strVal(`{"command":"false"}`)), + ), + claudeRecord("api_request", kv("cost_usd", intVal(0)), kv("input_tokens", intVal(0))), + }}}, + }, + { + Resource: codexTestResource(), + ScopeLogs: []*logsv1.ScopeLogs{{LogRecords: []*logsv1.LogRecord{ + codexRecord("conversation_starts", kv("mcp_servers", strVal("a, b")), kv("reasoning_enabled", boolVal(false))), + }}}, + }, + }} + _, err := collogsv1.NewLogsServiceClient(conn).Export(ctx, logs) + require.NoError(t, err) + + metrics := &collmetricsv1.ExportMetricsServiceRequest{ResourceMetrics: []*metricsv1.ResourceMetrics{{ + Resource: claudeTestResource(), + ScopeMetrics: []*metricsv1.ScopeMetrics{{Metrics: []*metricsv1.Metric{ + sumMetric("claude_code.active_time.total", metricsv1.AggregationTemporality_AGGREGATION_TEMPORALITY_DELTA, 12, kv("type", strVal("user"))), + }}}, + }}} + _, err = collmetricsv1.NewMetricsServiceClient(conn).Export(ctx, metrics) + require.NoError(t, err) + + bodies := backend.captured() + require.Len(t, bodies, 3) // one per resource (claude logs, codex logs, claude metrics) + claudeLogs, codexLogs, claudeMetrics := bodies[0], bodies[1], bodies[2] + + // False and zero values are sent, not omitted. + assert.Contains(t, claudeLogs, `"success":false`) + assert.Contains(t, claudeLogs, `"durationMs":0`) + assert.Contains(t, claudeLogs, `"costUsd":0`) + assert.Contains(t, claudeLogs, `"inputTokens":0`) + assert.Contains(t, claudeLogs, `"timestampMs":1791446400123`) + assert.Contains(t, claudeLogs, `"timestamp":1791446400,`, "seconds are kept for older servers") + assert.Contains(t, claudeLogs, `"sequence":41`) + assert.Contains(t, claudeLogs, `"promptId":"6f1c1d6e-prompt"`) + assert.Contains(t, claudeLogs, `"callId":"toolu_01"`) + assert.Contains(t, claudeLogs, `"toolInput":"{\"command\":\"false\"}"`) + assert.Contains(t, claudeLogs, `"entrypoint":"cli"`) + assert.Contains(t, claudeLogs, `"eventId":"ot1:`) + + var claudeReq struct { + Events []map[string]any `json:"events"` + } + require.NoError(t, json.Unmarshal([]byte(claudeLogs), &claudeReq)) + require.Len(t, claudeReq.Events, 2) + attrs, _ := claudeReq.Events[0]["attributes"].(map[string]any) + assert.Equal(t, "https://github.com/example-org/example-repo", attrs["vcs.repository.url.full"]) + + assert.Contains(t, codexLogs, `"mcpServers":["a","b"]`) + assert.Contains(t, codexLogs, `"reasoningEnabled":false`) + assert.Contains(t, codexLogs, `"sessionId":"conv-1"`) + assert.Contains(t, codexLogs, `"machineName":"dev-box"`) + + assert.Contains(t, claudeMetrics, `"timestampMs":1791446400123`) + assert.Contains(t, claudeMetrics, `"attributes":{"type":"user"}`) + assert.Contains(t, claudeMetrics, `"metricId":"ot1:`) + assert.NotContains(t, claudeMetrics, `"tokenType"`) +} + +// TestAICodeOtelServer_AcceptsLargeExports checks the raised receive limit: gRPC's 4 MB default +// would reject this export. +func TestAICodeOtelServer_AcceptsLargeExports(t *testing.T) { + backend := newRawBackend(t) + conn := startTestOtelServer(t, backend.server.URL) + + var records []*logsv1.LogRecord + for i := 0; i < 100; i++ { + records = append(records, codexRecord("tool_result", + kv("call_id", strVal(strings.Repeat("c", 8))), + kv("output", strVal(strings.Repeat("o", 60<<10))), + )) + } + req := &collogsv1.ExportLogsServiceRequest{ResourceLogs: []*logsv1.ResourceLogs{{ + Resource: codexTestResource(), + ScopeLogs: []*logsv1.ScopeLogs{{LogRecords: records}}, + }}} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := collogsv1.NewLogsServiceClient(conn).Export(ctx, req) + require.NoError(t, err) + + events := 0 + for _, body := range backend.captured() { + var got model.AICodeOtelRequest + require.NoError(t, json.Unmarshal([]byte(body), &got)) + events += len(got.Events) + } + assert.Equal(t, 100, events) +} diff --git a/go.mod b/go.mod index 5fe86a6..3db5379 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( golang.org/x/net v0.48.0 golang.org/x/sys v0.39.0 google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -85,6 +86,5 @@ require ( golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/model/aicode_backfill_claude.go b/model/aicode_backfill_claude.go index 12df285..93e8dab 100644 --- a/model/aicode_backfill_claude.go +++ b/model/aicode_backfill_claude.go @@ -252,7 +252,7 @@ func (p *claudeParser) handleAssistant(l *claudeTranscriptLine, ts time.Time) { isError: l.IsAPIErrorMessage, } if req.isError { - req.errorText = capBackfillText(claudeBlocksText(blocks), backfillMaxErrorBytes) + req.errorText = CapAICodeText(claudeBlocksText(blocks), backfillMaxErrorBytes) if req.model == claudeSyntheticModel { req.model = "" } @@ -345,10 +345,10 @@ func (p *claudeParser) build(opts BackfillOptions) []*BackfillSession { if r.isError { e.Error = r.errorText } else { - e.InputTokens = intRef(r.usage.InputTokens) - e.OutputTokens = intRef(r.usage.OutputTokens) - e.CacheReadTokens = intRef(r.usage.CacheReadInputTokens) - e.CacheCreationTokens = intRef(r.usage.CacheCreationInputTokens) + e.InputTokens = IntRef(r.usage.InputTokens) + e.OutputTokens = IntRef(r.usage.OutputTokens) + e.CacheReadTokens = IntRef(r.usage.CacheReadInputTokens) + e.CacheCreationTokens = IntRef(r.usage.CacheCreationInputTokens) e.CostUSD = r.costUSD } session(sid).Events = append(session(sid).Events, e) @@ -358,9 +358,9 @@ func (p *claudeParser) build(opts BackfillOptions) []*BackfillSession { pr := p.prompts[uuid] sid := p.earliestSession(pr.sessions) e := newBackfillEvent(AICodeClientClaudeCode, AICodeEventUserPrompt, "prompt:"+uuid, sid, pr.ts) - e.PromptLength = intRef(utf8.RuneCountInString(pr.text)) + e.PromptLength = IntRef(utf8.RuneCountInString(pr.text)) if !opts.NoPrompts { - e.Prompt = capBackfillText(pr.text, backfillMaxPromptBytes) + e.Prompt = CapAICodeText(pr.text, backfillMaxPromptBytes) } session(sid).Events = append(session(sid).Events, e) } @@ -374,7 +374,7 @@ func (p *claudeParser) build(opts BackfillOptions) []*BackfillSession { sid := p.earliestSession(res.sessions) e := newBackfillEvent(AICodeClientClaudeCode, AICodeEventToolResult, "tool:"+id, sid, res.ts) e.ToolName = use.name - e.Success = boolRef(!res.isError) + e.Success = BoolRef(!res.isError) e.DurationMs = res.durationMs e.ToolParameters = use.params session(sid).Events = append(session(sid).Events, e) @@ -513,10 +513,10 @@ func claudeToolDuration(raw json.RawMessage) *int { return nil } if result.DurationMs != nil { - return intRef(int(*result.DurationMs)) + return IntRef(int(*result.DurationMs)) } if result.TotalDurationMs != nil { - return intRef(int(*result.TotalDurationMs)) + return IntRef(int(*result.TotalDurationMs)) } return nil } diff --git a/model/aicode_backfill_codex.go b/model/aicode_backfill_codex.go index 34083ff..09d521e 100644 --- a/model/aicode_backfill_codex.go +++ b/model/aicode_backfill_codex.go @@ -362,9 +362,9 @@ func (p *codexParser) addPrompt(st *codexFileState, text, rawTs string, ts time. return } e := newBackfillEvent(AICodeClientCodex, AICodeEventUserPrompt, naturalKey, st.sessionID, ts) - e.PromptLength = intRef(utf8.RuneCountInString(text)) + e.PromptLength = IntRef(utf8.RuneCountInString(text)) if !p.opts.NoPrompts { - e.Prompt = capBackfillText(text, backfillMaxPromptBytes) + e.Prompt = CapAICodeText(text, backfillMaxPromptBytes) } st.events = append(st.events, e) } @@ -420,10 +420,10 @@ func (p *codexParser) handleTokenCount(st *codexFileState, msg *codexEventMsg, r e.EventKind = codexResponseCompleted e.Model = model // Same semantics as the live pipeline: input includes cached tokens. - e.InputTokens = intRef(usage.InputTokens) - e.CacheReadTokens = intRef(usage.CachedInputTokens) - e.OutputTokens = intRef(usage.OutputTokens) - e.ReasoningTokens = intRef(usage.ReasoningOutputTokens) + e.InputTokens = IntRef(usage.InputTokens) + e.CacheReadTokens = IntRef(usage.CachedInputTokens) + e.OutputTokens = IntRef(usage.OutputTokens) + e.ReasoningTokens = IntRef(usage.ReasoningOutputTokens) st.events = append(st.events, e) } @@ -574,10 +574,10 @@ func codexToolOutcome(raw json.RawMessage) (*bool, *int) { var success *bool var duration *int if structured.Metadata.ExitCode != nil { - success = boolRef(*structured.Metadata.ExitCode == 0) + success = BoolRef(*structured.Metadata.ExitCode == 0) } if structured.Metadata.DurationSeconds != nil { - duration = intRef(int(*structured.Metadata.DurationSeconds * 1000)) + duration = IntRef(int(*structured.Metadata.DurationSeconds * 1000)) } return success, duration } @@ -586,12 +586,12 @@ func codexToolOutcome(raw json.RawMessage) (*bool, *int) { var duration *int if m := codexExitCodePattern.FindStringSubmatch(text); m != nil { if code, err := strconv.Atoi(m[1]); err == nil { - success = boolRef(code == 0) + success = BoolRef(code == 0) } } if m := codexWallTimePattern.FindStringSubmatch(text); m != nil { if secs, err := strconv.ParseFloat(m[1], 64); err == nil { - duration = intRef(int(secs * 1000)) + duration = IntRef(int(secs * 1000)) } } return success, duration diff --git a/model/aicode_backfill_codex_test.go b/model/aicode_backfill_codex_test.go index 7ed00d8..773b251 100644 --- a/model/aicode_backfill_codex_test.go +++ b/model/aicode_backfill_codex_test.go @@ -206,10 +206,10 @@ func TestCodexToolOutcome(t *testing.T) { wantSuccess *bool wantDuration *int }{ - {name: "json metadata", output: `"{\"output\":\"ok\",\"metadata\":{\"exit_code\":0,\"duration_seconds\":0.25}}"`, wantSuccess: boolRef(true), wantDuration: intRef(250)}, - {name: "text form", output: `"Exit code: 2\nWall time: 1.5 seconds\nOutput:\nboom"`, wantSuccess: boolRef(false), wantDuration: intRef(1500)}, + {name: "json metadata", output: `"{\"output\":\"ok\",\"metadata\":{\"exit_code\":0,\"duration_seconds\":0.25}}"`, wantSuccess: BoolRef(true), wantDuration: IntRef(250)}, + {name: "text form", output: `"Exit code: 2\nWall time: 1.5 seconds\nOutput:\nboom"`, wantSuccess: BoolRef(false), wantDuration: IntRef(1500)}, {name: "plain output", output: `"hello"`}, - {name: "object output", output: `{"metadata":{"exit_code":0}}`, wantSuccess: boolRef(true)}, + {name: "object output", output: `{"metadata":{"exit_code":0}}`, wantSuccess: BoolRef(true)}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/model/aicode_backfill_common.go b/model/aicode_backfill_common.go index 69ed6f2..4f0ef64 100644 --- a/model/aicode_backfill_common.go +++ b/model/aicode_backfill_common.go @@ -15,7 +15,6 @@ import ( "sort" "strings" "time" - "unicode/utf8" ) // Text limits for backfilled events. They keep a 500-event batch well under @@ -316,20 +315,3 @@ func finalizeBackfillSession(s *BackfillSession) { s.End = last } } - -// capBackfillText truncates s to at most maxBytes without splitting a UTF-8 -// character. -func capBackfillText(s string, maxBytes int) string { - if len(s) <= maxBytes { - return s - } - cut := maxBytes - for cut > 0 && !utf8.RuneStart(s[cut]) { - cut-- - } - return s[:cut] -} - -func intRef(v int) *int { return &v } - -func boolRef(v bool) *bool { return &v } diff --git a/model/aicode_otel_claude_settings.go b/model/aicode_otel_claude_settings.go index f430014..b73edae 100644 --- a/model/aicode_otel_claude_settings.go +++ b/model/aicode_otel_claude_settings.go @@ -48,9 +48,16 @@ func claudeSettingsOtelEnvVars() []claudeSettingsEnvVar { {"OTEL_METRIC_EXPORT_INTERVAL", "10000"}, {"OTEL_LOGS_EXPORT_INTERVAL", "5000"}, {"OTEL_LOG_USER_PROMPTS", "1"}, + // Tool details: Bash commands, MCP/skill names, file paths, truncated tool input, errors. + {"OTEL_LOG_TOOL_DETAILS", "1"}, + {"OTEL_LOG_ASSISTANT_RESPONSES", "1"}, {"OTEL_METRICS_INCLUDE_SESSION_ID", "true"}, {"OTEL_METRICS_INCLUDE_VERSION", "true"}, {"OTEL_METRICS_INCLUDE_ACCOUNT_UUID", "true"}, + {"OTEL_METRICS_INCLUDE_ENTRYPOINT", "true"}, + {"OTEL_METRICS_INCLUDE_REPOSITORY", "true"}, + // The server sums metric data points, which is only correct for delta temporality. + {"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", "delta"}, {"OTEL_RESOURCE_ATTRIBUTES", claudeSettingsResourceAttributes()}, } } @@ -169,6 +176,34 @@ func (s *ClaudeSettingsAICodeOtelEnvService) Check() error { return nil } +// MissingManagedKeys returns the managed env vars that settings.json doesn't set, in install +// order. Settings written by an older `shelltime cc install` lack the newer keys. A key the user +// set to another value (such as OTEL_LOG_TOOL_DETAILS=0 to opt out) isn't reported. A missing +// file yields every key. +func (s *ClaudeSettingsAICodeOtelEnvService) MissingManagedKeys() ([]string, error) { + data, _, err := s.readSettings() + if err != nil { + return nil, err + } + + var settings struct { + Env map[string]any `json:"env"` + } + if data != nil { + if err := json.Unmarshal(data, &settings); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", s.settingsPath, err) + } + } + + var missing []string + for _, v := range claudeSettingsOtelEnvVars() { + if _, ok := settings.Env[v.Key]; !ok { + missing = append(missing, v.Key) + } + } + return missing, nil +} + // StatusLineCommand returns the statusLine.command configured in settings.json. // A missing file or key yields an empty string and no error. func (s *ClaudeSettingsAICodeOtelEnvService) StatusLineCommand() (string, error) { diff --git a/model/aicode_otel_claude_settings_test.go b/model/aicode_otel_claude_settings_test.go index de422e8..5982959 100644 --- a/model/aicode_otel_claude_settings_test.go +++ b/model/aicode_otel_claude_settings_test.go @@ -269,3 +269,61 @@ func TestClaudeSettings_StatusLineCommand(t *testing.T) { _, err = svc.StatusLineCommand() assert.Error(t, err) } + +func TestClaudeSettingsOtel_InstallEnablesDetailsAndDeltaTemporality(t *testing.T) { + svc, path := setupClaudeSettingsTest(t) + + require.NoError(t, svc.Install()) + + env := readClaudeSettingsEnv(t, path) + assert.Equal(t, "1", env["OTEL_LOG_TOOL_DETAILS"]) + assert.Equal(t, "1", env["OTEL_LOG_ASSISTANT_RESPONSES"]) + assert.Equal(t, "true", env["OTEL_METRICS_INCLUDE_ENTRYPOINT"]) + assert.Equal(t, "true", env["OTEL_METRICS_INCLUDE_REPOSITORY"]) + assert.Equal(t, "delta", env["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"]) +} + +func TestClaudeSettingsOtel_MissingManagedKeys(t *testing.T) { + svc, path := setupClaudeSettingsTest(t) + + all := make([]string, 0, len(claudeSettingsOtelEnvVars())) + for _, v := range claudeSettingsOtelEnvVars() { + all = append(all, v.Key) + } + + missing, err := svc.MissingManagedKeys() + require.NoError(t, err, "a missing file is not an error") + assert.Equal(t, all, missing) + + // Settings written by an older `cc install`. + writeClaudeSettings(t, path, `{"env": { + "CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_METRICS_EXPORTER": "otlp", "OTEL_LOGS_EXPORTER": "otlp", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", "OTEL_EXPORTER_OTLP_ENDPOINT": "`+AICodeOtelEndpoint+`", + "OTEL_METRIC_EXPORT_INTERVAL": "10000", "OTEL_LOGS_EXPORT_INTERVAL": "5000", "OTEL_LOG_USER_PROMPTS": "1", + "OTEL_METRICS_INCLUDE_SESSION_ID": "true", "OTEL_METRICS_INCLUDE_VERSION": "true", + "OTEL_METRICS_INCLUDE_ACCOUNT_UUID": "true", "OTEL_RESOURCE_ATTRIBUTES": "team.id=shelltime"}}`) + missing, err = svc.MissingManagedKeys() + require.NoError(t, err) + assert.Equal(t, []string{ + "OTEL_LOG_TOOL_DETAILS", + "OTEL_LOG_ASSISTANT_RESPONSES", + "OTEL_METRICS_INCLUDE_ENTRYPOINT", + "OTEL_METRICS_INCLUDE_REPOSITORY", + "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", + }, missing) + + require.NoError(t, svc.Install()) + missing, err = svc.MissingManagedKeys() + require.NoError(t, err) + assert.Empty(t, missing) + + // An explicit opt-out is the user's choice, not a missing key. + writeClaudeSettings(t, path, `{"env":{"OTEL_LOG_TOOL_DETAILS":"0"}}`) + missing, err = svc.MissingManagedKeys() + require.NoError(t, err) + assert.NotContains(t, missing, "OTEL_LOG_TOOL_DETAILS") + + writeClaudeSettings(t, path, `{"env": `) + _, err = svc.MissingManagedKeys() + assert.Error(t, err) +} diff --git a/model/aicode_otel_types.go b/model/aicode_otel_types.go index 404ce06..99bd8cf 100644 --- a/model/aicode_otel_types.go +++ b/model/aicode_otel_types.go @@ -1,5 +1,7 @@ package model +import "unicode/utf8" + // 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 { @@ -27,6 +29,14 @@ type AICodeOtelResourceAttributes struct { HostArch string WSLVersion string + // ServiceName is the resource service.name (claude-code, claude-code-desktop, codex_cli_rs, ...). + // It is the Entrypoint fallback. + ServiceName string + // HostName is the resource host.name (sent by Codex). It is the MachineName fallback. + HostName string + // Entrypoint is app.entrypoint when it is set on the resource. + Entrypoint string + // Additional attributes from data points UserID string // from user.id (hashed identifier) UserEmail string // from user.email @@ -36,60 +46,84 @@ type AICodeOtelResourceAttributes struct { MachineName string // from machine.name TeamID string // from team.id Pwd string // from pwd + + // Attributes holds the resource attributes that have no dedicated field (service.name, + // service.version, wsl.version, env, vcs.*, host.name, custom OTEL_RESOURCE_ATTRIBUTES keys). + // They are merged into each event's attributes. + Attributes map[string]any } // AICodeOtelEvent represents an event from Claude Code or Codex (api_request, tool_result, etc.) -// with embedded resource attributes for a flat, session-less structure +// with embedded resource attributes for a flat, session-less structure. +// +// Optional counts, costs and flags are pointers so that false and zero values survive omitempty +// (a failed tool call must be sent as "success": false). type AICodeOtelEvent struct { - EventID string `json:"eventId"` - EventType string `json:"eventType"` - Timestamp int64 `json:"timestamp"` - EventTimestamp string `json:"eventTimestamp,omitempty"` // ISO 8601 timestamp + EventID string `json:"eventId"` + EventType string `json:"eventType"` + Timestamp int64 `json:"timestamp"` // unix seconds, kept for older servers + TimestampMs int64 `json:"timestampMs,omitempty"` // unix milliseconds + EventTimestamp string `json:"eventTimestamp,omitempty"` // ISO 8601 timestamp + Model string `json:"model,omitempty"` - CostUSD float64 `json:"costUsd,omitempty"` - DurationMs int `json:"durationMs,omitempty"` - InputTokens int `json:"inputTokens,omitempty"` - 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 + CostUSD *float64 `json:"costUsd,omitempty"` + DurationMs *int `json:"durationMs,omitempty"` + InputTokens *int `json:"inputTokens,omitempty"` + OutputTokens *int `json:"outputTokens,omitempty"` + CacheReadTokens *int `json:"cacheReadTokens,omitempty"` + CacheCreationTokens *int `json:"cacheCreationTokens,omitempty"` + ReasoningTokens *int `json:"reasoningTokens,omitempty"` // Codex: reasoning output tokens ToolName string `json:"toolName,omitempty"` - Success bool `json:"success,omitempty"` + Success *bool `json:"success,omitempty"` Decision string `json:"decision,omitempty"` Source string `json:"source,omitempty"` Error string `json:"error,omitempty"` - PromptLength int `json:"promptLength,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"` + 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 + // Correlation and request details (payload v2) + PromptID string `json:"promptId,omitempty"` // Claude prompt.id + Sequence *int64 `json:"sequence,omitempty"` // Claude event.sequence (0-based) + RequestID string `json:"requestId,omitempty"` // API request id + Speed string `json:"speed,omitempty"` // "fast" / "normal" + QuerySource string `json:"querySource,omitempty"` // subsystem that issued the request + Response string `json:"response,omitempty"` // assistant / agent response text + ResponseLength *int `json:"responseLength,omitempty"` + ToolInput string `json:"toolInput,omitempty"` // raw tool input (Claude JSON, Codex arguments) + Entrypoint string `json:"entrypoint,omitempty"` // app.entrypoint, Codex originator, or service.name + + // Attributes carries every attribute without a dedicated field, so nothing is dropped silently. + Attributes map[string]any `json:"attributes,omitempty"` + + // Tool call id: Codex call_id, Claude tool_use_id CallID string `json:"callId,omitempty"` // Codex-specific fields for sse_event EventKind string `json:"eventKind,omitempty"` - ToolTokens int `json:"toolTokens,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"` + 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"` + ReasoningEnabled *bool `json:"reasoningEnabled,omitempty"` ReasoningEffort string `json:"reasoningEffort,omitempty"` ReasoningSummary string `json:"reasoningSummary,omitempty"` - MaxOutputTokens int `json:"maxOutputTokens,omitempty"` - AutoCompactTokenLimit int `json:"autoCompactTokenLimit,omitempty"` + MaxOutputTokens *int `json:"maxOutputTokens,omitempty"` + AutoCompactTokenLimit *int `json:"autoCompactTokenLimit,omitempty"` // Codex-specific fields for tool_result - ToolArguments map[string]interface{} `json:"toolArguments,omitempty"` + ToolArguments map[string]interface{} `json:"toolArguments,omitempty"` // legacy tool_arguments JSON object ToolOutput string `json:"toolOutput,omitempty"` // Embedded resource attributes (previously in session) @@ -119,16 +153,20 @@ type AICodeOtelEvent struct { // AICodeOtelMetric represents a metric data point from Claude Code or Codex // with embedded resource attributes for a flat, session-less structure type AICodeOtelMetric struct { - MetricID string `json:"metricId"` - MetricType string `json:"metricType"` - Timestamp int64 `json:"timestamp"` - Value float64 `json:"value"` - Model string `json:"model,omitempty"` - TokenType string `json:"tokenType,omitempty"` - LinesType string `json:"linesType,omitempty"` - Tool string `json:"tool,omitempty"` - Decision string `json:"decision,omitempty"` - Language string `json:"language,omitempty"` + MetricID string `json:"metricId"` + MetricType string `json:"metricType"` + Timestamp int64 `json:"timestamp"` // unix seconds, kept for older servers + TimestampMs int64 `json:"timestampMs,omitempty"` // unix milliseconds + Value float64 `json:"value"` + Model string `json:"model,omitempty"` + TokenType string `json:"tokenType,omitempty"` + LinesType string `json:"linesType,omitempty"` + Tool string `json:"tool,omitempty"` + Decision string `json:"decision,omitempty"` + Language string `json:"language,omitempty"` + + // Attributes carries every data point attribute without a dedicated field. + Attributes map[string]any `json:"attributes,omitempty"` // Embedded resource attributes (previously in session) SessionID string `json:"sessionId,omitempty"` @@ -162,6 +200,40 @@ type AICodeOtelResponse struct { Message string `json:"message,omitempty"` } +// AICodeOtelIDPrefix marks event and metric ids derived from the OTLP payload itself, so an +// exporter retry produces the same id and the server can de-duplicate it (like backfill's bf1:). +const AICodeOtelIDPrefix = "ot1:" + +// Limits applied to what the daemon forwards. +const ( + // AICodeOtelMaxTextBytes caps large text fields (tool input/output, responses). + AICodeOtelMaxTextBytes = 64 << 10 + // AICodeOtelMaxAttributeBytes caps each string value in the attributes catch-all. + AICodeOtelMaxAttributeBytes = 2 << 10 + // AICodeOtelMaxAttributes caps the number of keys in the attributes catch-all. + AICodeOtelMaxAttributes = 64 + // AICodeOtelMaxRequestBytes bounds the JSON body of one POST /api/v1/cc/otel request. + AICodeOtelMaxRequestBytes = 8 << 20 +) + +// AICodeOtelDroppedEvents lists event names (without the claude_code./codex. prefix) that the +// daemon never forwards: opt-in, very large payloads with no use on ShellTime. +var AICodeOtelDroppedEvents = []string{ + "api_request_body", + "api_response_body", + "system_prompt", +} + +// IsDroppedAICodeOtelEvent reports whether an event name (prefix already stripped) is dropped. +func IsDroppedAICodeOtelEvent(name string) bool { + for _, dropped := range AICodeOtelDroppedEvents { + if name == dropped { + return true + } + } + return false +} + // OTEL source identifiers const ( AICodeOtelSourceClaudeCode = "claude-code" @@ -190,6 +262,10 @@ const ( AICodeEventExecCommand = "exec_command" // Codex: shell command execution AICodeEventConversationStarts = "conversation_starts" // Codex: conversation/session start AICodeEventSSEEvent = "sse_event" // Codex: SSE streaming event + + AICodeEventAssistantResponse = "assistant_response" // Claude: assistant response text + AICodeEventAgentResponse = "agent_response" // Codex: final agent response text + AICodeEventTurnCost = "turn_cost" // Codex: estimated cost of a turn ) // Token types for AICodeMetricTokenUsage @@ -205,3 +281,27 @@ const ( AICodeLinesTypeAdded = "added" AICodeLinesTypeRemoved = "removed" ) + +// IntRef returns a pointer to v. +func IntRef(v int) *int { return &v } + +// Int64Ref returns a pointer to v. +func Int64Ref(v int64) *int64 { return &v } + +// Float64Ref returns a pointer to v. +func Float64Ref(v float64) *float64 { return &v } + +// BoolRef returns a pointer to v. +func BoolRef(v bool) *bool { return &v } + +// CapAICodeText truncates s to at most maxBytes without splitting a UTF-8 character. +func CapAICodeText(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + cut := maxBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} diff --git a/model/api_aicode_otel.go b/model/api_aicode_otel.go index 852a4c0..3e8279c 100644 --- a/model/api_aicode_otel.go +++ b/model/api_aicode_otel.go @@ -2,6 +2,7 @@ package model import ( "context" + "encoding/json" "net/http" "time" ) @@ -29,3 +30,57 @@ func SendAICodeOtelData(ctx context.Context, req *AICodeOtelRequest, endpoint En return &resp, nil } + +// SplitAICodeOtelRequest splits req into requests whose JSON bodies stay within maxBytes, keeping +// events and metrics in their original order. Like PackBackfillBatches it estimates each item by +// encoding it on its own; an item larger than the budget goes alone in its request. +func SplitAICodeOtelRequest(req *AICodeOtelRequest, maxBytes int) []*AICodeOtelRequest { + if req == nil { + return nil + } + envelope := AICodeOtelRequest{Host: req.Host, Project: req.Project, Source: req.Source} + // `,"events":[]` and `,"metrics":[]` are not part of the empty envelope's encoding. + budget := maxBytes - aiCodeOtelJSONSize(envelope) - len(`,"events":[],"metrics":[]`) + + var out []*AICodeOtelRequest + cur := envelope + curBytes := 0 + flush := func() { + if len(cur.Events) > 0 || len(cur.Metrics) > 0 { + chunk := cur + out = append(out, &chunk) + } + cur = envelope + curBytes = 0 + } + fits := func(size int) bool { + return (len(cur.Events) == 0 && len(cur.Metrics) == 0) || curBytes+size <= budget + } + + for _, e := range req.Events { + size := aiCodeOtelJSONSize(e) + 1 + if !fits(size) { + flush() + } + cur.Events = append(cur.Events, e) + curBytes += size + } + for _, m := range req.Metrics { + size := aiCodeOtelJSONSize(m) + 1 + if !fits(size) { + flush() + } + cur.Metrics = append(cur.Metrics, m) + curBytes += size + } + flush() + return out +} + +func aiCodeOtelJSONSize(v any) int { + buf, err := json.Marshal(v) + if err != nil { + return 0 + } + return len(buf) +} diff --git a/model/api_aicode_otel_test.go b/model/api_aicode_otel_test.go new file mode 100644 index 0000000..ede6ed8 --- /dev/null +++ b/model/api_aicode_otel_test.go @@ -0,0 +1,120 @@ +package model + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSplitAICodeOtelRequest(t *testing.T) { + req := &AICodeOtelRequest{Host: "host", Project: "/repo", Source: AICodeOtelSourceClaudeCode} + for i := 0; i < 50; i++ { + req.Events = append(req.Events, AICodeOtelEvent{ + EventID: fmt.Sprintf("ot1:%02d", i), + EventType: AICodeEventToolResult, + ToolInput: strings.Repeat("x", 1000), + Success: BoolRef(false), + }) + } + for i := 0; i < 20; i++ { + req.Metrics = append(req.Metrics, AICodeOtelMetric{MetricID: fmt.Sprintf("m%02d", i), MetricType: AICodeMetricTokenUsage, Value: float64(i)}) + } + + const maxBytes = 8 << 10 + chunks := SplitAICodeOtelRequest(req, maxBytes) + require.Greater(t, len(chunks), 1) + + var eventIDs, metricIDs []string + for _, chunk := range chunks { + body, err := json.Marshal(chunk) + require.NoError(t, err) + assert.LessOrEqual(t, len(body), maxBytes, "each body stays within the limit") + assert.Equal(t, "host", chunk.Host) + assert.Equal(t, "/repo", chunk.Project) + assert.Equal(t, AICodeOtelSourceClaudeCode, chunk.Source) + assert.True(t, len(chunk.Events) > 0 || len(chunk.Metrics) > 0, "no empty requests") + for _, e := range chunk.Events { + eventIDs = append(eventIDs, e.EventID) + } + for _, m := range chunk.Metrics { + metricIDs = append(metricIDs, m.MetricID) + } + } + require.Len(t, eventIDs, 50) + require.Len(t, metricIDs, 20) + for i, id := range eventIDs { + assert.Equal(t, fmt.Sprintf("ot1:%02d", i), id, "order is kept") + } + for i, id := range metricIDs { + assert.Equal(t, fmt.Sprintf("m%02d", i), id) + } +} + +func TestSplitAICodeOtelRequest_SmallRequestIsOneChunk(t *testing.T) { + req := &AICodeOtelRequest{Host: "h", Project: "p", Events: []AICodeOtelEvent{{EventID: "a"}, {EventID: "b"}}} + chunks := SplitAICodeOtelRequest(req, AICodeOtelMaxRequestBytes) + require.Len(t, chunks, 1) + assert.Equal(t, req.Events, chunks[0].Events) + + assert.Empty(t, SplitAICodeOtelRequest(&AICodeOtelRequest{Host: "h"}, AICodeOtelMaxRequestBytes)) + assert.Empty(t, SplitAICodeOtelRequest(nil, AICodeOtelMaxRequestBytes)) +} + +func TestSplitAICodeOtelRequest_OversizedItemGoesAlone(t *testing.T) { + req := &AICodeOtelRequest{Events: []AICodeOtelEvent{ + {EventID: "small-1"}, + {EventID: "huge", ToolOutput: strings.Repeat("o", 4000)}, + {EventID: "small-2"}, + }} + chunks := SplitAICodeOtelRequest(req, 1000) + require.Len(t, chunks, 3) + assert.Equal(t, "small-1", chunks[0].Events[0].EventID) + assert.Equal(t, "huge", chunks[1].Events[0].EventID) + assert.Equal(t, "small-2", chunks[2].Events[0].EventID) +} + +func TestAICodeOtelEvent_PointersKeepFalseAndZero(t *testing.T) { + body, err := json.Marshal(AICodeOtelEvent{ + EventID: "ot1:x", + EventType: AICodeEventToolResult, + Success: BoolRef(false), + DurationMs: IntRef(0), + CostUSD: Float64Ref(0), + Sequence: Int64Ref(0), + InputTokens: IntRef(0), + TimestampMs: 1791446400123, + Attributes: map[string]any{"error_type": "ShellError"}, + ToolInput: `{"command":"ls"}`, + PromptLength: IntRef(0), + }) + require.NoError(t, err) + s := string(body) + for _, want := range []string{`"success":false`, `"durationMs":0`, `"costUsd":0`, `"sequence":0`, `"inputTokens":0`, `"promptLength":0`, `"timestampMs":1791446400123`, `"attributes":{"error_type":"ShellError"}`, `"toolInput":"{\"command\":\"ls\"}"`} { + assert.Contains(t, s, want) + } + // Unset optional fields stay out. + for _, absent := range []string{"outputTokens", "statusCode", "reasoningEnabled", "response", "promptId"} { + assert.NotContains(t, s, `"`+absent+`"`) + } +} + +func TestIsDroppedAICodeOtelEvent(t *testing.T) { + for _, name := range []string{"api_request_body", "api_response_body", "system_prompt"} { + assert.True(t, IsDroppedAICodeOtelEvent(name), name) + } + for _, name := range []string{"api_request", "user_prompt", "claude_code.api_request_body", ""} { + assert.False(t, IsDroppedAICodeOtelEvent(name), name) + } +} + +func TestCapAICodeText(t *testing.T) { + assert.Equal(t, "abc", CapAICodeText("abc", 10)) + assert.Equal(t, "ab", CapAICodeText("abc", 2)) + // "é" is 2 bytes: cutting inside it backs off to the previous character boundary. + assert.Equal(t, "a", CapAICodeText("aé", 2)) + assert.Equal(t, "", CapAICodeText("é", 1)) +} diff --git a/model/codex_otel_config.go b/model/codex_otel_config.go index 9de0a1a..552cebe 100644 --- a/model/codex_otel_config.go +++ b/model/codex_otel_config.go @@ -13,6 +13,14 @@ const ( codexConfigFile = "config.toml" ) +// codexOtelManagedFlags are the [otel] flags `shelltime codex install` turns on. Together with +// the exporter they are the only keys Install writes and Uninstall removes. +var codexOtelManagedFlags = []string{ + "log_user_prompt", + // Emits codex.agent_response with the final answer text. + "log_agent_responses", +} + // CodexOtelConfigService handles Codex OTEL configuration type CodexOtelConfigService interface { Install() error @@ -20,6 +28,9 @@ type CodexOtelConfigService interface { Check() (bool, error) // Endpoint returns otel.exporter.otlp-grpc.endpoint, or "" when it is not set. Endpoint() (string, error) + // MissingManagedKeys returns the managed [otel] flags that aren't set, such as + // log_agent_responses in a config written by an older `shelltime codex install`. + MissingManagedKeys() ([]string, error) } type codexOtelConfigService struct { @@ -35,126 +46,149 @@ func NewCodexOtelConfigService() CodexOtelConfigService { } } -// 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 +// readConfig parses ~/.codex/config.toml. A missing or empty file yields an empty map. +func (s *codexOtelConfigService) readConfig() (map[string]interface{}, error) { config := make(map[string]interface{}) - if data, err := os.ReadFile(s.configPath); err == nil && len(data) > 0 { + data, err := os.ReadFile(s.configPath) + if os.IsNotExist(err) { + return config, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + if len(data) > 0 { if err := toml.Unmarshal(data, &config); err != nil { - return fmt.Errorf("failed to parse existing config: %w", err) + return nil, fmt.Errorf("failed to parse config: %w", err) } } + return config, nil +} - // 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 +func (s *codexOtelConfigService) writeConfig(config map[string]interface{}) error { 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 +// Install merges ShellTime's OTEL settings into the [otel] table of ~/.codex/config.toml, +// keeping the user's other [otel] keys (environment, trace_exporter, ...). +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 - data, err := os.ReadFile(s.configPath) + config, err := s.readConfig() if err != nil { - return fmt.Errorf("failed to read config file: %w", err) + return fmt.Errorf("failed to parse existing config: %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) - } + otel, _ := config["otel"].(map[string]interface{}) + if otel == nil { + otel = make(map[string]interface{}) } - - // 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) + for _, flag := range codexOtelManagedFlags { + otel[flag] = true } - - if err := os.WriteFile(s.configPath, newData, 0644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) + // `exporter` selects exactly one exporter ("none", "statsig", {otlp-http = ...} or + // {otlp-grpc = ...}), so it is replaced rather than merged. + // Format: exporter = { otlp-grpc = {endpoint = "..."} } + otel["exporter"] = map[string]interface{}{ + "otlp-grpc": map[string]interface{}{ + "endpoint": AICodeOtelEndpoint, + }, } + config["otel"] = otel - return nil + return s.writeConfig(config) } -// Check returns true if OTEL is configured in ~/.codex/config.toml -func (s *codexOtelConfigService) Check() (bool, error) { +// Uninstall removes ShellTime's OTEL settings from ~/.codex/config.toml: the managed flags and +// the exporter when it still points at the ShellTime daemon. The [otel] table is deleted only +// when nothing else is left in it. +func (s *codexOtelConfigService) Uninstall() error { + // Check if config file exists if _, err := os.Stat(s.configPath); os.IsNotExist(err) { - return false, nil + return nil // Nothing to uninstall } - data, err := os.ReadFile(s.configPath) + config, err := s.readConfig() if err != nil { - return false, fmt.Errorf("failed to read config file: %w", err) + return 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) - } + otel, ok := config["otel"].(map[string]interface{}) + if !ok { + return nil + } + for _, flag := range codexOtelManagedFlags { + delete(otel, flag) + } + if codexOtelGRPCEndpoint(otel) == AICodeOtelEndpoint { + delete(otel, "exporter") + } + if len(otel) == 0 { + delete(config, "otel") + } else { + config["otel"] = otel } - _, exists := config["otel"] - return exists, nil + return s.writeConfig(config) } -func (s *codexOtelConfigService) Endpoint() (string, error) { - data, err := os.ReadFile(s.configPath) - if os.IsNotExist(err) { - return "", nil +// Check returns true when ~/.codex/config.toml exports OTEL to the ShellTime daemon. A user's own +// [otel] table without our exporter (for example only `environment`) does not count, since +// Install merges into that table instead of replacing it. +func (s *codexOtelConfigService) Check() (bool, error) { + config, err := s.readConfig() + if err != nil { + return false, err } + otel, _ := config["otel"].(map[string]interface{}) + return codexOtelGRPCEndpoint(otel) == AICodeOtelEndpoint, nil +} + +func (s *codexOtelConfigService) Endpoint() (string, error) { + config, err := s.readConfig() if err != nil { - return "", fmt.Errorf("failed to read config file: %w", err) + return "", err } + otel, _ := config["otel"].(map[string]interface{}) + return codexOtelGRPCEndpoint(otel), nil +} - // Walk a generic map: `exporter` may also be a plain string such as "none". - config := make(map[string]interface{}) - if err := toml.Unmarshal(data, &config); err != nil { - return "", fmt.Errorf("failed to parse config: %w", err) +func (s *codexOtelConfigService) MissingManagedKeys() ([]string, error) { + config, err := s.readConfig() + if err != nil { + return nil, err } - var node interface{} = config - for _, key := range []string{"otel", "exporter", "otlp-grpc", "endpoint"} { + otel, _ := config["otel"].(map[string]interface{}) + var missing []string + for _, flag := range codexOtelManagedFlags { + if _, ok := otel[flag]; !ok { + missing = append(missing, flag) + } + } + return missing, nil +} + +// codexOtelGRPCEndpoint returns exporter.otlp-grpc.endpoint of an [otel] table, or "". It walks +// generic maps because `exporter` may also be a plain string such as "none". +func codexOtelGRPCEndpoint(otel map[string]interface{}) string { + var node interface{} = otel + for _, key := range []string{"exporter", "otlp-grpc", "endpoint"} { table, ok := node.(map[string]interface{}) if !ok { - return "", nil + return "" } node = table[key] } endpoint, _ := node.(string) - return endpoint, nil + return endpoint } diff --git a/model/codex_otel_config_test.go b/model/codex_otel_config_test.go index c1599d9..87cea97 100644 --- a/model/codex_otel_config_test.go +++ b/model/codex_otel_config_test.go @@ -33,6 +33,7 @@ func TestCodexOtelConfig_InstallCreatesConfig(t *testing.T) { otel, ok := parsed["otel"].(map[string]interface{}) require.True(t, ok, "otel table should be present") assert.Equal(t, true, otel["log_user_prompt"]) + assert.Equal(t, true, otel["log_agent_responses"]) exporter, ok := otel["exporter"].(map[string]interface{}) require.True(t, ok) @@ -94,6 +95,32 @@ func TestCodexOtelConfig_Uninstall(t *testing.T) { assert.NotContains(t, parsed, "otel") } +func TestCodexOtelConfig_CheckRequiresShellTimeExporter(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + svc := NewCodexOtelConfigService() + configPath := filepath.Join(home, codexConfigDir, codexConfigFile) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + + // The user's own [otel] table without our exporter is not "installed". + require.NoError(t, os.WriteFile(configPath, []byte("[otel]\nenvironment = \"prod\"\n"), 0644)) + ok, err := svc.Check() + require.NoError(t, err) + assert.False(t, ok) + + // An exporter pointing somewhere else isn't ours either. + require.NoError(t, os.WriteFile(configPath, []byte( + "[otel]\nexporter = { otlp-grpc = { endpoint = \"http://collector:4317\" } }\n"), 0644)) + ok, err = svc.Check() + require.NoError(t, err) + assert.False(t, ok) + + require.NoError(t, svc.Install()) + ok, err = svc.Check() + require.NoError(t, err) + assert.True(t, ok) +} + func TestCodexOtelConfig_CheckMalformedConfig(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -134,3 +161,147 @@ func TestCodexOtelConfig_Endpoint(t *testing.T) { _, err = svc.Endpoint() assert.Error(t, err) } + +func readCodexConfig(t *testing.T, path string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var parsed map[string]interface{} + require.NoError(t, toml.Unmarshal(data, &parsed)) + return parsed +} + +// userCodexOtelConfig has [otel] keys of the user's own next to an exporter shelltime replaces. +const userCodexOtelConfig = `model = "gpt-5-codex" + +[otel] +environment = "dev" +log_user_prompt = false +trace_exporter = "none" + +[otel.exporter.otlp-http] +endpoint = "https://collector.example.com/v1/logs" +protocol = "binary" + +[profiles.fast] +model = "gpt-5-mini" +` + +func TestCodexOtelConfig_InstallMergesIntoExistingOtelTable(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, codexConfigDir, codexConfigFile) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + require.NoError(t, os.WriteFile(configPath, []byte(userCodexOtelConfig), 0644)) + + svc := NewCodexOtelConfigService() + require.NoError(t, svc.Install()) + + parsed := readCodexConfig(t, configPath) + assert.Equal(t, "gpt-5-codex", parsed["model"]) + assert.Contains(t, parsed, "profiles", "other tables survive") + + otel := parsed["otel"].(map[string]interface{}) + assert.Equal(t, "dev", otel["environment"], "the user's other [otel] keys are kept") + assert.Equal(t, "none", otel["trace_exporter"]) + assert.Equal(t, true, otel["log_user_prompt"]) + assert.Equal(t, true, otel["log_agent_responses"]) + // exporter selects one exporter, so it is replaced, not merged with otlp-http. + assert.Equal(t, map[string]interface{}{"otlp-grpc": map[string]interface{}{"endpoint": AICodeOtelEndpoint}}, otel["exporter"]) + + endpoint, err := svc.Endpoint() + require.NoError(t, err) + assert.Equal(t, AICodeOtelEndpoint, endpoint) +} + +func TestCodexOtelConfig_InstallIsIdempotent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, codexConfigDir, codexConfigFile) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + require.NoError(t, os.WriteFile(configPath, []byte(userCodexOtelConfig), 0644)) + + svc := NewCodexOtelConfigService() + require.NoError(t, svc.Install()) + first, err := os.ReadFile(configPath) + require.NoError(t, err) + + require.NoError(t, svc.Install()) + second, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, string(first), string(second)) +} + +func TestCodexOtelConfig_UninstallRemovesOnlyManagedKeys(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, codexConfigDir, codexConfigFile) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + require.NoError(t, os.WriteFile(configPath, []byte(userCodexOtelConfig), 0644)) + + svc := NewCodexOtelConfigService() + require.NoError(t, svc.Install()) + require.NoError(t, svc.Uninstall()) + + parsed := readCodexConfig(t, configPath) + otel, ok := parsed["otel"].(map[string]interface{}) + require.True(t, ok, "[otel] stays while the user's keys are in it") + assert.Equal(t, map[string]interface{}{"environment": "dev", "trace_exporter": "none"}, otel) + assert.Equal(t, "gpt-5-codex", parsed["model"]) + + endpoint, err := svc.Endpoint() + require.NoError(t, err) + assert.Empty(t, endpoint) +} + +func TestCodexOtelConfig_UninstallKeepsForeignExporter(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, codexConfigDir, codexConfigFile) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + foreign := "[otel]\nlog_user_prompt = true\n\n[otel.exporter.otlp-grpc]\nendpoint = \"http://collector:4317\"\n" + require.NoError(t, os.WriteFile(configPath, []byte(foreign), 0644)) + + svc := NewCodexOtelConfigService() + require.NoError(t, svc.Uninstall()) + + endpoint, err := svc.Endpoint() + require.NoError(t, err) + assert.Equal(t, "http://collector:4317", endpoint, "an exporter pointing elsewhere isn't shelltime's") + otel := readCodexConfig(t, configPath)["otel"].(map[string]interface{}) + assert.NotContains(t, otel, "log_user_prompt") +} + +func TestCodexOtelConfig_MissingManagedKeys(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, codexConfigDir, codexConfigFile) + svc := NewCodexOtelConfigService() + + missing, err := svc.MissingManagedKeys() + require.NoError(t, err) + assert.Equal(t, []string{"log_user_prompt", "log_agent_responses"}, missing) + + // A config written by an older `codex install`. + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + older := "[otel]\nlog_user_prompt = true\n\n[otel.exporter.otlp-grpc]\nendpoint = \"" + AICodeOtelEndpoint + "\"\n" + require.NoError(t, os.WriteFile(configPath, []byte(older), 0644)) + missing, err = svc.MissingManagedKeys() + require.NoError(t, err) + assert.Equal(t, []string{"log_agent_responses"}, missing) + + require.NoError(t, svc.Install()) + missing, err = svc.MissingManagedKeys() + require.NoError(t, err) + assert.Empty(t, missing) + + // An explicit false is the user's choice. + require.NoError(t, os.WriteFile(configPath, []byte("[otel]\nlog_user_prompt = false\nlog_agent_responses = false\n"), 0644)) + missing, err = svc.MissingManagedKeys() + require.NoError(t, err) + assert.Empty(t, missing) + + require.NoError(t, os.WriteFile(configPath, []byte("[otel\n"), 0644)) + _, err = svc.MissingManagedKeys() + assert.Error(t, err) +}