diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a888d7c..dd96ac1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,6 +18,8 @@ builds: - CGO_ENABLED=0 id: openstatus main: ./cmd/openstatus + ldflags: + - -s -w -X github.com/openstatusHQ/cli/internal/version.Version={{ .Tag }} goos: - linux - windows diff --git a/README.md b/README.md index 2ad640e..6af9ffa 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,22 @@ openstatus whoami `openstatus terraform generate` then writes the matching `base_url` into `provider.tf`. The `check` command always uses the public speed checker. +## Usage Data + +Requests to the API carry a `User-Agent` with the CLI version and platform, plus the command being run (`x-openstatus-cli-command`, e.g. `monitors list`) and a random per-run ID (`x-openstatus-cli-invocation`). We use them to understand which commands are used. No arguments, flag values or file contents are sent. + +To stop sending the command and run ID, set either variable (`0`, `false`, `no` and `off` leave it enabled): + +```bash +export DO_NOT_TRACK=1 +# or +export OPENSTATUS_NO_TELEMETRY=1 +``` + +Opting out does not remove the `User-Agent`: like any HTTP client, the CLI still identifies itself with its version and platform (e.g. `openstatus-cli/v1.3.2 (darwin; arm64)`). + +When `OPENSTATUS_API_URL` points at a self-hosted instance, the command and run ID only go to that instance; `check` does not send them to openstatus.dev. + ## Development ### Run Tests diff --git a/internal/api/client.go b/internal/api/client.go index f4d574f..38ef6d1 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -56,7 +56,8 @@ func resolveBaseURL(v string) (string, error) { const PlayCheckerURL = "https://www.openstatus.dev/play/checker/api" var DefaultHTTPClient = &http.Client{ - Timeout: 30 * time.Second, + Timeout: 30 * time.Second, + Transport: NewTransport(nil), } func NewAuthInterceptor(apiKey string) connect.UnaryInterceptorFunc { diff --git a/internal/api/useragent.go b/internal/api/useragent.go new file mode 100644 index 0000000..e96dc76 --- /dev/null +++ b/internal/api/useragent.go @@ -0,0 +1,118 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "net/url" + "os" + "runtime" + "strconv" + "strings" + "sync/atomic" + + "github.com/openstatusHQ/cli/internal/version" +) + +// Headers sent on every request so the API can attribute usage to CLI +// commands without the CLI making any extra call. +const ( + HeaderCLICommand = "x-openstatus-cli-command" + HeaderCLIInvocation = "x-openstatus-cli-invocation" +) + +// userAgent identifies the CLI, e.g. "openstatus-cli/v1.3.2 (darwin; arm64)". +var userAgent = "openstatus-cli/" + version.Version + " (" + runtime.GOOS + "; " + runtime.GOARCH + ")" + +var ( + command atomic.Pointer[string] + // invocationID is random per process: it lets the API count one command + // run once even when it makes several requests. + invocationID = newInvocationID() +) + +// SetCommand records the command being run, e.g. "monitors apply". Call it +// before the command makes any request. +func SetCommand(name string) { command.Store(&name) } + +// UsageOptOut reports whether the user asked not to share command usage, via +// DO_NOT_TRACK or OPENSTATUS_NO_TELEMETRY. +func UsageOptOut() bool { + return isSet(os.Getenv("DO_NOT_TRACK")) || isSet(os.Getenv("OPENSTATUS_NO_TELEMETRY")) +} + +// isSet treats empty and falsy values (0, false, no, off, any case) as unset, +// and anything else as an opt-out. +func isSet(v string) bool { + v = strings.TrimSpace(v) + if v == "" || strings.EqualFold(v, "no") || strings.EqualFold(v, "off") { + return false + } + if b, err := strconv.ParseBool(v); err == nil { + return b + } + return true +} + +// sendsUsage reports whether a request to u may carry the command and +// invocation ID: only requests to the configured API, plus the hosted speed +// checker when the CLI targets openstatus Cloud. A self-hosted setup never +// sends them to openstatus.dev. +func sendsUsage(u *url.URL) bool { + if UsageOptOut() { + return false + } + if u.Host == hostOf(BaseURL) { + return true + } + return BaseURL == DefaultBaseURL && u.Host == hostOf(PlayCheckerURL) +} + +func hostOf(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return u.Host +} + +func newInvocationID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// cliTransport tags outgoing requests with the CLI headers. +type cliTransport struct { + base http.RoundTripper +} + +// NewTransport wraps base (http.DefaultTransport when nil) so every request +// carries the CLI user agent and, when sendsUsage allows it, the command and +// invocation ID. +func NewTransport(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return cliTransport{base: base} +} + +func (t cliTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // A RoundTripper must not modify the caller's request. + req = req.Clone(req.Context()) + + // Keep any existing agent (e.g. connect-go's) after ours. + ua := userAgent + if existing := req.Header.Get("User-Agent"); existing != "" { + ua += " " + existing + } + req.Header.Set("User-Agent", ua) + + if sendsUsage(req.URL) { + req.Header.Set(HeaderCLIInvocation, invocationID) + if name := command.Load(); name != nil && *name != "" { + req.Header.Set(HeaderCLICommand, *name) + } + } + return t.base.RoundTrip(req) +} diff --git a/internal/api/useragent_test.go b/internal/api/useragent_test.go new file mode 100644 index 0000000..469255a --- /dev/null +++ b/internal/api/useragent_test.go @@ -0,0 +1,163 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + "runtime" + "testing" + + "github.com/openstatusHQ/cli/internal/version" +) + +// setBaseURL points the API base URL at u for the duration of the test. +func setBaseURL(t *testing.T, u string) { + t.Helper() + prev := BaseURL + BaseURL = u + t.Cleanup(func() { BaseURL = prev }) +} + +// roundTrip sends req through the CLI transport to a test server configured as +// the API, and returns the headers the server received. +func roundTrip(t *testing.T, req *http.Request) http.Header { + t.Helper() + var got http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + })) + defer srv.Close() + setBaseURL(t, srv.URL) + + if req == nil { + var err error + if req, err = http.NewRequest(http.MethodGet, srv.URL, http.NoBody); err != nil { + t.Fatal(err) + } + } else { + req.URL, _ = req.URL.Parse(srv.URL) + } + resp, err := (&http.Client{Transport: NewTransport(nil)}).Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return got +} + +func TestCLITransport(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("OPENSTATUS_NO_TELEMETRY", "") + t.Cleanup(func() { SetCommand("") }) + SetCommand("monitors apply") + + req, _ := http.NewRequest(http.MethodGet, "http://placeholder", http.NoBody) + req.Header.Set("x-openstatus-key", "secret") + got := roundTrip(t, req) + + if want := "openstatus-cli/" + version.Version + " (" + runtime.GOOS + "; " + runtime.GOARCH + ")"; got.Get("User-Agent") != want { + t.Errorf("User-Agent = %q, want %q", got.Get("User-Agent"), want) + } + if got.Get(HeaderCLICommand) != "monitors apply" { + t.Errorf("%s = %q, want %q", HeaderCLICommand, got.Get(HeaderCLICommand), "monitors apply") + } + if id := got.Get(HeaderCLIInvocation); len(id) != 32 || id != invocationID { + t.Errorf("%s = %q, want the process invocation ID", HeaderCLIInvocation, id) + } + if got.Get("x-openstatus-key") != "secret" { + t.Error("existing headers must be preserved") + } + if req.Header.Get("User-Agent") != "" { + t.Error("transport must not mutate the caller's request") + } +} + +func TestCLITransportKeepsExistingUserAgent(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, "http://placeholder", http.NoBody) + req.Header.Set("User-Agent", "connect-go/1.20.0 (go1.25.0)") + got := roundTrip(t, req) + + if want := userAgent + " connect-go/1.20.0 (go1.25.0)"; got.Get("User-Agent") != want { + t.Errorf("User-Agent = %q, want %q", got.Get("User-Agent"), want) + } +} + +func TestCLITransportWithoutCommand(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("OPENSTATUS_NO_TELEMETRY", "") + t.Cleanup(func() { SetCommand("") }) + SetCommand("") + + got := roundTrip(t, nil) + if _, ok := got[http.CanonicalHeaderKey(HeaderCLICommand)]; ok { + t.Errorf("%s should be omitted when no command is set", HeaderCLICommand) + } +} + +func TestCLITransportOptOut(t *testing.T) { + t.Cleanup(func() { SetCommand("") }) + SetCommand("monitors apply") + + for _, env := range []string{"DO_NOT_TRACK", "OPENSTATUS_NO_TELEMETRY"} { + t.Run(env, func(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("OPENSTATUS_NO_TELEMETRY", "") + t.Setenv(env, "1") + + got := roundTrip(t, nil) + for _, h := range []string{HeaderCLICommand, HeaderCLIInvocation} { + if _, ok := got[http.CanonicalHeaderKey(h)]; ok { + t.Errorf("%s should be omitted when %s is set", h, env) + } + } + if got.Get("User-Agent") != userAgent { + t.Errorf("User-Agent = %q, want %q", got.Get("User-Agent"), userAgent) + } + }) + } +} + +func TestSendsUsage(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("OPENSTATUS_NO_TELEMETRY", "") + checker, _ := url.Parse(PlayCheckerURL) + cloud, _ := url.Parse(DefaultBaseURL + "/rpc/x") + selfHosted, _ := url.Parse("https://api.example.com/rpc/x") + other, _ := url.Parse("https://elsewhere.example.com/") + + tests := []struct { + name string + base string + u *url.URL + want bool + }{ + {"cloud api", DefaultBaseURL, cloud, true}, + {"cloud speed checker", DefaultBaseURL, checker, true}, + {"cloud other host", DefaultBaseURL, other, false}, + {"self-hosted api", "https://api.example.com", selfHosted, true}, + {"self-hosted speed checker", "https://api.example.com", checker, false}, + {"self-hosted cloud api", "https://api.example.com", cloud, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setBaseURL(t, tt.base) + if got := sendsUsage(tt.u); got != tt.want { + t.Errorf("sendsUsage(%s) with BaseURL %s = %v, want %v", tt.u, tt.base, got, tt.want) + } + }) + } +} + +func TestUsageOptOutValues(t *testing.T) { + for v, want := range map[string]bool{ + "": false, " ": false, "0": false, "false": false, "FALSE": false, "False": false, + "no": false, "NO": false, "off": false, "Off": false, " 0 ": false, + "1": true, "true": true, "TRUE": true, "yes": true, " 1 ": true, + } { + t.Setenv("OPENSTATUS_NO_TELEMETRY", "") + t.Setenv("DO_NOT_TRACK", v) + if got := UsageOptOut(); got != want { + t.Errorf("DO_NOT_TRACK=%q: UsageOptOut() = %v, want %v", v, got, want) + } + } +} diff --git a/internal/check/client.go b/internal/check/client.go index a0be209..e2ba3a8 100644 --- a/internal/check/client.go +++ b/internal/check/client.go @@ -28,7 +28,7 @@ func Run(ctx context.Context, client *http.Client, payload Payload, onRow OnRow) onRow = func(RegionResult) {} } if client == nil { - client = &http.Client{Timeout: defaultTimeout} + client = &http.Client{Timeout: defaultTimeout, Transport: api.NewTransport(nil)} } body, err := json.Marshal(payload) diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 589071e..1e728bb 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -4,10 +4,12 @@ import ( "context" "os" "os/signal" + "strings" "syscall" "github.com/urfave/cli/v3" + "github.com/openstatusHQ/cli/internal/api" "github.com/openstatusHQ/cli/internal/check" output "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/login" @@ -19,6 +21,7 @@ import ( "github.com/openstatusHQ/cli/internal/statuspage" "github.com/openstatusHQ/cli/internal/statusreport" "github.com/openstatusHQ/cli/internal/terraform" + "github.com/openstatusHQ/cli/internal/version" "github.com/openstatusHQ/cli/internal/whoami" ) @@ -44,7 +47,7 @@ Get started: openstatus pl list List your private locations https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, - Version: "v1.3.2", + Version: version.Version, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "json", @@ -86,9 +89,31 @@ https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, terraform.TerraformCmd(), }, } + trackInvocation(app.Commands) return app } +// trackInvocation records the canonical command path (aliases resolve to the +// full name) for API requests. It hooks each runnable command's Before, which +// urfave/cli runs after its parents' Before hooks and ahead of flag actions and +// the command's own Action, so requests made from any of those carry it. +func trackInvocation(cmds []*cli.Command) { + for _, c := range cmds { + trackInvocation(c.Commands) + if c.Action == nil { + continue + } + before := c.Before + c.Before = func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + api.SetCommand(strings.TrimPrefix(cmd.FullName(), cmd.Root().Name+" ")) + if before == nil { + return ctx, nil + } + return before(ctx, cmd) + } + } +} + func RunApp(app *cli.Command) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 526ac4b..8c33ac2 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/openstatusHQ/cli/internal/cmd" + "github.com/openstatusHQ/cli/internal/version" ) func Test_NewApp(t *testing.T) { @@ -20,8 +21,8 @@ func Test_NewApp(t *testing.T) { t.Errorf("Expected app name 'openstatus', got %s", app.Name) } - if app.Version != "v1.3.2" { - t.Errorf("Expected version 'v1.3.2', got %s", app.Version) + if app.Version != version.Version { + t.Errorf("Expected version %q, got %s", version.Version, app.Version) } if !app.Suggest { diff --git a/internal/cmd/track_test.go b/internal/cmd/track_test.go new file mode 100644 index 0000000..e58bae1 --- /dev/null +++ b/internal/cmd/track_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/urfave/cli/v3" + + "github.com/openstatusHQ/cli/internal/api" +) + +func Test_trackInvocation(t *testing.T) { + var gotCommands []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCommands = append(gotCommands, r.Header.Get(api.HeaderCLICommand)) + })) + defer srv.Close() + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("OPENSTATUS_NO_TELEMETRY", "") + prevBase := api.BaseURL + api.BaseURL = srv.URL + t.Cleanup(func() { + api.BaseURL = prevBase + api.SetCommand("") + }) + + call := func() error { + resp, err := (&http.Client{Transport: api.NewTransport(nil)}).Get(srv.URL) + if err != nil { + return err + } + return resp.Body.Close() + } + app := &cli.Command{ + Name: "openstatus", + Commands: []*cli.Command{{ + Name: "private-locations", + Aliases: []string{"pl"}, + Commands: []*cli.Command{{ + Name: "list", + // A request from the command's own Before hook must be tagged too. + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + return ctx, call() + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return call() + }, + }}, + }}, + } + trackInvocation(app.Commands) + + if err := app.Run(context.Background(), []string{"openstatus", "pl", "list"}); err != nil { + t.Fatal(err) + } + want := []string{"private-locations list", "private-locations list"} + if len(gotCommands) != len(want) || gotCommands[0] != want[0] || gotCommands[1] != want[1] { + t.Errorf("command headers = %q, want %q", gotCommands, want) + } +} diff --git a/internal/run/run.go b/internal/run/run.go index 61c6b0f..f5973d1 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -214,7 +214,7 @@ tests: wg.Add(1) go func(idx, id int) { defer wg.Done() - res, err := MonitorTrigger(ctx, http.DefaultClient, apiKey, fmt.Sprintf("%d", id)) + res, err := MonitorTrigger(ctx, api.DefaultHTTPClient, apiKey, fmt.Sprintf("%d", id)) mu.Lock() results[idx] = indexedResult{index: idx, result: res, err: err} mu.Unlock() diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..ae465cc --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,7 @@ +// Package version holds the CLI release version, shared by the command tree +// and the API client's User-Agent. +package version + +// Version is overridden at release time via +// -ldflags "-X github.com/openstatusHQ/cli/internal/version.Version=". +var Version = "v1.3.2"