From 8abfaff46deedac40d5678b938d3eed461dbd520 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 13 Dec 2025 23:50:48 +0800 Subject: [PATCH 1/4] feat(daemon): add Claude Code OTEL v2 passthrough collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a gRPC OTEL collector in the daemon that receives telemetry data from Claude Code and forwards it to the ShellTime backend in real-time (no local buffering). - Add gRPC server implementing OTEL MetricsService and LogsService - Parse Claude Code metrics (token usage, cost, LOC, commits, PRs) - Parse Claude Code events (api_request, tool_result, user_prompt) - Forward data immediately to POST /api/v1/cc/otel endpoint - Add [ccotel] config section with enabled and grpcPort options This is v2 of CC tracking, complementing the existing v1 ccusage CLI-based approach which uses daily aggregates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/daemon/main.go | 15 +- daemon/ccotel_processor.go | 396 +++++++++++++++++++++++++++++++++++++ daemon/ccotel_server.go | 85 ++++++++ go.mod | 6 +- model/api_ccotel.go | 51 +++++ model/ccotel_types.go | 147 ++++++++++++++ model/config.go | 13 +- model/types.go | 12 +- 8 files changed, 719 insertions(+), 6 deletions(-) create mode 100644 daemon/ccotel_processor.go create mode 100644 daemon/ccotel_server.go create mode 100644 model/api_ccotel.go create mode 100644 model/ccotel_types.go diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index ffc878c..e8ba4bd 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -96,7 +96,7 @@ func main() { go daemon.SocketTopicProccessor(msg) - // Start CCUsage service if enabled + // Start CCUsage service if enabled (v1 - ccusage CLI based) if cfg.CCUsage != nil && cfg.CCUsage.Enabled != nil && *cfg.CCUsage.Enabled { ccUsageService := model.NewCCUsageService(cfg, cmdService) if err := ccUsageService.Start(ctx); err != nil { @@ -107,6 +107,19 @@ func main() { } } + // Start CCOtel service if enabled (v2 - OTEL gRPC passthrough) + var ccOtelServer *daemon.CCOtelServer + if cfg.CCOtel != nil && cfg.CCOtel.Enabled != nil && *cfg.CCOtel.Enabled { + ccOtelProcessor := daemon.NewCCOtelProcessor(cfg) + ccOtelServer = daemon.NewCCOtelServer(cfg.CCOtel.GRPCPort, ccOtelProcessor) + if err := ccOtelServer.Start(); err != nil { + slog.Error("Failed to start CCOtel gRPC server", slog.Any("err", err)) + } else { + slog.Info("CCOtel gRPC server started", slog.Int("port", cfg.CCOtel.GRPCPort)) + defer ccOtelServer.Stop() + } + } + // Create processor instance processor := daemon.NewSocketHandler(daemonConfig, pubsub) diff --git a/daemon/ccotel_processor.go b/daemon/ccotel_processor.go new file mode 100644 index 0000000..bebe89c --- /dev/null +++ b/daemon/ccotel_processor.go @@ -0,0 +1,396 @@ +package daemon + +import ( + "context" + "log/slog" + "os" + "time" + + "github.com/google/uuid" + "github.com/malamtime/cli/model" + 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" +) + +// CCOtelProcessor handles OTEL data parsing and forwarding to the backend +type CCOtelProcessor struct { + config model.ShellTimeConfig + endpoint model.Endpoint + hostname string +} + +// NewCCOtelProcessor creates a new CCOtel processor +func NewCCOtelProcessor(config model.ShellTimeConfig) *CCOtelProcessor { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + + return &CCOtelProcessor{ + config: config, + endpoint: model.Endpoint{ + Token: config.Token, + APIEndpoint: config.APIEndpoint, + }, + hostname: hostname, + } +} + +// ProcessMetrics receives OTEL metrics and forwards to backend immediately +func (p *CCOtelProcessor) ProcessMetrics(ctx context.Context, req *collmetricsv1.ExportMetricsServiceRequest) (*collmetricsv1.ExportMetricsServiceResponse, error) { + slog.Debug("CCOtel: Processing metrics request", "resourceMetricsCount", len(req.GetResourceMetrics())) + + for _, rm := range req.GetResourceMetrics() { + resource := rm.GetResource() + + // Check if this is from Claude Code + if !isClaudeCodeResource(resource) { + slog.Debug("CCOtel: Skipping non-Claude Code resource") + continue + } + + session := extractSessionFromResource(resource) + project := p.detectProject(resource) + + var metrics []model.CCOtelMetric + + for _, sm := range rm.GetScopeMetrics() { + for _, m := range sm.GetMetrics() { + parsedMetrics := p.parseMetric(m) + metrics = append(metrics, parsedMetrics...) + } + } + + if len(metrics) == 0 { + continue + } + + // Build and send request immediately + ccReq := &model.CCOtelRequest{ + Host: p.hostname, + Project: project, + Session: session, + Metrics: metrics, + } + + resp, err := model.SendCCOtelData(ctx, ccReq, p.endpoint) + if err != nil { + slog.Error("CCOtel: Failed to send metrics to backend", "error", err) + // Continue processing - passthrough mode, we don't retry + } else { + slog.Debug("CCOtel: Metrics sent to backend", "metricsProcessed", resp.MetricsProcessed) + } + } + + return &collmetricsv1.ExportMetricsServiceResponse{}, nil +} + +// ProcessLogs receives OTEL logs/events and forwards to backend immediately +func (p *CCOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.ExportLogsServiceRequest) (*collogsv1.ExportLogsServiceResponse, error) { + slog.Debug("CCOtel: Processing logs request", "resourceLogsCount", len(req.GetResourceLogs())) + + for _, rl := range req.GetResourceLogs() { + resource := rl.GetResource() + + // Check if this is from Claude Code + if !isClaudeCodeResource(resource) { + slog.Debug("CCOtel: Skipping non-Claude Code resource") + continue + } + + session := extractSessionFromResource(resource) + project := p.detectProject(resource) + + var events []model.CCOtelEvent + + for _, sl := range rl.GetScopeLogs() { + for _, lr := range sl.GetLogRecords() { + event := p.parseLogRecord(lr) + if event != nil { + events = append(events, *event) + } + } + } + + if len(events) == 0 { + continue + } + + // Build and send request immediately + ccReq := &model.CCOtelRequest{ + Host: p.hostname, + Project: project, + Session: session, + Events: events, + } + + resp, err := model.SendCCOtelData(ctx, ccReq, p.endpoint) + if err != nil { + slog.Error("CCOtel: Failed to send events to backend", "error", err) + // Continue processing - passthrough mode, we don't retry + } else { + slog.Debug("CCOtel: Events sent to backend", "eventsProcessed", resp.EventsProcessed) + } + } + + return &collogsv1.ExportLogsServiceResponse{}, nil +} + +// isClaudeCodeResource checks if the resource is from Claude Code +func isClaudeCodeResource(resource *resourcev1.Resource) bool { + if resource == nil { + return false + } + + for _, attr := range resource.GetAttributes() { + if attr.GetKey() == "service.name" { + return attr.GetValue().GetStringValue() == "claude-code" + } + } + return false +} + +// extractSessionFromResource extracts session info from resource attributes +func extractSessionFromResource(resource *resourcev1.Resource) *model.CCOtelSession { + session := &model.CCOtelSession{ + StartedAt: time.Now().Unix(), + } + + if resource == nil { + return session + } + + for _, attr := range resource.GetAttributes() { + key := attr.GetKey() + value := attr.GetValue() + + switch key { + case "session.id": + session.SessionID = value.GetStringValue() + case "app.version": + session.AppVersion = value.GetStringValue() + case "organization.id": + session.OrganizationID = value.GetStringValue() + case "user.account_uuid": + session.UserAccountUUID = value.GetStringValue() + case "terminal.type": + session.TerminalType = value.GetStringValue() + case "service.version": + session.ServiceVersion = value.GetStringValue() + case "os.type": + session.OSType = value.GetStringValue() + case "os.version": + session.OSVersion = value.GetStringValue() + case "host.arch": + session.HostArch = value.GetStringValue() + } + } + + // Generate session ID if not present + if session.SessionID == "" { + session.SessionID = uuid.New().String() + } + + return session +} + +// detectProject extracts project from resource attributes or environment +func (p *CCOtelProcessor) detectProject(resource *resourcev1.Resource) string { + // First check resource attributes + if resource != nil { + for _, attr := range resource.GetAttributes() { + if attr.GetKey() == "project" || attr.GetKey() == "project.path" { + return attr.GetValue().GetStringValue() + } + } + } + + // Fall back to environment variables + if project := os.Getenv("CLAUDE_CODE_PROJECT"); project != "" { + return project + } + if pwd := os.Getenv("PWD"); pwd != "" { + return pwd + } + + return "unknown" +} + +// parseMetric parses an OTEL metric into CCOtelMetric(s) +func (p *CCOtelProcessor) parseMetric(m *metricsv1.Metric) []model.CCOtelMetric { + var metrics []model.CCOtelMetric + + name := m.GetName() + metricType := mapMetricName(name) + if metricType == "" { + return metrics // Unknown metric, skip + } + + // Handle different metric data types + switch data := m.GetData().(type) { + case *metricsv1.Metric_Sum: + for _, dp := range data.Sum.GetDataPoints() { + metric := model.CCOtelMetric{ + MetricID: uuid.New().String(), + MetricType: metricType, + Timestamp: int64(dp.GetTimeUnixNano() / 1e9), // Convert to seconds + Value: getDataPointValue(dp), + } + // Extract attributes + for _, attr := range dp.GetAttributes() { + applyMetricAttribute(&metric, attr) + } + metrics = append(metrics, metric) + } + case *metricsv1.Metric_Gauge: + for _, dp := range data.Gauge.GetDataPoints() { + metric := model.CCOtelMetric{ + MetricID: uuid.New().String(), + MetricType: metricType, + Timestamp: int64(dp.GetTimeUnixNano() / 1e9), + Value: getDataPointValue(dp), + } + for _, attr := range dp.GetAttributes() { + applyMetricAttribute(&metric, attr) + } + metrics = append(metrics, metric) + } + } + + return metrics +} + +// parseLogRecord parses an OTEL log record into a CCOtelEvent +func (p *CCOtelProcessor) parseLogRecord(lr *logsv1.LogRecord) *model.CCOtelEvent { + event := &model.CCOtelEvent{ + EventID: uuid.New().String(), + Timestamp: int64(lr.GetTimeUnixNano() / 1e9), // Convert to seconds + } + + // Extract event type and other attributes + for _, attr := range lr.GetAttributes() { + key := attr.GetKey() + value := attr.GetValue() + + switch key { + case "event.name": + event.EventType = mapEventName(value.GetStringValue()) + case "model": + event.Model = value.GetStringValue() + case "cost_usd": + event.CostUSD = value.GetDoubleValue() + case "duration_ms": + event.DurationMs = int(value.GetIntValue()) + case "input_tokens": + event.InputTokens = int(value.GetIntValue()) + case "output_tokens": + event.OutputTokens = int(value.GetIntValue()) + case "cache_read_tokens": + event.CacheReadTokens = int(value.GetIntValue()) + case "cache_creation_tokens": + event.CacheCreationTokens = int(value.GetIntValue()) + case "tool_name": + event.ToolName = value.GetStringValue() + case "success": + event.Success = value.GetBoolValue() + case "decision": + event.Decision = value.GetStringValue() + case "source": + event.Source = value.GetStringValue() + case "error": + event.Error = value.GetStringValue() + case "prompt_length": + event.PromptLength = int(value.GetIntValue()) + case "status_code": + event.StatusCode = int(value.GetIntValue()) + case "attempt": + event.Attempt = int(value.GetIntValue()) + case "language": + event.Language = value.GetStringValue() + } + } + + // Skip if no event type was extracted + if event.EventType == "" { + return nil + } + + return event +} + +// mapMetricName maps OTEL metric names to our internal types +func mapMetricName(name string) string { + switch name { + case "claude_code.session.count": + return model.CCMetricSessionCount + case "claude_code.token.usage": + return model.CCMetricTokenUsage + case "claude_code.cost.usage": + return model.CCMetricCostUsage + case "claude_code.lines_of_code.count": + return model.CCMetricLinesOfCodeCount + case "claude_code.commit.count": + return model.CCMetricCommitCount + case "claude_code.pull_request.count": + return model.CCMetricPullRequestCount + case "claude_code.active_time.total": + return model.CCMetricActiveTimeTotal + case "claude_code.code_edit_tool.decision": + return model.CCMetricCodeEditToolDecision + default: + return "" + } +} + +// mapEventName maps OTEL event names to our internal types +func mapEventName(name string) string { + switch name { + case "claude_code.user_prompt": + return model.CCEventUserPrompt + case "claude_code.tool_result": + return model.CCEventToolResult + case "claude_code.api_request": + return model.CCEventApiRequest + case "claude_code.api_error": + return model.CCEventApiError + case "claude_code.tool_decision": + return model.CCEventToolDecision + 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) { + case *metricsv1.NumberDataPoint_AsDouble: + return v.AsDouble + case *metricsv1.NumberDataPoint_AsInt: + return float64(v.AsInt) + default: + return 0 + } +} + +// applyMetricAttribute applies an attribute to a metric +func applyMetricAttribute(metric *model.CCOtelMetric, attr *commonv1.KeyValue) { + key := attr.GetKey() + value := attr.GetValue() + + switch key { + case "type": + metric.TokenType = value.GetStringValue() + case "model": + metric.Model = value.GetStringValue() + case "tool": + metric.Tool = value.GetStringValue() + case "decision": + metric.Decision = value.GetStringValue() + case "language": + metric.Language = value.GetStringValue() + } +} diff --git a/daemon/ccotel_server.go b/daemon/ccotel_server.go new file mode 100644 index 0000000..918c468 --- /dev/null +++ b/daemon/ccotel_server.go @@ -0,0 +1,85 @@ +package daemon + +import ( + "context" + "fmt" + "log/slog" + "net" + + collogsv1 "go.opentelemetry.io/proto/otlp/collector/logs/v1" + collmetricsv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + "google.golang.org/grpc" +) + +// CCOtelServer is the gRPC server for receiving OTEL data from Claude Code +type CCOtelServer struct { + port int + processor *CCOtelProcessor + grpcServer *grpc.Server + listener net.Listener +} + +// NewCCOtelServer creates a new CCOtel gRPC server +func NewCCOtelServer(port int, processor *CCOtelProcessor) *CCOtelServer { + return &CCOtelServer{ + port: port, + processor: processor, + } +} + +// Start starts the gRPC server +func (s *CCOtelServer) Start() error { + addr := fmt.Sprintf(":%d", s.port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + s.listener = listener + + s.grpcServer = grpc.NewServer() + + // Register OTEL collector services + collmetricsv1.RegisterMetricsServiceServer(s.grpcServer, &metricsServiceServer{processor: s.processor}) + collogsv1.RegisterLogsServiceServer(s.grpcServer, &logsServiceServer{processor: s.processor}) + + slog.Info("CCOtel gRPC server starting", "port", s.port) + + // Start serving in a goroutine + go func() { + if err := s.grpcServer.Serve(listener); err != nil { + slog.Error("CCOtel gRPC server error", "error", err) + } + }() + + return nil +} + +// Stop gracefully stops the gRPC server +func (s *CCOtelServer) Stop() { + if s.grpcServer != nil { + slog.Info("CCOtel gRPC server stopping") + s.grpcServer.GracefulStop() + } +} + +// metricsServiceServer implements the OTEL MetricsService +type metricsServiceServer struct { + collmetricsv1.UnimplementedMetricsServiceServer + processor *CCOtelProcessor +} + +// Export handles incoming metrics export requests +func (s *metricsServiceServer) Export(ctx context.Context, req *collmetricsv1.ExportMetricsServiceRequest) (*collmetricsv1.ExportMetricsServiceResponse, error) { + return s.processor.ProcessMetrics(ctx, req) +} + +// logsServiceServer implements the OTEL LogsService +type logsServiceServer struct { + collogsv1.UnimplementedLogsServiceServer + processor *CCOtelProcessor +} + +// Export handles incoming logs export requests +func (s *logsServiceServer) Export(ctx context.Context, req *collogsv1.ExportLogsServiceRequest) (*collogsv1.ExportLogsServiceResponse, error) { + return s.processor.ProcessLogs(ctx, req) +} diff --git a/go.mod b/go.mod index 67adc1e..6b50e08 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/briandowns/spinner v1.23.2 github.com/go-git/go-git/v5 v5.16.4 + github.com/google/uuid v1.6.0 github.com/gookit/color v1.6.0 github.com/lithammer/shortuuid/v3 v3.0.7 github.com/olekukonko/tablewriter v1.1.2 @@ -22,6 +23,8 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/trace v1.39.0 + go.opentelemetry.io/proto/otlp v1.9.0 + google.golang.org/grpc v1.77.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -45,7 +48,6 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-resty/resty/v2 v2.17.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -75,14 +77,12 @@ require ( go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/sdk/log v0.15.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect 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/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/model/api_ccotel.go b/model/api_ccotel.go new file mode 100644 index 0000000..502891b --- /dev/null +++ b/model/api_ccotel.go @@ -0,0 +1,51 @@ +package model + +import ( + "context" + "net/http" + "time" +) + +// SendCCOtelData sends OTEL data to the backend immediately +// POST /api/v1/cc/otel +func SendCCOtelData(ctx context.Context, req *CCOtelRequest, endpoint Endpoint) (*CCOtelResponse, error) { + ctx, span := modelTracer.Start(ctx, "ccotel.send") + defer span.End() + + var resp CCOtelResponse + err := SendHTTPRequestJSON(HTTPRequestOptions[*CCOtelRequest, CCOtelResponse]{ + Context: ctx, + Endpoint: endpoint, + Method: http.MethodPost, + Path: "/api/v1/cc/otel", + Payload: req, + Response: &resp, + Timeout: 30 * time.Second, + }) + + if err != nil { + return nil, err + } + + return &resp, nil +} + +// SendCCSessionEnd notifies the backend that a session has ended +// POST /api/v1/cc/session/end +func SendCCSessionEnd(ctx context.Context, req *CCSessionEndRequest, endpoint Endpoint) error { + ctx, span := modelTracer.Start(ctx, "ccotel.session.end") + defer span.End() + + var resp CCSessionEndResponse + err := SendHTTPRequestJSON(HTTPRequestOptions[*CCSessionEndRequest, CCSessionEndResponse]{ + Context: ctx, + Endpoint: endpoint, + Method: http.MethodPost, + Path: "/api/v1/cc/session/end", + Payload: req, + Response: &resp, + Timeout: 30 * time.Second, + }) + + return err +} diff --git a/model/ccotel_types.go b/model/ccotel_types.go new file mode 100644 index 0000000..fd0e047 --- /dev/null +++ b/model/ccotel_types.go @@ -0,0 +1,147 @@ +package model + +// CCOtelRequest is the main request to POST /api/v1/cc/otel +type CCOtelRequest struct { + Host string `json:"host"` + Project string `json:"project"` + Session *CCOtelSession `json:"session"` + Events []CCOtelEvent `json:"events,omitempty"` + Metrics []CCOtelMetric `json:"metrics,omitempty"` +} + +// CCOtelSession represents session data for Claude Code OTEL tracking +type CCOtelSession struct { + SessionID string `json:"sessionId"` + AppVersion string `json:"appVersion"` + OrganizationID string `json:"organizationId,omitempty"` + UserAccountUUID string `json:"userAccountUuid,omitempty"` + TerminalType string `json:"terminalType"` + ServiceVersion string `json:"serviceVersion"` + OSType string `json:"osType"` + OSVersion string `json:"osVersion"` + HostArch string `json:"hostArch"` + StartedAt int64 `json:"startedAt"` + EndedAt int64 `json:"endedAt,omitempty"` + ActiveTimeSeconds int `json:"activeTimeSeconds,omitempty"` + TotalPrompts int `json:"totalPrompts,omitempty"` + TotalToolCalls int `json:"totalToolCalls,omitempty"` + TotalApiRequests int `json:"totalApiRequests,omitempty"` + TotalCostUSD float64 `json:"totalCostUsd,omitempty"` + LinesAdded int `json:"linesAdded,omitempty"` + LinesRemoved int `json:"linesRemoved,omitempty"` + CommitsCreated int `json:"commitsCreated,omitempty"` + PRsCreated int `json:"prsCreated,omitempty"` + TotalInputTokens int64 `json:"totalInputTokens,omitempty"` + TotalOutputTokens int64 `json:"totalOutputTokens,omitempty"` + TotalCacheReadTokens int64 `json:"totalCacheReadTokens,omitempty"` + TotalCacheCreationTokens int64 `json:"totalCacheCreationTokens,omitempty"` +} + +// CCOtelEvent represents an event from Claude Code (api_request, tool_result, etc.) +type CCOtelEvent struct { + EventID string `json:"eventId"` + EventType string `json:"eventType"` + Timestamp int64 `json:"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"` + ToolName string `json:"toolName,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"` + ToolParameters map[string]interface{} `json:"toolParameters,omitempty"` + StatusCode int `json:"statusCode,omitempty"` + Attempt int `json:"attempt,omitempty"` + Language string `json:"language,omitempty"` +} + +// CCOtelMetric represents a metric data point from Claude Code +type CCOtelMetric 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"` +} + +// CCOtelResponse is the response from POST /api/v1/cc/otel +type CCOtelResponse struct { + Success bool `json:"success"` + SessionID int64 `json:"sessionId,omitempty"` + EventsProcessed int `json:"eventsProcessed"` + MetricsProcessed int `json:"metricsProcessed"` + Message string `json:"message,omitempty"` +} + +// CCSessionEndRequest is the request to POST /api/v1/cc/session/end +type CCSessionEndRequest struct { + Host string `json:"host"` + SessionID string `json:"sessionId"` + EndedAt int64 `json:"endedAt"` + ActiveTimeSeconds int `json:"activeTimeSeconds,omitempty"` + TotalPrompts int `json:"totalPrompts,omitempty"` + TotalToolCalls int `json:"totalToolCalls,omitempty"` + TotalApiRequests int `json:"totalApiRequests,omitempty"` + TotalCostUSD float64 `json:"totalCostUsd,omitempty"` + LinesAdded int `json:"linesAdded,omitempty"` + LinesRemoved int `json:"linesRemoved,omitempty"` + CommitsCreated int `json:"commitsCreated,omitempty"` + PRsCreated int `json:"prsCreated,omitempty"` + TotalInputTokens int64 `json:"totalInputTokens,omitempty"` + TotalOutputTokens int64 `json:"totalOutputTokens,omitempty"` + TotalCacheReadTokens int64 `json:"totalCacheReadTokens,omitempty"` + TotalCacheCreationTokens int64 `json:"totalCacheCreationTokens,omitempty"` +} + +// CCSessionEndResponse is the response from POST /api/v1/cc/session/end +type CCSessionEndResponse struct { + Code int `json:"code"` + Data interface{} `json:"data"` + Message string `json:"message"` +} + +// Claude Code OTEL metric types +const ( + CCMetricSessionCount = "session_count" + CCMetricLinesOfCodeCount = "lines_of_code_count" + CCMetricPullRequestCount = "pull_request_count" + CCMetricCommitCount = "commit_count" + CCMetricCostUsage = "cost_usage" + CCMetricTokenUsage = "token_usage" + CCMetricCodeEditToolDecision = "code_edit_tool_decision" + CCMetricActiveTimeTotal = "active_time_total" +) + +// Claude Code OTEL event types +const ( + CCEventUserPrompt = "user_prompt" + CCEventToolResult = "tool_result" + CCEventApiRequest = "api_request" + CCEventApiError = "api_error" + CCEventToolDecision = "tool_decision" +) + +// Token types for CCMetricTokenUsage +const ( + CCTokenTypeInput = "input" + CCTokenTypeOutput = "output" + CCTokenTypeCacheRead = "cacheRead" + CCTokenTypeCacheCreation = "cacheCreation" +) + +// Lines types for CCMetricLinesOfCodeCount +const ( + CCLinesTypeAdded = "added" + CCLinesTypeRemoved = "removed" +) diff --git a/model/config.go b/model/config.go index 2328cb4..e892d08 100644 --- a/model/config.go +++ b/model/config.go @@ -62,6 +62,12 @@ func mergeConfig(base, local *ShellTimeConfig) { if len(local.Exclude) > 0 { base.Exclude = local.Exclude } + if local.CCUsage != nil { + base.CCUsage = local.CCUsage + } + if local.CCOtel != nil { + base.CCOtel = local.CCOtel + } } func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeConfig, err error) { @@ -125,7 +131,12 @@ func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeCo if config.AI == nil { config.AI = DefaultAIConfig } - + + // Initialize CCOtel config with default port if enabled but port not set + if config.CCOtel != nil && config.CCOtel.GRPCPort == 0 { + config.CCOtel.GRPCPort = 4317 // default OTEL gRPC port + } + UserShellTimeConfig = config return } diff --git a/model/types.go b/model/types.go index 1865948..87ea6bc 100644 --- a/model/types.go +++ b/model/types.go @@ -21,6 +21,12 @@ type CCUsage struct { Enabled *bool `toml:"enabled"` } +// CCOtel configuration for OTEL-based Claude Code tracking (v2) +type CCOtel struct { + Enabled *bool `toml:"enabled"` + GRPCPort int `toml:"grpcPort"` // default: 4317 +} + type ShellTimeConfig struct { Token string APIEndpoint string @@ -53,8 +59,11 @@ type ShellTimeConfig struct { // Commands matching any of these patterns will not be synced to the server Exclude []string `toml:"exclude"` - // CCUsage configuration for Claude Code usage tracking + // CCUsage configuration for Claude Code usage tracking (v1 - ccusage CLI based) CCUsage *CCUsage `toml:"ccusage"` + + // CCOtel configuration for OTEL-based Claude Code tracking (v2 - gRPC passthrough) + CCOtel *CCOtel `toml:"ccotel"` } var DefaultAIConfig = &AIConfig{ @@ -80,4 +89,5 @@ var DefaultConfig = ShellTimeConfig{ AI: DefaultAIConfig, Exclude: []string{}, CCUsage: nil, + CCOtel: nil, } From cb252ce271b08319e6a711dab78dba2426a226ce Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 14 Dec 2025 00:15:45 +0800 Subject: [PATCH 2/4] feat(cli): add cc install command for Claude Code OTEL setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `shelltime cc install` command that automatically configures Claude Code OTEL environment variables in shell config files. - Add CCOtelEnvService interface with bash/zsh/fish implementations - Support markers for clean install/uninstall - Auto-detect and install for all supported shells - Aliases: `cc i` for install, `cc u` for uninstall 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/cli/main.go | 1 + commands/cc.go | 83 +++++++++ model/ccotel_env.go | 417 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 501 insertions(+) create mode 100644 commands/cc.go create mode 100644 model/ccotel_env.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 05ee580..d529ef5 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -105,6 +105,7 @@ func main() { commands.DotfilesCommand, commands.DoctorCommand, commands.QueryCommand, + commands.CCCommand, } err = app.Run(os.Args) if err != nil { diff --git a/commands/cc.go b/commands/cc.go new file mode 100644 index 0000000..a3691d8 --- /dev/null +++ b/commands/cc.go @@ -0,0 +1,83 @@ +package commands + +import ( + "github.com/gookit/color" + "github.com/malamtime/cli/model" + "github.com/urfave/cli/v2" +) + +var CCCommand = &cli.Command{ + Name: "cc", + Usage: "Claude Code integration commands", + Subcommands: []*cli.Command{ + CCInstallCommand, + CCUninstallCommand, + }, +} + +var CCInstallCommand = &cli.Command{ + Name: "install", + Aliases: []string{"i"}, + Usage: "Install Claude Code OTEL environment configuration to shell config files", + Action: commandCCInstall, +} + +var CCUninstallCommand = &cli.Command{ + Name: "uninstall", + Aliases: []string{"u"}, + Usage: "Remove Claude Code OTEL environment configuration from shell config files", + Action: commandCCUninstall, +} + +func commandCCInstall(c *cli.Context) error { + color.Yellow.Println("Installing Claude Code OTEL configuration...") + + // Create shell services + zshService := model.NewZshCCOtelEnvService() + fishService := model.NewFishCCOtelEnvService() + bashService := model.NewBashCCOtelEnvService() + + // Install for all shells (non-blocking failures) + if err := zshService.Install(); err != nil { + color.Red.Printf("Failed to install for zsh: %v\n", err) + } + + if err := fishService.Install(); err != nil { + color.Red.Printf("Failed to install for fish: %v\n", err) + } + + if err := bashService.Install(); err != nil { + color.Red.Printf("Failed to install for bash: %v\n", err) + } + + color.Green.Println("Claude Code OTEL configuration has been installed!") + color.Yellow.Println("Please restart your shell or source your config file to apply changes.") + + return nil +} + +func commandCCUninstall(c *cli.Context) error { + color.Yellow.Println("Removing Claude Code OTEL configuration...") + + // Create shell services + zshService := model.NewZshCCOtelEnvService() + fishService := model.NewFishCCOtelEnvService() + bashService := model.NewBashCCOtelEnvService() + + // Uninstall from all shells + if err := zshService.Uninstall(); err != nil { + color.Red.Printf("Failed to uninstall from zsh: %v\n", err) + } + + if err := fishService.Uninstall(); err != nil { + color.Red.Printf("Failed to uninstall from fish: %v\n", err) + } + + if err := bashService.Uninstall(); err != nil { + color.Red.Printf("Failed to uninstall from bash: %v\n", err) + } + + color.Green.Println("Claude Code OTEL configuration has been removed!") + + return nil +} diff --git a/model/ccotel_env.go b/model/ccotel_env.go new file mode 100644 index 0000000..770ac25 --- /dev/null +++ b/model/ccotel_env.go @@ -0,0 +1,417 @@ +package model + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/gookit/color" +) + +const ( + ccOtelMarkerStart = "# >>> shelltime cc otel >>>" + ccOtelMarkerEnd = "# <<< shelltime cc otel <<<" +) + +// CCOtelEnvService interface for shell-specific env var setup +type CCOtelEnvService interface { + Match(shellName string) bool + Install() error + Check() error + Uninstall() error + ShellName() string +} + +// baseCCOtelEnvService provides common functionality +type baseCCOtelEnvService struct{} + +func (b *baseCCOtelEnvService) backupFile(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } + + timestamp := time.Now().Format("20060102150405") + backupPath := fmt.Sprintf("%s.bak.%s", path, timestamp) + + srcFile, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to create backup file: %w", err) + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + return nil +} + +func (b *baseCCOtelEnvService) addEnvLines(filePath string, envLines []string) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + // Add env lines + lines = append(lines, envLines...) + + // Write the updated content back + if err := os.WriteFile(filePath, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil { + return fmt.Errorf("failed to write updated file: %w", err) + } + + return nil +} + +func (b *baseCCOtelEnvService) removeEnvLines(filePath string) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + var newLines []string + scanner := bufio.NewScanner(file) + inBlock := false + + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, ccOtelMarkerStart) { + inBlock = true + continue + } + if strings.Contains(line, ccOtelMarkerEnd) { + inBlock = false + continue + } + if !inBlock { + newLines = append(newLines, line) + } + } + + // Write the filtered content back + if err := os.WriteFile(filePath, []byte(strings.Join(newLines, "\n")+"\n"), 0644); err != nil { + return fmt.Errorf("failed to write updated file: %w", err) + } + + return nil +} + +func (b *baseCCOtelEnvService) checkEnvLines(filePath string) (bool, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return false, fmt.Errorf("failed to read file: %w", err) + } + + return strings.Contains(string(content), ccOtelMarkerStart), nil +} + +// ============================================================================= +// Bash Implementation +// ============================================================================= + +type BashCCOtelEnvService struct { + baseCCOtelEnvService + shellName string + configPath string + envLines []string +} + +func NewBashCCOtelEnvService() CCOtelEnvService { + configPath := os.ExpandEnv("$HOME/.bashrc") + envLines := []string{ + "", + ccOtelMarkerStart, + "export CLAUDE_CODE_ENABLE_TELEMETRY=1", + "export OTEL_METRICS_EXPORTER=otlp", + "export OTEL_LOGS_EXPORTER=otlp", + "export OTEL_EXPORTER_OTLP_PROTOCOL=grpc", + "export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317", + ccOtelMarkerEnd, + } + + return &BashCCOtelEnvService{ + shellName: "bash", + configPath: configPath, + envLines: envLines, + } +} + +func (s *BashCCOtelEnvService) Match(shellName string) bool { + return strings.Contains(strings.ToLower(shellName), strings.ToLower(s.shellName)) +} + +func (s *BashCCOtelEnvService) ShellName() string { + return s.shellName +} + +func (s *BashCCOtelEnvService) Install() error { + // Create config file if it doesn't exist + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + if err := os.WriteFile(s.configPath, []byte(""), 0644); err != nil { + return fmt.Errorf("failed to create bash config file: %w", err) + } + } + + // Check if already installed + installed, err := s.checkEnvLines(s.configPath) + if err != nil { + return err + } + if installed { + color.Green.Printf("Claude Code OTEL config is already installed in %s\n", s.configPath) + return nil + } + + // Backup the file + if err := s.backupFile(s.configPath); err != nil { + return err + } + + // Add env lines + if err := s.addEnvLines(s.configPath, s.envLines); err != nil { + return err + } + + color.Green.Printf("Claude Code OTEL config installed in %s\n", s.configPath) + return nil +} + +func (s *BashCCOtelEnvService) Uninstall() error { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return nil + } + + // Backup the file + if err := s.backupFile(s.configPath); err != nil { + return err + } + + return s.removeEnvLines(s.configPath) +} + +func (s *BashCCOtelEnvService) Check() error { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return fmt.Errorf("bash config file not found at %s", s.configPath) + } + + installed, err := s.checkEnvLines(s.configPath) + if err != nil { + return err + } + if !installed { + return fmt.Errorf("Claude Code OTEL config not found in %s", s.configPath) + } + + return nil +} + +// ============================================================================= +// Zsh Implementation +// ============================================================================= + +type ZshCCOtelEnvService struct { + baseCCOtelEnvService + shellName string + configPath string + envLines []string +} + +func NewZshCCOtelEnvService() CCOtelEnvService { + configPath := os.ExpandEnv("$HOME/.zshrc") + envLines := []string{ + "", + ccOtelMarkerStart, + "export CLAUDE_CODE_ENABLE_TELEMETRY=1", + "export OTEL_METRICS_EXPORTER=otlp", + "export OTEL_LOGS_EXPORTER=otlp", + "export OTEL_EXPORTER_OTLP_PROTOCOL=grpc", + "export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317", + ccOtelMarkerEnd, + } + + return &ZshCCOtelEnvService{ + shellName: "zsh", + configPath: configPath, + envLines: envLines, + } +} + +func (s *ZshCCOtelEnvService) Match(shellName string) bool { + return strings.Contains(strings.ToLower(shellName), strings.ToLower(s.shellName)) +} + +func (s *ZshCCOtelEnvService) ShellName() string { + return s.shellName +} + +func (s *ZshCCOtelEnvService) Install() error { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return fmt.Errorf("zsh config file not found at %s", s.configPath) + } + + // Check if already installed + installed, err := s.checkEnvLines(s.configPath) + if err != nil { + return err + } + if installed { + color.Green.Printf("Claude Code OTEL config is already installed in %s\n", s.configPath) + return nil + } + + // Backup the file + if err := s.backupFile(s.configPath); err != nil { + return err + } + + // Add env lines + if err := s.addEnvLines(s.configPath, s.envLines); err != nil { + return err + } + + color.Green.Printf("Claude Code OTEL config installed in %s\n", s.configPath) + return nil +} + +func (s *ZshCCOtelEnvService) Uninstall() error { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return nil + } + + // Backup the file + if err := s.backupFile(s.configPath); err != nil { + return err + } + + return s.removeEnvLines(s.configPath) +} + +func (s *ZshCCOtelEnvService) Check() error { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return fmt.Errorf("zsh config file not found at %s", s.configPath) + } + + installed, err := s.checkEnvLines(s.configPath) + if err != nil { + return err + } + if !installed { + return fmt.Errorf("Claude Code OTEL config not found in %s", s.configPath) + } + + return nil +} + +// ============================================================================= +// Fish Implementation +// ============================================================================= + +type FishCCOtelEnvService struct { + baseCCOtelEnvService + shellName string + configPath string + envLines []string +} + +func NewFishCCOtelEnvService() CCOtelEnvService { + configPath := os.ExpandEnv("$HOME/.config/fish/config.fish") + envLines := []string{ + "", + ccOtelMarkerStart, + "set -gx CLAUDE_CODE_ENABLE_TELEMETRY 1", + "set -gx OTEL_METRICS_EXPORTER otlp", + "set -gx OTEL_LOGS_EXPORTER otlp", + "set -gx OTEL_EXPORTER_OTLP_PROTOCOL grpc", + "set -gx OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4317", + ccOtelMarkerEnd, + } + + return &FishCCOtelEnvService{ + shellName: "fish", + configPath: configPath, + envLines: envLines, + } +} + +func (s *FishCCOtelEnvService) Match(shellName string) bool { + return strings.Contains(strings.ToLower(shellName), strings.ToLower(s.shellName)) +} + +func (s *FishCCOtelEnvService) ShellName() string { + return s.shellName +} + +func (s *FishCCOtelEnvService) Install() error { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return fmt.Errorf("fish config file not found at %s", s.configPath) + } + + // Check if already installed + installed, err := s.checkEnvLines(s.configPath) + if err != nil { + return err + } + if installed { + color.Green.Printf("Claude Code OTEL config is already installed in %s\n", s.configPath) + return nil + } + + // Backup the file + if err := s.backupFile(s.configPath); err != nil { + return err + } + + // Add env lines + if err := s.addEnvLines(s.configPath, s.envLines); err != nil { + return err + } + + color.Green.Printf("Claude Code OTEL config installed in %s\n", s.configPath) + return nil +} + +func (s *FishCCOtelEnvService) Uninstall() error { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return nil + } + + // Backup the file + if err := s.backupFile(s.configPath); err != nil { + return err + } + + return s.removeEnvLines(s.configPath) +} + +func (s *FishCCOtelEnvService) Check() error { + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { + return fmt.Errorf("fish config file not found at %s", s.configPath) + } + + installed, err := s.checkEnvLines(s.configPath) + if err != nil { + return err + } + if !installed { + return fmt.Errorf("Claude Code OTEL config not found in %s", s.configPath) + } + + return nil +} From 27d518d4ed5591d6af36f98fc9b2dfa6e06b5995 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 14 Dec 2025 11:38:56 +0800 Subject: [PATCH 3/4] refactor(cli): simplify cc otel env injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove backup functionality for simpler direct writes - Extract OTEL endpoint to constant for easy modification - Always remove existing env blocks before installing fresh config 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- model/ccotel_env.go | 93 +++++---------------------------------------- 1 file changed, 10 insertions(+), 83 deletions(-) diff --git a/model/ccotel_env.go b/model/ccotel_env.go index 770ac25..976a585 100644 --- a/model/ccotel_env.go +++ b/model/ccotel_env.go @@ -3,10 +3,8 @@ package model import ( "bufio" "fmt" - "io" "os" "strings" - "time" "github.com/gookit/color" ) @@ -14,6 +12,7 @@ import ( const ( ccOtelMarkerStart = "# >>> shelltime cc otel >>>" ccOtelMarkerEnd = "# <<< shelltime cc otel <<<" + ccOtelEndpoint = "http://localhost:54027" ) // CCOtelEnvService interface for shell-specific env var setup @@ -28,33 +27,6 @@ type CCOtelEnvService interface { // baseCCOtelEnvService provides common functionality type baseCCOtelEnvService struct{} -func (b *baseCCOtelEnvService) backupFile(path string) error { - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil - } - - timestamp := time.Now().Format("20060102150405") - backupPath := fmt.Sprintf("%s.bak.%s", path, timestamp) - - srcFile, err := os.Open(path) - if err != nil { - return fmt.Errorf("failed to open source file: %w", err) - } - defer srcFile.Close() - - dstFile, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("failed to create backup file: %w", err) - } - defer dstFile.Close() - - if _, err := io.Copy(dstFile, srcFile); err != nil { - return fmt.Errorf("failed to copy file: %w", err) - } - - return nil -} - func (b *baseCCOtelEnvService) addEnvLines(filePath string, envLines []string) error { file, err := os.Open(filePath) if err != nil { @@ -142,7 +114,7 @@ func NewBashCCOtelEnvService() CCOtelEnvService { "export OTEL_METRICS_EXPORTER=otlp", "export OTEL_LOGS_EXPORTER=otlp", "export OTEL_EXPORTER_OTLP_PROTOCOL=grpc", - "export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317", + "export OTEL_EXPORTER_OTLP_ENDPOINT=" + ccOtelEndpoint, ccOtelMarkerEnd, } @@ -169,18 +141,8 @@ func (s *BashCCOtelEnvService) Install() error { } } - // Check if already installed - installed, err := s.checkEnvLines(s.configPath) - if err != nil { - return err - } - if installed { - color.Green.Printf("Claude Code OTEL config is already installed in %s\n", s.configPath) - return nil - } - - // Backup the file - if err := s.backupFile(s.configPath); err != nil { + // Remove existing env lines first + if err := s.removeEnvLines(s.configPath); err != nil { return err } @@ -198,11 +160,6 @@ func (s *BashCCOtelEnvService) Uninstall() error { return nil } - // Backup the file - if err := s.backupFile(s.configPath); err != nil { - return err - } - return s.removeEnvLines(s.configPath) } @@ -242,7 +199,7 @@ func NewZshCCOtelEnvService() CCOtelEnvService { "export OTEL_METRICS_EXPORTER=otlp", "export OTEL_LOGS_EXPORTER=otlp", "export OTEL_EXPORTER_OTLP_PROTOCOL=grpc", - "export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317", + "export OTEL_EXPORTER_OTLP_ENDPOINT=" + ccOtelEndpoint, ccOtelMarkerEnd, } @@ -266,18 +223,8 @@ func (s *ZshCCOtelEnvService) Install() error { return fmt.Errorf("zsh config file not found at %s", s.configPath) } - // Check if already installed - installed, err := s.checkEnvLines(s.configPath) - if err != nil { - return err - } - if installed { - color.Green.Printf("Claude Code OTEL config is already installed in %s\n", s.configPath) - return nil - } - - // Backup the file - if err := s.backupFile(s.configPath); err != nil { + // Remove existing env lines first + if err := s.removeEnvLines(s.configPath); err != nil { return err } @@ -295,11 +242,6 @@ func (s *ZshCCOtelEnvService) Uninstall() error { return nil } - // Backup the file - if err := s.backupFile(s.configPath); err != nil { - return err - } - return s.removeEnvLines(s.configPath) } @@ -339,7 +281,7 @@ func NewFishCCOtelEnvService() CCOtelEnvService { "set -gx OTEL_METRICS_EXPORTER otlp", "set -gx OTEL_LOGS_EXPORTER otlp", "set -gx OTEL_EXPORTER_OTLP_PROTOCOL grpc", - "set -gx OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4317", + "set -gx OTEL_EXPORTER_OTLP_ENDPOINT " + ccOtelEndpoint, ccOtelMarkerEnd, } @@ -363,18 +305,8 @@ func (s *FishCCOtelEnvService) Install() error { return fmt.Errorf("fish config file not found at %s", s.configPath) } - // Check if already installed - installed, err := s.checkEnvLines(s.configPath) - if err != nil { - return err - } - if installed { - color.Green.Printf("Claude Code OTEL config is already installed in %s\n", s.configPath) - return nil - } - - // Backup the file - if err := s.backupFile(s.configPath); err != nil { + // Remove existing env lines first + if err := s.removeEnvLines(s.configPath); err != nil { return err } @@ -392,11 +324,6 @@ func (s *FishCCOtelEnvService) Uninstall() error { return nil } - // Backup the file - if err := s.backupFile(s.configPath); err != nil { - return err - } - return s.removeEnvLines(s.configPath) } From a27600c48dc131bf66fb7aff9fa13ce76d7bd8ac Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 14 Dec 2025 13:02:25 +0800 Subject: [PATCH 4/4] refactor(config): consolidate daemon config into model package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move socket path configuration from daemon-specific config to the main ShellTimeConfig. This simplifies the architecture by having a single source of truth for configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/daemon/main.go | 29 +++---------- commands/track.go | 2 +- daemon/config.go | 104 --------------------------------------------- daemon/socket.go | 5 ++- model/config.go | 11 +++-- model/types.go | 10 +++++ 6 files changed, 29 insertions(+), 132 deletions(-) delete mode 100644 daemon/config.go diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index e8ba4bd..7f7bff9 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -2,10 +2,10 @@ package main import ( "context" + "fmt" "log/slog" "os" "os/signal" - "path/filepath" "syscall" "github.com/ThreeDotsLabs/watermill" @@ -25,15 +25,6 @@ var ( ppToken = "" ) -func getConfigPath() string { - homeDir, err := os.UserHomeDir() - if err != nil { - slog.Error("Failed to get user home directory", slog.Any("err", err)) - return "" - } - return filepath.Join(homeDir, ".shelltime", "config.toml") -} - func main() { l := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ AddSource: true, @@ -41,20 +32,15 @@ func main() { })) slog.SetDefault(l) - daemonConfigService := daemon.NewConfigService(daemon.DefaultConfigPath) - daemonConfig, err := daemonConfigService.GetConfig() + ctx := context.Background() + configFile := os.ExpandEnv(fmt.Sprintf("%s/%s/%s", "$HOME", model.COMMAND_BASE_STORAGE_FOLDER, "config.toml")) + daemonConfigService := model.NewConfigService(configFile) + cfg, err := daemonConfigService.ReadConfigFile(ctx) if err != nil { slog.Error("Failed to get daemon config", slog.Any("err", err)) return } - cs, err := daemonConfigService.GetUserConfig() - if err != nil { - slog.Error("Failed to get user config", slog.Any("err", err)) - return - } - - ctx := context.Background() uptraceOptions := []uptrace.Option{ uptrace.WithDSN(uptraceDsn), uptrace.WithServiceName("cli-daemon"), @@ -66,7 +52,6 @@ func main() { uptraceOptions = append(uptraceOptions, uptrace.WithResourceAttributes(attribute.String("hostname", hs))) } - cfg, err := cs.ReadConfigFile(ctx) if err != nil || cfg.EnableMetrics == nil || *cfg.EnableMetrics == false || @@ -82,7 +67,7 @@ func main() { defer uptrace.Shutdown(ctx) defer uptrace.ForceFlush(ctx) - daemon.Init(cs, version) + daemon.Init(daemonConfigService, version) model.InjectVar(version) cmdService := model.NewCommandService() @@ -121,7 +106,7 @@ func main() { } // Create processor instance - processor := daemon.NewSocketHandler(daemonConfig, pubsub) + processor := daemon.NewSocketHandler(&cfg, pubsub) // Start processor if err := processor.Start(); err != nil { diff --git a/commands/track.go b/commands/track.go index f176430..aa23599 100644 --- a/commands/track.go +++ b/commands/track.go @@ -264,7 +264,7 @@ func DoSyncData( trackingData []model.TrackingData, meta model.TrackingMetaData, ) error { - socketPath := daemon.DefaultSocketPath + socketPath := config.SocketPath isSocketReady := daemon.IsSocketReady(ctx, socketPath) logrus.Traceln("is socket ready: ", isSocketReady) diff --git a/daemon/config.go b/daemon/config.go deleted file mode 100644 index be48467..0000000 --- a/daemon/config.go +++ /dev/null @@ -1,104 +0,0 @@ -package daemon - -import ( - "os" - "path/filepath" - - "github.com/malamtime/cli/model" - "gopkg.in/yaml.v3" -) - -const ( - DefaultSocketPath = "/tmp/shelltime.sock" - DefaultConfigPath = "/etc/shelltime/config.yml" -) - -type DaemonConfig struct { - SocketPath string `yaml:"socketPath"` - SystemUser string `yaml:"sysUser"` -} - -// ConfigService defines the interface for daemon configuration operations -type ConfigService interface { - GetConfig() (*DaemonConfig, error) - CreateDefault() (*DaemonConfig, error) - GetUserConfig() (model.ConfigService, error) -} - -// configService implements ConfigService -type configService struct { - configPath string -} - -// NewConfigService creates a new instance of ConfigService -func NewConfigService(configPath string) ConfigService { - return &configService{ - configPath: configPath, - } -} - -// GetConfig reads and returns the daemon configuration -func (s *configService) GetConfig() (*DaemonConfig, error) { - if _, err := os.Stat(s.configPath); os.IsNotExist(err) { - return s.CreateDefault() - } - - data, err := os.ReadFile(s.configPath) - if err != nil { - return nil, err - } - - var config DaemonConfig - err = yaml.Unmarshal(data, &config) - if err != nil { - return nil, err - } - - return &config, nil -} - -// CreateDefault creates and returns a default daemon configuration -func (s *configService) CreateDefault() (*DaemonConfig, error) { - _, username, err := model.SudoGetBaseFolder() - if err != nil { - return nil, err - } - - config := &DaemonConfig{ - SocketPath: DefaultSocketPath, - SystemUser: username, - } - - data, err := yaml.Marshal(config) - if err != nil { - return nil, err - } - - err = os.MkdirAll(filepath.Dir(s.configPath), 0755) - if err != nil { - return nil, err - } - - err = os.WriteFile(s.configPath, data, 0644) - if err != nil { - return nil, err - } - - return config, nil -} - -func (s *configService) GetUserConfig() (model.ConfigService, error) { - c, err := s.GetConfig() - if err != nil { - return nil, err - } - - baseFolder, err := model.SudoGetUserBaseFolder(c.SystemUser) - if err != nil { - return nil, err - } - - return model.NewConfigService( - filepath.Join(baseFolder, "config.toml"), - ), nil -} diff --git a/daemon/socket.go b/daemon/socket.go index 704b5fa..c717d7c 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -8,6 +8,7 @@ import ( "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" + "github.com/malamtime/cli/model" ) type SocketMessageType string @@ -23,14 +24,14 @@ type SocketMessage struct { } type SocketHandler struct { - config *DaemonConfig + config *model.ShellTimeConfig listener net.Listener channel *GoChannel stopChan chan struct{} } -func NewSocketHandler(config *DaemonConfig, ch *GoChannel) *SocketHandler { +func NewSocketHandler(config *model.ShellTimeConfig, ch *GoChannel) *SocketHandler { return &SocketHandler{ config: config, channel: ch, diff --git a/model/config.go b/model/config.go index e892d08..f7a2fd5 100644 --- a/model/config.go +++ b/model/config.go @@ -68,6 +68,9 @@ func mergeConfig(base, local *ShellTimeConfig) { if local.CCOtel != nil { base.CCOtel = local.CCOtel } + if local.SocketPath != "" { + base.SocketPath = local.SocketPath + } } func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeConfig, err error) { @@ -95,7 +98,6 @@ func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeCo baseName := strings.TrimSuffix(configFile, ext) // Construct local config filename: baseName + ".local" + ext localConfigFile := baseName + ".local" + ext - if localConfig, localErr := os.ReadFile(localConfigFile); localErr == nil { // Parse local config and merge with base config var localSettings ShellTimeConfig @@ -126,7 +128,7 @@ func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeCo if config.DataMasking == nil { config.DataMasking = &truthy } - + // Initialize AI config with defaults if not present if config.AI == nil { config.AI = DefaultAIConfig @@ -134,7 +136,10 @@ func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeCo // Initialize CCOtel config with default port if enabled but port not set if config.CCOtel != nil && config.CCOtel.GRPCPort == 0 { - config.CCOtel.GRPCPort = 4317 // default OTEL gRPC port + config.CCOtel.GRPCPort = 54027 // default OTEL gRPC port + } + if config.SocketPath == "" { + config.SocketPath = DefaultSocketPath } UserShellTimeConfig = config diff --git a/model/types.go b/model/types.go index 87ea6bc..c293f72 100644 --- a/model/types.go +++ b/model/types.go @@ -1,5 +1,9 @@ package model +const ( + DefaultSocketPath = "/tmp/shelltime.sock" +) + type Endpoint struct { APIEndpoint string `toml:"apiEndpoint"` Token string `token:"token"` @@ -64,6 +68,10 @@ type ShellTimeConfig struct { // CCOtel configuration for OTEL-based Claude Code tracking (v2 - gRPC passthrough) CCOtel *CCOtel `toml:"ccotel"` + + // SocketPath is the path to the Unix domain socket used for communication + // between the CLI and the daemon. + SocketPath string `toml:"socketPath"` } var DefaultAIConfig = &AIConfig{ @@ -90,4 +98,6 @@ var DefaultConfig = ShellTimeConfig{ Exclude: []string{}, CCUsage: nil, CCOtel: nil, + + SocketPath: DefaultSocketPath, }