Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ builds:
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
- -X main.sentryDSN={{ .Env.SENTRY_DSN }}
- -X main.posthogKey={{ if index .Env "POSTHOG_KEY" }}{{ .Env.POSTHOG_KEY }}{{ end }}
goos:
- linux
- windows
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
213 changes: 213 additions & 0 deletions cli/core/posthog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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
telemetryRaw map[string]interface{} // preserves unknown fields from disk
posthogWg sync.WaitGroup
)

// 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),
}
telemetryRaw = make(map[string]interface{})
path := getTelemetryPath()
if path == "" {
return
}
data, err := os.ReadFile(path)
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)
}
})
return telemetryCache
}

// saveTelemetryState writes the telemetry state to disk, preserving unknown fields
func saveTelemetryState(state *telemetryState) {
path := getTelemetryPath()
if path == "" {
return
}
dir := filepath.Dir(path)
_ = os.MkdirAll(dir, 0755)

// 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
}
_ = os.WriteFile(path, data, 0600)
}

// 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 != "" {
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
}

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 {
return
}
defer resp.Body.Close()
}()
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// 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
}
// Skip telemetry in subprocess spawned by detectInstalledVersion()
if os.Getenv("BL_SKIP_TELEMETRY") == "1" {
return
}

state := loadTelemetryState()
if state.CLI == cliVersion {
return
}

capturePosthogEvent("Installed CLI", map[string]string{
"version": cliVersion,
})

state.CLI = cliVersion
saveTelemetryState(state)
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// 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 for all in-flight PostHog requests to complete,
// with a maximum timeout of 5 seconds to avoid blocking indefinitely.
func FlushPosthog() {
if PosthogAPIKey == "" {
return
}
done := make(chan struct{})
go func() {
posthogWg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
}
}
3 changes: 3 additions & 0 deletions cli/core/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
3 changes: 3 additions & 0 deletions cli/core/sentry.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func ExitWithError(err error) {
sentry.CaptureException(err)
sentry.Flush(2 * time.Second)
}
FlushPosthog()
os.Exit(1)
}

Expand All @@ -93,6 +94,7 @@ func ExitWithMessage(msg string) {
sentry.CaptureMessage(msg)
sentry.Flush(2 * time.Second)
}
FlushPosthog()
os.Exit(1)
}

Expand All @@ -101,5 +103,6 @@ func Exit(code int) {
if code != 0 && SentryDSN != "" {
sentry.Flush(2 * time.Second)
}
FlushPosthog()
os.Exit(code)
}
59 changes: 57 additions & 2 deletions cli/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,69 @@ 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.
// 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)
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

return nil
}

// detectInstalledVersion runs the newly installed binary to get its version.
// 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 ""
}
// 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 {
// Fallback: try EvalSymlinks on the original path
resolvedPath, err = filepath.EvalSymlinks(execPath)
if err != nil {
resolvedPath = execPath
}
}
cmd := exec.Command(resolvedPath, "version")
cmd.Env = append(os.Environ(), "BL_SKIP_TELEMETRY=1")
out, err := cmd.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 ""
}
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.

// upgradeViaBrew upgrades the CLI using Homebrew
Expand Down
15 changes: 11 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import (
)

var (
version = "dev"
commit = "none"
date = "unknown"
sentryDSN = ""
version = "dev"
commit = "none"
date = "unknown"
sentryDSN = ""
posthogKey = ""
)

func main() {
Expand All @@ -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)
Expand Down
Loading