feat(daemon): add Claude Code OTEL v2 passthrough collector#140
feat(daemon): add Claude Code OTEL v2 passthrough collector#140
Conversation
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 <noreply@anthropic.com>
Summary of ChangesHello @AnnatarHe, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the daemon's telemetry capabilities by integrating a new OpenTelemetry (OTEL) gRPC collector. This system provides a standardized and efficient mechanism for collecting and immediately forwarding Claude Code usage metrics and events to a backend API. The focus is on a passthrough model, ensuring that telemetry data is transmitted in real-time for prompt analysis and monitoring. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
Codecov Report❌ Patch coverage is
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request introduces a passthrough OTEL collector for Claude Code telemetry, which is a significant feature. The implementation includes a new gRPC server, a data processor for OTEL metrics and logs, and corresponding API client logic. The code is generally well-structured and follows good practices. My review focuses on a potential issue with session timestamping that could impact analytics, and opportunities to improve performance and maintainability in the data processing logic.
| session := &model.CCOtelSession{ | ||
| StartedAt: time.Now().Unix(), | ||
| } |
There was a problem hiding this comment.
The StartedAt timestamp for a session is set to time.Now().Unix() every time a batch of telemetry is received. For a single, long-running user session, this will result in multiple different StartedAt values being sent to the backend for the same session.id. This will make it impossible to accurately calculate session durations.
The session start time should be determined once when the session begins and then passed along with all telemetry for that session. Ideally, the client should include this as a resource attribute (e.g., session.started_at), which can then be parsed here.
| 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) { |
There was a problem hiding this comment.
There is significant code duplication between ProcessMetrics and ProcessLogs. The overall structure is identical:
- Iterate over resources in the request.
- Check if it's a Claude Code resource.
- Extract session and project information.
- Loop through scopes and data points/log records to parse them.
- Build and send a request to the backend.
This duplication makes the code harder to maintain, as any changes to the common logic will need to be applied in both functions. Consider refactoring the common parts into a generic helper function to improve maintainability.
| var metrics []model.CCOtelMetric | ||
|
|
||
| for _, sm := range rm.GetScopeMetrics() { | ||
| for _, m := range sm.GetMetrics() { | ||
| parsedMetrics := p.parseMetric(m) | ||
| metrics = append(metrics, parsedMetrics...) | ||
| } | ||
| } |
There was a problem hiding this comment.
The metrics slice is grown by appending inside nested loops. For requests with a large number of metrics, this can lead to multiple slice reallocations, impacting performance. Since this is part of a telemetry pipeline, optimizing for throughput is important.
You can improve this by pre-calculating the total number of metrics within the ResourceMetrics and pre-allocating the slice with the required capacity.
| var metrics []model.CCOtelMetric | |
| for _, sm := range rm.GetScopeMetrics() { | |
| for _, m := range sm.GetMetrics() { | |
| parsedMetrics := p.parseMetric(m) | |
| metrics = append(metrics, parsedMetrics...) | |
| } | |
| } | |
| capacity := 0 | |
| for _, sm := range rm.GetScopeMetrics() { | |
| capacity += len(sm.GetMetrics()) | |
| } | |
| metrics := make([]model.CCOtelMetric, 0, capacity) | |
| for _, sm := range rm.GetScopeMetrics() { | |
| for _, m := range sm.GetMetrics() { | |
| parsedMetrics := p.parseMetric(m) | |
| metrics = append(metrics, parsedMetrics...) | |
| } | |
| } |
| 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) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Similar to ProcessMetrics, the events slice is grown by appending in a loop, which can be inefficient for large numbers of log records. Pre-allocating the slice with an estimated capacity will improve performance and reduce memory allocations.
| 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) | |
| } | |
| } | |
| } | |
| capacity := 0 | |
| for _, sl := range rl.GetScopeLogs() { | |
| capacity += len(sl.GetLogRecords()) | |
| } | |
| events := make([]model.CCOtelEvent, 0, capacity) | |
| for _, sl := range rl.GetScopeLogs() { | |
| for _, lr := range sl.GetLogRecords() { | |
| event := p.parseLogRecord(lr) | |
| if event != nil { | |
| events = append(events, *event) | |
| } | |
| } | |
| } |
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Summary
claude_code.token.usage,claude_code.cost.usage, etc.)api_request,tool_result,user_prompt, etc.)POST /api/v1/cc/otelNew Files
daemon/ccotel_server.godaemon/ccotel_processor.gomodel/ccotel_types.gomodel/api_ccotel.goConfiguration
User Setup
Test plan
[ccotel]config enabled/api/v1/cc/otel🤖 Generated with Claude Code