From 5621a19dda52d65a3aec9447cc872c383bcdcab3 Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Sat, 7 Mar 2026 09:14:21 -0800 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20anonymous=20PostH?= =?UTF-8?q?og=20telemetry=20to=20mb=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track installs, DAU, and command usage via PostHog with anonymous UUIDs. Opt-out via --disable-telemetry flag or MB_DISABLE_TELEMETRY=1. --- cmd/down/down.go | 16 +++- cmd/pull/pull.go | 16 +++- cmd/ssh/ssh.go | 12 ++- cmd/up/up.go | 15 +++- go.mod | 4 + go.sum | 8 ++ main.go | 21 +++++ pkg/telemetry/posthog.go | 172 +++++++++++++++++++++++++++++++++++++ pkg/telemetry/telemetry.go | 64 ++++++++++++++ 9 files changed, 314 insertions(+), 14 deletions(-) create mode 100644 pkg/telemetry/posthog.go create mode 100644 pkg/telemetry/telemetry.go diff --git a/cmd/down/down.go b/cmd/down/down.go index 7f06aeb..e9ab935 100644 --- a/cmd/down/down.go +++ b/cmd/down/down.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/papercomputeco/masterblaster/pkg/daemon/client" + "github.com/papercomputeco/masterblaster/pkg/telemetry" "github.com/papercomputeco/masterblaster/pkg/ui" ) @@ -35,12 +36,13 @@ func NewDownCmd(configDirFn func() string) *cobra.Command { Short: downShortDesc, Long: downLongDesc, Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { name := "" if len(args) > 0 { name = args[0] } - return runDown(configDirFn(), name, force) + telem := telemetry.FromContext(cmd.Context()) + return runDown(configDirFn(), name, force, telem) }, } @@ -49,14 +51,20 @@ func NewDownCmd(configDirFn func() string) *cobra.Command { return cmd } -func runDown(baseDir, name string, force bool) error { +func runDown(baseDir, name string, force bool, telem *telemetry.PosthogClient) error { if err := client.EnsureDaemon(baseDir); err != nil { return err } c := client.New(baseDir) - return ui.Step(os.Stderr, "Stopping sandbox...", func() error { + err := ui.Step(os.Stderr, "Stopping sandbox...", func() error { _, err := c.Down(name, force) return err }) + + if telem != nil { + telem.CaptureDown(err == nil) + } + + return err } diff --git a/cmd/pull/pull.go b/cmd/pull/pull.go index 38ae799..e099641 100644 --- a/cmd/pull/pull.go +++ b/cmd/pull/pull.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/papercomputeco/masterblaster/pkg/mixtapes" + "github.com/papercomputeco/masterblaster/pkg/telemetry" "github.com/papercomputeco/masterblaster/pkg/ui" ) @@ -40,14 +41,21 @@ func NewPullCmd(configDirFn func() string) *cobra.Command { Short: pullShortDesc, Long: pullLongDesc, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runPull(configDirFn(), args[0]) + RunE: func(cmd *cobra.Command, args []string) error { + telem := telemetry.FromContext(cmd.Context()) + return runPull(configDirFn(), args[0], telem) }, } } -func runPull(baseDir, rawRef string) error { - return ui.Step(os.Stderr, fmt.Sprintf("Pulling mixtape %q...", rawRef), func() error { +func runPull(baseDir, rawRef string, telem *telemetry.PosthogClient) error { + err := ui.Step(os.Stderr, fmt.Sprintf("Pulling mixtape %q...", rawRef), func() error { return mixtapes.Pull(baseDir, rawRef) }) + + if telem != nil { + telem.CapturePull(rawRef, err == nil) + } + + return err } diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 3b2ec9b..46036fb 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -9,6 +9,7 @@ import ( "github.com/papercomputeco/masterblaster/pkg/daemon/client" "github.com/papercomputeco/masterblaster/pkg/ssh" + "github.com/papercomputeco/masterblaster/pkg/telemetry" "github.com/papercomputeco/masterblaster/pkg/ui" ) @@ -35,12 +36,13 @@ func NewSSHCmd(configDirFn func() string, verboseFn func() bool) *cobra.Command Short: sshShortDesc, Long: sshLongDesc, Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { name := "" if len(args) > 0 { name = args[0] } - return runSSH(configDirFn(), name, user, verboseFn()) + telem := telemetry.FromContext(cmd.Context()) + return runSSH(configDirFn(), name, user, verboseFn(), telem) }, } @@ -49,7 +51,7 @@ func NewSSHCmd(configDirFn func() string, verboseFn func() bool) *cobra.Command return cmd } -func runSSH(baseDir, name, user string, verbose bool) error { +func runSSH(baseDir, name, user string, verbose bool, telem *telemetry.PosthogClient) error { if err := client.EnsureDaemon(baseDir); err != nil { return err } @@ -73,5 +75,9 @@ func runSSH(baseDir, name, user string, verbose bool) error { ui.Info("Connecting to %s@127.0.0.1:%d", user, sb.SSHPort) } + if telem != nil { + telem.CaptureSSH() + } + return ssh.ExecSSH(user, "127.0.0.1", sb.SSHPort, sb.SSHKeyPath) } diff --git a/cmd/up/up.go b/cmd/up/up.go index 63989d7..1580060 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -12,6 +12,7 @@ import ( "github.com/papercomputeco/masterblaster/pkg/daemon" "github.com/papercomputeco/masterblaster/pkg/daemon/client" + "github.com/papercomputeco/masterblaster/pkg/telemetry" "github.com/papercomputeco/masterblaster/pkg/ui" ) @@ -37,8 +38,9 @@ func NewUpCmd(configDirFn func() string) *cobra.Command { Short: upShortDesc, Long: upLongDesc, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - return runUp(configDirFn(), cfgPath) + RunE: func(cmd *cobra.Command, _ []string) error { + telem := telemetry.FromContext(cmd.Context()) + return runUp(configDirFn(), cfgPath, telem) }, } @@ -47,7 +49,7 @@ func NewUpCmd(configDirFn func() string) *cobra.Command { return cmd } -func runUp(baseDir, cfgPath string) error { +func runUp(baseDir, cfgPath string, telem *telemetry.PosthogClient) error { // Resolve config path if cfgPath == "" { cwd, err := os.Getwd() @@ -78,9 +80,16 @@ func runUp(baseDir, cfgPath string) error { resp, stepErr = c.Up("", cfgPath) return stepErr }); err != nil { + if telem != nil { + telem.CaptureUp("", false) + } return err } + if telem != nil { + telem.CaptureUp("", true) + } + if len(resp.Sandboxes) > 0 { sb := resp.Sandboxes[0] fmt.Fprintln(os.Stderr) diff --git a/go.mod b/go.mod index 3db0634..5bc4e27 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,11 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.2 github.com/google/go-containerregistry v0.21.0 + github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.18.4 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 + github.com/posthog/posthog-go v1.10.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 golang.org/x/crypto v0.48.0 @@ -32,8 +34,10 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index f3ca316..a28c358 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -54,6 +56,10 @@ github.com/google/go-containerregistry v0.21.0 h1:ocqxUOczFwAZQBMNE7kuzfqvDe0VWo github.com/google/go-containerregistry v0.21.0/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -92,6 +98,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/main.go b/main.go index 3d10817..9c39bfe 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,13 @@ package main import ( + "context" "os" "github.com/spf13/cobra" "github.com/papercomputeco/masterblaster/pkg/ui" + "github.com/papercomputeco/masterblaster/pkg/utils" destroycmder "github.com/papercomputeco/masterblaster/cmd/destroy" downcmder "github.com/papercomputeco/masterblaster/cmd/down" @@ -20,6 +22,7 @@ import ( versioncmder "github.com/papercomputeco/masterblaster/cmd/version" vmhostcmder "github.com/papercomputeco/masterblaster/cmd/vmhost" "github.com/papercomputeco/masterblaster/pkg/mbconfig" + "github.com/papercomputeco/masterblaster/pkg/telemetry" ) const rootLongDesc string = `Masterblaster (mb) is an AI agent sandbox management, build, and @@ -39,12 +42,30 @@ func NewMbCmd() *cobra.Command { SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + disableTelem, _ := cmd.Flags().GetBool("disable-telemetry") + if os.Getenv("MB_DISABLE_TELEMETRY") == "1" { + disableTelem = true + } + + telem := telemetry.NewPosthogClient(!disableTelem, utils.Version) + telem.CaptureInstall() + telem.CaptureCommandRun(cmd.Name()) + + cmd.SetContext(context.WithValue(cmd.Context(), telemetry.Key, telem)) + return mbconfig.Init(cmd) }, + PersistentPostRunE: func(cmd *cobra.Command, _ []string) error { + if telem := telemetry.FromContext(cmd.Context()); telem != nil { + telem.Done() + } + return nil + }, } cmd.PersistentFlags().String("config-dir", "", "Config directory (default: $XDG_CONFIG_HOME/mb)") cmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output") + cmd.PersistentFlags().Bool("disable-telemetry", false, "Disable anonymous telemetry") cmd.AddCommand(servecmder.NewServeCmd(mbconfig.ConfigDir)) cmd.AddCommand(initcmder.NewInitCmd()) diff --git a/pkg/telemetry/posthog.go b/pkg/telemetry/posthog.go new file mode 100644 index 0000000..5ab74e5 --- /dev/null +++ b/pkg/telemetry/posthog.go @@ -0,0 +1,172 @@ +package telemetry + +import ( + "context" + "runtime" + + "github.com/posthog/posthog-go" +) + +var ( + // Write-only key, safe to embed in source. + writeOnlyPublicPosthogKey = "phc_xCBFT1jetPLJIRGTqJ9Q0YuG5I1jhXtUkxYkNBEAXRY" + posthogEndpoint = "https://us.i.posthog.com" +) + +// contextKey is an unexported type for context keys in this package. +type contextKey struct{} + +// Key is the context key used to store and retrieve the PosthogClient. +var Key = contextKey{} + +// FromContext retrieves the PosthogClient from a context. Returns nil if absent. +func FromContext(ctx context.Context) *PosthogClient { + if t, ok := ctx.Value(Key).(*PosthogClient); ok { + return t + } + return nil +} + +// PosthogClient wraps the PostHog SDK for anonymous CLI telemetry. +type PosthogClient struct { + client posthog.Client + activated bool + uniqueID string + isFirstRun bool + version string +} + +// NewPosthogClient creates a new telemetry client. +// If activated is false, all capture methods are no-ops. +func NewPosthogClient(activated bool, version string) *PosthogClient { + client, err := posthog.NewWithConfig( + writeOnlyPublicPosthogKey, + posthog.Config{ + Endpoint: posthogEndpoint, + }, + ) + if err != nil { + // Config is static; this should never happen. + return &PosthogClient{activated: false} + } + + uniqueID, isFirstRun, _ := getOrCreateUniqueID() + + return &PosthogClient{ + client: client, + activated: activated, + uniqueID: uniqueID, + isFirstRun: isFirstRun, + version: version, + } +} + +// Done flushes pending events and closes the client. +func (p *PosthogClient) Done() { + if p.client != nil { + _ = p.client.Close() + } +} + +func (p *PosthogClient) baseProperties() posthog.Properties { + return posthog.NewProperties(). + Set("version", p.version). + Set("os", runtime.GOOS). + Set("arch", runtime.GOARCH) +} + +// CaptureInstall tracks first-time installs. +func (p *PosthogClient) CaptureInstall() { + if !p.activated || !p.isFirstRun { + return + } + props := p.baseProperties().Set("event_type", "install") + _ = p.client.Enqueue(posthog.Capture{ + DistinctId: p.uniqueID, + Event: "mb_cli_installed", + Properties: props, + }) +} + +// CaptureCommandRun tracks command usage for DAU calculation. +func (p *PosthogClient) CaptureCommandRun(command string) { + if !p.activated { + return + } + props := p.baseProperties().Set("command", command) + _ = p.client.Enqueue(posthog.Capture{ + DistinctId: p.uniqueID, + Event: "mb_cli_command_run", + Properties: props, + }) +} + +// CaptureUp tracks sandbox creation. +func (p *PosthogClient) CaptureUp(mixtape string, success bool) { + if !p.activated { + return + } + props := p.baseProperties(). + Set("mixtape", mixtape). + Set("success", success) + _ = p.client.Enqueue(posthog.Capture{ + DistinctId: p.uniqueID, + Event: "mb_cli_sandbox_created", + Properties: props, + }) +} + +// CaptureDown tracks sandbox shutdown. +func (p *PosthogClient) CaptureDown(success bool) { + if !p.activated { + return + } + props := p.baseProperties().Set("success", success) + _ = p.client.Enqueue(posthog.Capture{ + DistinctId: p.uniqueID, + Event: "mb_cli_sandbox_stopped", + Properties: props, + }) +} + +// CaptureSSH tracks SSH connections. +func (p *PosthogClient) CaptureSSH() { + if !p.activated { + return + } + _ = p.client.Enqueue(posthog.Capture{ + DistinctId: p.uniqueID, + Event: "mb_cli_ssh_connected", + Properties: p.baseProperties(), + }) +} + +// CapturePull tracks mixtape pulls. +func (p *PosthogClient) CapturePull(mixtape string, success bool) { + if !p.activated { + return + } + props := p.baseProperties(). + Set("mixtape", mixtape). + Set("success", success) + _ = p.client.Enqueue(posthog.Capture{ + DistinctId: p.uniqueID, + Event: "mb_cli_mixtape_pulled", + Properties: props, + }) +} + +// CaptureError tracks errors anonymously. +func (p *PosthogClient) CaptureError(command string, errType string) { + if !p.activated { + return + } + props := p.baseProperties(). + Set("command", command). + Set("error_type", errType) + _ = p.client.Enqueue(posthog.Capture{ + DistinctId: p.uniqueID, + Event: "mb_cli_error", + Properties: props, + }) +} diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go new file mode 100644 index 0000000..0d0abdf --- /dev/null +++ b/pkg/telemetry/telemetry.go @@ -0,0 +1,64 @@ +// Package telemetry provides anonymous usage tracking for the mb CLI. +// Telemetry is opt-out via --disable-telemetry or MB_DISABLE_TELEMETRY=1. +package telemetry + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" +) + +var telemetryFilePath = filepath.Join(os.Getenv("HOME"), ".mb", "telemetry.json") + +type userTelemetryConfig struct { + ID string `json:"id"` + FirstRunDate string `json:"first_run_date,omitempty"` +} + +// getOrCreateUniqueID reads or creates the user's anonymous unique ID. +// Returns the ID, whether this is the first run, and any error. +func getOrCreateUniqueID() (string, bool, error) { + if _, err := os.Stat(telemetryFilePath); os.IsNotExist(err) { + return createTelemetryUUID() + } + + data, err := os.ReadFile(telemetryFilePath) + if err != nil { + return createTelemetryUUID() + } + + var teleData userTelemetryConfig + if err := json.Unmarshal(data, &teleData); err != nil || teleData.ID == "" { + return createTelemetryUUID() + } + + return teleData.ID, false, nil +} + +func createTelemetryUUID() (string, bool, error) { + newUUID := uuid.New().String() + + teleData := userTelemetryConfig{ + ID: newUUID, + FirstRunDate: time.Now().UTC().Format(time.RFC3339), + } + + data, err := json.Marshal(teleData) + if err != nil { + return "", true, fmt.Errorf("creating telemetry data: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(telemetryFilePath), 0755); err != nil { + return "", true, fmt.Errorf("creating telemetry directory: %w", err) + } + + if err := os.WriteFile(telemetryFilePath, data, 0600); err != nil { + return "", true, fmt.Errorf("writing telemetry file: %w", err) + } + + return newUUID, true, nil +} From 5a17d77f06fc37b98446e2560875df5ca3c8f440 Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Sun, 8 Mar 2026 06:31:24 -0700 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=85=20test:=20Add=20telemetry=20test?= =?UTF-8?q?=20suite,=20CI=20detection,=20and=20nil-safe=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 11 Ginkgo tests covering UUID persistence, CI detection, context round-trip, nil receiver safety, and invalid JSON recovery - Add IsCI() to auto-disable telemetry in CI environments - Make all capture methods nil-receiver safe, removing nil guards at call sites - Add WithContext/FromContext helpers matching tapes PR pattern --- cmd/down/down.go | 4 +- cmd/pull/pull.go | 4 +- cmd/ssh/ssh.go | 4 +- cmd/up/up.go | 8 +- main.go | 9 +- pkg/telemetry/export_test.go | 14 +++ pkg/telemetry/posthog.go | 32 +++--- pkg/telemetry/telemetry.go | 40 ++++++-- pkg/telemetry/telemetry_suite_test.go | 13 +++ pkg/telemetry/telemetry_test.go | 136 ++++++++++++++++++++++++++ 10 files changed, 221 insertions(+), 43 deletions(-) create mode 100644 pkg/telemetry/export_test.go create mode 100644 pkg/telemetry/telemetry_suite_test.go create mode 100644 pkg/telemetry/telemetry_test.go diff --git a/cmd/down/down.go b/cmd/down/down.go index e9ab935..bee11ea 100644 --- a/cmd/down/down.go +++ b/cmd/down/down.go @@ -62,9 +62,7 @@ func runDown(baseDir, name string, force bool, telem *telemetry.PosthogClient) e return err }) - if telem != nil { - telem.CaptureDown(err == nil) - } + telem.CaptureDown(err == nil) return err } diff --git a/cmd/pull/pull.go b/cmd/pull/pull.go index e099641..b7b6cfd 100644 --- a/cmd/pull/pull.go +++ b/cmd/pull/pull.go @@ -53,9 +53,7 @@ func runPull(baseDir, rawRef string, telem *telemetry.PosthogClient) error { return mixtapes.Pull(baseDir, rawRef) }) - if telem != nil { - telem.CapturePull(rawRef, err == nil) - } + telem.CapturePull(rawRef, err == nil) return err } diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 46036fb..79bdc1a 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -75,9 +75,7 @@ func runSSH(baseDir, name, user string, verbose bool, telem *telemetry.PosthogCl ui.Info("Connecting to %s@127.0.0.1:%d", user, sb.SSHPort) } - if telem != nil { - telem.CaptureSSH() - } + telem.CaptureSSH() return ssh.ExecSSH(user, "127.0.0.1", sb.SSHPort, sb.SSHKeyPath) } diff --git a/cmd/up/up.go b/cmd/up/up.go index 1580060..31d79d0 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -80,15 +80,11 @@ func runUp(baseDir, cfgPath string, telem *telemetry.PosthogClient) error { resp, stepErr = c.Up("", cfgPath) return stepErr }); err != nil { - if telem != nil { - telem.CaptureUp("", false) - } + telem.CaptureUp("", false) return err } - if telem != nil { - telem.CaptureUp("", true) - } + telem.CaptureUp("", true) if len(resp.Sandboxes) > 0 { sb := resp.Sandboxes[0] diff --git a/main.go b/main.go index 9c39bfe..be95fc6 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "context" "os" "github.com/spf13/cobra" @@ -43,7 +42,7 @@ func NewMbCmd() *cobra.Command { SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { disableTelem, _ := cmd.Flags().GetBool("disable-telemetry") - if os.Getenv("MB_DISABLE_TELEMETRY") == "1" { + if os.Getenv("MB_DISABLE_TELEMETRY") == "1" || telemetry.IsCI() { disableTelem = true } @@ -51,14 +50,12 @@ func NewMbCmd() *cobra.Command { telem.CaptureInstall() telem.CaptureCommandRun(cmd.Name()) - cmd.SetContext(context.WithValue(cmd.Context(), telemetry.Key, telem)) + cmd.SetContext(telemetry.WithContext(cmd.Context(), telem)) return mbconfig.Init(cmd) }, PersistentPostRunE: func(cmd *cobra.Command, _ []string) error { - if telem := telemetry.FromContext(cmd.Context()); telem != nil { - telem.Done() - } + telemetry.FromContext(cmd.Context()).Done() return nil }, } diff --git a/pkg/telemetry/export_test.go b/pkg/telemetry/export_test.go new file mode 100644 index 0000000..e81812f --- /dev/null +++ b/pkg/telemetry/export_test.go @@ -0,0 +1,14 @@ +package telemetry + +// SetTelemetryFilePath overrides the telemetry state file path for testing. +// It returns the previous path so callers can restore it. +func SetTelemetryFilePath(path string) string { + prev := telemetryFilePath + telemetryFilePath = path + return prev +} + +// GetOrCreateUniqueID is an exported alias for testing. +func GetOrCreateUniqueID() (string, bool, error) { + return getOrCreateUniqueID() +} diff --git a/pkg/telemetry/posthog.go b/pkg/telemetry/posthog.go index 5ab74e5..b512577 100644 --- a/pkg/telemetry/posthog.go +++ b/pkg/telemetry/posthog.go @@ -16,18 +16,19 @@ var ( // contextKey is an unexported type for context keys in this package. type contextKey struct{} -// Key is the context key used to store and retrieve the PosthogClient. -var Key = contextKey{} +// WithContext returns a copy of ctx with the telemetry client attached. +func WithContext(ctx context.Context, c *PosthogClient) context.Context { + return context.WithValue(ctx, contextKey{}, c) +} // FromContext retrieves the PosthogClient from a context. Returns nil if absent. func FromContext(ctx context.Context) *PosthogClient { - if t, ok := ctx.Value(Key).(*PosthogClient); ok { - return t - } - return nil + c, _ := ctx.Value(contextKey{}).(*PosthogClient) + return c } // PosthogClient wraps the PostHog SDK for anonymous CLI telemetry. +// All capture methods are nil-safe: calling them on a nil *PosthogClient is a no-op. type PosthogClient struct { client posthog.Client activated bool @@ -63,9 +64,10 @@ func NewPosthogClient(activated bool, version string) *PosthogClient { // Done flushes pending events and closes the client. func (p *PosthogClient) Done() { - if p.client != nil { - _ = p.client.Close() + if p == nil || p.client == nil { + return } + _ = p.client.Close() } func (p *PosthogClient) baseProperties() posthog.Properties { @@ -77,7 +79,7 @@ func (p *PosthogClient) baseProperties() posthog.Properties { // CaptureInstall tracks first-time installs. func (p *PosthogClient) CaptureInstall() { - if !p.activated || !p.isFirstRun { + if p == nil || !p.activated || !p.isFirstRun { return } props := p.baseProperties().Set("event_type", "install") @@ -90,7 +92,7 @@ func (p *PosthogClient) CaptureInstall() { // CaptureCommandRun tracks command usage for DAU calculation. func (p *PosthogClient) CaptureCommandRun(command string) { - if !p.activated { + if p == nil || !p.activated { return } props := p.baseProperties().Set("command", command) @@ -103,7 +105,7 @@ func (p *PosthogClient) CaptureCommandRun(command string) { // CaptureUp tracks sandbox creation. func (p *PosthogClient) CaptureUp(mixtape string, success bool) { - if !p.activated { + if p == nil || !p.activated { return } props := p.baseProperties(). @@ -118,7 +120,7 @@ func (p *PosthogClient) CaptureUp(mixtape string, success bool) { // CaptureDown tracks sandbox shutdown. func (p *PosthogClient) CaptureDown(success bool) { - if !p.activated { + if p == nil || !p.activated { return } props := p.baseProperties().Set("success", success) @@ -131,7 +133,7 @@ func (p *PosthogClient) CaptureDown(success bool) { // CaptureSSH tracks SSH connections. func (p *PosthogClient) CaptureSSH() { - if !p.activated { + if p == nil || !p.activated { return } _ = p.client.Enqueue(posthog.Capture{ @@ -143,7 +145,7 @@ func (p *PosthogClient) CaptureSSH() { // CapturePull tracks mixtape pulls. func (p *PosthogClient) CapturePull(mixtape string, success bool) { - if !p.activated { + if p == nil || !p.activated { return } props := p.baseProperties(). @@ -158,7 +160,7 @@ func (p *PosthogClient) CapturePull(mixtape string, success bool) { // CaptureError tracks errors anonymously. func (p *PosthogClient) CaptureError(command string, errType string) { - if !p.activated { + if p == nil || !p.activated { return } props := p.baseProperties(). diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 0d0abdf..d85ae9f 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -1,5 +1,6 @@ // Package telemetry provides anonymous usage tracking for the mb CLI. -// Telemetry is opt-out via --disable-telemetry or MB_DISABLE_TELEMETRY=1. +// Telemetry is opt-out via --disable-telemetry, MB_DISABLE_TELEMETRY=1, +// or automatic CI environment detection. package telemetry import ( @@ -12,9 +13,12 @@ import ( "github.com/google/uuid" ) +// telemetryFilePath is the path to the persistent telemetry state file. +// It is a var so tests can override it. var telemetryFilePath = filepath.Join(os.Getenv("HOME"), ".mb", "telemetry.json") -type userTelemetryConfig struct { +// State is the persistent telemetry state stored in ~/.mb/telemetry.json. +type State struct { ID string `json:"id"` FirstRunDate string `json:"first_run_date,omitempty"` } @@ -31,23 +35,23 @@ func getOrCreateUniqueID() (string, bool, error) { return createTelemetryUUID() } - var teleData userTelemetryConfig - if err := json.Unmarshal(data, &teleData); err != nil || teleData.ID == "" { + var state State + if err := json.Unmarshal(data, &state); err != nil || state.ID == "" { return createTelemetryUUID() } - return teleData.ID, false, nil + return state.ID, false, nil } func createTelemetryUUID() (string, bool, error) { newUUID := uuid.New().String() - teleData := userTelemetryConfig{ + state := State{ ID: newUUID, FirstRunDate: time.Now().UTC().Format(time.RFC3339), } - data, err := json.Marshal(teleData) + data, err := json.Marshal(state) if err != nil { return "", true, fmt.Errorf("creating telemetry data: %w", err) } @@ -62,3 +66,25 @@ func createTelemetryUUID() (string, bool, error) { return newUUID, true, nil } + +// ciEnvVars is the list of environment variables used to detect CI environments. +var ciEnvVars = []string{ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "TRAVIS", + "JENKINS_URL", + "BUILDKITE", + "CODEBUILD_BUILD_ID", +} + +// IsCI returns true if the process appears to be running in a CI environment. +func IsCI() bool { + for _, env := range ciEnvVars { + if os.Getenv(env) != "" { + return true + } + } + return false +} diff --git a/pkg/telemetry/telemetry_suite_test.go b/pkg/telemetry/telemetry_suite_test.go new file mode 100644 index 0000000..04c897a --- /dev/null +++ b/pkg/telemetry/telemetry_suite_test.go @@ -0,0 +1,13 @@ +package telemetry_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTelemetry(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Telemetry Suite") +} diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go new file mode 100644 index 0000000..5c25ffe --- /dev/null +++ b/pkg/telemetry/telemetry_test.go @@ -0,0 +1,136 @@ +package telemetry_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/papercomputeco/masterblaster/pkg/telemetry" +) + +var _ = Describe("Telemetry", func() { + Describe("UUID persistence", func() { + var ( + tmpDir string + oldPath string + ) + + BeforeEach(func() { + tmpDir = GinkgoT().TempDir() + statePath := filepath.Join(tmpDir, "telemetry.json") + oldPath = telemetry.SetTelemetryFilePath(statePath) + }) + + AfterEach(func() { + telemetry.SetTelemetryFilePath(oldPath) + }) + + It("creates a new state file on first run", func() { + id, isFirst, err := telemetry.GetOrCreateUniqueID() + Expect(err).NotTo(HaveOccurred()) + Expect(isFirst).To(BeTrue()) + Expect(id).NotTo(BeEmpty()) + }) + + It("reuses existing UUID on subsequent runs", func() { + id1, isFirst1, err := telemetry.GetOrCreateUniqueID() + Expect(err).NotTo(HaveOccurred()) + Expect(isFirst1).To(BeTrue()) + + id2, isFirst2, err := telemetry.GetOrCreateUniqueID() + Expect(err).NotTo(HaveOccurred()) + Expect(isFirst2).To(BeFalse()) + Expect(id2).To(Equal(id1)) + }) + + It("writes the state file with 0600 permissions", func() { + _, _, err := telemetry.GetOrCreateUniqueID() + Expect(err).NotTo(HaveOccurred()) + + info, err := os.Stat(filepath.Join(tmpDir, "telemetry.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + }) + + It("stores valid JSON with expected fields", func() { + _, _, err := telemetry.GetOrCreateUniqueID() + Expect(err).NotTo(HaveOccurred()) + + data, err := os.ReadFile(filepath.Join(tmpDir, "telemetry.json")) + Expect(err).NotTo(HaveOccurred()) + + var state telemetry.State + Expect(json.Unmarshal(data, &state)).To(Succeed()) + Expect(state.ID).NotTo(BeEmpty()) + Expect(state.FirstRunDate).NotTo(BeEmpty()) + }) + + It("regenerates UUID when state file contains invalid JSON", func() { + statePath := filepath.Join(tmpDir, "telemetry.json") + Expect(os.WriteFile(statePath, []byte("not json"), 0600)).To(Succeed()) + + id, isFirst, err := telemetry.GetOrCreateUniqueID() + Expect(err).NotTo(HaveOccurred()) + Expect(isFirst).To(BeTrue()) + Expect(id).NotTo(BeEmpty()) + }) + }) + + Describe("IsCI", func() { + It("returns true when CI is set", func() { + GinkgoT().Setenv("CI", "true") + Expect(telemetry.IsCI()).To(BeTrue()) + }) + + It("returns true when GITHUB_ACTIONS is set", func() { + GinkgoT().Setenv("GITHUB_ACTIONS", "true") + Expect(telemetry.IsCI()).To(BeTrue()) + }) + + It("returns false when no CI env vars are set", func() { + for _, env := range []string{ + "CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", + "TRAVIS", "JENKINS_URL", "BUILDKITE", "CODEBUILD_BUILD_ID", + } { + GinkgoT().Setenv(env, "") + } + Expect(telemetry.IsCI()).To(BeFalse()) + }) + }) + + Describe("Context", func() { + It("round-trips a client through context", func() { + ctx := context.Background() + Expect(telemetry.FromContext(ctx)).To(BeNil()) + + ctx = telemetry.WithContext(ctx, nil) + Expect(telemetry.FromContext(ctx)).To(BeNil()) + }) + }) + + Describe("PosthogClient nil safety", func() { + It("does not panic when calling capture methods on nil client", func() { + var client *telemetry.PosthogClient + Expect(func() { + client.CaptureInstall() + client.CaptureCommandRun("test") + client.CaptureUp("mixtape", true) + client.CaptureDown(true) + client.CaptureSSH() + client.CapturePull("mixtape", true) + client.CaptureError("test", "runtime") + }).NotTo(Panic()) + }) + + It("does not panic when calling Done on nil client", func() { + var client *telemetry.PosthogClient + Expect(func() { + client.Done() + }).NotTo(Panic()) + }) + }) +}) From 99fa8881e38e65038d632307077451baceea4545 Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Sun, 8 Mar 2026 06:38:40 -0700 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Address=20code=20revi?= =?UTF-8?q?ew=20findings=20for=20telemetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flush telemetry before ExecSSH (process replace skips PostRunE) - Track actual subcommand via cmd.CommandPath() instead of root cmd.Name() - Return nil when telemetry disabled (skip PostHog connection and UUID creation) - Use os.UserHomeDir() at call time instead of os.Getenv("HOME") at init - Pass actual mixtape name from sandbox response in CaptureUp - Drop MB_DISABLE_TELEMETRY env var for tapes consistency (use flag + CI detection) --- cmd/down/down.go | 1 + cmd/pull/pull.go | 1 + cmd/ssh/ssh.go | 4 +++ cmd/up/up.go | 7 +++++- main.go | 3 +-- pkg/telemetry/export_test.go | 4 +-- pkg/telemetry/posthog.go | 28 +++++++++++---------- pkg/telemetry/telemetry.go | 47 ++++++++++++++++++++++++++---------- 8 files changed, 64 insertions(+), 31 deletions(-) diff --git a/cmd/down/down.go b/cmd/down/down.go index bee11ea..4279c4a 100644 --- a/cmd/down/down.go +++ b/cmd/down/down.go @@ -42,6 +42,7 @@ func NewDownCmd(configDirFn func() string) *cobra.Command { name = args[0] } telem := telemetry.FromContext(cmd.Context()) + telem.CaptureCommandRun(cmd.CommandPath()) return runDown(configDirFn(), name, force, telem) }, } diff --git a/cmd/pull/pull.go b/cmd/pull/pull.go index b7b6cfd..1d5f822 100644 --- a/cmd/pull/pull.go +++ b/cmd/pull/pull.go @@ -43,6 +43,7 @@ func NewPullCmd(configDirFn func() string) *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { telem := telemetry.FromContext(cmd.Context()) + telem.CaptureCommandRun(cmd.CommandPath()) return runPull(configDirFn(), args[0], telem) }, } diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 79bdc1a..cc83626 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -42,6 +42,7 @@ func NewSSHCmd(configDirFn func() string, verboseFn func() bool) *cobra.Command name = args[0] } telem := telemetry.FromContext(cmd.Context()) + telem.CaptureCommandRun(cmd.CommandPath()) return runSSH(configDirFn(), name, user, verboseFn(), telem) }, } @@ -76,6 +77,9 @@ func runSSH(baseDir, name, user string, verbose bool, telem *telemetry.PosthogCl } telem.CaptureSSH() + // ExecSSH replaces the process via syscall.Exec, so PersistentPostRunE + // never runs. Flush telemetry now to ensure events reach PostHog. + telem.Done() return ssh.ExecSSH(user, "127.0.0.1", sb.SSHPort, sb.SSHKeyPath) } diff --git a/cmd/up/up.go b/cmd/up/up.go index 31d79d0..309f09f 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -40,6 +40,7 @@ func NewUpCmd(configDirFn func() string) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { telem := telemetry.FromContext(cmd.Context()) + telem.CaptureCommandRun(cmd.CommandPath()) return runUp(configDirFn(), cfgPath, telem) }, } @@ -84,7 +85,11 @@ func runUp(baseDir, cfgPath string, telem *telemetry.PosthogClient) error { return err } - telem.CaptureUp("", true) + mixtapeName := "" + if len(resp.Sandboxes) > 0 { + mixtapeName = resp.Sandboxes[0].Mixtape + } + telem.CaptureUp(mixtapeName, true) if len(resp.Sandboxes) > 0 { sb := resp.Sandboxes[0] diff --git a/main.go b/main.go index be95fc6..ef1fc78 100644 --- a/main.go +++ b/main.go @@ -42,13 +42,12 @@ func NewMbCmd() *cobra.Command { SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { disableTelem, _ := cmd.Flags().GetBool("disable-telemetry") - if os.Getenv("MB_DISABLE_TELEMETRY") == "1" || telemetry.IsCI() { + if telemetry.IsCI() { disableTelem = true } telem := telemetry.NewPosthogClient(!disableTelem, utils.Version) telem.CaptureInstall() - telem.CaptureCommandRun(cmd.Name()) cmd.SetContext(telemetry.WithContext(cmd.Context(), telem)) diff --git a/pkg/telemetry/export_test.go b/pkg/telemetry/export_test.go index e81812f..2ffaf13 100644 --- a/pkg/telemetry/export_test.go +++ b/pkg/telemetry/export_test.go @@ -3,8 +3,8 @@ package telemetry // SetTelemetryFilePath overrides the telemetry state file path for testing. // It returns the previous path so callers can restore it. func SetTelemetryFilePath(path string) string { - prev := telemetryFilePath - telemetryFilePath = path + prev := telemetryFilePathOverride + telemetryFilePathOverride = path return prev } diff --git a/pkg/telemetry/posthog.go b/pkg/telemetry/posthog.go index b512577..3d1d681 100644 --- a/pkg/telemetry/posthog.go +++ b/pkg/telemetry/posthog.go @@ -31,15 +31,19 @@ func FromContext(ctx context.Context) *PosthogClient { // All capture methods are nil-safe: calling them on a nil *PosthogClient is a no-op. type PosthogClient struct { client posthog.Client - activated bool uniqueID string isFirstRun bool version string } // NewPosthogClient creates a new telemetry client. -// If activated is false, all capture methods are no-ops. +// Returns nil when activated is false, skipping the PostHog connection and +// UUID file creation entirely. Nil-safe methods make this transparent to callers. func NewPosthogClient(activated bool, version string) *PosthogClient { + if !activated { + return nil + } + client, err := posthog.NewWithConfig( writeOnlyPublicPosthogKey, posthog.Config{ @@ -47,15 +51,13 @@ func NewPosthogClient(activated bool, version string) *PosthogClient { }, ) if err != nil { - // Config is static; this should never happen. - return &PosthogClient{activated: false} + return nil } uniqueID, isFirstRun, _ := getOrCreateUniqueID() return &PosthogClient{ client: client, - activated: activated, uniqueID: uniqueID, isFirstRun: isFirstRun, version: version, @@ -64,7 +66,7 @@ func NewPosthogClient(activated bool, version string) *PosthogClient { // Done flushes pending events and closes the client. func (p *PosthogClient) Done() { - if p == nil || p.client == nil { + if p == nil { return } _ = p.client.Close() @@ -79,7 +81,7 @@ func (p *PosthogClient) baseProperties() posthog.Properties { // CaptureInstall tracks first-time installs. func (p *PosthogClient) CaptureInstall() { - if p == nil || !p.activated || !p.isFirstRun { + if p == nil || !p.isFirstRun { return } props := p.baseProperties().Set("event_type", "install") @@ -92,7 +94,7 @@ func (p *PosthogClient) CaptureInstall() { // CaptureCommandRun tracks command usage for DAU calculation. func (p *PosthogClient) CaptureCommandRun(command string) { - if p == nil || !p.activated { + if p == nil { return } props := p.baseProperties().Set("command", command) @@ -105,7 +107,7 @@ func (p *PosthogClient) CaptureCommandRun(command string) { // CaptureUp tracks sandbox creation. func (p *PosthogClient) CaptureUp(mixtape string, success bool) { - if p == nil || !p.activated { + if p == nil { return } props := p.baseProperties(). @@ -120,7 +122,7 @@ func (p *PosthogClient) CaptureUp(mixtape string, success bool) { // CaptureDown tracks sandbox shutdown. func (p *PosthogClient) CaptureDown(success bool) { - if p == nil || !p.activated { + if p == nil { return } props := p.baseProperties().Set("success", success) @@ -133,7 +135,7 @@ func (p *PosthogClient) CaptureDown(success bool) { // CaptureSSH tracks SSH connections. func (p *PosthogClient) CaptureSSH() { - if p == nil || !p.activated { + if p == nil { return } _ = p.client.Enqueue(posthog.Capture{ @@ -145,7 +147,7 @@ func (p *PosthogClient) CaptureSSH() { // CapturePull tracks mixtape pulls. func (p *PosthogClient) CapturePull(mixtape string, success bool) { - if p == nil || !p.activated { + if p == nil { return } props := p.baseProperties(). @@ -160,7 +162,7 @@ func (p *PosthogClient) CapturePull(mixtape string, success bool) { // CaptureError tracks errors anonymously. func (p *PosthogClient) CaptureError(command string, errType string) { - if p == nil || !p.activated { + if p == nil { return } props := p.baseProperties(). diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index d85ae9f..f42a0b9 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -1,6 +1,6 @@ // Package telemetry provides anonymous usage tracking for the mb CLI. -// Telemetry is opt-out via --disable-telemetry, MB_DISABLE_TELEMETRY=1, -// or automatic CI environment detection. +// Telemetry is opt-out via --disable-telemetry flag, config, or automatic +// CI environment detection. package telemetry import ( @@ -13,9 +13,28 @@ import ( "github.com/google/uuid" ) -// telemetryFilePath is the path to the persistent telemetry state file. -// It is a var so tests can override it. -var telemetryFilePath = filepath.Join(os.Getenv("HOME"), ".mb", "telemetry.json") +// telemetryFileName is the state file name within ~/.mb/. +const telemetryFileName = "telemetry.json" + +// telemetryDir returns the directory for the telemetry state file. +// Resolved at call time so $HOME changes and os.UserHomeDir work correctly. +func telemetryDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), ".mb") + } + return filepath.Join(home, ".mb") +} + +// telemetryFilePathOverride allows tests to override the state file path. +var telemetryFilePathOverride string + +func resolvedTelemetryFilePath() string { + if telemetryFilePathOverride != "" { + return telemetryFilePathOverride + } + return filepath.Join(telemetryDir(), telemetryFileName) +} // State is the persistent telemetry state stored in ~/.mb/telemetry.json. type State struct { @@ -26,24 +45,26 @@ type State struct { // getOrCreateUniqueID reads or creates the user's anonymous unique ID. // Returns the ID, whether this is the first run, and any error. func getOrCreateUniqueID() (string, bool, error) { - if _, err := os.Stat(telemetryFilePath); os.IsNotExist(err) { - return createTelemetryUUID() + fp := resolvedTelemetryFilePath() + + if _, err := os.Stat(fp); os.IsNotExist(err) { + return createTelemetryUUID(fp) } - data, err := os.ReadFile(telemetryFilePath) + data, err := os.ReadFile(fp) if err != nil { - return createTelemetryUUID() + return createTelemetryUUID(fp) } var state State if err := json.Unmarshal(data, &state); err != nil || state.ID == "" { - return createTelemetryUUID() + return createTelemetryUUID(fp) } return state.ID, false, nil } -func createTelemetryUUID() (string, bool, error) { +func createTelemetryUUID(fp string) (string, bool, error) { newUUID := uuid.New().String() state := State{ @@ -56,11 +77,11 @@ func createTelemetryUUID() (string, bool, error) { return "", true, fmt.Errorf("creating telemetry data: %w", err) } - if err := os.MkdirAll(filepath.Dir(telemetryFilePath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil { return "", true, fmt.Errorf("creating telemetry directory: %w", err) } - if err := os.WriteFile(telemetryFilePath, data, 0600); err != nil { + if err := os.WriteFile(fp, data, 0600); err != nil { return "", true, fmt.Errorf("writing telemetry file: %w", err) } From a40ea32c5820631eadd9d1d7d95d79f609d32788 Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Sun, 8 Mar 2026 06:51:29 -0700 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Align=20telemetry?= =?UTF-8?q?=20with=20tapes=20PR=20conventions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add $lib: "mb-cli" property for PostHog source attribution - Extract initTelemetry/closeTelemetry into named functions matching tapes pattern --- main.go | 45 +++++++++++++++++++++++++--------------- pkg/telemetry/posthog.go | 3 ++- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/main.go b/main.go index ef1fc78..5a706e3 100644 --- a/main.go +++ b/main.go @@ -40,23 +40,8 @@ func NewMbCmd() *cobra.Command { Long: rootLongDesc, SilenceUsage: true, SilenceErrors: true, - PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - disableTelem, _ := cmd.Flags().GetBool("disable-telemetry") - if telemetry.IsCI() { - disableTelem = true - } - - telem := telemetry.NewPosthogClient(!disableTelem, utils.Version) - telem.CaptureInstall() - - cmd.SetContext(telemetry.WithContext(cmd.Context(), telem)) - - return mbconfig.Init(cmd) - }, - PersistentPostRunE: func(cmd *cobra.Command, _ []string) error { - telemetry.FromContext(cmd.Context()).Done() - return nil - }, + PersistentPreRunE: initTelemetry, + PersistentPostRunE: closeTelemetry, } cmd.PersistentFlags().String("config-dir", "", "Config directory (default: $XDG_CONFIG_HOME/mb)") @@ -79,6 +64,32 @@ func NewMbCmd() *cobra.Command { return cmd } +// initTelemetry initializes anonymous telemetry and stores the client in the +// command context. Telemetry is silently skipped when disabled via flag or CI +// detection -- errors during init never block command execution. +func initTelemetry(cmd *cobra.Command, _ []string) error { + if disabled, _ := cmd.Flags().GetBool("disable-telemetry"); disabled { + return mbconfig.Init(cmd) + } + + if telemetry.IsCI() { + return mbconfig.Init(cmd) + } + + telem := telemetry.NewPosthogClient(true, utils.Version) + telem.CaptureInstall() + + cmd.SetContext(telemetry.WithContext(cmd.Context(), telem)) + + return mbconfig.Init(cmd) +} + +// closeTelemetry flushes pending events and shuts down the PostHog client. +func closeTelemetry(cmd *cobra.Command, _ []string) error { + telemetry.FromContext(cmd.Context()).Done() + return nil +} + func main() { cmd := NewMbCmd() diff --git a/pkg/telemetry/posthog.go b/pkg/telemetry/posthog.go index 3d1d681..fee7925 100644 --- a/pkg/telemetry/posthog.go +++ b/pkg/telemetry/posthog.go @@ -76,7 +76,8 @@ func (p *PosthogClient) baseProperties() posthog.Properties { return posthog.NewProperties(). Set("version", p.version). Set("os", runtime.GOOS). - Set("arch", runtime.GOARCH) + Set("arch", runtime.GOARCH). + Set("$lib", "mb-cli") } // CaptureInstall tracks first-time installs. From 529bad3d11a0ab4e7216b14afe92ac29da8d364a Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Sun, 8 Mar 2026 20:15:16 -0700 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Apply=20tapes=20PR=20?= =?UTF-8?q?#149=20review=20feedback=20to=20telemetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move PostHog API key from hardcoded source to build-time ldflag injection, bind disable-telemetry flag through Viper for proper config/flag/env precedence, and thread the key through Dagger build and release pipelines. --- .dagger/main.go | 8 ++++++++ .dagger/release.go | 12 ++++++++++-- main.go | 19 +++++++++++++------ makefile | 8 ++++++++ pkg/mbconfig/mbconfig.go | 6 ++++++ pkg/telemetry/posthog.go | 21 +++++++++++++-------- 6 files changed, 58 insertions(+), 16 deletions(-) diff --git a/.dagger/main.go b/.dagger/main.go index e814e35..411c3c5 100644 --- a/.dagger/main.go +++ b/.dagger/main.go @@ -92,6 +92,10 @@ func (m *Masterblaster) BuildRelease( // Git commit SHA of build commit string, + + // PostHog telemetry public key + // +optional + postHogPublicKey string, ) *dagger.Directory { buildtime := time.Now() @@ -103,6 +107,10 @@ func (m *Masterblaster) BuildRelease( fmt.Sprintf("-X 'github.com/papercomputeco/masterblaster/pkg/utils.Buildtime=%s'", buildtime), } + if postHogPublicKey != "" { + ldflags = append(ldflags, fmt.Sprintf("-X 'github.com/papercomputeco/masterblaster/pkg/telemetry.PostHogAPIKey=%s'", postHogPublicKey)) + } + dir := m.Build(ctx, strings.Join(ldflags, " ")) return dag.Checksumer().Checksum(dir) } diff --git a/.dagger/release.go b/.dagger/release.go index 5dd0c34..a66dd04 100644 --- a/.dagger/release.go +++ b/.dagger/release.go @@ -29,8 +29,12 @@ func (m *Masterblaster) ReleaseLatest( // Bucket secret access key secretAccessKey *dagger.Secret, + + // PostHog telemetry public key + // +optional + postHogPublicKey string, ) (*dagger.Directory, error) { - artifacts := m.BuildRelease(ctx, version, commit) + artifacts := m.BuildRelease(ctx, version, commit, postHogPublicKey) uploader := dag.Bucketuploader(endpoint, bucket, accessKeyId, secretAccessKey) if err := uploader.UploadLatest(ctx, artifacts, version); err != nil { @@ -59,8 +63,12 @@ func (m *Masterblaster) ReleaseNightly( // Bucket secret access key secretAccessKey *dagger.Secret, + + // PostHog telemetry public key + // +optional + postHogPublicKey string, ) (*dagger.Directory, error) { - artifacts := m.BuildRelease(ctx, "nightly", commit) + artifacts := m.BuildRelease(ctx, "nightly", commit, postHogPublicKey) uploader := dag.Bucketuploader(endpoint, bucket, accessKeyId, secretAccessKey) if err := uploader.UploadNightly(ctx, artifacts); err != nil { diff --git a/main.go b/main.go index 5a706e3..296d969 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/papercomputeco/masterblaster/pkg/ui" "github.com/papercomputeco/masterblaster/pkg/utils" @@ -65,15 +66,21 @@ func NewMbCmd() *cobra.Command { } // initTelemetry initializes anonymous telemetry and stores the client in the -// command context. Telemetry is silently skipped when disabled via flag or CI -// detection -- errors during init never block command execution. +// command context. Telemetry is silently skipped when disabled via config/flag/env +// or CI detection -- errors during init never block command execution. func initTelemetry(cmd *cobra.Command, _ []string) error { - if disabled, _ := cmd.Flags().GetBool("disable-telemetry"); disabled { - return mbconfig.Init(cmd) + // Init config first so Viper binds the disable-telemetry flag/env/config. + if err := mbconfig.Init(cmd); err != nil { + return err + } + + // Viper handles flag < env < config precedence for disable-telemetry. + if viper.GetBool("disable-telemetry") { + return nil } if telemetry.IsCI() { - return mbconfig.Init(cmd) + return nil } telem := telemetry.NewPosthogClient(true, utils.Version) @@ -81,7 +88,7 @@ func initTelemetry(cmd *cobra.Command, _ []string) error { cmd.SetContext(telemetry.WithContext(cmd.Context(), telem)) - return mbconfig.Init(cmd) + return nil } // closeTelemetry flushes pending events and shuts down the PostHog client. diff --git a/makefile b/makefile index 3339141..a3e29a4 100644 --- a/makefile +++ b/makefile @@ -7,18 +7,24 @@ BIN_NAME := mb VERSION ?= $(shell git describe --tags --always --dirty) COMMIT ?= $(shell git rev-parse HEAD) BUILDTIME ?= $(shell date -u '+%Y-%m-%d %H:%M:%S') +POSTHOG_API_KEY ?= LDFLAGS := -s -w \ -X 'github.com/papercomputeco/masterblaster/pkg/utils.Version=$(VERSION)' \ -X 'github.com/papercomputeco/masterblaster/pkg/utils.Sha=$(COMMIT)' \ -X 'github.com/papercomputeco/masterblaster/pkg/utils.Buildtime=$(BUILDTIME)' +ifneq ($(POSTHOG_API_KEY),) +LDFLAGS += -X 'github.com/papercomputeco/masterblaster/pkg/telemetry.PostHogAPIKey=$(POSTHOG_API_KEY)' +endif + .PHONY: build build: ## Builds all cross-platform release artifacts via Dagger dagger call \ build-release \ --version $(VERSION) \ --commit $(COMMIT) \ + $(if $(POSTHOG_API_KEY),--post-hog-public-key $(POSTHOG_API_KEY)) \ export \ --path ./build @@ -77,6 +83,7 @@ release: ## Builds and releases mb artifacts release-latest \ --version=${VERSION} \ --commit=${COMMIT} \ + $(if $(POSTHOG_API_KEY),--post-hog-public-key $(POSTHOG_API_KEY)) \ --endpoint=env://BUCKET_ENDPOINT \ --bucket=env://BUCKET_NAME \ --access-key-id=env://BUCKET_ACCESS_KEY_ID \ @@ -87,6 +94,7 @@ nightly: ## Builds and releases mb artifacts with the nightly tag dagger call \ release-nightly \ --commit=${COMMIT} \ + $(if $(POSTHOG_API_KEY),--post-hog-public-key $(POSTHOG_API_KEY)) \ --endpoint=env://BUCKET_ENDPOINT \ --bucket=env://BUCKET_NAME \ --access-key-id=env://BUCKET_ACCESS_KEY_ID \ diff --git a/pkg/mbconfig/mbconfig.go b/pkg/mbconfig/mbconfig.go index a7c8496..dcaef2b 100644 --- a/pkg/mbconfig/mbconfig.go +++ b/pkg/mbconfig/mbconfig.go @@ -42,10 +42,16 @@ func Init(cmd *cobra.Command) error { return err } } + if f := cmd.Root().PersistentFlags().Lookup("disable-telemetry"); f != nil { + if err := viper.BindPFlag("disable-telemetry", f); err != nil { + return err + } + } // Set defaults after binding so flags/env take precedence. viper.SetDefault("config-dir", defaultConfigDir()) viper.SetDefault("verbose", false) + viper.SetDefault("disable-telemetry", false) // Attempt to read a config file from the resolved config directory. // This is intentionally best-effort: if the file doesn't exist yet, diff --git a/pkg/telemetry/posthog.go b/pkg/telemetry/posthog.go index fee7925..6866958 100644 --- a/pkg/telemetry/posthog.go +++ b/pkg/telemetry/posthog.go @@ -8,9 +8,13 @@ import ( ) var ( - // Write-only key, safe to embed in source. - writeOnlyPublicPosthogKey = "phc_xCBFT1jetPLJIRGTqJ9Q0YuG5I1jhXtUkxYkNBEAXRY" - posthogEndpoint = "https://us.i.posthog.com" + // PostHogAPIKey is the PostHog write-only project API key. + // Injected at build time via ldflags; defaults to empty (telemetry disabled). + PostHogAPIKey = "" + + // PostHogEndpoint is the PostHog ingestion endpoint. + // Injected at build time via ldflags; defaults to the US region. + PostHogEndpoint = "https://us.i.posthog.com" ) // contextKey is an unexported type for context keys in this package. @@ -37,17 +41,18 @@ type PosthogClient struct { } // NewPosthogClient creates a new telemetry client. -// Returns nil when activated is false, skipping the PostHog connection and -// UUID file creation entirely. Nil-safe methods make this transparent to callers. +// Returns nil when activated is false or PostHogAPIKey is empty, skipping the +// PostHog connection and UUID file creation entirely. Nil-safe methods make +// this transparent to callers. func NewPosthogClient(activated bool, version string) *PosthogClient { - if !activated { + if !activated || PostHogAPIKey == "" { return nil } client, err := posthog.NewWithConfig( - writeOnlyPublicPosthogKey, + PostHogAPIKey, posthog.Config{ - Endpoint: posthogEndpoint, + Endpoint: PostHogEndpoint, }, ) if err != nil { From 2497511e49e2fe275e46a860b2eb96fc073f1278 Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Sun, 8 Mar 2026 20:31:50 -0700 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Inject=20PostHog=20AP?= =?UTF-8?q?I=20key=20secret=20in=20release=20and=20nightly=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass POSTHOG_API_KEY secret to dagger calls and make apple-build in both release and nightly workflows so production builds have telemetry enabled. --- .github/workflows/nightly.yaml | 3 ++- .github/workflows/release.yaml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index ad7c29f..dec89ba 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -79,6 +79,7 @@ jobs: dagger call \ release-nightly \ --commit="${{ github.sha }}" \ + --post-hog-public-key="${{ secrets.POSTHOG_API_KEY }}" \ --endpoint=env://BUCKET_ENDPOINT \ --bucket=env://BUCKET_NAME \ --access-key-id=env://BUCKET_ACCESS_KEY_ID \ @@ -114,7 +115,7 @@ jobs: go-version-file: go.mod - name: Build and codesign darwin/arm64 binary - run: make apple-build VERSION=nightly COMMIT="${{ github.sha }}" + run: make apple-build VERSION=nightly COMMIT="${{ github.sha }}" POSTHOG_API_KEY="${{ secrets.POSTHOG_API_KEY }}" - name: Generate checksum run: shasum -a 256 build/darwin/arm64/mb > build/darwin/arm64/mb.sha256 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ba50d0f..e3c4a27 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -29,6 +29,7 @@ jobs: release-latest \ --version="${{ github.event.release.tag_name }}" \ --commit="${{ github.sha }}" \ + --post-hog-public-key="${{ secrets.POSTHOG_API_KEY }}" \ --endpoint=env://BUCKET_ENDPOINT \ --bucket=env://BUCKET_NAME \ --access-key-id=env://BUCKET_ACCESS_KEY_ID \ @@ -65,7 +66,7 @@ jobs: go-version-file: go.mod - name: Build and codesign darwin/arm64 binary - run: make apple-build + run: make apple-build POSTHOG_API_KEY="${{ secrets.POSTHOG_API_KEY }}" - name: Generate checksum run: shasum -a 256 build/darwin/arm64/mb > build/darwin/arm64/mb.sha256