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/.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 diff --git a/cmd/down/down.go b/cmd/down/down.go index 7f06aeb..4279c4a 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,14 @@ 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()) + telem.CaptureCommandRun(cmd.CommandPath()) + return runDown(configDirFn(), name, force, telem) }, } @@ -49,14 +52,18 @@ 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 }) + + telem.CaptureDown(err == nil) + + return err } diff --git a/cmd/pull/pull.go b/cmd/pull/pull.go index 38ae799..1d5f822 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,20 @@ 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()) + telem.CaptureCommandRun(cmd.CommandPath()) + 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) }) + + telem.CapturePull(rawRef, err == nil) + + return err } diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 3b2ec9b..cc83626 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,14 @@ 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()) + telem.CaptureCommandRun(cmd.CommandPath()) + return runSSH(configDirFn(), name, user, verboseFn(), telem) }, } @@ -49,7 +52,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 +76,10 @@ func runSSH(baseDir, name, user string, verbose bool) error { ui.Info("Connecting to %s@127.0.0.1:%d", user, sb.SSHPort) } + 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 63989d7..309f09f 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,10 @@ 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()) + telem.CaptureCommandRun(cmd.CommandPath()) + return runUp(configDirFn(), cfgPath, telem) }, } @@ -47,7 +50,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 +81,16 @@ func runUp(baseDir, cfgPath string) error { resp, stepErr = c.Up("", cfgPath) return stepErr }); err != nil { + telem.CaptureUp("", false) return err } + mixtapeName := "" + if len(resp.Sandboxes) > 0 { + mixtapeName = resp.Sandboxes[0].Mixtape + } + telem.CaptureUp(mixtapeName, 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..296d969 100644 --- a/main.go +++ b/main.go @@ -4,8 +4,10 @@ import ( "os" "github.com/spf13/cobra" + "github.com/spf13/viper" "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 @@ -38,13 +41,13 @@ func NewMbCmd() *cobra.Command { Long: rootLongDesc, SilenceUsage: true, SilenceErrors: true, - PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - return mbconfig.Init(cmd) - }, + PersistentPreRunE: initTelemetry, + PersistentPostRunE: closeTelemetry, } 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()) @@ -62,6 +65,38 @@ 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 config/flag/env +// or CI detection -- errors during init never block command execution. +func initTelemetry(cmd *cobra.Command, _ []string) error { + // 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 nil + } + + telem := telemetry.NewPosthogClient(true, utils.Version) + telem.CaptureInstall() + + cmd.SetContext(telemetry.WithContext(cmd.Context(), telem)) + + return nil +} + +// 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/makefile b/makefile index 3339141..36167f1 100644 --- a/makefile +++ b/makefile @@ -7,11 +7,13 @@ 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)' + -X 'github.com/papercomputeco/masterblaster/pkg/utils.Buildtime=$(BUILDTIME)' \ + -X 'github.com/papercomputeco/masterblaster/pkg/telemetry.PostHogAPIKey=$(POSTHOG_API_KEY)' .PHONY: build build: ## Builds all cross-platform release artifacts via Dagger @@ -19,6 +21,7 @@ build: ## Builds all cross-platform release artifacts via Dagger build-release \ --version $(VERSION) \ --commit $(COMMIT) \ + --post-hog-public-key="$(POSTHOG_API_KEY)" \ export \ --path ./build @@ -77,6 +80,7 @@ release: ## Builds and releases mb artifacts release-latest \ --version=${VERSION} \ --commit=${COMMIT} \ + --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 +91,7 @@ nightly: ## Builds and releases mb artifacts with the nightly tag dagger call \ release-nightly \ --commit=${COMMIT} \ + --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/export_test.go b/pkg/telemetry/export_test.go new file mode 100644 index 0000000..2ffaf13 --- /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 := telemetryFilePathOverride + telemetryFilePathOverride = 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 new file mode 100644 index 0000000..6866958 --- /dev/null +++ b/pkg/telemetry/posthog.go @@ -0,0 +1,182 @@ +package telemetry + +import ( + "context" + "runtime" + + "github.com/posthog/posthog-go" +) + +var ( + // 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. +type contextKey struct{} + +// 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 { + 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 + uniqueID string + isFirstRun bool + version string +} + +// NewPosthogClient creates a new telemetry client. +// 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 || PostHogAPIKey == "" { + return nil + } + + client, err := posthog.NewWithConfig( + PostHogAPIKey, + posthog.Config{ + Endpoint: PostHogEndpoint, + }, + ) + if err != nil { + return nil + } + + uniqueID, isFirstRun, _ := getOrCreateUniqueID() + + return &PosthogClient{ + client: client, + uniqueID: uniqueID, + isFirstRun: isFirstRun, + version: version, + } +} + +// Done flushes pending events and closes the client. +func (p *PosthogClient) Done() { + if p == nil { + return + } + _ = p.client.Close() +} + +func (p *PosthogClient) baseProperties() posthog.Properties { + return posthog.NewProperties(). + Set("version", p.version). + Set("os", runtime.GOOS). + Set("arch", runtime.GOARCH). + Set("$lib", "mb-cli") +} + +// CaptureInstall tracks first-time installs. +func (p *PosthogClient) CaptureInstall() { + if p == nil || !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 == nil { + 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 == nil { + 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 == nil { + 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 == nil { + 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 == nil { + 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 == nil { + 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..f42a0b9 --- /dev/null +++ b/pkg/telemetry/telemetry.go @@ -0,0 +1,111 @@ +// Package telemetry provides anonymous usage tracking for the mb CLI. +// Telemetry is opt-out via --disable-telemetry flag, config, or automatic +// CI environment detection. +package telemetry + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" +) + +// 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 { + 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) { + fp := resolvedTelemetryFilePath() + + if _, err := os.Stat(fp); os.IsNotExist(err) { + return createTelemetryUUID(fp) + } + + data, err := os.ReadFile(fp) + if err != nil { + return createTelemetryUUID(fp) + } + + var state State + if err := json.Unmarshal(data, &state); err != nil || state.ID == "" { + return createTelemetryUUID(fp) + } + + return state.ID, false, nil +} + +func createTelemetryUUID(fp string) (string, bool, error) { + newUUID := uuid.New().String() + + state := State{ + ID: newUUID, + FirstRunDate: time.Now().UTC().Format(time.RFC3339), + } + + data, err := json.Marshal(state) + if err != nil { + return "", true, fmt.Errorf("creating telemetry data: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil { + return "", true, fmt.Errorf("creating telemetry directory: %w", err) + } + + if err := os.WriteFile(fp, data, 0600); err != nil { + return "", true, fmt.Errorf("writing telemetry file: %w", err) + } + + 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()) + }) + }) +})