diff --git a/go.mod b/go.mod index 7f61ab1..61e1885 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/go-playground/validator/v10 v10.30.3 github.com/goccy/go-yaml v1.19.2 github.com/gofrs/flock v0.13.0 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 @@ -75,7 +76,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/internal/cli/root.go b/internal/cli/root.go index 0c404e0..ed556f2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -105,6 +105,7 @@ func NewRootCmd(opts Options) *cobra.Command { newSelfCmd(g), newStoreCmd(g), newAliasCmd(g), + newTelemetryCmd(g), newVersionCmd(), ) addStubCommands(root, g) diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index b52f38e..43b9072 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -36,6 +36,5 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) { stub("logs", "Stream service logs", "v2 (spec 16)"), stub("dashboard", "Live TUI cockpit", "v2 (spec 16)"), stub("ide", "Generate devcontainer/.code-workspace/launch configs", "v2 (spec 17)"), - stub("telemetry", "Opt-in usage telemetry (default OFF)", "a later release (spec 20)"), ) } diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go new file mode 100644 index 0000000..13a5f77 --- /dev/null +++ b/internal/cli/telemetry.go @@ -0,0 +1,175 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/store" + "github.com/open-source-cloud/devstack/internal/telemetry" + "github.com/open-source-cloud/devstack/internal/version" +) + +// newTelemetryCmd wires `telemetry status|enable|disable|show` — the opt-in, +// default-OFF usage-telemetry scaffold (spec 20). Consent is persisted in the +// global $DEVSTACK_HOME config.yaml (never committed, never in the ledger). This +// build is SHIP-EMPTY: no real endpoint is wired, so even when enabled the only +// sink is the no-op sink and nothing leaves the machine. +func newTelemetryCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "telemetry", + Short: "Opt-in anonymous usage telemetry (default OFF)", + Long: "Opt-in anonymous usage telemetry (spec 20).\n\n" + + "Telemetry is DEFAULT OFF and strictly opt-in: nothing is collected or sent\n" + + "until you run `telemetry enable`. This build is ship-empty — no network\n" + + "endpoint is wired, so even when enabled nothing leaves your machine. Only\n" + + "coarse, non-identifying counters would ever be sent (see `telemetry show`):\n" + + "command name, flag names, ok/error outcome, an error category enum, a\n" + + "bucketed duration, os/arch, whether WSL2, the tool version, and a random\n" + + "install id. Never paths, repo names, secrets, env values, or error strings.", + } + cmd.AddCommand( + newTelemetryStatusCmd(g), + newTelemetryEnableCmd(g), + newTelemetryDisableCmd(g), + newTelemetryShowCmd(g), + ) + return cmd +} + +func newTelemetryStatusCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show whether telemetry is enabled and where it would send", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + consent, err := store.TelemetryConsent() + if err != nil { + return err + } + endpoint := telemetry.DefaultEndpoint + if g.JSON { + return writeJSON(cmd, map[string]any{ + "enabled": consent.Enabled, + "installId": consent.InstallID, + "consentAt": consent.ConsentAt, + "endpoint": endpoint, + "shipEmpty": endpoint == "", + "configPath": store.ConfigPath(), + }) + } + if g.Quiet { + return nil + } + w := cmd.OutOrStdout() + state := "disabled" + if consent.Enabled { + state = "enabled" + } + fmt.Fprintf(w, "telemetry: %s (default OFF)\n", state) + if endpoint == "" { + fmt.Fprintf(w, " endpoint: (none — ship-empty; nothing is ever sent)\n") + } else { + fmt.Fprintf(w, " endpoint: %s\n", endpoint) + } + if consent.InstallID != "" { + fmt.Fprintf(w, " install id: %s\n", consent.InstallID) + } + fmt.Fprintf(w, " config: %s\n", store.ConfigPath()) + fmt.Fprintf(w, "run `%s telemetry show` to see the exact event that would be sent\n", rootName(cmd)) + return nil + }, + } +} + +func newTelemetryEnableCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "enable", + Short: "Opt in to anonymous usage telemetry", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + consent, err := store.SetTelemetry(true) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"enabled": true, "installId": consent.InstallID}) + } + if g.Quiet { + return nil + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "telemetry enabled. install id: %s\n", consent.InstallID) + if telemetry.DefaultEndpoint == "" { + fmt.Fprintf(w, "note: this build is ship-empty — no endpoint is wired, so nothing is actually sent yet.\n") + } + fmt.Fprintf(w, "disable any time with `%s telemetry disable`.\n", rootName(cmd)) + return nil + }, + } +} + +func newTelemetryDisableCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "disable", + Short: "Opt out of usage telemetry (honored on the next invocation)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if _, err := store.SetTelemetry(false); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"enabled": false}) + } + if g.Quiet { + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), "telemetry disabled.") + return nil + }, + } +} + +func newTelemetryShowCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Print the exact event that would be sent (performs no network I/O)", + Long: "Print, byte-for-byte, the exact event devstack would send for a synthetic\n" + + "invocation. This is the trust primitive: it performs ZERO network I/O and\n" + + "lets you verify nothing sensitive is collected. It is the exhaustive\n" + + "allowlist — there is no free-form field that could carry a path or secret.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + consent, err := store.TelemetryConsent() + if err != nil { + return err + } + // A synthetic sample so `show` works whether or not telemetry is on. + sample := telemetry.NewEvent(telemetry.EventInput{ + Command: "up", + Flags: []string{"--build"}, + Err: nil, + Duration: 1840 * time.Millisecond, + ToolVersion: version.Version, + InstallID: consent.InstallID, + }) + if g.JSON { + return writeJSON(cmd, map[string]any{ + "enabled": consent.Enabled, + "endpoint": telemetry.DefaultEndpoint, + "event": sample, + }) + } + w := cmd.OutOrStdout() + endpoint := telemetry.DefaultEndpoint + if endpoint == "" { + endpoint = "(none — ship-empty; nothing is ever sent)" + } + fmt.Fprintf(w, "endpoint: %s\n", endpoint) + fmt.Fprintf(w, "enabled: %v\n", consent.Enabled) + fmt.Fprintln(w, "the exact event that would be sent (allowlist — no paths/secrets/PII):") + return writeJSON(cmd, sample) + }, + } +} diff --git a/internal/cli/telemetry_test.go b/internal/cli/telemetry_test.go new file mode 100644 index 0000000..b88f4a7 --- /dev/null +++ b/internal/cli/telemetry_test.go @@ -0,0 +1,112 @@ +package cli + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +func TestTelemetryRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + // The stub must be gone: telemetry is now a real command group. + for _, sub := range []string{"status", "enable", "disable", "show"} { + c, _, err := root.Find([]string{"telemetry", sub}) + if err != nil || c.Name() != sub || c.RunE == nil { + t.Errorf("telemetry %s not registered as a real command: %v", sub, err) + } + } +} + +func TestTelemetryStatusDefaultOff(t *testing.T) { + t.Setenv("DEVSTACK_HOME", filepath.Join(t.TempDir(), ".devstack")) + + out, err := runCmd(t, "telemetry", "status", "--json") + if err != nil { + t.Fatalf("telemetry status: %v\n%s", err, out) + } + var res struct { + Enabled bool `json:"enabled"` + Endpoint string `json:"endpoint"` + ShipEmpty bool `json:"shipEmpty"` + InstallID string `json:"installId"` + } + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("bad json: %v\n%s", err, out) + } + if res.Enabled { + t.Error("telemetry must report OFF by default") + } + if res.Endpoint != "" || !res.ShipEmpty { + t.Errorf("ship-empty: endpoint=%q shipEmpty=%v", res.Endpoint, res.ShipEmpty) + } + if res.InstallID != "" { + t.Errorf("a disabled install must carry no install id, got %q", res.InstallID) + } +} + +func TestTelemetryEnableDisableRoundTripCLI(t *testing.T) { + t.Setenv("DEVSTACK_HOME", filepath.Join(t.TempDir(), ".devstack")) + + // enable + out, err := runCmd(t, "telemetry", "enable", "--json") + if err != nil { + t.Fatalf("enable: %v\n%s", err, out) + } + var en struct { + Enabled bool `json:"enabled"` + InstallID string `json:"installId"` + } + if err := json.Unmarshal([]byte(out), &en); err != nil { + t.Fatalf("bad json: %v\n%s", err, out) + } + if !en.Enabled || en.InstallID == "" { + t.Fatalf("enable json = %+v; want enabled with install id", en) + } + + // status reflects it + out, _ = runCmd(t, "telemetry", "status", "--json") + if !strings.Contains(out, `"enabled": true`) { + t.Errorf("status should report enabled after enable:\n%s", out) + } + + // disable + if out, err := runCmd(t, "telemetry", "disable", "--json"); err != nil { + t.Fatalf("disable: %v\n%s", err, out) + } + out, _ = runCmd(t, "telemetry", "status", "--json") + if !strings.Contains(out, `"enabled": false`) { + t.Errorf("status should report disabled after disable:\n%s", out) + } +} + +// TestTelemetryShowNoPII asserts `telemetry show` prints only allowlisted fields — +// no path/secret/PII — mirroring the spec-04 no-secret-in-output guardrail. +func TestTelemetryShowNoPII(t *testing.T) { + t.Setenv("DEVSTACK_HOME", filepath.Join(t.TempDir(), ".devstack")) + + out, err := runCmd(t, "telemetry", "show", "--json") + if err != nil { + t.Fatalf("telemetry show: %v\n%s", err, out) + } + var res struct { + Endpoint string `json:"endpoint"` + Event map[string]any `json:"event"` + } + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("bad json: %v\n%s", err, out) + } + if res.Endpoint != "" { + t.Errorf("ship-empty show should print an empty endpoint, got %q", res.Endpoint) + } + allowed := map[string]bool{ + "command": true, "flags": true, "outcome": true, "error_category": true, + "duration_ms": true, "os": true, "arch": true, "is_wsl2": true, + "tool_version": true, "install_id": true, + } + for k := range res.Event { + if !allowed[k] { + t.Errorf("telemetry show event carried un-allowlisted key %q", k) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 5bb8449..0ea65be 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -18,8 +18,10 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/goccy/go-yaml" + "github.com/google/uuid" "github.com/open-source-cloud/devstack/internal/config" ) @@ -78,6 +80,21 @@ type Config struct { APIVersion string `yaml:"apiVersion"` Kind string `yaml:"kind"` Shared map[string]config.SharedSvc `yaml:"shared"` + // Telemetry is the per-user/per-machine opt-in usage-telemetry consent + // (spec 20). It lives here — never in workspace.yaml (must not be committed) + // and never in state.db (it's user policy, not ledger state). Default OFF: a + // zero value / missing block means telemetry has never been enabled. + Telemetry TelemetryConfig `yaml:"telemetry"` +} + +// TelemetryConfig is the persisted telemetry consent. Enabled defaults to false +// and is only ever set true by an explicit `telemetry enable`. InstallID is a +// random UUIDv4 minted on first enable (rotatable, not machine-derived); it is +// cleared when telemetry is disabled. +type TelemetryConfig struct { + Enabled bool `yaml:"enabled"` + InstallID string `yaml:"installId,omitempty"` + ConsentAt string `yaml:"consentAt,omitempty"` } // DefaultConfig is the seed written by `store init`: one warm Postgres, Redis, @@ -139,6 +156,61 @@ func (c Config) Save() error { return os.Rename(tmpName, ConfigPath()) } +// loadOrDefault returns the persisted store config, or a fresh DefaultConfig when +// the store has not been initialized yet. Used by the telemetry consent mutators +// so `telemetry enable` works before `store init`. +func loadOrDefault() (*Config, error) { + cfg, ok, err := Load() + if err != nil { + return nil, err + } + if !ok { + c := DefaultConfig() + return &c, nil + } + return cfg, nil +} + +// TelemetryConsent reads the persisted telemetry consent. A missing/uninitialized +// store means "never decided" → OFF (default). It never errors on absence. +func TelemetryConsent() (TelemetryConfig, error) { + cfg, ok, err := Load() + if err != nil { + return TelemetryConfig{}, err + } + if !ok { + return TelemetryConfig{}, nil + } + return cfg.Telemetry, nil +} + +// SetTelemetry flips the persisted consent and saves the store config (creating +// the store with defaults if needed). Enabling mints a random UUIDv4 install id +// and stamps consentAt if not already set; disabling clears the install id so a +// disabled user carries no correlatable identifier. Returns the resulting consent. +func SetTelemetry(enabled bool) (TelemetryConfig, error) { + cfg, err := loadOrDefault() + if err != nil { + return TelemetryConfig{}, err + } + cfg.Telemetry.Enabled = enabled + if enabled { + if cfg.Telemetry.InstallID == "" { + cfg.Telemetry.InstallID = uuid.NewString() + } + if cfg.Telemetry.ConsentAt == "" { + cfg.Telemetry.ConsentAt = time.Now().UTC().Format(time.RFC3339) + } + } else { + cfg.Telemetry.InstallID = "" + cfg.Telemetry.ConsentAt = "" + } + if err := cfg.Save(); err != nil { + return TelemetryConfig{}, err + } + return cfg.Telemetry, nil +} + // templatesReadme is seeded into ~/.devstack/templates so the dir is discoverable. const templatesReadme = `# ~/.devstack/templates diff --git a/internal/store/store_test.go b/internal/store/store_test.go index c83a65c..b039098 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -62,6 +62,65 @@ func TestInitAndLoadRoundTrip(t *testing.T) { } } +func TestTelemetryDefaultOff(t *testing.T) { + t.Setenv(HomeEnv, t.TempDir()) + // Never decided (no store) → OFF, no error. + consent, err := TelemetryConsent() + if err != nil { + t.Fatal(err) + } + if consent.Enabled { + t.Fatal("telemetry must default OFF with no store present") + } +} + +func TestTelemetryEnableDisableRoundTrip(t *testing.T) { + t.Setenv(HomeEnv, t.TempDir()) + + // Enable works even before `store init` and mints an install id + consentAt. + on, err := SetTelemetry(true) + if err != nil { + t.Fatal(err) + } + if !on.Enabled || on.InstallID == "" || on.ConsentAt == "" { + t.Fatalf("enable produced %+v; want enabled with install id + consentAt", on) + } + + // Persisted to the global config.yaml. + got, err := TelemetryConsent() + if err != nil { + t.Fatal(err) + } + if !got.Enabled || got.InstallID != on.InstallID { + t.Fatalf("persisted consent = %+v, want enabled with install id %q", got, on.InstallID) + } + + // Disable flips it OFF and clears the correlatable install id. + off, err := SetTelemetry(false) + if err != nil { + t.Fatal(err) + } + if off.Enabled || off.InstallID != "" { + t.Fatalf("disable produced %+v; want disabled with no install id", off) + } + got, err = TelemetryConsent() + if err != nil { + t.Fatal(err) + } + if got.Enabled { + t.Fatal("consent should be OFF after disable") + } + + // Enabling shared-service defaults are preserved (we didn't clobber the store). + cfg, ok, err := Load() + if err != nil || !ok { + t.Fatalf("Load: ok=%v err=%v", ok, err) + } + if _, ok := cfg.Shared["postgres"]; !ok { + t.Error("enable/disable clobbered the default shared services") + } +} + func TestLoadAbsentIsNotError(t *testing.T) { t.Setenv(HomeEnv, t.TempDir()) cfg, ok, err := Load() diff --git a/internal/telemetry/categorize.go b/internal/telemetry/categorize.go new file mode 100644 index 0000000..7d6c807 --- /dev/null +++ b/internal/telemetry/categorize.go @@ -0,0 +1,38 @@ +package telemetry + +import "strings" + +// CategorizeError maps a wrapped error to one of the closed Category* enum values. +// It inspects known signatures (ARCHITECTURE §7.6) but returns ONLY the enum — the +// raw err.Error() text is never returned or transmitted, so an IP, path, username, +// or repo name embedded in the message cannot leak. An unmatched error becomes +// CategoryOther. +func CategorizeError(err error) string { + if err == nil { + return "" + } + msg := strings.ToLower(err.Error()) + switch { + case containsAny(msg, "cannot connect to the docker daemon", "is the docker daemon running", "dockerd", "/var/run/docker.sock"): + return CategoryDockerDaemonUnreachable + case containsAny(msg, "compose", "docker compose") && containsAny(msg, "too old", "version", "requires", "unsupported"): + return CategoryComposeTooOld + case containsAny(msg, "port is already allocated", "address already in use", "port in use", "bind: address already"): + return CategoryPortInUse + case containsAny(msg, "authentication failed", "could not read username", "terminal prompts disabled", "askpass", "permission denied (publickey"): + return CategoryGitAuthPrompt + case containsAny(msg, "database is locked", "resource temporarily unavailable", "could not acquire lock", "state locked", "busy"): + return CategoryStateLocked + default: + return CategoryOther + } +} + +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/internal/telemetry/httpsink.go b/internal/telemetry/httpsink.go new file mode 100644 index 0000000..0e8a02f --- /dev/null +++ b/internal/telemetry/httpsink.go @@ -0,0 +1,63 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// HTTPSink is a pluggable, best-effort HTTP sink for a self-hoster to POST the +// allowlisted Event as JSON to their OWN collector. +// +// IT IS UNUSED AND DISABLED BY DEFAULT. This build never constructs an HTTPSink +// (DefaultSink returns NoopSink and DefaultEndpoint is empty). It exists only so +// that, once a project-operated collector and its published privacy policy exist, +// wiring real transport is a small, reviewed change rather than a new subsystem. +// +// TODO(spec 20): the real transport should be a pure-Go OTLP/HTTP exporter +// (go.opentelemetry.io/otel + otlploghttp) modeling events as OTel log records on +// the `devstack.cli.command` scope, honoring OTEL_EXPORTER_OTLP_ENDPOINT and a +// deterministic head sampler (errors always sent). This plain JSON POST is a +// scaffold placeholder, deliberately not wired. +type HTTPSink struct { + // Endpoint is the collector URL. When empty, Send is a no-op (belt-and-braces + // against accidental activation). + Endpoint string + // Client is the HTTP client; a zero value uses a short-timeout default so a + // hung collector can never block a command. + Client *http.Client +} + +// Send POSTs ev as JSON, best-effort. A missing endpoint, network error, timeout, +// or non-2xx is returned to the caller but the Recorder swallows it — telemetry +// never changes a command's exit code. In this build no caller ever invokes it. +func (s HTTPSink) Send(ctx context.Context, ev Event) error { + if s.Endpoint == "" { + return nil // disabled: nothing configured, nothing sent + } + body, err := json.Marshal(ev) + if err != nil { + return err + } + client := s.Client + if client == nil { + client = &http.Client{Timeout: 500 * time.Millisecond} + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.Endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("telemetry endpoint returned %s", resp.Status) + } + return nil +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..4052d3e --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,186 @@ +// Package telemetry is the opt-in, privacy-first usage-telemetry scaffold +// (spec 20). It is DEFAULT OFF and, in this build, ships EMPTY: no real network +// endpoint is wired. The only sink that is actually used is the no-op sink, which +// records nothing and makes no network calls. A pluggable HTTP sink exists for a +// self-hoster to attach later, but it is unused and disabled by default. +// +// Design pillars: +// +// - Strictly opt-in. Nothing is collected, queued, or sent until the user has +// affirmatively enabled telemetry (persisted consent, default false). The +// Recorder short-circuits BEFORE constructing or sending an event when +// disabled — proven by test. +// - Allowlist, never denylist. The Event struct has ONLY the coarse, +// non-identifying fields below. There is no free-form string map and no +// reflection "dump everything" path, so a future field cannot accidentally +// carry PII. Adding a field is a deliberate, reviewed change. +// - Never capture paths, repo/project names, secrets, env names/values, +// ${ref} keys, git remotes, hostnames, usernames, IPs, or raw error strings. +// Errors are mapped to a small closed enum (CategorizeError) — never the raw +// err.Error() text. +// - Best-effort. A sink failure never affects a command's exit code, output, +// or latency (callers run Record in a detached goroutine with a budget). +// +// What WOULD be sent, exhaustively, is the Event struct — see its fields. Run +// `devstack telemetry show` to print a real sample byte-for-byte. +package telemetry + +import ( + "context" + "runtime" + "time" + + "github.com/open-source-cloud/devstack/internal/xdg" +) + +// DefaultEndpoint is intentionally EMPTY (ship-empty, spec 20 Q-TELEMETRY): no +// project-operated collector exists yet, so telemetry can be enabled but goes +// nowhere until a self-hoster sets an endpoint. When this is empty the CLI wires +// the no-op sink regardless of consent. +// +// TODO(spec 20): once a project-operated OTLP collector exists (with a published +// data-retention + source-IP-dropping policy), set this to that collector's +// OTLP/HTTP URL and switch DefaultSink to return the HTTP sink when an endpoint is +// configured. Until then this MUST stay empty so no traffic can leave a machine. +const DefaultEndpoint = "" + +// Outcome classes (closed set). +const ( + OutcomeOK = "ok" + OutcomeError = "error" +) + +// Error categories — a closed enum mapped from wrapped-error signatures +// (ARCHITECTURE §7.6). The raw err.Error() is NEVER transmitted; only one of +// these stable strings. An unmatched error becomes CategoryOther (no text). +const ( + CategoryDockerDaemonUnreachable = "docker_daemon_unreachable" + CategoryComposeTooOld = "compose_too_old" + CategoryPortInUse = "port_in_use" + CategoryGitAuthPrompt = "git_auth_prompt" + CategoryStateLocked = "state_locked" + CategoryOther = "error_other" +) + +// Event is the EXHAUSTIVE allowlist of what telemetry would send. Every field is +// a coarse, non-identifying counter. There is deliberately no metadata map and no +// free-form string field, so PII is structurally impossible to attach. +type Event struct { + // Command is the cobra command path only (e.g. "up", "shared gc") — never + // argument or flag VALUES. + Command string `json:"command"` + // Flags are the NAMES of flags that were set (e.g. ["--build"]) — never their + // values. + Flags []string `json:"flags,omitempty"` + // Outcome is OutcomeOK or OutcomeError. + Outcome string `json:"outcome"` + // ErrorCategory is one of the Category* enum values, set only on error. Never + // a raw error string. + ErrorCategory string `json:"error_category,omitempty"` + // DurationMS is the wall time bucketed to the nearest 100ms to blunt + // fingerprinting. + DurationMS int64 `json:"duration_ms"` + // OS/Arch are runtime.GOOS / runtime.GOARCH. + OS string `json:"os"` + Arch string `json:"arch"` + // IsWSL2 comes from the shared WSL detector — the single highest-value + // dimension. + IsWSL2 bool `json:"is_wsl2"` + // ToolVersion is the ldflags-stamped devstack version. + ToolVersion string `json:"tool_version"` + // InstallID is a random UUIDv4 generated once, not derived from anything + // machine-identifying. It correlates events from one install for funnel + // analysis, nothing more. + InstallID string `json:"install_id"` +} + +// EventInput carries the ONLY caller-supplied, pre-vetted fields NewEvent will +// accept. Everything else (os/arch/wsl2/duration bucketing) is filled in here so +// a caller cannot smuggle an un-allowlisted value into the payload. +type EventInput struct { + Command string + Flags []string + Err error // categorized to the enum; the raw text is discarded + Duration time.Duration // bucketed to 100ms + ToolVersion string + InstallID string +} + +// NewEvent builds a redacted Event from vetted inputs. It is the ONLY constructor: +// it discards the raw error (keeping only the category enum), buckets the +// duration, and fills os/arch/wsl2 from the runtime — so no path, hostname, or +// error string can reach the wire. +func NewEvent(in EventInput) Event { + outcome := OutcomeOK + var cat string + if in.Err != nil { + outcome = OutcomeError + cat = CategorizeError(in.Err) + } + return Event{ + Command: in.Command, + Flags: in.Flags, + Outcome: outcome, + ErrorCategory: cat, + DurationMS: bucketMS(in.Duration), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + IsWSL2: xdg.IsWSL2(), + ToolVersion: in.ToolVersion, + InstallID: in.InstallID, + } +} + +// bucketMS rounds a duration to the nearest 100ms (never negative). +func bucketMS(d time.Duration) int64 { + if d <= 0 { + return 0 + } + ms := d.Milliseconds() + return ((ms + 50) / 100) * 100 +} + +// Sink is the transport contract. Send delivers one event; it MUST be best-effort +// from the caller's perspective (any error is swallowed by the Recorder). No sink +// in this build performs real network I/O by default. +type Sink interface { + Send(ctx context.Context, ev Event) error +} + +// NoopSink is the ONLY sink wired in this ship-empty build: it records nothing and +// makes no network call. It exists so the whole pipeline is exercisable (and +// testable) without ever emitting a byte off-machine. +type NoopSink struct{} + +// Send discards the event. +func (NoopSink) Send(context.Context, Event) error { return nil } + +// DefaultSink returns the sink the CLI uses. Ship-empty: always NoopSink until a +// real endpoint is configured and wired (see DefaultEndpoint TODO). +func DefaultSink() Sink { return NoopSink{} } + +// Recorder decides whether an event is sent. It is the consent gate: when Enabled +// is false (the default) Record short-circuits and NEVER touches the sink — a +// different branch from "sampled out". Both are covered by tests. +type Recorder struct { + // Enabled reflects persisted consent AND all auto-off conditions resolved by + // the caller. If false, nothing is sent. + Enabled bool + // Sink is the transport; defaults to NoopSink when nil. + Sink Sink +} + +// Record sends ev best-effort. It returns whether the sink was invoked (useful for +// tests and --debug). A nil/absent consent means no send. Any sink error is +// swallowed: telemetry must never change a command's outcome. +func (r Recorder) Record(ctx context.Context, ev Event) (sent bool) { + if !r.Enabled { + return false + } + sink := r.Sink + if sink == nil { + sink = NoopSink{} + } + _ = sink.Send(ctx, ev) + return true +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 0000000..263ade4 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,189 @@ +package telemetry + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +// countingSink records how many times Send was invoked so we can prove the +// consent gate short-circuits before ever touching a sink. +type countingSink struct { + calls int + lastEv Event +} + +func (c *countingSink) Send(_ context.Context, ev Event) error { + c.calls++ + c.lastEv = ev + return nil +} + +func TestRecorderDisabledNeverCallsSink(t *testing.T) { + sink := &countingSink{} + r := Recorder{Enabled: false, Sink: sink} + sent := r.Record(context.Background(), Event{Command: "up"}) + if sent { + t.Fatal("Record reported sent=true while disabled") + } + if sink.calls != 0 { + t.Fatalf("disabled recorder called the sink %d times; want 0", sink.calls) + } +} + +func TestRecorderEnabledCallsSink(t *testing.T) { + sink := &countingSink{} + r := Recorder{Enabled: true, Sink: sink} + sent := r.Record(context.Background(), Event{Command: "up"}) + if !sent || sink.calls != 1 { + t.Fatalf("enabled recorder: sent=%v calls=%d; want true/1", sent, sink.calls) + } + if sink.lastEv.Command != "up" { + t.Fatalf("sink got command %q", sink.lastEv.Command) + } +} + +// failingSink returns an error to prove the Recorder swallows it (best-effort). +type failingSink struct{ calls int } + +func (f *failingSink) Send(context.Context, Event) error { + f.calls++ + return errors.New("collector exploded") +} + +func TestRecorderSwallowsSinkError(t *testing.T) { + sink := &failingSink{} + r := Recorder{Enabled: true, Sink: sink} + if sent := r.Record(context.Background(), Event{}); !sent { + t.Fatal("expected sent=true even when the sink errors") + } + if sink.calls != 1 { + t.Fatalf("sink calls = %d, want 1", sink.calls) + } +} + +func TestNoopSinkIsDefaultAndSendsNothing(t *testing.T) { + if _, ok := DefaultSink().(NoopSink); !ok { + t.Fatalf("DefaultSink() = %T, want NoopSink (ship-empty)", DefaultSink()) + } + if DefaultEndpoint != "" { + t.Fatalf("DefaultEndpoint = %q, want empty (ship-empty)", DefaultEndpoint) + } + if err := (NoopSink{}).Send(context.Background(), Event{}); err != nil { + t.Fatalf("NoopSink.Send err = %v", err) + } +} + +// TestEventAllowlistOnly is the privacy guardrail: the Event struct must contain +// ONLY the exhaustive allowlist of coarse fields and no free-form string map or +// escape hatch through which PII could travel. +func TestEventAllowlistOnly(t *testing.T) { + allowed := map[string]bool{ + "Command": true, "Flags": true, "Outcome": true, "ErrorCategory": true, + "DurationMS": true, "OS": true, "Arch": true, "IsWSL2": true, + "ToolVersion": true, "InstallID": true, + } + et := reflect.TypeOf(Event{}) + for i := 0; i < et.NumField(); i++ { + f := et.Field(i) + if !allowed[f.Name] { + t.Errorf("Event has un-allowlisted field %q — PII risk", f.Name) + } + // No map fields: a map[string]... would be a denylist-defeating escape hatch. + if f.Type.Kind() == reflect.Map { + t.Errorf("Event field %q is a map — no free-form metadata allowed", f.Name) + } + } +} + +// TestNewEventRedactsRawError proves the raw err.Error() text (which may embed a +// path, IP, or username) never reaches the payload — only the category enum does. +func TestNewEventRedactsRawError(t *testing.T) { + raw := "open /home/alice/acme-secrets/.env: dial tcp 10.0.0.5:5432: connection refused" + ev := NewEvent(EventInput{ + Command: "up", + Err: errors.New(raw), + Duration: 1234 * time.Millisecond, + ToolVersion: "9.9.9", + InstallID: "install-xyz", + }) + if ev.Outcome != OutcomeError { + t.Fatalf("outcome = %q, want error", ev.Outcome) + } + if ev.ErrorCategory == "" { + t.Fatal("expected an error category") + } + blob, err := json.Marshal(ev) + if err != nil { + t.Fatal(err) + } + s := string(blob) + for _, leak := range []string{"alice", "acme-secrets", ".env", "10.0.0.5", "5432", "connection refused"} { + if strings.Contains(s, leak) { + t.Errorf("payload leaked sensitive substring %q: %s", leak, s) + } + } +} + +func TestNewEventBucketsDuration(t *testing.T) { + cases := []struct { + in time.Duration + want int64 + }{ + {0, 0}, + {-5 * time.Millisecond, 0}, + {49 * time.Millisecond, 0}, + {51 * time.Millisecond, 100}, + {1840 * time.Millisecond, 1800}, + {1851 * time.Millisecond, 1900}, + } + for _, c := range cases { + if got := NewEvent(EventInput{Duration: c.in}).DurationMS; got != c.want { + t.Errorf("bucket(%v) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestNewEventOKOutcomeHasNoCategory(t *testing.T) { + ev := NewEvent(EventInput{Command: "status", Err: nil}) + if ev.Outcome != OutcomeOK { + t.Fatalf("outcome = %q, want ok", ev.Outcome) + } + if ev.ErrorCategory != "" { + t.Fatalf("ok outcome carried a category %q", ev.ErrorCategory) + } +} + +func TestCategorizeError(t *testing.T) { + cases := []struct { + msg string + want string + }{ + {"Cannot connect to the Docker daemon at unix:///var/run/docker.sock", CategoryDockerDaemonUnreachable}, + {"docker compose version 2.10 is too old, requires >= 2.20", CategoryComposeTooOld}, + {"Error: port is already allocated", CategoryPortInUse}, + {"fatal: Authentication failed for 'https://github.com/x/y'", CategoryGitAuthPrompt}, + {"database is locked", CategoryStateLocked}, + {"some totally unknown failure with /home/bob/secret", CategoryOther}, + } + for _, c := range cases { + if got := CategorizeError(errors.New(c.msg)); got != c.want { + t.Errorf("CategorizeError(%q) = %q, want %q", c.msg, got, c.want) + } + } + if got := CategorizeError(nil); got != "" { + t.Errorf("CategorizeError(nil) = %q, want empty", got) + } +} + +func TestHTTPSinkDisabledWhenEndpointEmpty(t *testing.T) { + // An empty endpoint must never dial — belt-and-braces against accidental + // activation of the (unused) HTTP sink. + if err := (HTTPSink{Endpoint: ""}).Send(context.Background(), Event{}); err != nil { + t.Fatalf("HTTPSink with empty endpoint should be a no-op, got %v", err) + } +}