From a28464a8484afdd57f934b23ced57fb821139396 Mon Sep 17 00:00:00 2001 From: Peter Jaap Blaakmeer Date: Fri, 10 Apr 2026 13:36:28 +0200 Subject: [PATCH 1/2] Add opt-in anonymous usage telemetry Adds an opt-in telemetry subsystem that records the canonical command name, exit code, duration, MageBox version and OS/arch on each invocation. No arguments, flag values, paths or project names are collected. Custom project commands are normalized to the literal "run" so user-defined names cannot leak. A first-run consent prompt asks the user to enable or decline; the answer is persisted in the global config and never re-prompted. The telemetry subcommand (enable/disable/status/show/reset-id/purge) lets the user inspect and manage state, and "telemetry show" displays the exact payloads that have been or would be sent. The HTTP send is handed off to a fully detached child process (Setsid, stdio routed to /dev/null, Start without Wait) so the parent CLI exit path never blocks on the network. A hung ingestion server cannot delay the user at all; failed sends are silent and leave the event on a local disk spool for the next invocation to retry. --- cmd/magebox/root.go | 59 ++++- cmd/magebox/telemetry.go | 229 +++++++++++++++++ internal/config/global.go | 23 ++ internal/telemetry/consent.go | 101 ++++++++ internal/telemetry/detach.go | 73 ++++++ internal/telemetry/id.go | 57 +++++ internal/telemetry/sender.go | 80 ++++++ internal/telemetry/spool.go | 104 ++++++++ internal/telemetry/telemetry.go | 225 +++++++++++++++++ internal/telemetry/telemetry_test.go | 353 +++++++++++++++++++++++++++ 10 files changed, 1302 insertions(+), 2 deletions(-) create mode 100644 cmd/magebox/telemetry.go create mode 100644 internal/telemetry/consent.go create mode 100644 internal/telemetry/detach.go create mode 100644 internal/telemetry/id.go create mode 100644 internal/telemetry/sender.go create mode 100644 internal/telemetry/spool.go create mode 100644 internal/telemetry/telemetry.go create mode 100644 internal/telemetry/telemetry_test.go diff --git a/cmd/magebox/root.go b/cmd/magebox/root.go index 02689b2..ae4b090 100644 --- a/cmd/magebox/root.go +++ b/cmd/magebox/root.go @@ -3,11 +3,13 @@ package main import ( "fmt" "os" + "time" "github.com/spf13/cobra" "qoliber/magebox/internal/cli" "qoliber/magebox/internal/config" + "qoliber/magebox/internal/telemetry" "qoliber/magebox/internal/updater" "qoliber/magebox/internal/verbose" ) @@ -20,7 +22,24 @@ var verbosity int // versionChecker runs an async update check in the background var versionChecker *updater.VersionChecker +// telemetryStart and telemetryCommand capture the command that's about to run +// so that main() can record an event on exit regardless of whether the +// command succeeded. They are set in PersistentPreRun and read after +// rootCmd.Execute() returns. Zero value means "nothing to record". +var ( + telemetryStart time.Time + telemetryCommand string +) + func main() { + // If this process was spawned by a parent to flush telemetry, run only + // the flush and exit. This short-circuits the rest of main() so the + // child never runs custom command delegation, consent prompts, version + // checks, or any CLI command. Must be the very first thing in main(). + if telemetry.FlushFromEnv() { + return + } + // If the first non-flag argument is not a known command, // check if it's a custom command from .magebox and delegate to "run". if len(os.Args) > 1 { @@ -58,9 +77,30 @@ func main() { } } - if err := rootCmd.Execute(); err != nil { + err := rootCmd.Execute() + exitCode := 0 + if err != nil { + exitCode = 1 + } + + // Record the command event after Execute returns, so failed commands + // are captured too. If PersistentPreRun never fired (unknown command, + // --version, etc.) telemetryCommand is empty and Record is a no-op. + if telemetryCommand != "" && !telemetry.ShouldSkipCommand(telemetryCommand) { + if home, herr := os.UserHomeDir(); herr == nil { + telemetry.Record(telemetry.RecordInput{ + HomeDir: home, + MBVersion: version, + Command: telemetryCommand, + ExitCode: exitCode, + Duration: time.Since(telemetryStart), + }) + } + } + + if err != nil { fmt.Fprintln(os.Stderr, err) - os.Exit(1) + os.Exit(exitCode) } } @@ -82,6 +122,21 @@ with Docker for services like MySQL, Redis, OpenSearch, and Varnish.`, verbose.Env() } + // Capture command metadata for the exit-path telemetry record. + telemetryCommand = telemetry.CanonicalCommand(cmd.CommandPath()) + telemetryStart = time.Now() + + // First-run telemetry consent prompt. Runs at most once per install + // and is silently skipped for non-interactive runs and for the + // telemetry subcommand itself. + if homeDir, err := os.UserHomeDir(); err == nil { + if cfg, err := config.LoadGlobalConfig(homeDir); err == nil { + if telemetry.ShouldPrompt(cfg) { + _, _ = telemetry.MaybePrompt(homeDir, cfg, telemetryCommand, os.Stdin, os.Stdout) + } + } + } + // Start async version check (skip for self-update and dev builds) if cmd.Name() != "self-update" && version != "dev" { if homeDir, err := os.UserHomeDir(); err == nil { diff --git a/cmd/magebox/telemetry.go b/cmd/magebox/telemetry.go new file mode 100644 index 0000000..fb12d37 --- /dev/null +++ b/cmd/magebox/telemetry.go @@ -0,0 +1,229 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "qoliber/magebox/internal/cli" + "qoliber/magebox/internal/config" + "qoliber/magebox/internal/telemetry" +) + +var telemetryCmd = &cobra.Command{ + Use: "telemetry", + Short: "Manage anonymous usage telemetry", + Long: `Manage MageBox's opt-in anonymous usage telemetry. + +Telemetry is disabled by default. When enabled, MageBox records the command +name, exit code, duration, version, OS and architecture of each invocation, +and sends the batch to the public ingestion server at telemetry.magebox.dev. + +No arguments, flag values, paths, hostnames or project names are collected. +See https://telemetry.magebox.dev for the full schema and public dashboard.`, + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var telemetryEnableCmd = &cobra.Command{ + Use: "enable", + Short: "Enable anonymous usage telemetry", + RunE: runTelemetryEnable, +} + +var telemetryDisableCmd = &cobra.Command{ + Use: "disable", + Short: "Disable anonymous usage telemetry", + RunE: runTelemetryDisable, +} + +var telemetryStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show telemetry status", + RunE: runTelemetryStatus, +} + +var telemetryShowCmd = &cobra.Command{ + Use: "show", + Short: "Show the last events recorded locally", + Long: `Show the last events MageBox has recorded locally. This is the exact data +that would be (or has been) sent to the ingestion server. + +Use this to verify what telemetry contains before you enable it.`, + RunE: runTelemetryShow, +} + +var telemetryResetIDCmd = &cobra.Command{ + Use: "reset-id", + Short: "Generate a new anonymous installation ID", + RunE: runTelemetryResetID, +} + +var telemetryPurgeCmd = &cobra.Command{ + Use: "purge", + Short: "Remove all local telemetry state (id, spool, log)", + RunE: runTelemetryPurge, +} + +func init() { + telemetryCmd.AddCommand( + telemetryEnableCmd, + telemetryDisableCmd, + telemetryStatusCmd, + telemetryShowCmd, + telemetryResetIDCmd, + telemetryPurgeCmd, + ) + rootCmd.AddCommand(telemetryCmd) +} + +func runTelemetryEnable(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + cfg, err := config.LoadGlobalConfig(homeDir) + if err != nil { + cli.PrintError("Failed to load config: %v", err) + return nil + } + if cfg.Telemetry == nil { + cfg.Telemetry = &config.TelemetryConfig{} + } + cfg.Telemetry.Enabled = true + cfg.Telemetry.Prompted = true + if err := config.SaveGlobalConfig(homeDir, cfg); err != nil { + cli.PrintError("Failed to save config: %v", err) + return nil + } + cli.PrintSuccess("Telemetry enabled") + cli.PrintInfo("Review events locally with %s", cli.Command("magebox telemetry show")) + cli.PrintInfo("Public dashboard: https://telemetry.magebox.dev") + return nil +} + +func runTelemetryDisable(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + cfg, err := config.LoadGlobalConfig(homeDir) + if err != nil { + cli.PrintError("Failed to load config: %v", err) + return nil + } + if cfg.Telemetry == nil { + cfg.Telemetry = &config.TelemetryConfig{} + } + cfg.Telemetry.Enabled = false + cfg.Telemetry.Prompted = true + if err := config.SaveGlobalConfig(homeDir, cfg); err != nil { + cli.PrintError("Failed to save config: %v", err) + return nil + } + cli.PrintSuccess("Telemetry disabled") + cli.PrintInfo("Purge local telemetry state with %s", cli.Command("magebox telemetry purge")) + return nil +} + +func runTelemetryStatus(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + cfg, err := config.LoadGlobalConfig(homeDir) + if err != nil { + cli.PrintError("Failed to load config: %v", err) + return nil + } + + cli.PrintTitle("MageBox Telemetry") + fmt.Println() + + enabled := cfg.Telemetry != nil && cfg.Telemetry.Enabled + state := "disabled" + if enabled { + state = "enabled" + } + fmt.Printf(" %-12s %s\n", "status:", cli.Highlight(state)) + + endpoint := telemetry.DefaultEndpoint + if cfg.Telemetry != nil && cfg.Telemetry.Endpoint != "" { + endpoint = cfg.Telemetry.Endpoint + } + fmt.Printf(" %-12s %s\n", "endpoint:", cli.Highlight(endpoint)) + + anonID := telemetry.ReadAnonID(homeDir) + if anonID == "" { + anonID = "(not yet generated)" + } + fmt.Printf(" %-12s %s\n", "anon_id:", cli.Highlight(anonID)) + + fmt.Println() + cli.PrintInfo("Review recent events: %s", cli.Command("magebox telemetry show")) + return nil +} + +func runTelemetryShow(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + events, err := telemetry.ReadLog(homeDir) + if err != nil { + cli.PrintError("Failed to read telemetry log: %v", err) + return nil + } + + cli.PrintTitle("Local telemetry log") + fmt.Println() + fmt.Println("This is the exact payload MageBox has recorded locally. Every field") + fmt.Println("shown here is everything that would be (or has been) sent.") + fmt.Println() + + if len(events) == 0 { + cli.PrintInfo("No events recorded yet.") + return nil + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + for _, ev := range events { + if err := enc.Encode(ev); err != nil { + return err + } + } + fmt.Println() + cli.PrintInfo("%d event(s) in local log", len(events)) + return nil +} + +func runTelemetryResetID(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + id, err := telemetry.ResetAnonID(homeDir) + if err != nil { + cli.PrintError("Failed to reset anonymous ID: %v", err) + return nil + } + cli.PrintSuccess("New anonymous ID: %s", id) + return nil +} + +func runTelemetryPurge(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + if err := telemetry.Purge(homeDir); err != nil { + cli.PrintError("Failed to purge telemetry state: %v", err) + return nil + } + cli.PrintSuccess("Local telemetry state removed") + return nil +} diff --git a/internal/config/global.go b/internal/config/global.go index e1e609e..967e19d 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -53,6 +53,29 @@ type GlobalConfig struct { // Sandbox configures the bubblewrap sandbox for AI coding agents Sandbox *SandboxConfig `yaml:"sandbox,omitempty"` + + // Telemetry controls the opt-in anonymous usage reporting. Nil or + // disabled means nothing is collected or sent. See internal/telemetry + // and docs/telemetry.md for the full schema. + Telemetry *TelemetryConfig `yaml:"telemetry,omitempty"` +} + +// TelemetryConfig holds the opt-in usage reporting settings. See +// internal/telemetry for the full behavior and payload schema. +type TelemetryConfig struct { + // Enabled is the master switch. When false, no events are recorded or + // sent. Defaults to false — telemetry is opt-in. + Enabled bool `yaml:"enabled"` + + // Endpoint is the ingestion URL events are POSTed to. Empty means use + // the default (telemetry.DefaultEndpoint). Exposed to make local + // testing and self-hosted ingestion servers possible. + Endpoint string `yaml:"endpoint,omitempty"` + + // Prompted is set to true once the user has seen the first-run consent + // prompt at least once, regardless of their answer. Prevents us from + // re-asking on every invocation. + Prompted bool `yaml:"prompted,omitempty"` } // ProfilingConfig contains credentials for profiling tools diff --git a/internal/telemetry/consent.go b/internal/telemetry/consent.go new file mode 100644 index 0000000..0fe40c2 --- /dev/null +++ b/internal/telemetry/consent.go @@ -0,0 +1,101 @@ +package telemetry + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "qoliber/magebox/internal/config" +) + +// consentPrompt is the exact text shown to the user on first run. It is +// intentionally verbose: we would rather the prompt be skipped because the +// user read it and said no than have them opt in without understanding. +const consentPrompt = `MageBox can send anonymous usage statistics to help prioritize improvements. + + What is sent: command name, exit code, duration, MageBox version, OS, arch + What is NOT: flag values, arguments, paths, hostnames, IPs, project names + +Everything is opt-in. You can review the last events with: + magebox telemetry show + +The public dashboard lives at: https://telemetry.magebox.dev +You can disable at any time with: magebox telemetry disable + +Enable telemetry? [y/N] ` + +// ShouldPrompt reports whether the first-run prompt should be shown. It +// returns false once the user has been prompted once (regardless of answer) +// so we never ask twice. +func ShouldPrompt(cfg *config.GlobalConfig) bool { + if cfg == nil || cfg.Telemetry == nil { + return true + } + return !cfg.Telemetry.Prompted +} + +// StdinIsInteractive reports whether stdin is attached to a terminal. The +// consent prompt should be suppressed in non-interactive contexts (CI, pipes) +// because blocking on stdin.Read would hang the command forever. +func StdinIsInteractive() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + +// MaybePrompt runs the first-run telemetry consent flow if appropriate. It is +// a no-op when: +// - telemetry has already been prompted, +// - stdin is not a terminal, +// - the command being run is itself a telemetry management command (to avoid +// surprising users running `magebox telemetry disable`). +// +// The user's choice is persisted to the global config. Returns the enabled +// state the caller should use for the remainder of the process. +func MaybePrompt(homeDir string, cfg *config.GlobalConfig, currentCommand string, in io.Reader, out io.Writer) (bool, error) { + if cfg.Telemetry == nil { + cfg.Telemetry = &config.TelemetryConfig{} + } + + // Skip for meta commands and telemetry subcommand itself. + if ShouldSkipCommand(currentCommand) { + return cfg.Telemetry.Enabled, nil + } + if !ShouldPrompt(cfg) { + return cfg.Telemetry.Enabled, nil + } + if !StdinIsInteractive() { + // Mark prompted so we don't re-ask next time, but leave disabled. + cfg.Telemetry.Prompted = true + _ = config.SaveGlobalConfig(homeDir, cfg) + return false, nil + } + + _, _ = fmt.Fprint(out, "\n"+consentPrompt) + reader := bufio.NewReader(in) + answer, _ := reader.ReadString('\n') + enabled := isYes(answer) + + cfg.Telemetry.Enabled = enabled + cfg.Telemetry.Prompted = true + if err := config.SaveGlobalConfig(homeDir, cfg); err != nil { + return enabled, err + } + + if enabled { + _, _ = fmt.Fprintln(out, "Telemetry enabled. Thanks — you can disable any time with `magebox telemetry disable`.") + } else { + _, _ = fmt.Fprintln(out, "Telemetry disabled. Enable later with `magebox telemetry enable`.") + } + _, _ = fmt.Fprintln(out) + return enabled, nil +} + +func isYes(s string) bool { + s = strings.TrimSpace(strings.ToLower(s)) + return s == "y" || s == "yes" +} diff --git a/internal/telemetry/detach.go b/internal/telemetry/detach.go new file mode 100644 index 0000000..90374cd --- /dev/null +++ b/internal/telemetry/detach.go @@ -0,0 +1,73 @@ +package telemetry + +import ( + "os" + "os/exec" + "syscall" +) + +// Environment variables used to signal a detached child process to perform +// the spool flush. When the MageBox binary sees envFlushTrigger=1 at startup +// it runs the flush and exits without executing any other CLI code. +const ( + envFlushTrigger = "MAGEBOX_TELEMETRY_FLUSH" + envFlushHome = "MAGEBOX_TELEMETRY_HOME" + envFlushEndpoint = "MAGEBOX_TELEMETRY_ENDPOINT" +) + +// flusher is the function Record uses to hand spooled events off to the +// network. In production it spawns a fully detached child process so the +// parent can exit immediately regardless of network state. Tests override it +// with flushSpool for synchronous assertions against an httptest server. +var flusher = detachFlush + +// detachFlush re-invokes the current binary with a marker env var that tells +// the child to run the HTTP send and exit. It returns immediately after +// Start(); a slow or dead ingestion server cannot delay the parent at all. +// +// The child is placed in a new session (Setsid) so it outlives its parent's +// process group, and all three stdio streams are routed to /dev/null so it +// can never write to the parent's TTY. When the parent exits, the child is +// reparented to init/systemd, which handles reaping. +// +// All failure modes are silent: a missing executable path, a failed +// os.OpenFile on /dev/null, or a failed Start() all leave the event on the +// local spool for the next invocation to retry. +func detachFlush(homeDir, endpoint string) { + exe, err := os.Executable() + if err != nil { + return + } + cmd := exec.Command(exe) + cmd.Env = append(os.Environ(), + envFlushTrigger+"=1", + envFlushHome+"="+homeDir, + envFlushEndpoint+"="+endpoint, + ) + if devNull, derr := os.OpenFile(os.DevNull, os.O_RDWR, 0); derr == nil { + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + defer func() { _ = devNull.Close() }() + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + _ = cmd.Start() + // Intentionally no Wait — the child is fully detached and will be + // reaped by init once the parent exits. +} + +// FlushFromEnv reports whether this process was spawned by detachFlush. When +// it returns true the caller (main) must exit immediately — the flush has +// already been performed and no CLI command should run. Returns false for +// normal invocations. +func FlushFromEnv() bool { + if os.Getenv(envFlushTrigger) != "1" { + return false + } + home := os.Getenv(envFlushHome) + endpoint := os.Getenv(envFlushEndpoint) + if home != "" && endpoint != "" { + flushSpool(home, endpoint) + } + return true +} diff --git a/internal/telemetry/id.go b/internal/telemetry/id.go new file mode 100644 index 0000000..bf227b1 --- /dev/null +++ b/internal/telemetry/id.go @@ -0,0 +1,57 @@ +package telemetry + +import ( + "os" + "strings" + + "github.com/google/uuid" +) + +// loadOrCreateAnonID returns the anonymous installation ID stored at +// ~/.magebox/telemetry.id, creating it the first time it's requested. The ID +// is a v4 UUID — it contains no data about the user, host or filesystem and +// can be safely regenerated with `magebox telemetry reset-id`. +func loadOrCreateAnonID(homeDir string) (string, error) { + p := PathsFor(homeDir) + if data, err := os.ReadFile(p.ID); err == nil { + id := strings.TrimSpace(string(data)) + if _, err := uuid.Parse(id); err == nil { + return id, nil + } + // Fall through on corrupt content — regenerate. + } + + if err := os.MkdirAll(p.Dir, 0755); err != nil { + return "", err + } + + id := uuid.NewString() + if err := os.WriteFile(p.ID, []byte(id+"\n"), 0644); err != nil { + return "", err + } + return id, nil +} + +// ResetAnonID generates and persists a new anonymous ID, returning it. Used by +// `magebox telemetry reset-id`. +func ResetAnonID(homeDir string) (string, error) { + p := PathsFor(homeDir) + if err := os.MkdirAll(p.Dir, 0755); err != nil { + return "", err + } + id := uuid.NewString() + if err := os.WriteFile(p.ID, []byte(id+"\n"), 0644); err != nil { + return "", err + } + return id, nil +} + +// ReadAnonID returns the current anonymous ID, or empty string if none exists. +// Unlike loadOrCreateAnonID it never creates the file — used by status/show. +func ReadAnonID(homeDir string) string { + data, err := os.ReadFile(PathsFor(homeDir).ID) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} diff --git a/internal/telemetry/sender.go b/internal/telemetry/sender.go new file mode 100644 index 0000000..ad34249 --- /dev/null +++ b/internal/telemetry/sender.go @@ -0,0 +1,80 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "time" +) + +// flushBudget caps how long the detached flush child spends attempting to +// send spooled events. It can be generous: the child runs out-of-band from +// the user's CLI invocation, so the parent has already exited by the time +// this matters. Tests use this same budget against an in-process httptest +// server, where responses come back in microseconds. +const flushBudget = 10 * time.Second + +// batchPayload is the body shape the ingestion server expects. Keeping the +// batch wrapper even for single-event sends means the server has one code path. +type batchPayload struct { + SchemaVersion int `json:"schema_version"` + Events []Event `json:"events"` +} + +// flushSpool reads the spool, attempts to POST it to endpoint within +// flushBudget, and clears the spool on success. On failure it leaves the +// spool intact for the next invocation. +func flushSpool(homeDir, endpoint string) { + events, err := readSpool(homeDir) + if err != nil || len(events) == 0 { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), flushBudget) + defer cancel() + + if err := send(ctx, endpoint, events); err != nil { + return + } + + _ = clearSpool(homeDir) +} + +func send(ctx context.Context, endpoint string, events []Event) error { + payload := batchPayload{ + SchemaVersion: SchemaVersion, + Events: events, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "MageBox-Telemetry") + + client := &http.Client{Timeout: flushBudget} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + return &httpError{Status: resp.StatusCode} +} + +type httpError struct { + Status int +} + +func (e *httpError) Error() string { + return http.StatusText(e.Status) +} diff --git a/internal/telemetry/spool.go b/internal/telemetry/spool.go new file mode 100644 index 0000000..ace0e4b --- /dev/null +++ b/internal/telemetry/spool.go @@ -0,0 +1,104 @@ +package telemetry + +import ( + "encoding/json" + "os" +) + +// spoolMax caps the on-disk spool so a long-offline install can't grow +// unbounded. Oldest entries are dropped on overflow. +const spoolMax = 100 + +// logMax caps the rolling log read by `telemetry show`. +const logMax = 50 + +func appendSpool(homeDir string, ev Event) error { + p := PathsFor(homeDir) + if err := os.MkdirAll(p.Dir, 0755); err != nil { + return err + } + return appendCapped(p.Spool, ev, spoolMax) +} + +func appendLog(homeDir string, ev Event) error { + p := PathsFor(homeDir) + if err := os.MkdirAll(p.Dir, 0755); err != nil { + return err + } + return appendCapped(p.Log, ev, logMax) +} + +// appendCapped appends ev to path and, if the resulting file has more than +// max entries, rewrites it keeping only the most recent max entries. +func appendCapped(path string, ev Event, max int) error { + line, err := json.Marshal(ev) + if err != nil { + return err + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return err + } + _, werr := f.Write(append(line, '\n')) + cerr := f.Close() + if werr != nil { + return werr + } + if cerr != nil { + return cerr + } + + // Trim if over cap. Cheap: read all, keep tail, rewrite. + data, err := os.ReadFile(path) + if err != nil { + return err + } + events := decodeJSONL(data) + if len(events) <= max { + return nil + } + trimmed := events[len(events)-max:] + return writeJSONL(path, trimmed) +} + +func writeJSONL(path string, events []Event) error { + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + enc := json.NewEncoder(f) + for _, ev := range events { + if err := enc.Encode(ev); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return err + } + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + return os.Rename(tmp, path) +} + +func readSpool(homeDir string) ([]Event, error) { + data, err := os.ReadFile(PathsFor(homeDir).Spool) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return decodeJSONL(data), nil +} + +func clearSpool(homeDir string) error { + p := PathsFor(homeDir).Spool + err := os.Remove(p) + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..e58d799 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,225 @@ +// Package telemetry provides opt-in, anonymous usage reporting for MageBox. +// +// Design goals: +// - Opt-in only. Nothing is ever sent unless the user has explicitly enabled +// telemetry through the first-run prompt or `magebox telemetry enable`. +// - No PII. Only the command name, exit code, duration, MageBox version and +// OS/arch are collected. Positional arguments and flag values are never +// captured. See Event for the exact schema. +// - Transparent. Every event recorded locally is also written to a rolling +// log at ~/.magebox/telemetry-log.jsonl so users can verify with +// `magebox telemetry show` exactly what has been or would be sent. +// - Non-blocking. Record spools to disk synchronously (cheap) and then +// hands the HTTP send off to a fully detached child process. A broken +// network or a hung ingestion server cannot delay the user's CLI exit. +package telemetry + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/google/uuid" + + "qoliber/magebox/internal/config" +) + +// SchemaVersion is the version of the event schema. Bump whenever fields are +// added, removed or renamed so the ingestion server can route appropriately. +const SchemaVersion = 1 + +// DefaultEndpoint is where events are posted unless overridden in config. +const DefaultEndpoint = "https://telemetry.magebox.dev/v1/event" + +// maxCommandLen is a belt-and-suspenders cap on the command field length so a +// stray very long command path can never bloat the payload. +const maxCommandLen = 64 + +// Event is the exact payload sent to the ingestion server. Every field here is +// considered public — if you add a field, document it in docs/telemetry.md and +// bump SchemaVersion. +type Event struct { + SchemaVersion int `json:"schema_version"` + EventID string `json:"event_id"` + AnonID string `json:"anon_id"` + Command string `json:"command"` + ExitCode int `json:"exit_code"` + DurationMs int64 `json:"duration_ms"` + MBVersion string `json:"mb_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Timestamp string `json:"ts"` +} + +// RecordInput holds the parameters the CLI passes to Record. Keeping it as a +// struct means hook-site code reads clearly and adding future fields doesn't +// break callers. +type RecordInput struct { + HomeDir string + MBVersion string + Command string + ExitCode int + Duration time.Duration +} + +// Record builds an event and writes it to the local spool and rolling log, +// then hands the HTTP send off to a fully detached child process via +// flusher. It is safe to call from any command exit path and never blocks +// the caller on the network. +// +// If telemetry is disabled in the global config, Record returns immediately +// without touching disk. +func Record(in RecordInput) { + if in.HomeDir == "" || in.Command == "" { + return + } + + cfg, err := config.LoadGlobalConfig(in.HomeDir) + if err != nil || cfg.Telemetry == nil || !cfg.Telemetry.Enabled { + return + } + + anonID, err := loadOrCreateAnonID(in.HomeDir) + if err != nil { + return + } + + ev := Event{ + SchemaVersion: SchemaVersion, + EventID: uuid.NewString(), + AnonID: anonID, + Command: truncate(in.Command, maxCommandLen), + ExitCode: in.ExitCode, + DurationMs: in.Duration.Milliseconds(), + MBVersion: in.MBVersion, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + + // Always append to the rolling log so `telemetry show` can display it. + _ = appendLog(in.HomeDir, ev) + + // Spool to disk so a failed send survives until next invocation. + if err := appendSpool(in.HomeDir, ev); err != nil { + return + } + + endpoint := cfg.Telemetry.Endpoint + if endpoint == "" { + endpoint = DefaultEndpoint + } + + // Fire-and-forget: hand the HTTP send off to a detached child process + // and return. Tests override flusher with a synchronous implementation + // so they can assert against an httptest server in-process. + flusher(in.HomeDir, endpoint) +} + +// CanonicalCommand turns a Cobra cmd.CommandPath() into the canonical form we +// record: the path with the leading "magebox" root stripped. The bare root +// command (no subcommand) canonicalizes to the empty string, which +// ShouldSkipCommand then filters out. For custom project commands handled by +// the delegation in main.go, the caller should pass the literal string "run" +// so that no user-defined command name leaks. +func CanonicalCommand(commandPath string) string { + cmd := strings.TrimSpace(commandPath) + if cmd == "magebox" { + return "" + } + cmd = strings.TrimPrefix(cmd, "magebox ") + return strings.TrimSpace(cmd) +} + +// ShouldSkipCommand returns true for commands that should never be recorded, +// either because they are telemetry-management commands themselves, or because +// recording them would be noise (help, version, completion). +func ShouldSkipCommand(cmd string) bool { + if cmd == "" { + return true + } + // Meta / self-referential + switch cmd { + case "help", "completion", "version", "self-update": + return true + } + if strings.HasPrefix(cmd, "telemetry") { + return true + } + if strings.HasPrefix(cmd, "help ") || strings.HasPrefix(cmd, "completion ") { + return true + } + return false +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} + +// PathsFor returns the on-disk paths used by the telemetry package, rooted at +// the given home directory. Exposed for tests and the `telemetry show` / +// `telemetry purge` commands. +type Paths struct { + Dir string + ID string + Spool string + Log string +} + +func PathsFor(homeDir string) Paths { + base := filepath.Join(homeDir, ".magebox") + return Paths{ + Dir: base, + ID: filepath.Join(base, "telemetry.id"), + Spool: filepath.Join(base, "telemetry-spool.jsonl"), + Log: filepath.Join(base, "telemetry-log.jsonl"), + } +} + +// ReadLog returns events in the local rolling log in chronological order. It +// returns an empty slice (not an error) if the log doesn't exist yet. +func ReadLog(homeDir string) ([]Event, error) { + p := PathsFor(homeDir).Log + data, err := os.ReadFile(p) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return decodeJSONL(data), nil +} + +// Purge removes all telemetry state (id, spool, log) from disk. Used by +// `magebox telemetry purge`. +func Purge(homeDir string) error { + p := PathsFor(homeDir) + for _, path := range []string{p.ID, p.Spool, p.Log} { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func decodeJSONL(data []byte) []Event { + var out []Event + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var ev Event + if err := json.Unmarshal([]byte(line), &ev); err != nil { + continue + } + out = append(out, ev) + } + return out +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 0000000..3905a62 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,353 @@ +package telemetry + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + + "qoliber/magebox/internal/config" +) + +func TestCanonicalCommand(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"root only", "magebox", ""}, + {"top-level", "magebox start", "start"}, + {"nested", "magebox service redis", "service redis"}, + {"no prefix", "start", "start"}, + {"trailing space", "magebox start ", "start"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := CanonicalCommand(tc.in); got != tc.want { + t.Errorf("CanonicalCommand(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestShouldSkipCommand(t *testing.T) { + skip := []string{ + "", + "help", + "help start", + "completion", + "completion bash", + "version", + "self-update", + "telemetry", + "telemetry enable", + } + for _, c := range skip { + if !ShouldSkipCommand(c) { + t.Errorf("ShouldSkipCommand(%q) = false, want true", c) + } + } + + keep := []string{"start", "stop", "service redis", "run", "db import"} + for _, c := range keep { + if ShouldSkipCommand(c) { + t.Errorf("ShouldSkipCommand(%q) = true, want false", c) + } + } +} + +func TestLoadOrCreateAnonID_PersistsAndReuses(t *testing.T) { + home := t.TempDir() + + first, err := loadOrCreateAnonID(home) + if err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := uuid.Parse(first); err != nil { + t.Fatalf("first id is not a UUID: %v", err) + } + + second, err := loadOrCreateAnonID(home) + if err != nil { + t.Fatalf("second call: %v", err) + } + if first != second { + t.Errorf("id was regenerated on second call: %q != %q", first, second) + } +} + +func TestLoadOrCreateAnonID_RegeneratesOnCorrupt(t *testing.T) { + home := t.TempDir() + p := PathsFor(home) + if err := os.MkdirAll(p.Dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p.ID, []byte("not a uuid\n"), 0644); err != nil { + t.Fatal(err) + } + + id, err := loadOrCreateAnonID(home) + if err != nil { + t.Fatalf("loadOrCreateAnonID: %v", err) + } + if _, err := uuid.Parse(id); err != nil { + t.Errorf("expected regenerated uuid, got %q", id) + } +} + +func TestResetAnonID(t *testing.T) { + home := t.TempDir() + first, err := loadOrCreateAnonID(home) + if err != nil { + t.Fatal(err) + } + second, err := ResetAnonID(home) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Error("ResetAnonID returned the same id") + } + read := ReadAnonID(home) + if read != second { + t.Errorf("ReadAnonID = %q, want %q", read, second) + } +} + +func TestAppendCapped_RingBuffer(t *testing.T) { + home := t.TempDir() + p := PathsFor(home) + if err := os.MkdirAll(p.Dir, 0755); err != nil { + t.Fatal(err) + } + + // Write logMax+5 events; we should end up with exactly logMax. + for i := 0; i < logMax+5; i++ { + ev := Event{EventID: uuid.NewString(), Command: "start", Timestamp: time.Now().UTC().Format(time.RFC3339)} + if err := appendLog(home, ev); err != nil { + t.Fatalf("appendLog: %v", err) + } + } + + events, err := ReadLog(home) + if err != nil { + t.Fatalf("ReadLog: %v", err) + } + if len(events) != logMax { + t.Errorf("log length = %d, want %d", len(events), logMax) + } +} + +func TestRecord_DisabledIsNoop(t *testing.T) { + home := t.TempDir() + + // No telemetry config saved → disabled by default. + Record(RecordInput{ + HomeDir: home, + MBVersion: "test", + Command: "start", + ExitCode: 0, + Duration: 100 * time.Millisecond, + }) + + // Nothing should have been written. + p := PathsFor(home) + for _, path := range []string{p.ID, p.Spool, p.Log} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("expected %s to not exist when disabled, got err=%v", filepath.Base(path), err) + } + } +} + +func TestRecord_EndToEnd_SendsAndClearsSpool(t *testing.T) { + // Swap the detached flusher for the synchronous one so we can assert + // against the httptest server in-process. + origFlusher := flusher + flusher = flushSpool + defer func() { flusher = origFlusher }() + + home := t.TempDir() + + var received atomic.Int32 + var gotPayload batchPayload + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotPayload) + received.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + cfg := &config.GlobalConfig{ + Telemetry: &config.TelemetryConfig{ + Enabled: true, + Endpoint: srv.URL, + Prompted: true, + }, + } + if err := config.SaveGlobalConfig(home, cfg); err != nil { + t.Fatal(err) + } + + Record(RecordInput{ + HomeDir: home, + MBVersion: "1.14.0", + Command: "start", + ExitCode: 0, + Duration: 250 * time.Millisecond, + }) + + if got := received.Load(); got != 1 { + t.Errorf("received count = %d, want 1", got) + } + + if len(gotPayload.Events) != 1 { + t.Fatalf("payload events = %d, want 1", len(gotPayload.Events)) + } + ev := gotPayload.Events[0] + if ev.Command != "start" { + t.Errorf("command = %q, want start", ev.Command) + } + if ev.DurationMs != 250 { + t.Errorf("duration_ms = %d, want 250", ev.DurationMs) + } + if ev.MBVersion != "1.14.0" { + t.Errorf("mb_version = %q, want 1.14.0", ev.MBVersion) + } + if _, err := uuid.Parse(ev.AnonID); err != nil { + t.Errorf("anon_id is not a uuid: %q", ev.AnonID) + } + + // Spool should be empty after successful flush. + if data, _ := os.ReadFile(PathsFor(home).Spool); len(strings.TrimSpace(string(data))) != 0 { + t.Errorf("spool not cleared after send: %q", string(data)) + } + + // But log should contain the event. + logged, err := ReadLog(home) + if err != nil { + t.Fatal(err) + } + if len(logged) != 1 { + t.Errorf("log length = %d, want 1", len(logged)) + } +} + +func TestRecord_FailedSendKeepsSpool(t *testing.T) { + // Use the synchronous flusher so the failed send is observable before + // the test asserts on the surviving spool. + origFlusher := flusher + flusher = flushSpool + defer func() { flusher = origFlusher }() + + home := t.TempDir() + + // Server always errors — spool should survive for the next run. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + cfg := &config.GlobalConfig{ + Telemetry: &config.TelemetryConfig{ + Enabled: true, + Endpoint: srv.URL, + Prompted: true, + }, + } + if err := config.SaveGlobalConfig(home, cfg); err != nil { + t.Fatal(err) + } + + Record(RecordInput{ + HomeDir: home, + MBVersion: "1.14.0", + Command: "stop", + ExitCode: 1, + Duration: 10 * time.Millisecond, + }) + + spooled, err := readSpool(home) + if err != nil { + t.Fatal(err) + } + if len(spooled) != 1 { + t.Errorf("spool length = %d, want 1 (should survive failed send)", len(spooled)) + } + if len(spooled) == 1 && spooled[0].ExitCode != 1 { + t.Errorf("spooled exit_code = %d, want 1", spooled[0].ExitCode) + } +} + +func TestPurge_RemovesAllState(t *testing.T) { + home := t.TempDir() + p := PathsFor(home) + if err := os.MkdirAll(p.Dir, 0755); err != nil { + t.Fatal(err) + } + for _, f := range []string{p.ID, p.Spool, p.Log} { + if err := os.WriteFile(f, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + if err := Purge(home); err != nil { + t.Fatalf("Purge: %v", err) + } + for _, f := range []string{p.ID, p.Spool, p.Log} { + if _, err := os.Stat(f); !os.IsNotExist(err) { + t.Errorf("%s still exists after Purge", filepath.Base(f)) + } + } +} + +func TestShouldPrompt(t *testing.T) { + if !ShouldPrompt(nil) { + t.Error("ShouldPrompt(nil) = false, want true") + } + cfg := &config.GlobalConfig{} + if !ShouldPrompt(cfg) { + t.Error("ShouldPrompt(empty) = false, want true") + } + cfg.Telemetry = &config.TelemetryConfig{Prompted: false} + if !ShouldPrompt(cfg) { + t.Error("ShouldPrompt(prompted=false) = false, want true") + } + cfg.Telemetry.Prompted = true + if ShouldPrompt(cfg) { + t.Error("ShouldPrompt(prompted=true) = true, want false") + } +} + +func TestMaybePrompt_SkipsForTelemetrySubcommand(t *testing.T) { + home := t.TempDir() + cfg := &config.GlobalConfig{} + enabled, err := MaybePrompt(home, cfg, "telemetry enable", strings.NewReader("y\n"), io.Discard) + if err != nil { + t.Fatal(err) + } + if enabled { + t.Error("enabled = true, want false (should have been skipped)") + } + if cfg.Telemetry != nil && cfg.Telemetry.Prompted { + t.Error("Prompted was set even though prompt should have been skipped") + } +} + +func TestTruncate(t *testing.T) { + if truncate("hello", 64) != "hello" { + t.Error("short string should be unchanged") + } + long := strings.Repeat("a", 100) + if got := truncate(long, 64); len(got) != 64 { + t.Errorf("truncate cap = %d, want 64", len(got)) + } +} From 1e024dc1202eaa1f86e31c9eba13a10b4dc2245b Mon Sep 17 00:00:00 2001 From: Peter Jaap Blaakmeer Date: Fri, 10 Apr 2026 14:13:47 +0200 Subject: [PATCH 2/2] Clarify flushSpool comment and silence Close errcheck The previous wording didn't mention that flushSpool is the entry point used by both the detached flush child and the in-process test swap. --- internal/telemetry/sender.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/telemetry/sender.go b/internal/telemetry/sender.go index ad34249..a85222d 100644 --- a/internal/telemetry/sender.go +++ b/internal/telemetry/sender.go @@ -24,7 +24,8 @@ type batchPayload struct { // flushSpool reads the spool, attempts to POST it to endpoint within // flushBudget, and clears the spool on success. On failure it leaves the -// spool intact for the next invocation. +// spool intact for the next invocation. It is used by the detached flush +// child (via FlushFromEnv in detach.go) and swapped in directly by tests. func flushSpool(homeDir, endpoint string) { events, err := readSpool(homeDir) if err != nil || len(events) == 0 { @@ -63,7 +64,7 @@ func send(ctx context.Context, endpoint string, events []Event) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil