Skip to content

feat(daemon): add coding heartbeat tracking with offline persistence#154

Merged
AnnatarHe merged 2 commits intomainfrom
feat/daemon-coding-heartbeat
Dec 25, 2025
Merged

feat(daemon): add coding heartbeat tracking with offline persistence#154
AnnatarHe merged 2 commits intomainfrom
feat/daemon-coding-heartbeat

Conversation

@AnnatarHe
Copy link
Copy Markdown
Contributor

Summary

  • Add coding heartbeat tracking feature (similar to wakatime) for tracking editor activity
  • Heartbeats are sent to /api/v1/heartbeats endpoint; failed ones are saved locally
  • Background resync service retries failed heartbeats every 30 minutes
  • Feature is gated by codeTracking.enabled config option

Changes

  • model/heartbeat.go: HeartbeatPayload, HeartbeatData, HeartbeatResponse types
  • model/api_heartbeat.go: SendHeartbeatsToServer() API function
  • model/db.go: Added HEARTBEAT_LOG_FILE path variable
  • model/types.go: Added CodeTracking config struct
  • daemon/socket.go: Added SocketMessageTypeHeartbeat handler
  • daemon/handlers.go: Added routing for heartbeat message type
  • daemon/handlers.heartbeat.go: Handler that sends to server or saves to file on failure
  • daemon/heartbeat_resync.go: Background service for periodic retry
  • cmd/daemon/main.go: Start resync service when codeTracking.enabled

Config Example

[codeTracking]
enabled = true

Test plan

  • Code compiles (go build ./...)
  • Daemon and model tests pass (go test ./daemon/... ./model/...)
  • Manual test: send heartbeat via socket, verify server receives it
  • Manual test: disconnect server, send heartbeat, verify saved to log file
  • Manual test: reconnect server, wait 30 mins (or restart daemon), verify resync

🤖 Generated with Claude Code

Add a new socket handler type for coding activity heartbeats (like wakatime).
When heartbeats fail to send to the server, they are saved locally and
retried every 30 minutes by a background resync service.

Changes:
- Add HeartbeatPayload/HeartbeatData types mirroring server API
- Add SendHeartbeatsToServer() API function
- Add SocketMessageTypeHeartbeat handler in daemon
- Add HeartbeatResyncService for periodic retry of failed heartbeats
- Add CodeTracking config struct (codeTracking.enabled)
- Add HEARTBEAT_LOG_FILE path variable

Feature is gated by codeTracking.enabled config option.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @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 introduces a significant new feature to the daemon: coding activity heartbeat tracking with robust offline persistence. The primary goal is to reliably capture and report user coding activity, even in scenarios where the server is temporarily unreachable. It achieves this by defining a new heartbeat data model, establishing an API for sending these heartbeats, and implementing a local storage and retry mechanism to ensure data integrity. The feature is opt-in via a configuration setting, providing users with control over its activation.

Highlights

  • Coding Activity Tracking: Introduces a new feature to track coding activity, similar to Wakatime, by sending "heartbeats" from the daemon to a /api/v1/heartbeats endpoint.
  • Offline Persistence: Implements a mechanism to save heartbeats locally to a log file if sending them to the server fails, ensuring no data is lost during network interruptions.
  • Background Resync Service: Adds a background service that periodically (every 30 minutes) attempts to resend any locally saved, failed heartbeats to the server.
  • Configuration Gating: The entire coding activity tracking feature is controlled by a new codeTracking.enabled configuration option, allowing users to enable or disable it.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable new feature for tracking coding activity via heartbeats, complete with offline persistence and a background resync service. The implementation is well-structured. My review focuses on ensuring data integrity, improving performance, and increasing maintainability. I've identified a critical race condition in the resync logic that could lead to data loss, as well as a bug where server-side failures might not be handled correctly. Additionally, there are several opportunities to improve performance by avoiding redundant file reads and inefficient data handling, and to enhance code clarity by reducing duplication. Please see the detailed comments for specific suggestions.

}

// resync reads failed heartbeats from the log file and attempts to send them
func (s *HeartbeatResyncService) resync(ctx context.Context) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There is a critical race condition in the resync logic that can lead to data loss. The current implementation reads the entire log file, processes it in memory, and then overwrites the original file. If a new failed heartbeat is written to the log file while processing is underway, it will be deleted when the file is overwritten.

To fix this, you should make the file handling atomic. A common pattern is:

  1. Atomically rename the log file (e.g., heartbeat.log to heartbeat.log.processing).
  2. New failed heartbeats can now be safely written to a new (and empty) heartbeat.log.
  3. Process the contents of heartbeat.log.processing.
  4. If any heartbeats from the processing file fail again, append them to the new heartbeat.log file.
  5. Delete heartbeat.log.processing once you're done with it.

Comment on lines +32 to +36
cfg, err := stConfig.ReadConfigFile(ctx)
if err != nil {
slog.Error("Failed to read config file", slog.Any("err", err))
return err
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This function calls stConfig.ReadConfigFile(ctx) on every invocation. Since ReadConfigFile reads and parses the configuration from disk each time, this is inefficient and will degrade performance under load from frequent heartbeats.

The configuration is already loaded in main.go. It should be passed down to the message processor and its handlers instead of being re-read from disk. Consider refactoring SocketTopicProccessor to accept the model.ShellTimeConfig object and pass it to this handler.

Comment thread model/api_heartbeat.go
Comment on lines +30 to +34
if err != nil {
return err
}

return nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The function assumes a successful API call if the HTTP request itself doesn't error. However, the server can return a 200 OK status but indicate a logical failure in the response body (e.g., success: false). These failures are not being handled, which could lead to data loss as the client would incorrectly assume the heartbeats were processed and not save them for retry.

You should inspect the response struct and return an error if the server-side operation was not successful.

// 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

Comment thread cmd/daemon/main.go
}

// Start heartbeat resync service if codeTracking is enabled
if cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The condition cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled is verbose and is also duplicated in daemon/socket.go (line 118). To improve readability and reduce duplication, consider adding a helper method to the CodeTracking struct.

For example, in model/types.go:

func (c *CodeTracking) IsEnabled() bool {
    return c != nil && c.Enabled != nil && *c.Enabled
}

Then the check in both places becomes a much cleaner if cfg.CodeTracking.IsEnabled().

Comment on lines +14 to +25
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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation marshals socketMsgPayload (which is a map[string]interface{}) to a JSON byte slice, only to immediately unmarshal it back into a model.HeartbeatPayload struct. This marshal/unmarshal cycle is inefficient.

A more performant approach is to use a library like mapstructure to directly decode the map into your struct.

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.


// 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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better platform compatibility (e.g., on Windows), it's recommended to use filepath.Join to construct file paths instead of string formatting with /. Additionally, os.UserHomeDir() is a more robust way to get the user's home directory than relying on the $HOME environment variable.

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 daemon/heartbeat_resync.go.


// resync reads failed heartbeats from the log file and attempts to send them
func (s *HeartbeatResyncService) resync(ctx context.Context) {
logFilePath := os.ExpandEnv(fmt.Sprintf("%s/%s", "$HOME", model.HEARTBEAT_LOG_FILE))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better platform compatibility, it's recommended to use filepath.Join to construct file paths instead of string formatting with /. Additionally, os.UserHomeDir() is a more robust way to get the user's home directory than relying on the $HOME environment variable.

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)

Comment on lines +91 to +98
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
lines = append(lines, line)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The resync function reads the entire contents of the heartbeat log file into memory. If a user is offline for an extended period, this file could become very large, leading to high memory consumption.

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 (.processing) one line at a time, and write any lines that fail to resync to the new log file directly, without buffering all lines in memory.

Comment thread model/db.go
COMMAND_PRE_STORAGE_FILE = COMMAND_STORAGE_FOLDER + "/pre.txt"
COMMAND_POST_STORAGE_FILE = COMMAND_STORAGE_FOLDER + "/post.txt"
COMMAND_CURSOR_STORAGE_FILE = COMMAND_STORAGE_FOLDER + "/cursor.txt"
HEARTBEAT_LOG_FILE = COMMAND_BASE_STORAGE_FOLDER + "/coding-heartbeat.data.log"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The HEARTBEAT_LOG_FILE variable is initialized here and then redundantly re-initialized with the same value inside the InitFolder function on line 38. To avoid redundancy and keep initialization logic in one place, you can remove this line and rely solely on the initialization within InitFolder.

Add caching mechanism to ReadConfigFile using functional options pattern.
Cache is stored in-memory with RWMutex for thread safety. Use WithSkipCache()
option to bypass cache and read from disk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented Dec 25, 2025

Codecov Report

❌ Patch coverage is 9.04523% with 181 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
daemon/heartbeat_resync.go 0.00% 88 Missing ⚠️
daemon/handlers.heartbeat.go 0.00% 40 Missing ⚠️
model/api_heartbeat.go 0.00% 19 Missing ⚠️
daemon/socket.go 0.00% 11 Missing ⚠️
model/config.go 47.05% 7 Missing and 2 partials ⚠️
cmd/daemon/main.go 0.00% 7 Missing ⚠️
daemon/handlers.go 62.50% 6 Missing ⚠️
model/db.go 0.00% 1 Missing ⚠️
Flag Coverage Δ
unittests 20.00% <9.04%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
model/db.go 0.00% <0.00%> (ø)
daemon/handlers.go 75.00% <62.50%> (-25.00%) ⬇️
cmd/daemon/main.go 0.00% <0.00%> (ø)
model/config.go 52.87% <47.05%> (-2.06%) ⬇️
daemon/socket.go 0.00% <0.00%> (ø)
model/api_heartbeat.go 0.00% <0.00%> (ø)
daemon/handlers.heartbeat.go 0.00% <0.00%> (ø)
daemon/heartbeat_resync.go 0.00% <0.00%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@AnnatarHe AnnatarHe merged commit f3f61bf into main Dec 25, 2025
2 of 3 checks passed
@AnnatarHe AnnatarHe deleted the feat/daemon-coding-heartbeat branch December 25, 2025 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant