feat: add PostHog to CLI (#603)

* feat: add PostHog usage analytics to CLI

Add anonymous command-level analytics to browseros-cli using the PostHog
Go SDK. Tracks which commands are executed, their success/failure status,
and duration — no PII or person profiles.

- New analytics package with Init/Track/Close singleton
- Distinct ID resolves from server's browseros_id (server.json), falls
  back to CLI-generated UUID (~/.config/browseros-cli/install_id)
- API key injected at build time via ldflags (dev builds = silent no-op)
- Server now writes browseros_id into server.json for cross-surface
  identity correlation

* fix: address PR review feedback for #603

- Return "unknown" for unrecognized args in commandName to avoid
  sending arbitrary user input to PostHog
- Revert goreleaser to {{ .Env.POSTHOG_API_KEY }} (intentional hard
  fail — release builds must have the key set)
- go mod tidy to fix posthog-go direct/indirect marker
- Add POSTHOG_API_KEY to .env.production.example
This commit is contained in:
Nikhil
2026-03-27 12:05:34 -07:00
committed by GitHub
parent 6c053a5f29
commit 517750e880
11 changed files with 342 additions and 9 deletions

View File

@@ -1,5 +1,8 @@
# Production upload env for CLI installer scripts
# Production build env for CLI
POSTHOG_API_KEY=
# Upload env for CLI installer scripts
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=

View File

@@ -13,7 +13,7 @@ builds:
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Version }}
- -s -w -X main.version={{ .Version }} -X browseros-cli/analytics.posthogAPIKey={{ .Env.POSTHOG_API_KEY }}
targets:
- darwin_amd64
- darwin_arm64

View File

@@ -1,14 +1,16 @@
BINARY := browseros-cli
SOURCES := $(shell find . -name '*.go')
VERSION ?= dev
POSTHOG_API_KEY ?=
LDFLAGS := -X main.version=$(VERSION) -X browseros-cli/analytics.posthogAPIKey=$(POSTHOG_API_KEY)
$(BINARY): $(SOURCES)
go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY) .
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
.PHONY: install clean vet test
install:
go install -ldflags "-X main.version=$(VERSION)" .
go install -ldflags "$(LDFLAGS)" .
clean:
rm -f $(BINARY)

View File

@@ -0,0 +1,129 @@
package analytics
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"browseros-cli/config"
"github.com/posthog/posthog-go"
)
var (
posthogAPIKey string // set via ldflags
posthogHost = "https://us.i.posthog.com"
)
const eventPrefix = "browseros.cli."
var svc *service
type service struct {
client posthog.Client
distinctID string
}
func Init(version string) {
if posthogAPIKey == "" {
return
}
distinctID := resolveDistinctID()
if distinctID == "" {
return
}
client, err := posthog.NewWithConfig(posthogAPIKey, posthog.Config{
Endpoint: posthogHost,
BatchSize: 10,
ShutdownTimeout: 3 * time.Second,
DefaultEventProperties: posthog.NewProperties().
Set("cli_version", version).
Set("os", runtime.GOOS).
Set("arch", runtime.GOARCH),
})
if err != nil {
return
}
svc = &service{client: client, distinctID: distinctID}
}
func Track(command string, success bool, duration time.Duration) {
if svc == nil {
return
}
svc.client.Enqueue(posthog.Capture{
DistinctId: svc.distinctID,
Event: eventPrefix + "command_executed",
Properties: posthog.NewProperties().
Set("command", command).
Set("success", success).
Set("duration_ms", duration.Milliseconds()).
Set("$process_person_profile", false),
})
}
func Close() {
if svc == nil {
return
}
svc.client.Close()
svc = nil
}
func resolveDistinctID() string {
if id := loadBrowserosID(); id != "" {
return id
}
return loadOrCreateInstallID()
}
func loadBrowserosID() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc struct {
BrowserosID string `json:"browseros_id"`
}
if json.Unmarshal(data, &sc) != nil {
return ""
}
return sc.BrowserosID
}
func loadOrCreateInstallID() string {
dir := config.Dir()
idPath := filepath.Join(dir, "install_id")
data, err := os.ReadFile(idPath)
if err == nil {
if id := strings.TrimSpace(string(data)); id != "" {
return id
}
}
id := generateUUID()
os.MkdirAll(dir, 0755)
os.WriteFile(idPath, []byte(id), 0644)
return id
}
func generateUUID() string {
var b [16]byte
rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 2
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

View File

@@ -0,0 +1,132 @@
package analytics
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"testing"
"time"
)
func TestGenerateUUID(t *testing.T) {
id := generateUUID()
uuidRe := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
if !uuidRe.MatchString(id) {
t.Errorf("generateUUID() = %q, does not match UUID v4 pattern", id)
}
id2 := generateUUID()
if id == id2 {
t.Error("generateUUID() returned the same value twice")
}
}
func TestLoadBrowserosID(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
// No server.json → empty
if got := loadBrowserosID(); got != "" {
t.Errorf("loadBrowserosID() = %q, want empty", got)
}
// server.json without browseros_id → empty
dir := filepath.Join(tmp, ".browseros")
os.MkdirAll(dir, 0755)
data, _ := json.Marshal(map[string]any{"server_port": 9100, "url": "http://127.0.0.1:9100"})
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
if got := loadBrowserosID(); got != "" {
t.Errorf("loadBrowserosID() = %q, want empty (no browseros_id field)", got)
}
// server.json with browseros_id → returns it
data, _ = json.Marshal(map[string]any{
"server_port": 9100,
"url": "http://127.0.0.1:9100",
"browseros_id": "test-uuid-1234",
})
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
if got := loadBrowserosID(); got != "test-uuid-1234" {
t.Errorf("loadBrowserosID() = %q, want %q", got, "test-uuid-1234")
}
}
func TestLoadOrCreateInstallID(t *testing.T) {
tmp := t.TempDir()
configDir := filepath.Join(tmp, "browseros-cli")
t.Setenv("XDG_CONFIG_HOME", tmp)
// First call creates the file
id := loadOrCreateInstallID()
if id == "" {
t.Fatal("loadOrCreateInstallID() returned empty string")
}
// File was persisted
data, err := os.ReadFile(filepath.Join(configDir, "install_id"))
if err != nil {
t.Fatalf("install_id file not created: %v", err)
}
if string(data) != id {
t.Errorf("persisted id = %q, want %q", string(data), id)
}
// Second call returns the same ID
id2 := loadOrCreateInstallID()
if id2 != id {
t.Errorf("loadOrCreateInstallID() = %q, want stable %q", id2, id)
}
}
func TestResolveDistinctID_PrefersBrowserosID(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("XDG_CONFIG_HOME", tmp)
// Write server.json with browseros_id
dir := filepath.Join(tmp, ".browseros")
os.MkdirAll(dir, 0755)
data, _ := json.Marshal(map[string]any{"browseros_id": "server-uuid"})
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
got := resolveDistinctID()
if got != "server-uuid" {
t.Errorf("resolveDistinctID() = %q, want %q", got, "server-uuid")
}
}
func TestResolveDistinctID_FallsBackToInstallID(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("XDG_CONFIG_HOME", tmp)
// No server.json → should generate install_id
got := resolveDistinctID()
if got == "" {
t.Error("resolveDistinctID() returned empty string")
}
}
func TestInitNoopsWithoutAPIKey(t *testing.T) {
old := posthogAPIKey
posthogAPIKey = ""
defer func() { posthogAPIKey = old }()
Init("1.0.0")
if svc != nil {
t.Error("Init() created service without API key")
}
}
func TestTrackAndCloseNoopWithoutInit(t *testing.T) {
old := svc
svc = nil
defer func() { svc = old }()
// Should not panic
Track("test", true, time.Second)
Close()
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"browseros-cli/analytics"
"browseros-cli/config"
"browseros-cli/mcp"
"browseros-cli/output"
@@ -113,11 +114,27 @@ var rootCmd = &cobra.Command{
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
analytics.Init(version)
start := time.Now()
err := rootCmd.Execute()
analytics.Track(commandName(os.Args[1:]), err == nil, time.Since(start))
analytics.Close()
if err != nil {
os.Exit(1)
}
}
func commandName(args []string) string {
cmd, _, err := rootCmd.Find(args)
if err != nil || cmd == rootCmd {
return "unknown"
}
return cmd.CommandPath()
}
func init() {
cobra.AddTemplateFunc("helpHeader", helpHeader)
cobra.AddTemplateFunc("helpCmdCol", helpCmdCol)

View File

@@ -0,0 +1,25 @@
package cmd
import "testing"
func TestCommandName(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{"empty args", nil, "unknown"},
{"known command", []string{"health"}, "browseros-cli health"},
{"unknown command", []string{"nonexistent"}, "unknown"},
{"subcommand", []string{"bookmark", "search"}, "browseros-cli bookmark search"},
{"known with extra args", []string{"snap", "--enhanced"}, "browseros-cli snap"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := commandName(tt.args)
if got != tt.want {
t.Errorf("commandName(%v) = %q, want %q", tt.args, got, tt.want)
}
})
}
}

View File

@@ -4,20 +4,24 @@ go 1.25.7
require (
github.com/fatih/color v1.18.0
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/posthog/posthog-go v1.11.2
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modelcontextprotocol/go-sdk v1.4.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,8 +1,20 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
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/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -12,6 +24,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
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.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q=
github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
@@ -21,6 +37,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -28,10 +46,11 @@ golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -116,6 +116,7 @@ export class Application {
server_version: VERSION,
browseros_version: this.config.instanceBrowserosVersion,
chromium_version: this.config.instanceChromiumVersion,
browseros_id: identity.getBrowserOSId(),
})
} catch (error) {
logger.warn('Failed to write server config for auto-discovery', {

View File

@@ -13,4 +13,5 @@ export interface ServerDiscoveryConfig {
server_version: string
browseros_version?: string
chromium_version?: string
browseros_id?: string
}