From b0d782583c4a8df20604f6722070d59b00dfb05d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 22:33:50 +0000 Subject: [PATCH 1/5] feat: add PostHog event tracking for CLI installs and upgrades - Add lightweight PostHog client (cli/core/posthog.go) using raw net/http - Track 'Installed CLI' event on first run of each new version - Track 'Upgraded CLI' event with old/new versions after successful upgrade - Deduplicate events via ~/.blaxel/telemetry.json - Inject PostHog API key at build time via ldflags - Fire-and-forget async HTTP POST, non-blocking ENG-2277 Co-Authored-By: tcrochet --- .github/workflows/release.yaml | 1 + .goreleaser.yaml | 1 + Makefile | 2 +- cli/core/posthog.go | 182 +++++++++++++++++++++++++++++++++ cli/core/root.go | 3 + cli/upgrade.go | 43 +++++++- main.go | 15 ++- 7 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 cli/core/posthog.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b091cbba..e7a4f453 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -38,6 +38,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} - name: Upload assets uses: actions/upload-artifact@v7 with: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 59cd8c64..6325e653 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -25,6 +25,7 @@ builds: - -X main.commit={{.Commit}} - -X main.date={{.Date}} - -X main.sentryDSN={{ .Env.SENTRY_DSN }} + - -X main.posthogKey={{ .Env.POSTHOG_KEY }} goos: - linux - windows diff --git a/Makefile b/Makefile index e1549b75..44147cf3 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ build: # Development build without goreleaser build-dev: @echo "🔨 Development build with commit: $(GIT_COMMIT_SHORT)" - go build -ldflags "-X main.version=dev -X main.commit=$(GIT_COMMIT) -X main.date=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) -X main.sentryDSN=$(SENTRY_DSN)" -o ./bin/blaxel ./ + go build -ldflags "-X main.version=dev -X main.commit=$(GIT_COMMIT) -X main.date=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) -X main.sentryDSN=$(SENTRY_DSN) -X main.posthogKey=$(POSTHOG_KEY)" -o ./bin/blaxel ./ cp ./bin/blaxel ~/.local/bin/blaxel; cp ~/.local/bin/blaxel ~/.local/bin/bl; rm -r ./bin; diff --git a/cli/core/posthog.go b/cli/core/posthog.go new file mode 100644 index 00000000..c70cd5c7 --- /dev/null +++ b/cli/core/posthog.go @@ -0,0 +1,182 @@ +package core + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +// PostHog API key injected at build time via ldflags +var PosthogAPIKey = "" + +// PostHog API endpoint +var PosthogHost = "https://us.i.posthog.com" + +// telemetryState stores the last reported versions to deduplicate events +type telemetryState struct { + DistinctID string `json:"distinct_id"` + CLI string `json:"cli,omitempty"` + SDKs map[string]string `json:"sdks,omitempty"` +} + +var ( + telemetryOnce sync.Once + telemetryCache *telemetryState +) + +// getTelemetryPath returns the path to the telemetry state file +func getTelemetryPath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".blaxel", "telemetry.json") +} + +// loadTelemetryState reads the telemetry state from disk +func loadTelemetryState() *telemetryState { + telemetryOnce.Do(func() { + telemetryCache = &telemetryState{ + SDKs: make(map[string]string), + } + path := getTelemetryPath() + if path == "" { + return + } + data, err := os.ReadFile(path) + if err != nil { + return + } + _ = json.Unmarshal(data, telemetryCache) + if telemetryCache.SDKs == nil { + telemetryCache.SDKs = make(map[string]string) + } + }) + return telemetryCache +} + +// saveTelemetryState writes the telemetry state to disk +func saveTelemetryState(state *telemetryState) { + path := getTelemetryPath() + if path == "" { + return + } + dir := filepath.Dir(path) + _ = os.MkdirAll(dir, 0755) + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return + } + _ = os.WriteFile(path, data, 0600) +} + +// getDistinctID returns the distinct ID for PostHog events. +// Uses the workspace email if available, otherwise generates and persists a UUID. +func getDistinctID() string { + state := loadTelemetryState() + if state.DistinctID != "" { + return state.DistinctID + } + state.DistinctID = generateUUID() + saveTelemetryState(state) + return state.DistinctID +} + +// capturePosthogEvent sends an event to PostHog via HTTP POST. +// This is fire-and-forget: errors are silently ignored. +func capturePosthogEvent(event string, properties map[string]string) { + if PosthogAPIKey == "" { + return + } + + distinctID := getDistinctID() + + payload := map[string]interface{}{ + "api_key": PosthogAPIKey, + "event": event, + "distinct_id": distinctID, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "properties": properties, + } + + data, err := json.Marshal(payload) + if err != nil { + return + } + + go func() { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Post(PosthogHost+"/capture/", "application/json", bytes.NewReader(data)) + if err != nil { + return + } + defer resp.Body.Close() + }() +} + +// TrackCLIInstalled checks if this CLI version has been reported and sends +// an "Installed CLI" event if it hasn't. +func TrackCLIInstalled(cliVersion string) { + if PosthogAPIKey == "" || cliVersion == "" || cliVersion == "dev" { + return + } + + state := loadTelemetryState() + if state.CLI == cliVersion { + return + } + + capturePosthogEvent("Installed CLI", map[string]string{ + "version": cliVersion, + }) + + state.CLI = cliVersion + saveTelemetryState(state) +} + +// TrackCLIUpgraded sends an "Upgraded CLI" event with old and new versions. +func TrackCLIUpgraded(oldVersion string, newVersion string) { + if PosthogAPIKey == "" { + return + } + if oldVersion == "" || newVersion == "" || oldVersion == newVersion { + return + } + + capturePosthogEvent("Upgraded CLI", map[string]string{ + "old_version": oldVersion, + "new_version": newVersion, + }) + + // Update the stored CLI version so "Installed CLI" won't re-fire + state := loadTelemetryState() + state.CLI = newVersion + saveTelemetryState(state) +} + +// generateUUID creates a random UUID v4 string without external dependencies. +func generateUUID() string { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + return "unknown" + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// FlushPosthog waits briefly to allow in-flight PostHog requests to complete. +func FlushPosthog() { + if PosthogAPIKey == "" { + return + } + // Give goroutines a moment to finish sending + time.Sleep(500 * time.Millisecond) +} diff --git a/cli/core/root.go b/cli/core/root.go index 6d3cd827..dfd47c97 100644 --- a/cli/core/root.go +++ b/cli/core/root.go @@ -420,6 +420,9 @@ func Execute(releaseVersion string, releaseCommit string, releaseDate string) er SetSentryTag("commit", commit) SetSentryTag("workspace", workspace) + // Track CLI installation (fires once per new version) + TrackCLIInstalled(version) + return rootCmd.Execute() } diff --git a/cli/upgrade.go b/cli/upgrade.go index 047ba927..3bdccbd9 100644 --- a/cli/upgrade.go +++ b/cli/upgrade.go @@ -133,14 +133,53 @@ func runUpgrade(targetVersion string, force bool) error { core.PrintInfo(fmt.Sprintf("Detected installation method: %s", method)) + oldVersion := core.GetVersion() + + var upgradeErr error switch method { case "brew": - return upgradeViaBrew(force) + upgradeErr = upgradeViaBrew(force) case "curl": - return upgradeViaCurl(targetVersion) + upgradeErr = upgradeViaCurl(targetVersion) default: return fmt.Errorf("unknown installation method: %s", method) } + + if upgradeErr != nil { + return upgradeErr + } + + // Detect new version after successful upgrade + newVersion := detectInstalledVersion() + if newVersion != "" && newVersion != oldVersion { + core.TrackCLIUpgraded(oldVersion, newVersion) + } + + return nil +} + +// detectInstalledVersion runs the newly installed binary to get its version. +func detectInstalledVersion() string { + execPath, err := os.Executable() + if err != nil { + return "" + } + realPath, err := filepath.EvalSymlinks(execPath) + if err != nil { + realPath = execPath + } + out, err := exec.Command(realPath, "version").Output() + if err != nil { + return "" + } + // Parse "Version: X.Y.Z" from the output + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Version:") { + return strings.TrimSpace(strings.TrimPrefix(line, "Version:")) + } + } + return "" } // upgradeViaBrew upgrades the CLI using Homebrew diff --git a/main.go b/main.go index f2a58c7f..e7481ae5 100644 --- a/main.go +++ b/main.go @@ -11,10 +11,11 @@ import ( ) var ( - version = "dev" - commit = "none" - date = "unknown" - sentryDSN = "" + version = "dev" + commit = "none" + date = "unknown" + sentryDSN = "" + posthogKey = "" ) func main() { @@ -36,6 +37,12 @@ func main() { defer core.RecoverWithSentry() } + // Initialize PostHog tracking + if blaxel.IsTrackingEnabled() { + core.PosthogAPIKey = posthogKey + defer core.FlushPosthog() + } + err := cli.Execute(version, commit, date) if err != nil { fmt.Println("Error", err) From ba5b5c0d278c48816d2d22fbb8accfab03d795c1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 22:38:07 +0000 Subject: [PATCH 2/5] fix: address review feedback - goreleaser env fallback, brew upgrade detection, comment fix - Use conditional template in .goreleaser.yaml so POSTHOG_KEY env var is optional - Clarify detectInstalledVersion re-resolves symlinks for brew upgrades - Fix misleading comment on getDistinctID (UUID only, no email) Co-Authored-By: tcrochet --- .goreleaser.yaml | 2 +- cli/core/posthog.go | 4 ++-- cli/upgrade.go | 15 +++++++++++---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6325e653..1ae4a16a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -25,7 +25,7 @@ builds: - -X main.commit={{.Commit}} - -X main.date={{.Date}} - -X main.sentryDSN={{ .Env.SENTRY_DSN }} - - -X main.posthogKey={{ .Env.POSTHOG_KEY }} + - -X main.posthogKey={{ if index .Env "POSTHOG_KEY" }}{{ .Env.POSTHOG_KEY }}{{ end }} goos: - linux - windows diff --git a/cli/core/posthog.go b/cli/core/posthog.go index c70cd5c7..cfe9a5c0 100644 --- a/cli/core/posthog.go +++ b/cli/core/posthog.go @@ -76,8 +76,8 @@ func saveTelemetryState(state *telemetryState) { _ = os.WriteFile(path, data, 0600) } -// getDistinctID returns the distinct ID for PostHog events. -// Uses the workspace email if available, otherwise generates and persists a UUID. +// getDistinctID returns a persistent anonymous UUID for PostHog events. +// The UUID is generated on first use and stored in ~/.blaxel/telemetry.json. func getDistinctID() string { state := loadTelemetryState() if state.DistinctID != "" { diff --git a/cli/upgrade.go b/cli/upgrade.go index 3bdccbd9..3d55c785 100644 --- a/cli/upgrade.go +++ b/cli/upgrade.go @@ -149,7 +149,10 @@ func runUpgrade(targetVersion string, force bool) error { return upgradeErr } - // Detect new version after successful upgrade + // Detect new version after successful upgrade. + // For brew upgrades, the old cellar binary is gone so we must resolve + // the symlink again (brew updates the /usr/local/bin symlink to the + // new cellar path). For curl upgrades the binary is replaced in-place. newVersion := detectInstalledVersion() if newVersion != "" && newVersion != oldVersion { core.TrackCLIUpgraded(oldVersion, newVersion) @@ -159,16 +162,20 @@ func runUpgrade(targetVersion string, force bool) error { } // detectInstalledVersion runs the newly installed binary to get its version. +// It re-resolves the executable symlink so that after a brew upgrade the new +// cellar path is followed rather than the stale old one. func detectInstalledVersion() string { execPath, err := os.Executable() if err != nil { return "" } - realPath, err := filepath.EvalSymlinks(execPath) + // Re-resolve the symlink: after brew upgrade the /usr/local/bin/bl symlink + // now points to the new cellar entry, so EvalSymlinks returns the new binary. + resolvedPath, err := filepath.EvalSymlinks(execPath) if err != nil { - realPath = execPath + resolvedPath = execPath } - out, err := exec.Command(realPath, "version").Output() + out, err := exec.Command(resolvedPath, "version").Output() if err != nil { return "" } From c2c86b9094d41f9b2fc7ce0c9ff27907b9af47e7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 22:43:47 +0000 Subject: [PATCH 3/5] fix: use exec.LookPath for brew upgrade version detection On macOS, os.Executable() returns the already-resolved cellar path, so EvalSymlinks cannot follow the updated symlink after brew upgrade. Using exec.LookPath finds the binary by name in PATH, which resolves through the updated /usr/local/bin symlink to the new cellar entry. Co-Authored-By: tcrochet --- cli/upgrade.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cli/upgrade.go b/cli/upgrade.go index 3d55c785..2920bab0 100644 --- a/cli/upgrade.go +++ b/cli/upgrade.go @@ -162,18 +162,25 @@ func runUpgrade(targetVersion string, force bool) error { } // detectInstalledVersion runs the newly installed binary to get its version. -// It re-resolves the executable symlink so that after a brew upgrade the new -// cellar path is followed rather than the stale old one. +// Uses exec.LookPath to find the binary by name so that after a brew upgrade +// the updated PATH symlink is resolved to the new cellar entry. func detectInstalledVersion() string { execPath, err := os.Executable() if err != nil { return "" } - // Re-resolve the symlink: after brew upgrade the /usr/local/bin/bl symlink - // now points to the new cellar entry, so EvalSymlinks returns the new binary. - resolvedPath, err := filepath.EvalSymlinks(execPath) + // Look up the binary by its base name in PATH so that after a brew upgrade + // the symlink in /usr/local/bin points to the new cellar entry. + // os.Executable() on macOS returns the already-resolved cellar path, + // so EvalSymlinks alone cannot follow the updated symlink. + binaryName := filepath.Base(execPath) + resolvedPath, err := exec.LookPath(binaryName) if err != nil { - resolvedPath = execPath + // Fallback: try EvalSymlinks on the original path + resolvedPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + resolvedPath = execPath + } } out, err := exec.Command(resolvedPath, "version").Output() if err != nil { From 20c1ef592c77dbc10827d5c87a29779374b638a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 22:55:01 +0000 Subject: [PATCH 4/5] fix: prevent spurious subprocess event, use WaitGroup for flush, preserve unknown telemetry fields Co-Authored-By: tcrochet --- cli/core/posthog.go | 41 ++++++++++++++++++++++++++++++++++++----- cli/upgrade.go | 4 +++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/cli/core/posthog.go b/cli/core/posthog.go index cfe9a5c0..86246f49 100644 --- a/cli/core/posthog.go +++ b/cli/core/posthog.go @@ -28,6 +28,8 @@ type telemetryState struct { var ( telemetryOnce sync.Once telemetryCache *telemetryState + telemetryRaw map[string]interface{} // preserves unknown fields from disk + posthogWg sync.WaitGroup ) // getTelemetryPath returns the path to the telemetry state file @@ -45,6 +47,7 @@ func loadTelemetryState() *telemetryState { telemetryCache = &telemetryState{ SDKs: make(map[string]string), } + telemetryRaw = make(map[string]interface{}) path := getTelemetryPath() if path == "" { return @@ -53,6 +56,8 @@ func loadTelemetryState() *telemetryState { if err != nil { return } + // Unmarshal into raw map to preserve unknown fields + _ = json.Unmarshal(data, &telemetryRaw) _ = json.Unmarshal(data, telemetryCache) if telemetryCache.SDKs == nil { telemetryCache.SDKs = make(map[string]string) @@ -61,7 +66,7 @@ func loadTelemetryState() *telemetryState { return telemetryCache } -// saveTelemetryState writes the telemetry state to disk +// saveTelemetryState writes the telemetry state to disk, preserving unknown fields func saveTelemetryState(state *telemetryState) { path := getTelemetryPath() if path == "" { @@ -69,7 +74,19 @@ func saveTelemetryState(state *telemetryState) { } dir := filepath.Dir(path) _ = os.MkdirAll(dir, 0755) - data, err := json.MarshalIndent(state, "", " ") + + // Merge known fields into raw map to preserve unknown fields from disk + merged := make(map[string]interface{}) + for k, v := range telemetryRaw { + merged[k] = v + } + merged["distinct_id"] = state.DistinctID + if state.CLI != "" { + merged["cli"] = state.CLI + } + merged["sdks"] = state.SDKs + + data, err := json.MarshalIndent(merged, "", " ") if err != nil { return } @@ -110,7 +127,9 @@ func capturePosthogEvent(event string, properties map[string]string) { return } + posthogWg.Add(1) go func() { + defer posthogWg.Done() client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Post(PosthogHost+"/capture/", "application/json", bytes.NewReader(data)) if err != nil { @@ -126,6 +145,10 @@ func TrackCLIInstalled(cliVersion string) { if PosthogAPIKey == "" || cliVersion == "" || cliVersion == "dev" { return } + // Skip telemetry in subprocess spawned by detectInstalledVersion() + if os.Getenv("BL_SKIP_TELEMETRY") == "1" { + return + } state := loadTelemetryState() if state.CLI == cliVersion { @@ -172,11 +195,19 @@ func generateUUID() string { return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } -// FlushPosthog waits briefly to allow in-flight PostHog requests to complete. +// FlushPosthog waits for all in-flight PostHog requests to complete, +// with a maximum timeout of 5 seconds to avoid blocking indefinitely. func FlushPosthog() { if PosthogAPIKey == "" { return } - // Give goroutines a moment to finish sending - time.Sleep(500 * time.Millisecond) + done := make(chan struct{}) + go func() { + posthogWg.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + } } diff --git a/cli/upgrade.go b/cli/upgrade.go index 2920bab0..8965366a 100644 --- a/cli/upgrade.go +++ b/cli/upgrade.go @@ -182,7 +182,9 @@ func detectInstalledVersion() string { resolvedPath = execPath } } - out, err := exec.Command(resolvedPath, "version").Output() + cmd := exec.Command(resolvedPath, "version") + cmd.Env = append(os.Environ(), "BL_SKIP_TELEMETRY=1") + out, err := cmd.Output() if err != nil { return "" } From 9e1da8422ee272b0629029b03833ddec80f6a750 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 23:03:46 +0000 Subject: [PATCH 5/5] fix: add FlushPosthog to ExitWithError/Exit paths to prevent event loss on os.Exit Co-Authored-By: tcrochet --- cli/core/sentry.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/core/sentry.go b/cli/core/sentry.go index 16548cd3..32dcaa45 100644 --- a/cli/core/sentry.go +++ b/cli/core/sentry.go @@ -84,6 +84,7 @@ func ExitWithError(err error) { sentry.CaptureException(err) sentry.Flush(2 * time.Second) } + FlushPosthog() os.Exit(1) } @@ -93,6 +94,7 @@ func ExitWithMessage(msg string) { sentry.CaptureMessage(msg) sentry.Flush(2 * time.Second) } + FlushPosthog() os.Exit(1) } @@ -101,5 +103,6 @@ func Exit(code int) { if code != 0 && SentryDSN != "" { sentry.Flush(2 * time.Second) } + FlushPosthog() os.Exit(code) }