-
Notifications
You must be signed in to change notification settings - Fork 0
feat(daemon): add coding heartbeat tracking with offline persistence #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package daemon | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "log/slog" | ||
| "os" | ||
|
|
||
| "github.com/malamtime/cli/model" | ||
| ) | ||
|
|
||
| func handlePubSubHeartbeat(ctx context.Context, socketMsgPayload interface{}) error { | ||
| pb, err := json.Marshal(socketMsgPayload) | ||
| if err != nil { | ||
| slog.Error("Failed to marshal the heartbeat payload again for unmarshal", slog.Any("payload", socketMsgPayload)) | ||
| return err | ||
| } | ||
|
|
||
| var heartbeatPayload model.HeartbeatPayload | ||
| err = json.Unmarshal(pb, &heartbeatPayload) | ||
| if err != nil { | ||
| slog.Error("Failed to parse heartbeat payload", slog.Any("payload", socketMsgPayload)) | ||
| return err | ||
| } | ||
|
Comment on lines
+14
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation marshals A more performant approach is to use a library like Example: import "github.com/mitchellh/mapstructure"
// ...
func handlePubSubHeartbeat(ctx context.Context, socketMsgPayload interface{}) error {
var heartbeatPayload model.HeartbeatPayload
err := mapstructure.Decode(socketMsgPayload, &heartbeatPayload)
if err != nil {
slog.Error("Failed to parse heartbeat payload", slog.Any("payload", socketMsgPayload), slog.Any("err", err))
return err
}
// ... rest of the function
}This avoids the overhead of the unnecessary JSON marshaling step. |
||
|
|
||
| if len(heartbeatPayload.Heartbeats) == 0 { | ||
| slog.Debug("Empty heartbeat payload, skipping") | ||
| return nil | ||
| } | ||
|
|
||
| cfg, err := stConfig.ReadConfigFile(ctx) | ||
| if err != nil { | ||
| slog.Error("Failed to read config file", slog.Any("err", err)) | ||
| return err | ||
| } | ||
|
Comment on lines
+32
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function calls The configuration is already loaded in |
||
|
|
||
| // Try to send to server | ||
| err = model.SendHeartbeatsToServer(ctx, cfg, heartbeatPayload) | ||
| if err != nil { | ||
| slog.Warn("Failed to send heartbeats to server, saving to local file", slog.Any("err", err)) | ||
| // On failure, save to local file | ||
| if saveErr := saveHeartbeatToFile(heartbeatPayload); saveErr != nil { | ||
| slog.Error("Failed to save heartbeat to local file", slog.Any("err", saveErr)) | ||
| return saveErr | ||
| } | ||
| // Return nil because we saved the data locally - don't nack the message | ||
| return nil | ||
| } | ||
|
|
||
| slog.Info("Successfully sent heartbeats to server", slog.Int("count", len(heartbeatPayload.Heartbeats))) | ||
| return nil | ||
| } | ||
|
|
||
| // saveHeartbeatToFile appends a heartbeat payload as a single JSON line to the log file | ||
| func saveHeartbeatToFile(payload model.HeartbeatPayload) error { | ||
| logFilePath := os.ExpandEnv(fmt.Sprintf("%s/%s", "$HOME", model.HEARTBEAT_LOG_FILE)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For better platform compatibility (e.g., on Windows), it's recommended to use Suggested change: homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home dir: %w", err)
}
logFilePath := filepath.Join(homeDir, model.HEARTBEAT_LOG_FILE)This change should also be applied in |
||
|
|
||
| // Open file for appending, create if not exists | ||
| file, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to open heartbeat log file: %w", err) | ||
| } | ||
| defer file.Close() | ||
|
|
||
| // Marshal payload to JSON | ||
| data, err := json.Marshal(payload) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to marshal heartbeat payload: %w", err) | ||
| } | ||
|
|
||
| // Write as single line with newline | ||
| _, err = file.Write(append(data, '\n')) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to write heartbeat to file: %w", err) | ||
| } | ||
|
|
||
| slog.Debug("Saved heartbeat to local file", slog.String("path", logFilePath), slog.Int("count", len(payload.Heartbeats))) | ||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| package daemon | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "log/slog" | ||
| "os" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/malamtime/cli/model" | ||
| ) | ||
|
|
||
| const ( | ||
| // HeartbeatResyncInterval is the interval for retrying failed heartbeats | ||
| HeartbeatResyncInterval = 30 * time.Minute | ||
| ) | ||
|
|
||
| // HeartbeatResyncService handles periodic resync of failed heartbeats | ||
| type HeartbeatResyncService struct { | ||
| config model.ShellTimeConfig | ||
| ticker *time.Ticker | ||
| stopChan chan struct{} | ||
| wg sync.WaitGroup | ||
| } | ||
|
|
||
| // NewHeartbeatResyncService creates a new heartbeat resync service | ||
| func NewHeartbeatResyncService(config model.ShellTimeConfig) *HeartbeatResyncService { | ||
| return &HeartbeatResyncService{ | ||
| config: config, | ||
| stopChan: make(chan struct{}), | ||
| } | ||
| } | ||
|
|
||
| // Start begins the periodic resync job | ||
| func (s *HeartbeatResyncService) Start(ctx context.Context) error { | ||
| s.ticker = time.NewTicker(HeartbeatResyncInterval) | ||
| s.wg.Add(1) | ||
|
|
||
| go func() { | ||
| defer s.wg.Done() | ||
|
|
||
| // Run once at startup | ||
| s.resync(ctx) | ||
|
|
||
| for { | ||
| select { | ||
| case <-s.ticker.C: | ||
| s.resync(ctx) | ||
| case <-s.stopChan: | ||
| return | ||
| case <-ctx.Done(): | ||
| return | ||
| } | ||
| } | ||
| }() | ||
|
|
||
| slog.Info("Heartbeat resync service started", slog.Duration("interval", HeartbeatResyncInterval)) | ||
| return nil | ||
| } | ||
|
|
||
| // Stop stops the resync service | ||
| func (s *HeartbeatResyncService) Stop() { | ||
| if s.ticker != nil { | ||
| s.ticker.Stop() | ||
| } | ||
| close(s.stopChan) | ||
| s.wg.Wait() | ||
| slog.Info("Heartbeat resync service stopped") | ||
| } | ||
|
|
||
| // resync reads failed heartbeats from the log file and attempts to send them | ||
| func (s *HeartbeatResyncService) resync(ctx context.Context) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a critical race condition in the To fix this, you should make the file handling atomic. A common pattern is:
|
||
| logFilePath := os.ExpandEnv(fmt.Sprintf("%s/%s", "$HOME", model.HEARTBEAT_LOG_FILE)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For better platform compatibility, it's recommended to use Suggested change: homeDir, err := os.UserHomeDir()
if err != nil {
slog.Error("could not get user home directory", slog.Any("err", err))
return
}
logFilePath := filepath.Join(homeDir, model.HEARTBEAT_LOG_FILE) |
||
|
|
||
| // Check if file exists | ||
| if _, err := os.Stat(logFilePath); os.IsNotExist(err) { | ||
| slog.Debug("No heartbeat log file found, nothing to resync") | ||
| return | ||
| } | ||
|
|
||
| // Read the file | ||
| file, err := os.Open(logFilePath) | ||
| if err != nil { | ||
| slog.Error("Failed to open heartbeat log file for resync", slog.Any("err", err)) | ||
| return | ||
| } | ||
|
|
||
| var lines []string | ||
| scanner := bufio.NewScanner(file) | ||
| for scanner.Scan() { | ||
| line := scanner.Text() | ||
| if line != "" { | ||
| lines = append(lines, line) | ||
| } | ||
| } | ||
|
Comment on lines
+91
to
+98
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The A more memory-efficient approach would be to process the file line-by-line. When combined with the fix for the race condition, you could read from the renamed file ( |
||
| file.Close() | ||
|
|
||
| if err := scanner.Err(); err != nil { | ||
| slog.Error("Error reading heartbeat log file", slog.Any("err", err)) | ||
| return | ||
| } | ||
|
|
||
| if len(lines) == 0 { | ||
| slog.Debug("No failed heartbeats to resync") | ||
| return | ||
| } | ||
|
|
||
| slog.Info("Starting heartbeat resync", slog.Int("pendingCount", len(lines))) | ||
|
|
||
| // Process each line | ||
| var failedLines []string | ||
| successCount := 0 | ||
|
|
||
| for _, line := range lines { | ||
| var payload model.HeartbeatPayload | ||
| if err := json.Unmarshal([]byte(line), &payload); err != nil { | ||
| slog.Error("Failed to parse heartbeat line, discarding", slog.Any("err", err), slog.String("line", line)) | ||
| continue | ||
| } | ||
|
|
||
| // Try to send to server | ||
| if err := model.SendHeartbeatsToServer(ctx, s.config, payload); err != nil { | ||
| slog.Warn("Failed to resync heartbeat, keeping for next retry", slog.Any("err", err)) | ||
| failedLines = append(failedLines, line) | ||
| } else { | ||
| successCount++ | ||
| } | ||
| } | ||
|
|
||
| // Rewrite the file with only failed lines | ||
| if err := s.rewriteLogFile(logFilePath, failedLines); err != nil { | ||
| slog.Error("Failed to update heartbeat log file", slog.Any("err", err)) | ||
| return | ||
| } | ||
|
|
||
| slog.Info("Heartbeat resync completed", | ||
| slog.Int("success", successCount), | ||
| slog.Int("remaining", len(failedLines))) | ||
| } | ||
|
|
||
| // rewriteLogFile atomically rewrites the log file with the given lines | ||
| func (s *HeartbeatResyncService) rewriteLogFile(logFilePath string, lines []string) error { | ||
| // If no lines remaining, remove the file | ||
| if len(lines) == 0 { | ||
| if err := os.Remove(logFilePath); err != nil && !os.IsNotExist(err) { | ||
| return fmt.Errorf("failed to remove empty log file: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // Write to temp file first | ||
| tempFile := logFilePath + ".tmp" | ||
| file, err := os.OpenFile(tempFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to create temp file: %w", err) | ||
| } | ||
|
|
||
| for _, line := range lines { | ||
| if _, err := file.WriteString(line + "\n"); err != nil { | ||
| file.Close() | ||
| os.Remove(tempFile) | ||
| return fmt.Errorf("failed to write to temp file: %w", err) | ||
| } | ||
| } | ||
|
|
||
| if err := file.Close(); err != nil { | ||
| os.Remove(tempFile) | ||
| return fmt.Errorf("failed to close temp file: %w", err) | ||
| } | ||
|
|
||
| // Atomic rename | ||
| if err := os.Rename(tempFile, logFilePath); err != nil { | ||
| os.Remove(tempFile) | ||
| return fmt.Errorf("failed to rename temp file: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package model | ||
|
|
||
| import ( | ||
| "context" | ||
| "net/http" | ||
| "time" | ||
| ) | ||
|
|
||
| // SendHeartbeatsToServer sends heartbeat data to the server | ||
| func SendHeartbeatsToServer(ctx context.Context, cfg ShellTimeConfig, payload HeartbeatPayload) error { | ||
| ctx, span := modelTracer.Start(ctx, "api.sendHeartbeats") | ||
| defer span.End() | ||
|
|
||
| endpoint := Endpoint{ | ||
| Token: cfg.Token, | ||
| APIEndpoint: cfg.APIEndpoint, | ||
| } | ||
|
|
||
| var response HeartbeatResponse | ||
| err := SendHTTPRequestJSON(HTTPRequestOptions[HeartbeatPayload, HeartbeatResponse]{ | ||
| Context: ctx, | ||
| Endpoint: endpoint, | ||
| Method: http.MethodPost, | ||
| Path: "/api/v1/heartbeats", | ||
| Payload: payload, | ||
| Response: &response, | ||
| Timeout: 10 * time.Second, | ||
| }) | ||
|
|
||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| return nil | ||
|
Comment on lines
+30
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The function assumes a successful API call if the HTTP request itself doesn't error. However, the server can return a You should inspect the // You will need to add "fmt" and "errors" to your imports.
if err != nil {
return err
}
if !response.Success {
msg := "heartbeat API request failed"
if response.Message != "" {
msg = fmt.Sprintf("%s: %s", msg, response.Message)
}
return errors.New(msg)
}
return nil |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The condition
cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabledis verbose and is also duplicated indaemon/socket.go(line 118). To improve readability and reduce duplication, consider adding a helper method to theCodeTrackingstruct.For example, in
model/types.go:Then the check in both places becomes a much cleaner
if cfg.CodeTracking.IsEnabled().