Skip to content

Commit 8e6c792

Browse files
feat: ✨ telemetry added
1 parent f47d758 commit 8e6c792

8 files changed

Lines changed: 258 additions & 0 deletions

File tree

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(gh api:*)"
5+
]
6+
}
7+
}

cmd/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/AkashRajpurohit/git-sync/pkg/gitlab"
1313
"github.com/AkashRajpurohit/git-sync/pkg/logger"
1414
"github.com/AkashRajpurohit/git-sync/pkg/raw"
15+
"github.com/AkashRajpurohit/git-sync/pkg/telemetry"
1516
ch "github.com/robfig/cron/v3"
1617
"github.com/spf13/cobra"
1718
)
@@ -77,6 +78,9 @@ var rootCmd = &cobra.Command{
7778
logger.Fatalf("Error validating config: %s", err)
7879
}
7980

81+
telemetry.Init(cfg.Telemetry)
82+
defer telemetry.Close()
83+
8084
// Create backup directory if it doesn't exist
8185
os.MkdirAll(cfg.BackupDir, os.ModePerm)
8286

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ go 1.24.5
55
require (
66
codeberg.org/mvdkleijn/forgejo-sdk/forgejo v1.2.0
77
github.com/google/go-github/v82 v82.0.0
8+
github.com/google/uuid v1.6.0
89
github.com/ktrysmt/go-bitbucket v0.9.86
10+
github.com/posthog/posthog-go v1.10.0
911
github.com/robfig/cron/v3 v3.0.1
1012
github.com/spf13/cobra v1.10.2
1113
github.com/spf13/viper v1.21.0
@@ -20,10 +22,12 @@ require (
2022
github.com/fsnotify/fsnotify v1.9.0 // indirect
2123
github.com/go-fed/httpsig v1.1.0 // indirect
2224
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
25+
github.com/goccy/go-json v0.10.5 // indirect
2326
github.com/google/go-querystring v1.2.0 // indirect
2427
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
2528
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
2629
github.com/hashicorp/go-version v1.7.0 // indirect
30+
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
2731
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2832
github.com/mitchellh/mapstructure v1.5.0 // indirect
2933
github.com/pelletier/go-toml/v2 v2.2.4 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@ github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
1717
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
1818
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
1919
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
20+
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
21+
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
2022
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
2123
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
2224
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
2325
github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk=
2426
github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM=
2527
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
2628
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
29+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
30+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
2731
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
2832
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
2933
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -32,6 +36,8 @@ github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISH
3236
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
3337
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
3438
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
39+
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
40+
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
3541
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3642
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
3743
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -50,6 +56,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
5056
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
5157
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5258
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
59+
github.com/posthog/posthog-go v1.10.0 h1:wfoy7Jfb4LigCoHYyMZoiJmmEoCLOkSaYfDxM/NtCqY=
60+
github.com/posthog/posthog-go v1.10.0/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY=
5361
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
5462
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
5563
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=

pkg/config/config.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ type GotifyConfig struct {
4242
Priority int `mapstructure:"priority"`
4343
}
4444

45+
type TelemetryConfig struct {
46+
Enabled bool `mapstructure:"enabled"`
47+
}
48+
4549
type Config struct {
4650
Username string `mapstructure:"username"`
4751
Token string `mapstructure:"token"` // Deprecated: Use Tokens instead
@@ -63,6 +67,7 @@ type Config struct {
6367
Concurrency int `mapstructure:"concurrency"`
6468
Retry RetryConfig `mapstructure:"retry"`
6569
Notification NotificationConfig `mapstructure:"notification"`
70+
Telemetry TelemetryConfig `mapstructure:"telemetry"`
6671
}
6772

6873
func expandPath(path string) string {
@@ -73,6 +78,10 @@ func expandPath(path string) string {
7378
return path
7479
}
7580

81+
func GetDefaultConfigDir() string {
82+
return filepath.Join(os.Getenv("HOME"), ".config", "git-sync")
83+
}
84+
7685
func GetConfigFile(cfgFile string) string {
7786
if cfgFile != "" {
7887
logger.Debug("Using config file: ", cfgFile)
@@ -113,6 +122,8 @@ func LoadConfig(cfgFile string) (Config, error) {
113122
viper.SetConfigFile(configFile)
114123
viper.SetConfigType("yaml")
115124

125+
viper.SetDefault("telemetry.enabled", true)
126+
116127
if err := viper.ReadInConfig(); err != nil {
117128
return config, err
118129
}
@@ -150,6 +161,7 @@ func SaveConfig(config Config, cfgFile string) error {
150161
viper.Set("concurrency", config.Concurrency)
151162
viper.Set("retry", config.Retry)
152163
viper.Set("notification", config.Notification)
164+
viper.Set("telemetry", config.Telemetry)
153165

154166
return viper.WriteConfig()
155167
}
@@ -180,6 +192,9 @@ func GetInitialConfig() Config {
180192
Count: 3,
181193
Delay: 5,
182194
},
195+
Telemetry: TelemetryConfig{
196+
Enabled: true,
197+
},
183198
Notification: NotificationConfig{
184199
Enabled: false,
185200
OnlyFailures: true,

pkg/sync/stats.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package sync
22

33
import (
44
"fmt"
5+
"runtime"
56
"sync"
67

78
"github.com/AkashRajpurohit/git-sync/pkg/config"
89
"github.com/AkashRajpurohit/git-sync/pkg/logger"
910
"github.com/AkashRajpurohit/git-sync/pkg/notification"
11+
"github.com/AkashRajpurohit/git-sync/pkg/telemetry"
12+
"github.com/AkashRajpurohit/git-sync/pkg/version"
1013
)
1114

1215
type SyncStats struct {
@@ -108,6 +111,24 @@ func LogSyncSummary(cfg *config.Config) {
108111
logger.Errorf("Failed to send notifications: %v", err)
109112
}
110113

114+
telemetry.CaptureEvent("sync_completed", map[string]interface{}{
115+
"platform": cfg.Platform,
116+
"clone_type": cfg.CloneType,
117+
"concurrency": cfg.Concurrency,
118+
"include_wiki": cfg.IncludeWiki,
119+
"include_issues": cfg.IncludeIssues,
120+
"include_forks": cfg.IncludeForks,
121+
"repos_success": stats.ReposSuccess,
122+
"repos_failed": len(stats.ReposFailed),
123+
"wikis_success": stats.WikisSuccess,
124+
"wikis_failed": len(stats.WikisFailed),
125+
"issues_success": stats.IssuesSuccess,
126+
"issues_failed": len(stats.IssuesFailed),
127+
"app_version": version.Version,
128+
"os": runtime.GOOS,
129+
"arch": runtime.GOARCH,
130+
})
131+
111132
// Reset stats for next sync
112133
stats = &SyncStats{}
113134
}

pkg/telemetry/telemetry.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package telemetry
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sync"
7+
8+
"github.com/AkashRajpurohit/git-sync/pkg/config"
9+
"github.com/AkashRajpurohit/git-sync/pkg/logger"
10+
"github.com/google/uuid"
11+
"github.com/posthog/posthog-go"
12+
)
13+
14+
const posthogAPIKey = "phc_IUI4rNyfkWwpSNPvWyrO0bOQkI0byAGyDQRKIQMGc7w"
15+
16+
var (
17+
client posthog.Client
18+
deviceID string
19+
disabled bool
20+
once sync.Once
21+
)
22+
23+
func Init(cfg config.TelemetryConfig) {
24+
once.Do(func() {
25+
if isOptedOut(cfg) {
26+
disabled = true
27+
logger.Debug("Telemetry is disabled")
28+
return
29+
}
30+
31+
id, err := getOrCreateDeviceID()
32+
if err != nil {
33+
logger.Debug("Failed to get device ID, disabling telemetry: ", err)
34+
disabled = true
35+
return
36+
}
37+
deviceID = id
38+
39+
c, err := posthog.NewWithConfig(posthogAPIKey, posthog.Config{
40+
Endpoint: "https://us.i.posthog.com",
41+
})
42+
if err != nil {
43+
logger.Debug("Failed to create PostHog client, disabling telemetry: ", err)
44+
disabled = true
45+
return
46+
}
47+
48+
client = c
49+
logger.Debug("Telemetry initialized")
50+
})
51+
}
52+
53+
func CaptureEvent(event string, properties map[string]interface{}) {
54+
if disabled || client == nil {
55+
return
56+
}
57+
58+
props := posthog.NewProperties()
59+
for k, v := range properties {
60+
props.Set(k, v)
61+
}
62+
63+
client.Enqueue(posthog.Capture{
64+
DistinctId: deviceID,
65+
Event: event,
66+
Properties: props,
67+
})
68+
}
69+
70+
func Close() {
71+
if client != nil {
72+
client.Close()
73+
}
74+
}
75+
76+
func isOptedOut(cfg config.TelemetryConfig) bool {
77+
if !cfg.Enabled {
78+
return true
79+
}
80+
81+
if os.Getenv("GIT_SYNC_NO_TELEMETRY") == "1" {
82+
return true
83+
}
84+
85+
return false
86+
}
87+
88+
func getOrCreateDeviceID() (string, error) {
89+
configDir := config.GetDefaultConfigDir()
90+
idFile := filepath.Join(configDir, ".device-id")
91+
92+
data, err := os.ReadFile(idFile)
93+
if err == nil && len(data) > 0 {
94+
return string(data), nil
95+
}
96+
97+
id := uuid.New().String()
98+
99+
if err := os.MkdirAll(configDir, os.ModePerm); err != nil {
100+
return "", err
101+
}
102+
103+
if err := os.WriteFile(idFile, []byte(id), 0600); err != nil {
104+
return "", err
105+
}
106+
107+
return id, nil
108+
}
109+
110+
func Reset() {
111+
if client != nil {
112+
client.Close()
113+
client = nil
114+
}
115+
deviceID = ""
116+
disabled = false
117+
once = sync.Once{}
118+
}

pkg/telemetry/telemetry_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package telemetry
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/AkashRajpurohit/git-sync/pkg/config"
9+
"github.com/AkashRajpurohit/git-sync/pkg/logger"
10+
)
11+
12+
func TestMain(m *testing.M) {
13+
logger.InitLogger("fatal")
14+
os.Exit(m.Run())
15+
}
16+
17+
func TestIsOptedOut_ConfigDisabled(t *testing.T) {
18+
cfg := config.TelemetryConfig{Enabled: false}
19+
if !isOptedOut(cfg) {
20+
t.Error("expected opt-out when config.Enabled is false")
21+
}
22+
}
23+
24+
func TestIsOptedOut_ConfigEnabled(t *testing.T) {
25+
cfg := config.TelemetryConfig{Enabled: true}
26+
if isOptedOut(cfg) {
27+
t.Error("expected opt-in when config.Enabled is true and no env vars set")
28+
}
29+
}
30+
31+
func TestIsOptedOut_GitSyncNoTelemetryEnv(t *testing.T) {
32+
t.Setenv("GIT_SYNC_NO_TELEMETRY", "1")
33+
cfg := config.TelemetryConfig{Enabled: true}
34+
if !isOptedOut(cfg) {
35+
t.Error("expected opt-out when GIT_SYNC_NO_TELEMETRY=1")
36+
}
37+
}
38+
39+
func TestGetOrCreateDeviceID(t *testing.T) {
40+
tmpDir := t.TempDir()
41+
t.Setenv("HOME", tmpDir)
42+
43+
id1, err := getOrCreateDeviceID()
44+
if err != nil {
45+
t.Fatalf("unexpected error: %v", err)
46+
}
47+
if id1 == "" {
48+
t.Fatal("expected non-empty device ID")
49+
}
50+
51+
// Second call should return the same ID
52+
id2, err := getOrCreateDeviceID()
53+
if err != nil {
54+
t.Fatalf("unexpected error on second call: %v", err)
55+
}
56+
if id1 != id2 {
57+
t.Errorf("expected same device ID, got %q and %q", id1, id2)
58+
}
59+
60+
// Verify the file exists
61+
idFile := filepath.Join(tmpDir, ".config", "git-sync", ".device-id")
62+
data, err := os.ReadFile(idFile)
63+
if err != nil {
64+
t.Fatalf("expected device ID file to exist: %v", err)
65+
}
66+
if string(data) != id1 {
67+
t.Errorf("file content %q doesn't match returned ID %q", string(data), id1)
68+
}
69+
}
70+
71+
func TestCaptureEventWhenDisabled(t *testing.T) {
72+
Reset()
73+
defer Reset()
74+
75+
Init(config.TelemetryConfig{Enabled: false})
76+
77+
// Should not panic or cause any side effects
78+
CaptureEvent("test_event", map[string]interface{}{
79+
"key": "value",
80+
})
81+
}

0 commit comments

Comments
 (0)