From 510c207eacc08c6a88137976f39cef20462eabeb Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 24 Feb 2026 22:29:15 -0800 Subject: [PATCH 1/8] feat(analytics): add PostHog analytics for CLI command tracking Add comprehensive, privacy-conscious analytics using PostHog that captures every command invocation with args, flags, execution context, and system info. Users are prompted to opt in during login, with preference stored in personal_settings.json. Tracked properties include: command path, duration, success/failure, OS/arch, CLI version, TTY/pipe detection, CI/SSH detection, shell, locale, timezone, GPU info, parent process, and cwd. --- go.mod | 8 +- go.sum | 12 +- main.go | 3 + pkg/analytics/parentprocess.go | 60 +++++++ pkg/analytics/posthog.go | 314 +++++++++++++++++++++++++++++++++ pkg/cmd/cmd.go | 14 ++ pkg/cmd/login/login.go | 14 ++ pkg/cmd/ls/ls.go | 2 +- pkg/cmdcontext/cmdcontext.go | 23 +++ pkg/files/files.go | 4 +- 10 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 pkg/analytics/parentprocess.go create mode 100644 pkg/analytics/posthog.go diff --git a/go.mod b/go.mod index f9cad5670..8d197bd70 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/manifoldco/promptui v0.9.0 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/errors v0.9.1 + github.com/posthog/posthog-go v1.10.0 github.com/robfig/cron/v3 v3.0.1 github.com/samber/lo v1.33.0 github.com/samber/mo v1.5.1 @@ -33,7 +34,7 @@ require ( github.com/spf13/afero v1.9.2 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.13.0 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.14.0 github.com/tweekmonster/luser v0.0.0-20161003172636-3fa38070dbd7 github.com/wk8/go-ordered-map/v2 v2.0.0 @@ -67,9 +68,10 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/gnostic-models v0.6.8 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect @@ -138,7 +140,7 @@ require ( github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.5 github.com/xlab/treeprint v1.2.0 // indirect go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect golang.org/x/net v0.48.0 // indirect diff --git a/go.sum b/go.sum index b4f2cc37a..d450015e8 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,8 @@ github.com/go-resty/resty/v2 v2.17.0 h1:pW9DeXcaL4Rrym4EZ8v7L19zZiIlWPg5YXAcVmt+ github.com/go-resty/resty/v2 v2.17.0/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -254,6 +254,8 @@ github.com/hashicorp/go-version v1.4.0 h1:aAQzgqIrRKRa7w75CKpbBxYsmUoPjzVm1W59ca github.com/hashicorp/go-version v1.4.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -356,6 +358,8 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posthog/posthog-go v1.10.0 h1:wfoy7Jfb4LigCoHYyMZoiJmmEoCLOkSaYfDxM/NtCqY= +github.com/posthog/posthog-go v1.10.0/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -408,8 +412,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= diff --git a/main.go b/main.go index c7b9bc5cb..66b4d97e9 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "os" + "github.com/brevdev/brev-cli/pkg/analytics" "github.com/brevdev/brev-cli/pkg/cmd" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" "github.com/brevdev/brev-cli/pkg/errors" @@ -11,9 +12,11 @@ import ( func main() { done := errors.GetDefaultErrorReporter().Setup() defer done() + defer analytics.Close() command := cmd.NewDefaultBrevCommand() if err := command.Execute(); err != nil { + analytics.CaptureCommandError() cmderrors.DisplayAndHandleError(err) done() os.Exit(1) //nolint:gocritic // manually call done diff --git a/pkg/analytics/parentprocess.go b/pkg/analytics/parentprocess.go new file mode 100644 index 000000000..8a5d07b37 --- /dev/null +++ b/pkg/analytics/parentprocess.go @@ -0,0 +1,60 @@ +package analytics + +import ( + "os" + "os/exec" + "runtime" + "strconv" + "strings" +) + +// getParentProcessInfo returns the name and full command line of the parent process. +func getParentProcessInfo() (name, cmdline string) { + ppid := os.Getppid() + if ppid <= 0 { + return "", "" + } + + switch runtime.GOOS { + case "linux": + return getParentProcessLinux(ppid) + case "darwin": + return getParentProcessDarwin(ppid) + default: + return "", "" + } +} + +func getParentProcessLinux(ppid int) (name, cmdline string) { + pidStr := strconv.Itoa(ppid) + + commBytes, err := os.ReadFile("/proc/" + pidStr + "/comm") + if err == nil { + name = strings.TrimSpace(string(commBytes)) + } + + cmdlineBytes, err := os.ReadFile("/proc/" + pidStr + "/cmdline") + if err == nil { + // /proc cmdline uses null bytes as separators + cmdline = strings.ReplaceAll(string(cmdlineBytes), "\x00", " ") + cmdline = strings.TrimSpace(cmdline) + } + + return name, cmdline +} + +func getParentProcessDarwin(ppid int) (name, cmdline string) { + pidStr := strconv.Itoa(ppid) + + out, err := exec.Command("ps", "-p", pidStr, "-o", "comm=").Output() // #nosec G204 + if err == nil { + name = strings.TrimSpace(string(out)) + } + + out, err = exec.Command("ps", "-p", pidStr, "-o", "args=").Output() // #nosec G204 + if err == nil { + cmdline = strings.TrimSpace(string(out)) + } + + return name, cmdline +} diff --git a/pkg/analytics/posthog.go b/pkg/analytics/posthog.go new file mode 100644 index 000000000..3e013eb9c --- /dev/null +++ b/pkg/analytics/posthog.go @@ -0,0 +1,314 @@ +package analytics + +import ( + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" + + "github.com/brevdev/brev-cli/pkg/cmd/version" + "github.com/brevdev/brev-cli/pkg/files" + "github.com/google/uuid" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const posthogAPIKey = "phc_NZZQP1QJCWzyeIH7P6UTjrSf9c64x4xjSktCuynAmNY" + +var ( + client posthog.Client + clientOnce sync.Once + clientErr error + + // Command timing + commandStartTime time.Time + + // Stored command context for error-path capture + storedCmd *cobra.Command + storedArgs []string + storedUser string +) + +func getClient() (posthog.Client, error) { + clientOnce.Do(func() { + client, clientErr = posthog.NewWithConfig(posthogAPIKey, posthog.Config{}) + }) + return client, clientErr +} + +// RecordCommandStart should be called from PersistentPreRunE to record the start time +// and store the command context for potential error-path capture. +func RecordCommandStart(cmd *cobra.Command, args []string) { + commandStartTime = time.Now() + storedCmd = cmd + storedArgs = args +} + +// IsAnalyticsEnabled returns whether analytics is enabled and whether the user has been asked. +func IsAnalyticsEnabled() (enabled bool, hasBeenAsked bool) { + settings := readSettings() + if settings.AnalyticsEnabled == nil { + return false, false + } + return *settings.AnalyticsEnabled, true +} + +// SetAnalyticsPreference persists the user's analytics preference. +func SetAnalyticsPreference(enabled bool) error { + fs := files.AppFs + home, err := getHomeDir() + if err != nil { + return err + } + settings, err := files.ReadPersonalSettings(fs, home) + if err != nil { + return err + } + settings.AnalyticsEnabled = &enabled + return files.WritePersonalSettings(fs, home, settings) +} + +// GetOrCreateAnalyticsID returns a stable anonymous UUID for tracking, creating one if needed. +func GetOrCreateAnalyticsID() string { + fs := files.AppFs + home, err := getHomeDir() + if err != nil { + return "" + } + settings, err := files.ReadPersonalSettings(fs, home) + if err != nil { + return "" + } + if settings.AnalyticsID != "" { + return settings.AnalyticsID + } + settings.AnalyticsID = uuid.New().String() + _ = files.WritePersonalSettings(fs, home, settings) + return settings.AnalyticsID +} + +// IdentifyUser links the anonymous analytics ID to a real user ID using PostHog Alias. +func IdentifyUser(userID string) { + enabled, asked := IsAnalyticsEnabled() + if !asked || !enabled { + return + } + + anonID := GetOrCreateAnalyticsID() + if anonID == "" || userID == "" { + return + } + + c, err := getClient() + if err != nil { + return + } + + _ = c.Enqueue(posthog.Alias{ + DistinctId: userID, + Alias: anonID, + }) +} + +// CaptureCommand captures a CLI command invocation event (success path). +func CaptureCommand(userID string, cmd *cobra.Command, args []string) { + // Store for potential error-path capture + storedCmd = cmd + storedArgs = args + storedUser = userID + + captureEvent(userID, cmd, args, true) +} + +// CaptureCommandError captures a CLI command failure event from main.go. +// Uses stored command context from PersistentPreRunE/PersistentPostRunE. +func CaptureCommandError() { + if storedCmd == nil { + return + } + // If CaptureCommand already ran (success path), don't double-capture. + // storedUser being set means PersistentPostRunE ran. + // We only get here on error, so PersistentPostRunE didn't run. + userID := storedUser + if userID == "" { + userID = GetOrCreateAnalyticsID() + } + captureEvent(userID, storedCmd, storedArgs, false) +} + +func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bool) { + enabled, asked := IsAnalyticsEnabled() + if !asked || !enabled { + return + } + + if userID == "" { + return + } + + c, err := getClient() + if err != nil { + return + } + + // Flags + flagMap := make(map[string]interface{}) + cmd.Flags().Visit(func(f *pflag.Flag) { + flagMap[f.Name] = f.Value.String() + }) + + // Parent process + parentName, parentCmdline := getParentProcessInfo() + + // CWD + cwd, _ := os.Getwd() + + // Duration + var durationMs int64 + if !commandStartTime.IsZero() { + durationMs = time.Since(commandStartTime).Milliseconds() + } + + // TTY / piped detection + stdinStat, _ := os.Stdin.Stat() + stdoutStat, _ := os.Stdout.Stat() + isTTY := (stdoutStat.Mode() & os.ModeCharDevice) != 0 + isStdinPiped := (stdinStat.Mode() & os.ModeCharDevice) == 0 + + // CI detection + isCI := detectCI() + + // SSH session detection + isSSH := os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" + + // GPU info + gpuInfo := getGPUInfo() + + properties := posthog.NewProperties(). + // Command info + Set("command", cmd.CommandPath()). + Set("args", strings.Join(args, " ")). + Set("flags", flagMap). + Set("succeeded", succeeded). + Set("duration_ms", durationMs). + // System + Set("os", runtime.GOOS). + Set("arch", runtime.GOARCH). + Set("num_cpus", runtime.NumCPU()). + Set("go_version", runtime.Version()). + Set("cli_version", version.Version). + // Context + Set("cwd", cwd). + Set("parent_process", parentName). + Set("parent_cmdline", parentCmdline). + // Terminal + Set("is_tty", isTTY). + Set("is_stdin_piped", isStdinPiped). + Set("shell", os.Getenv("SHELL")). + Set("terminal", os.Getenv("TERM")). + // Environment + Set("is_ci", isCI). + Set("is_ssh", isSSH). + Set("locale", getLocale()). + Set("timezone", getTimezone()). + // GPU + Set("gpu_info", gpuInfo) + + _ = c.Enqueue(posthog.Capture{ + DistinctId: userID, + Event: "cli_command", + Properties: properties, + }) +} + +// Close flushes any pending events and closes the PostHog client. +func Close() { + if client != nil { + _ = client.Close() + } +} + +func readSettings() *files.PersonalSettings { + fs := files.AppFs + home, err := getHomeDir() + if err != nil { + return &files.PersonalSettings{} + } + settings, err := files.ReadPersonalSettings(fs, home) + if err != nil { + return &files.PersonalSettings{} + } + return settings +} + +func getHomeDir() (string, error) { + return os.UserHomeDir() +} + +func detectCI() bool { + ciVars := []string{ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "TRAVIS", + "JENKINS_URL", + "BUILDKITE", + "CODEBUILD_BUILD_ID", + "TF_BUILD", + "BITBUCKET_PIPELINE", + } + for _, v := range ciVars { + if os.Getenv(v) != "" { + return true + } + } + return false +} + +func getLocale() string { + if lang := os.Getenv("LANG"); lang != "" { + return lang + } + if lcAll := os.Getenv("LC_ALL"); lcAll != "" { + return lcAll + } + return "" +} + +func getTimezone() string { + zone, _ := time.Now().Zone() + return zone +} + +func getGPUInfo() string { + out, err := exec.Command("nvidia-smi", "--query-gpu=name,memory.total,driver_version,count", "--format=csv,noheader,nounits").Output() // #nosec G204 + if err != nil { + // nvidia-smi not available or no NVIDIA GPU + if runtime.GOOS == "darwin" { + return getAppleGPUInfo() + } + return "" + } + return strings.TrimSpace(string(out)) +} + +func getAppleGPUInfo() string { + out, err := exec.Command("system_profiler", "SPDisplaysDataType", "-detailLevel", "mini").Output() // #nosec G204 + if err != nil { + return "" + } + // Extract just the chipset/model lines + lines := strings.Split(string(out), "\n") + var gpuLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "Chipset Model:") || strings.HasPrefix(trimmed, "VRAM") || strings.HasPrefix(trimmed, "Total Number of Cores:") { + gpuLines = append(gpuLines, trimmed) + } + } + return strings.Join(gpuLines, "; ") +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index e5c647422..04b53f0e2 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -4,6 +4,7 @@ package cmd import ( "fmt" + "github.com/brevdev/brev-cli/pkg/analytics" "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/cmd/background" "github.com/brevdev/brev-cli/pkg/cmd/clipboard" @@ -155,7 +156,20 @@ func NewBrevCommand() *cobra.Command { //nolint:funlen,gocognit,gocyclo // defin } } }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + userID := "" + user, err := noLoginCmdStore.GetCurrentUser() + if err == nil && user != nil { + userID = user.ID + } + if userID == "" { + userID = analytics.GetOrCreateAnalyticsID() + } + analytics.CaptureCommand(userID, cmd, args) + return nil + }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + analytics.RecordCommandStart(cmd, args) breverrors.GetDefaultErrorReporter().AddTag("command", cmd.Name()) // version info gets in the way of the output for // configure-env-vars, since shells are going to eval it diff --git a/pkg/cmd/login/login.go b/pkg/cmd/login/login.go index 4b19044f8..810692bf0 100644 --- a/pkg/cmd/login/login.go +++ b/pkg/cmd/login/login.go @@ -7,6 +7,7 @@ import ( "os/exec" "strings" + "github.com/brevdev/brev-cli/pkg/analytics" "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" "github.com/brevdev/brev-cli/pkg/cmd/hello" @@ -225,6 +226,19 @@ func (o LoginOptions) handleOnboarding(user *entity.User, _ *terminal.Terminal) newOnboardingStatus["usedCLI"] = true } + _, analyticsAsked := analytics.IsAnalyticsEnabled() + if !analyticsAsked { + choice := terminal.PromptSelectInput(terminal.PromptSelectContent{ + Label: "Help us improve Brev by sharing anonymous usage data?", + ErrorMsg: "Error: must choose an option", + Items: []string{"Yes, share anonymous usage data", "No, opt out"}, + }) + optIn := strings.HasPrefix(choice, "Yes") + _ = analytics.SetAnalyticsPreference(optIn) + } + + analytics.IdentifyUser(user.ID) + user, err = o.LoginStore.UpdateUser(user.ID, &entity.UpdateUser{ // username, name, and email are required fields, but we only care about onboarding status Username: user.Username, diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index f2dc0fd6c..da95aa89b 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -88,7 +88,7 @@ with other commands like stop, start, or delete.`, } } - return nil + return cmdcontext.InvokeParentPersistentPostRun(cmd, args) }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { err := cmdcontext.InvokeParentPersistentPreRun(cmd, args) diff --git a/pkg/cmdcontext/cmdcontext.go b/pkg/cmdcontext/cmdcontext.go index 85debd598..0be203d73 100644 --- a/pkg/cmdcontext/cmdcontext.go +++ b/pkg/cmdcontext/cmdcontext.go @@ -36,3 +36,26 @@ func InvokeParentPersistentPreRun(cmd *cobra.Command, args []string) error { return nil } + +// InvokeParentPersistentPostRun executes the immediate parent command's +// PersistentPostRunE and PersistentPostRun functions, in that order. +func InvokeParentPersistentPostRun(cmd *cobra.Command, args []string) error { + parentCmd := cmd.Parent() + if parentCmd == nil { + return nil + } + + parentPersistentPostRunE := parentCmd.PersistentPostRunE + if parentPersistentPostRunE != nil { + if err := parentPersistentPostRunE(cmd, args); err != nil { + return breverrors.WrapAndTrace(err) + } + } + + parentPersistentPostRun := parentCmd.PersistentPostRun + if parentPersistentPostRun != nil { + parentPersistentPostRun(cmd, args) + } + + return nil +} diff --git a/pkg/files/files.go b/pkg/files/files.go index 0b1796e7b..1b148b313 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -16,7 +16,9 @@ import ( ) type PersonalSettings struct { - DefaultEditor string `json:"default_editor"` + DefaultEditor string `json:"default_editor"` + AnalyticsEnabled *bool `json:"analytics_enabled,omitempty"` // nil = never asked, true = opted in, false = opted out + AnalyticsID string `json:"analytics_id,omitempty"` // stable anonymous ID for analytics } const ( From 90d4b3b2e4b591309eed838eb19c0759b4bf00c8 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 24 Feb 2026 22:29:56 -0800 Subject: [PATCH 2/8] chore(analytics): update PostHog project token --- pkg/analytics/posthog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/analytics/posthog.go b/pkg/analytics/posthog.go index 3e013eb9c..492e4b677 100644 --- a/pkg/analytics/posthog.go +++ b/pkg/analytics/posthog.go @@ -16,7 +16,7 @@ import ( "github.com/spf13/pflag" ) -const posthogAPIKey = "phc_NZZQP1QJCWzyeIH7P6UTjrSf9c64x4xjSktCuynAmNY" +const posthogAPIKey = "phc_PWWXIQgQ31lXWMGI2dnTY3FyjBh7gPcMhlno1RLapLm" var ( client posthog.Client From fdafc5758151f206726baa4f1d3ce9f1b8a9b651 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 24 Feb 2026 22:31:48 -0800 Subject: [PATCH 3/8] chore: update analytics opt-in prompt wording --- pkg/cmd/login/login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/login/login.go b/pkg/cmd/login/login.go index 810692bf0..0f9043de0 100644 --- a/pkg/cmd/login/login.go +++ b/pkg/cmd/login/login.go @@ -229,7 +229,7 @@ func (o LoginOptions) handleOnboarding(user *entity.User, _ *terminal.Terminal) _, analyticsAsked := analytics.IsAnalyticsEnabled() if !analyticsAsked { choice := terminal.PromptSelectInput(terminal.PromptSelectContent{ - Label: "Help us improve Brev by sharing anonymous usage data?", + Label: "Help us improve Brev by sharing usage data?", ErrorMsg: "Error: must choose an option", Items: []string{"Yes, share anonymous usage data", "No, opt out"}, }) From e8e2685cfbc9ad0cc1d56e596a5caa8986f54a04 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 24 Feb 2026 22:32:09 -0800 Subject: [PATCH 4/8] chore: update analytics opt-in choice wording --- pkg/cmd/login/login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/login/login.go b/pkg/cmd/login/login.go index 0f9043de0..98cf53f46 100644 --- a/pkg/cmd/login/login.go +++ b/pkg/cmd/login/login.go @@ -231,7 +231,7 @@ func (o LoginOptions) handleOnboarding(user *entity.User, _ *terminal.Terminal) choice := terminal.PromptSelectInput(terminal.PromptSelectContent{ Label: "Help us improve Brev by sharing usage data?", ErrorMsg: "Error: must choose an option", - Items: []string{"Yes, share anonymous usage data", "No, opt out"}, + Items: []string{"Yes, share usage data", "No, opt out"}, }) optIn := strings.HasPrefix(choice, "Yes") _ = analytics.SetAnalyticsPreference(optIn) From fb7fec87dc9ea51096bd04d04cd99c04b73b4470 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 24 Feb 2026 22:42:27 -0800 Subject: [PATCH 5/8] feat(analytics): add is_stdout_piped property --- pkg/analytics/posthog.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/analytics/posthog.go b/pkg/analytics/posthog.go index 492e4b677..6be3c83cc 100644 --- a/pkg/analytics/posthog.go +++ b/pkg/analytics/posthog.go @@ -177,6 +177,7 @@ func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bo stdoutStat, _ := os.Stdout.Stat() isTTY := (stdoutStat.Mode() & os.ModeCharDevice) != 0 isStdinPiped := (stdinStat.Mode() & os.ModeCharDevice) == 0 + isStdoutPiped := (stdoutStat.Mode() & os.ModeCharDevice) == 0 // CI detection isCI := detectCI() @@ -207,6 +208,7 @@ func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bo // Terminal Set("is_tty", isTTY). Set("is_stdin_piped", isStdinPiped). + Set("is_stdout_piped", isStdoutPiped). Set("shell", os.Getenv("SHELL")). Set("terminal", os.Getenv("TERM")). // Environment From 509c24bbce8ddc42e1a89900ec8d26e133f2ec94 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 24 Feb 2026 22:44:12 -0800 Subject: [PATCH 6/8] chore(analytics): remove parent_cmdline property --- pkg/analytics/posthog.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/analytics/posthog.go b/pkg/analytics/posthog.go index 6be3c83cc..1c99b8e1b 100644 --- a/pkg/analytics/posthog.go +++ b/pkg/analytics/posthog.go @@ -161,7 +161,7 @@ func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bo }) // Parent process - parentName, parentCmdline := getParentProcessInfo() + parentName, _ := getParentProcessInfo() // CWD cwd, _ := os.Getwd() @@ -204,7 +204,6 @@ func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bo // Context Set("cwd", cwd). Set("parent_process", parentName). - Set("parent_cmdline", parentCmdline). // Terminal Set("is_tty", isTTY). Set("is_stdin_piped", isStdinPiped). From e2c4219e8193e22f1733bd823b1223298f838eda Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 24 Feb 2026 22:44:35 -0800 Subject: [PATCH 7/8] chore(analytics): restore parent_cmdline property --- pkg/analytics/posthog.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/analytics/posthog.go b/pkg/analytics/posthog.go index 1c99b8e1b..6be3c83cc 100644 --- a/pkg/analytics/posthog.go +++ b/pkg/analytics/posthog.go @@ -161,7 +161,7 @@ func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bo }) // Parent process - parentName, _ := getParentProcessInfo() + parentName, parentCmdline := getParentProcessInfo() // CWD cwd, _ := os.Getwd() @@ -204,6 +204,7 @@ func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bo // Context Set("cwd", cwd). Set("parent_process", parentName). + Set("parent_cmdline", parentCmdline). // Terminal Set("is_tty", isTTY). Set("is_stdin_piped", isStdinPiped). From a65ca7a58631cceb303b3db383d99dbf02e5dadf Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 24 Feb 2026 22:52:32 -0800 Subject: [PATCH 8/8] fix: wrap errors to satisfy wrapcheck linter --- pkg/analytics/posthog.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/analytics/posthog.go b/pkg/analytics/posthog.go index 6be3c83cc..8ecc90b03 100644 --- a/pkg/analytics/posthog.go +++ b/pkg/analytics/posthog.go @@ -1,6 +1,7 @@ package analytics import ( + "fmt" "os" "os/exec" "runtime" @@ -65,10 +66,13 @@ func SetAnalyticsPreference(enabled bool) error { } settings, err := files.ReadPersonalSettings(fs, home) if err != nil { - return err + return fmt.Errorf("reading personal settings: %w", err) } settings.AnalyticsEnabled = &enabled - return files.WritePersonalSettings(fs, home, settings) + if err := files.WritePersonalSettings(fs, home, settings); err != nil { + return fmt.Errorf("writing personal settings: %w", err) + } + return nil } // GetOrCreateAnalyticsID returns a stable anonymous UUID for tracking, creating one if needed. @@ -247,7 +251,11 @@ func readSettings() *files.PersonalSettings { } func getHomeDir() (string, error) { - return os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home dir: %w", err) + } + return home, nil } func detectCI() bool {