From d18e26bf7173c698f7584b287863be736575f7a6 Mon Sep 17 00:00:00 2001 From: Sebastian Tiedtke Date: Wed, 18 Sep 2024 17:23:30 -0700 Subject: [PATCH] Telemetry --- internal/cmd/beta/server/server_start_cmd.go | 3 + internal/cmd/server.go | 3 + internal/telemetry/scarf.go | 110 +++++++++++++++++++ internal/telemetry/scarf_test.go | 71 ++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 internal/telemetry/scarf.go create mode 100644 internal/telemetry/scarf_test.go diff --git a/internal/cmd/beta/server/server_start_cmd.go b/internal/cmd/beta/server/server_start_cmd.go index 5afd6361..c1ecbba1 100644 --- a/internal/cmd/beta/server/server_start_cmd.go +++ b/internal/cmd/beta/server/server_start_cmd.go @@ -11,6 +11,7 @@ import ( "github.com/stateful/runme/v3/internal/config" "github.com/stateful/runme/v3/internal/config/autoconfig" "github.com/stateful/runme/v3/internal/server" + "github.com/stateful/runme/v3/internal/telemetry" ) func serverStartCmd() *cobra.Command { @@ -33,6 +34,8 @@ func serverStartCmd() *cobra.Command { TLSEnabled: cfg.ServerTLSEnabled, } + _ = telemetry.ReportUnlessNoTracking(logger) + logger.Debug("server config", zap.Any("config", serverCfg)) s, err := server.New(serverCfg, cmdFactory, logger) diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 640751b4..b5f5bee8 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -17,6 +17,7 @@ import ( "github.com/stateful/runme/v3/internal/project/projectservice" "github.com/stateful/runme/v3/internal/runner" runnerv2service "github.com/stateful/runme/v3/internal/runnerv2service" + "github.com/stateful/runme/v3/internal/telemetry" runmetls "github.com/stateful/runme/v3/internal/tls" parserv1 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/parser/v1" projectv1 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/project/v1" @@ -95,6 +96,8 @@ The kernel is used to run long running processes like shells and interacting wit return err } + _ = telemetry.ReportUnlessNoTracking(logger) + logger.Info("started listening", zap.String("addr", lis.Addr().String())) const maxMsgSize = 100 * 1024 * 1024 // 100 MiB diff --git a/internal/telemetry/scarf.go b/internal/telemetry/scarf.go new file mode 100644 index 00000000..587aef36 --- /dev/null +++ b/internal/telemetry/scarf.go @@ -0,0 +1,110 @@ +package telemetry + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/pkg/errors" + "go.uber.org/zap" +) + +const ( + base = "https://home.runme.dev/" + client = "Kernel" +) + +type LookupEnv func(key string) (string, bool) + +// Returns true if telemetry reporting is enabled, false otherwise. +func ReportUnlessNoTracking(logger *zap.Logger) bool { + if v := os.Getenv("DO_NOT_TRACK"); v != "" && v != "0" && v != "false" { + logger.Info("Telemetry reporting is disabled with DO_NOT_TRACK") + return false + } + + if v := os.Getenv("SCARF_NO_ANALYTICS"); v != "" && v != "0" && v != "false" { + logger.Info("Telemetry reporting is disabled with SCARF_NO_ANALYTICS") + return false + } + + logger.Info("Telemetry reporting is enabled") + + go func() { + err := report() + if err != nil { + logger.Warn("Error reporting telemetry", zap.Error(err)) + } + }() + + return true +} + +func report() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + encodedURL, err := buildURL(os.LookupEnv, client) + if err != nil { + return errors.Wrapf(err, "Error building telemtry URL") + } + + req, err := http.NewRequestWithContext(ctx, "GET", encodedURL.String(), nil) + if err != nil { + return errors.Wrapf(err, "Error creating telemetry request") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrapf(err, "Error sending telemetry request") + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("error sending telemetry request: status_code=%d, status=%s", resp.StatusCode, resp.Status) + } + + return nil +} + +func buildURL(lookup LookupEnv, client string) (*url.URL, error) { + baseAndClient := base + client + + props := []string{ + "extname", + "extversion", + "remotename", + "appname", + "product", + "platform", + "uikind", + } + + params := url.Values{} + for _, p := range props { + addValue(lookup, ¶ms, p) + } + + // until we have a non-extension-bundled reporting strategy, lets error + if len(params) == 0 { + return nil, fmt.Errorf("no telemetry properties provided") + } + + dst, err := url.Parse(baseAndClient) + if err != nil { + return nil, err + } + dst.RawQuery = params.Encode() + + return dst, nil +} + +func addValue(lookup LookupEnv, params *url.Values, prop string) { + if v, ok := lookup(fmt.Sprintf("TELEMETRY_%s", strings.ToUpper(prop))); ok { + params.Add(strings.ToLower(prop), v) + } +} diff --git a/internal/telemetry/scarf_test.go b/internal/telemetry/scarf_test.go new file mode 100644 index 00000000..b1d865c9 --- /dev/null +++ b/internal/telemetry/scarf_test.go @@ -0,0 +1,71 @@ +package telemetry + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestReportUnlessNoTracking(t *testing.T) { + t.Run("Track", func(t *testing.T) { + logger := zap.NewNop() + require.True(t, ReportUnlessNoTracking(logger)) + }) + + t.Run("DO_NOT_TRACK", func(t *testing.T) { + logger := zap.NewNop() + t.Setenv("DO_NOT_TRACK", "true") + require.False(t, ReportUnlessNoTracking(logger)) + }) + + t.Run("SCARF_NO_ANALYTICS", func(t *testing.T) { + logger := zap.NewNop() + t.Setenv("SCARF_NO_ANALYTICS", "true") + defer os.Unsetenv("SCARF_NO_ANALYTICS") + require.False(t, ReportUnlessNoTracking(logger)) + }) +} + +func TestUrlBuilder(t *testing.T) { + t.Parallel() + + t.Run("Full", func(t *testing.T) { + lookupEnv := createLookup(map[string]string{ + "TELEMETRY_EXTNAME": "stateful.runme", + "TELEMETRY_EXTVERSION": "3.7.7-dev.10", + "TELEMETRY_REMOTENAME": "none", + "TELEMETRY_APPNAME": "Visual Studio Code", + "TELEMETRY_PRODUCT": "desktop", + "TELEMETRY_PLATFORM": "darwin_arm64", + "TELEMETRY_UIKIND": "desktop", + }) + dst, err := buildURL(lookupEnv, "Kernel") + require.NoError(t, err) + require.Equal(t, "https://home.runme.dev/Kernel?appname=Visual+Studio+Code&extname=stateful.runme&extversion=3.7.7-dev.10&platform=darwin_arm64&product=desktop&remotename=none&uikind=desktop", dst.String()) + }) + + t.Run("Partial", func(t *testing.T) { + lookupEnv := createLookup(map[string]string{ + "TELEMETRY_EXTNAME": "stateful.runme", + "TELEMETRY_PLATFORM": "linux_x64", + }) + dst, err := buildURL(lookupEnv, "Kernel") + require.NoError(t, err) + require.Equal(t, "https://home.runme.dev/Kernel?extname=stateful.runme&platform=linux_x64", dst.String()) + }) + + t.Run("Empty", func(t *testing.T) { + lookupEnv := createLookup(map[string]string{}) + _, err := buildURL(lookupEnv, "Kernel") + require.Error(t, err, "no telemetry properties provided") + }) +} + +func createLookup(fixture map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := fixture[key] + return value, ok + } +}