Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions cmd/magebox/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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 {
Expand Down
229 changes: 229 additions & 0 deletions cmd/magebox/telemetry.go
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions internal/config/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading