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
8 changes: 8 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,14 @@ Track coding activity heartbeats:
```toml
[codeTracking]
enabled = false
# Optional: use a custom API endpoint for heartbeats (defaults to global apiEndpoint)
apiEndpoint = "https://api.custom-heartbeat.com"
# Optional: use a custom token for heartbeats (defaults to global token)
token = "custom-heartbeat-token"
```

When `apiEndpoint` or `token` is set under `[codeTracking]`, heartbeat data will use these values instead of the global configuration. This allows you to send coding activity to a different server or authenticate with a separate token.

---

## Advanced Settings
Expand Down Expand Up @@ -399,6 +405,8 @@ enabled = false

[codeTracking]
enabled = false
# apiEndpoint = "https://api.custom-heartbeat.com" # Optional: custom endpoint
# token = "custom-heartbeat-token" # Optional: custom token

# --- Log Management ---
[logCleanup]
Expand Down
17 changes: 15 additions & 2 deletions model/api_heartbeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,22 @@ func SendHeartbeatsToServer(ctx context.Context, cfg ShellTimeConfig, payload He
ctx, span := modelTracer.Start(ctx, "api.sendHeartbeats")
defer span.End()

// Use custom endpoint/token from CodeTracking if specified, otherwise fall back to global
apiEndpoint := cfg.APIEndpoint
token := cfg.Token

if cfg.CodeTracking != nil {
if cfg.CodeTracking.APIEndpoint != "" {
apiEndpoint = cfg.CodeTracking.APIEndpoint
}
if cfg.CodeTracking.Token != "" {
token = cfg.CodeTracking.Token
}
}

endpoint := Endpoint{
Token: cfg.Token,
APIEndpoint: cfg.APIEndpoint,
Token: token,
APIEndpoint: apiEndpoint,
}

var response HeartbeatResponse
Expand Down
133 changes: 133 additions & 0 deletions model/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,136 @@ enabled = true`
require.NotNil(t, config.CodeTracking, "CodeTracking should be present")
assert.True(t, *config.CodeTracking.Enabled, "CodeTracking.Enabled should be overridden by local config")
}

func TestCodeTrackingCustomEndpointAndToken(t *testing.T) {
// Create a temporary directory for test configs
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Create config file with custom CodeTracking endpoint and token
baseConfigPath := filepath.Join(tmpDir, "config.toml")
baseConfig := `Token = 'global-token'
APIEndpoint = 'https://api.global.com'

[codeTracking]
enabled = true
apiEndpoint = 'https://api.custom-heartbeat.com'
token = 'custom-heartbeat-token'`
err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644)
require.NoError(t, err)

cs := NewConfigService(baseConfigPath)
config, err := cs.ReadConfigFile(context.Background())
require.NoError(t, err)

// Verify global config values
assert.Equal(t, "global-token", config.Token)
assert.Equal(t, "https://api.global.com", config.APIEndpoint)

// Verify CodeTracking custom values
require.NotNil(t, config.CodeTracking, "CodeTracking should be present")
assert.True(t, *config.CodeTracking.Enabled)
assert.Equal(t, "https://api.custom-heartbeat.com", config.CodeTracking.APIEndpoint)
assert.Equal(t, "custom-heartbeat-token", config.CodeTracking.Token)
}

func TestCodeTrackingPartialCustomConfig(t *testing.T) {
testCases := []struct {
name string
config string
expectedAPIEndpoint string
expectedToken string
}{
{
name: "Only custom apiEndpoint",
config: `Token = 'global-token'
APIEndpoint = 'https://api.global.com'

[codeTracking]
enabled = true
apiEndpoint = 'https://api.custom.com'`,
expectedAPIEndpoint: "https://api.custom.com",
expectedToken: "", // empty, should fall back to global
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

This comment is slightly misleading. This test correctly verifies that config.CodeTracking.Token is empty when not provided in the config. However, it doesn't test the fallback behavior itself, which happens in SendHeartbeatsToServer. A clearer comment might be // empty, as it's not set in this config to avoid confusion about the scope of this test case.

},
{
name: "Only custom token",
config: `Token = 'global-token'
APIEndpoint = 'https://api.global.com'

[codeTracking]
enabled = true
token = 'custom-token'`,
expectedAPIEndpoint: "", // empty, should fall back to global
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

Similar to the previous test case, this comment could be misinterpreted. This test verifies that config.CodeTracking.APIEndpoint is empty, but the fallback to the global endpoint is handled elsewhere. To improve clarity, consider rewording the comment to something like // empty, as it's not set in this config.

expectedToken: "custom-token",
},
{
name: "No custom endpoint or token",
config: `Token = 'global-token'
APIEndpoint = 'https://api.global.com'

[codeTracking]
enabled = true`,
expectedAPIEndpoint: "",
expectedToken: "",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

baseConfigPath := filepath.Join(tmpDir, "config.toml")
err = os.WriteFile(baseConfigPath, []byte(tc.config), 0644)
require.NoError(t, err)

cs := NewConfigService(baseConfigPath)
config, err := cs.ReadConfigFile(context.Background())
require.NoError(t, err)

require.NotNil(t, config.CodeTracking, "CodeTracking should be present")
assert.Equal(t, tc.expectedAPIEndpoint, config.CodeTracking.APIEndpoint)
assert.Equal(t, tc.expectedToken, config.CodeTracking.Token)
})
}
}

func TestCodeTrackingMergeEndpointFromLocal(t *testing.T) {
// Create a temporary directory for test configs
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Create base config file with CodeTracking
baseConfigPath := filepath.Join(tmpDir, "config.toml")
baseConfig := `Token = 'base-token'
APIEndpoint = 'https://api.base.com'

[codeTracking]
enabled = true
apiEndpoint = 'https://api.base-heartbeat.com'
token = 'base-heartbeat-token'`
err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644)
require.NoError(t, err)

// Create local config file that overrides CodeTracking endpoint and token
localConfigPath := filepath.Join(tmpDir, "config.local.toml")
localConfig := `[codeTracking]
enabled = true
apiEndpoint = 'https://api.local-heartbeat.com'
token = 'local-heartbeat-token'`
err = os.WriteFile(localConfigPath, []byte(localConfig), 0644)
require.NoError(t, err)

cs := NewConfigService(baseConfigPath)
config, err := cs.ReadConfigFile(context.Background())
require.NoError(t, err)

// Verify local config overrides base CodeTracking
require.NotNil(t, config.CodeTracking, "CodeTracking should be present")
assert.True(t, *config.CodeTracking.Enabled)
assert.Equal(t, "https://api.local-heartbeat.com", config.CodeTracking.APIEndpoint)
assert.Equal(t, "local-heartbeat-token", config.CodeTracking.Token)
}
4 changes: 3 additions & 1 deletion model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ type CCOtel struct {

// CodeTracking configuration for coding activity heartbeat tracking
type CodeTracking struct {
Enabled *bool `toml:"enabled"`
Enabled *bool `toml:"enabled"`
APIEndpoint string `toml:"apiEndpoint"` // Custom API endpoint for heartbeats
Token string `toml:"token"` // Custom token for heartbeats
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 consistency with the surrounding code and Go formatting conventions, it's better to have a single space between the struct tag and the line comment. The extra spaces here seem to be a minor formatting artifact.

Suggested change
Token string `toml:"token"` // Custom token for heartbeats
Token string `toml:"token"` // Custom token for heartbeats

}

// LogCleanup configuration for automatic log file cleanup
Expand Down