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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Optional daemon services (feature-gated via config):
- **CCInfoTimerService**: Lazy-fetch background timer for Claude Code statusline data (cost, quota, git info), plus an always-on Anthropic usage sync (10-min interval, only while a `claude` process is running) that pushes quota to `/api/v1/anthropic-usage` without needing statusline activity
- **CodexUsageSyncService**: Syncs Codex usage to `/api/v1/codex-usage` every 10 min, only while a `codex`/`chatgpt` process is running (both intervals share the `usageSyncInterval` const in `daemon/usage_sync.go`)
- **SyncCircuitBreakerService**: Retry failed syncs with file-based persistence (`sync_pending.log`) and hourly recovery timer
- **AICodeOtelServer**: gRPC OTEL collector for AI coding CLI metrics/logs (Claude Code, Codex). In a Claude Code cloud session (`CLAUDE_CODE_REMOTE=true`, where the SessionStart hook starts the daemon) it adds the VM's `CLAUDE_CODE_REMOTE_SESSION_ID` as the `ccr.session.id` attribute to Claude Code records that lack it; the server marks those sessions as cloud sessions. `daemon/main_test.go` clears both variables so tests behave the same inside a cloud session
- **AICodeOtelServer**: gRPC OTEL collector for AI coding CLI metrics, logs and traces (Claude Code, Codex). Spans (`claude_code.interaction`, `llm_request`, `tool`, ...) go to the same `/api/v1/cc/otel` endpoint in `spans`; a span without `session.id` takes it from another span of its trace in the same export. `shelltime cc install` turns traces on (`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA`, `OTEL_TRACES_EXPORTER=otlp`). In a Claude Code cloud session (`CLAUDE_CODE_REMOTE=true`, where the SessionStart hook starts the daemon) it adds the VM's `CLAUDE_CODE_REMOTE_SESSION_ID` as the `ccr.session.id` attribute to Claude Code records that lack it; the server marks those sessions as cloud sessions. `daemon/main_test.go` clears both variables so tests behave the same inside a cloud session
- **HeartbeatResyncService**: Periodic resync of failed heartbeats (30-min interval)
- **CleanupTimerService**: Periodic log file cleanup (24-hour interval)

Expand Down Expand Up @@ -138,4 +138,4 @@ Releases use Release-Please (always-bump-patch) + Goreleaser. macOS builds inclu
- Encryption requires daemon mode and a token with encryption capability
- Shell hooks are platform-specific (bash, zsh, fish) - test on target shells
- CC statusline quota display is macOS-only (requires Keychain access to Claude Code OAuth token)
- AICodeOtel feature enables AI coding CLI metrics/logs passthrough via gRPC (port 54027) - supports Claude Code, Codex, and other OTEL-compatible CLIs
- AICodeOtel feature enables AI coding CLI metrics/logs/traces passthrough via gRPC (port 54027) - supports Claude Code, Codex, and other OTEL-compatible CLIs
2 changes: 1 addition & 1 deletion commands/doctor_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ func doctorCheckClaude(env *doctorEnv) []doctorResult {
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, ...).
// assistant responses, delta temporality, traces, ...).
if missing, err := svc.MissingManagedKeys(); err == nil && len(missing) > 0 {
results = append(results, doctorResult{
ID: "claude.otel_keys",
Expand Down
181 changes: 179 additions & 2 deletions daemon/aicode_otel_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import (
"github.com/malamtime/cli/model"
collogsv1 "go.opentelemetry.io/proto/otlp/collector/logs/v1"
collmetricsv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
colltracev1 "go.opentelemetry.io/proto/otlp/collector/trace/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"
tracev1 "go.opentelemetry.io/proto/otlp/trace/v1"
"google.golang.org/protobuf/proto"
)

Expand Down Expand Up @@ -257,6 +259,181 @@ func (p *AICodeOtelProcessor) ProcessLogs(ctx context.Context, req *collogsv1.Ex
return &collogsv1.ExportLogsServiceResponse{}, nil
}

// ProcessTraces receives OTEL spans and forwards them to the backend immediately
func (p *AICodeOtelProcessor) ProcessTraces(ctx context.Context, req *colltracev1.ExportTraceServiceRequest) (*colltracev1.ExportTraceServiceResponse, error) {
slog.Debug("AICodeOtel: Processing traces request", "resourceSpansCount", len(req.GetResourceSpans()), slog.Bool("debug", p.debug))

if p.debug {
p.writeDebugFile("aicode-otel-debug-traces.txt", req)
}

for _, rs := range req.GetResourceSpans() {
resource := rs.GetResource()

source := detectOtelSource(resource)
if source == "" {
slog.Debug("AICodeOtel: Skipping unknown resource")
continue
}

attrs := extractResourceAttributes(resource)
var spans []model.AICodeOtelSpan
for _, ss := range rs.GetScopeSpans() {
for _, s := range ss.GetSpans() {
if span := parseSpan(s, source, attrs); span != nil {
spans = append(spans, *span)
}
}
}
if len(spans) == 0 {
continue
}
fillSpanSessionIDs(spans)

p.send(ctx, &model.AICodeOtelRequest{
Host: p.hostname,
Project: p.detectProject(resource, source),
Source: source,
Spans: spans,
})
}

return &colltracev1.ExportTraceServiceResponse{}, nil
}

// spanIdentityAttributes are the account attributes Claude Code repeats on every span. They are
// the same for the whole session, so they are not forwarded with each span.
var spanIdentityAttributes = map[string]bool{
"user.id": true,
"user.email": true,
"user.account_uuid": true,
"user.account_id": true,
"organization.id": true,
}

// parseSpan converts an OTLP span into an AICodeOtelSpan. It returns nil for a span without
// valid trace and span ids.
func parseSpan(s *tracev1.Span, source string, res *model.AICodeOtelResourceAttributes) *model.AICodeOtelSpan {
traceID, spanID := otelID(s.GetTraceId(), 16), otelID(s.GetSpanId(), 8)
if traceID == "" || spanID == "" {
return nil
}
span := &model.AICodeOtelSpan{
TraceID: traceID,
SpanID: spanID,
ParentSpanID: otelID(s.GetParentSpanId(), 8),
Name: model.CapAICodeText(s.GetName(), model.AICodeOtelMaxAttributeBytes),
Kind: spanKind(s.GetKind()),
StartTimeUnixNano: unixNano(s.GetStartTimeUnixNano()),
EndTimeUnixNano: unixNano(s.GetEndTimeUnixNano()),
StatusMessage: model.CapAICodeText(s.GetStatus().GetMessage(), model.AICodeOtelMaxAttributeBytes),
SessionID: firstNonEmpty(res.SessionID, res.ConversationID),
ClientType: source,
}
switch s.GetStatus().GetCode() {
case tracev1.Status_STATUS_CODE_OK:
span.StatusCode = "ok"
case tracev1.Status_STATUS_CODE_ERROR:
span.StatusCode = "error"
}
if span.EndTimeUnixNano < span.StartTimeUnixNano {
span.EndTimeUnixNano = span.StartTimeUnixNano
}

var conversationID string
for _, attr := range s.GetAttributes() {
key := attr.GetKey()
switch {
// The session is kept in SessionID, not in the attributes.
case key == "session.id":
if id := anyString(attr.GetValue()); id != "" {
span.SessionID = id
}
case key == "conversation.id":
conversationID = anyString(attr.GetValue())
case spanIdentityAttributes[key]:
// Dropped: the same for every span of the session.
default:
span.Attributes = setAttribute(span.Attributes, key, anyValueToGo(attr.GetValue()))
}
}
if span.SessionID == "" {
span.SessionID = conversationID
}

for i, ev := range s.GetEvents() {
if i >= model.AICodeOtelMaxSpanEvents {
break
}
event := model.AICodeOtelSpanEvent{
Name: model.CapAICodeText(ev.GetName(), model.AICodeOtelMaxAttributeBytes),
TimeUnixNano: unixNano(ev.GetTimeUnixNano()),
}
for _, attr := range ev.GetAttributes() {
event.Attributes = setAttribute(event.Attributes, attr.GetKey(), anyValueToGo(attr.GetValue()))
}
span.Events = append(span.Events, event)
}

return span
}

// fillSpanSessionIDs gives spans without a session id the one another span of the same trace
// carries.
func fillSpanSessionIDs(spans []model.AICodeOtelSpan) {
sessions := make(map[string]string)
for _, s := range spans {
if s.SessionID != "" {
if _, ok := sessions[s.TraceID]; !ok {
sessions[s.TraceID] = s.SessionID
}
}
}
for i := range spans {
if spans[i].SessionID == "" {
spans[i].SessionID = sessions[spans[i].TraceID]
}
}
}

// otelID hex-encodes a trace or span id of the given byte length. A missing, malformed or
// all-zero id yields "".
func otelID(id []byte, size int) string {
if len(id) != size {
return ""
}
for _, b := range id {
if b != 0 {
return hex.EncodeToString(id)
}
}
return ""
}

func spanKind(kind tracev1.Span_SpanKind) string {
switch kind {
case tracev1.Span_SPAN_KIND_INTERNAL:
return "internal"
case tracev1.Span_SPAN_KIND_SERVER:
return "server"
case tracev1.Span_SPAN_KIND_CLIENT:
return "client"
case tracev1.Span_SPAN_KIND_PRODUCER:
return "producer"
case tracev1.Span_SPAN_KIND_CONSUMER:
return "consumer"
}
return ""
}

// unixNano converts an OTLP timestamp to int64, clamping values past the year 2262.
func unixNano(ts uint64) int64 {
if ts > math.MaxInt64 {
return math.MaxInt64
}
return int64(ts)
}

// 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) {
Expand All @@ -267,10 +444,10 @@ func (p *AICodeOtelProcessor) send(ctx context.Context, req *model.AICodeOtelReq
for _, chunk := range model.SplitAICodeOtelRequest(req, maxBytes) {
resp, err := model.SendAICodeOtelData(ctx, chunk, p.endpoint)
if err != nil {
slog.Error("AICodeOtel: Failed to send data to backend", "error", err, "events", len(chunk.Events), "metrics", len(chunk.Metrics))
slog.Error("AICodeOtel: Failed to send data to backend", "error", err, "events", len(chunk.Events), "metrics", len(chunk.Metrics), "spans", len(chunk.Spans))
continue
}
slog.Debug("AICodeOtel: Data sent to backend", "eventsProcessed", resp.EventsProcessed, "metricsProcessed", resp.MetricsProcessed)
slog.Debug("AICodeOtel: Data sent to backend", "eventsProcessed", resp.EventsProcessed, "metricsProcessed", resp.MetricsProcessed, "spansProcessed", resp.SpansProcessed)
}
}

Expand Down
13 changes: 13 additions & 0 deletions daemon/aicode_otel_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

collogsv1 "go.opentelemetry.io/proto/otlp/collector/logs/v1"
collmetricsv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
colltracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1"
"google.golang.org/grpc"
)

Expand Down Expand Up @@ -45,6 +46,7 @@ func (s *AICodeOtelServer) Start() error {
// Register OTEL collector services
collmetricsv1.RegisterMetricsServiceServer(s.grpcServer, &metricsServiceServer{processor: s.processor})
collogsv1.RegisterLogsServiceServer(s.grpcServer, &logsServiceServer{processor: s.processor})
colltracev1.RegisterTraceServiceServer(s.grpcServer, &traceServiceServer{processor: s.processor})

slog.Info("AICodeOtel gRPC server starting", "port", s.port)

Expand Down Expand Up @@ -87,3 +89,14 @@ type logsServiceServer struct {
func (s *logsServiceServer) Export(ctx context.Context, req *collogsv1.ExportLogsServiceRequest) (*collogsv1.ExportLogsServiceResponse, error) {
return s.processor.ProcessLogs(ctx, req)
}

// traceServiceServer implements the OTEL TraceService
type traceServiceServer struct {
colltracev1.UnimplementedTraceServiceServer
processor *AICodeOtelProcessor
}

// Export handles incoming trace export requests
func (s *traceServiceServer) Export(ctx context.Context, req *colltracev1.ExportTraceServiceRequest) (*colltracev1.ExportTraceServiceResponse, error) {
return s.processor.ProcessTraces(ctx, req)
}
Loading
Loading