From 71593f30defc396d49a89f74781c3748daec362a Mon Sep 17 00:00:00 2001 From: JulesFaucherre Date: Thu, 6 Jul 2023 11:56:15 +0200 Subject: [PATCH] chore: Added telemetry events --- clitest/clitest.go | 10 +++++++++ cmd/create_telemetry.go | 19 +++++++++++++---- cmd/root.go | 3 ++- cmd/setup.go | 7 +++++++ cmd/setup_test.go | 36 +++++++++++++++++++++++++++++++++ cmd/version.go | 9 +++++++++ settings/settings.go | 35 +++++++++++++++++--------------- telemetry/events.go | 24 ++++++++++++++++++++++ telemetry/telemetry.go | 45 ++++++++++++++++++++++++++++++++++++++--- 9 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 telemetry/events.go diff --git a/clitest/clitest.go b/clitest/clitest.go index 62e83180b..ae7bd8033 100644 --- a/clitest/clitest.go +++ b/clitest/clitest.go @@ -11,9 +11,11 @@ import ( "runtime" "github.com/CircleCI-Public/circleci-cli/api/graphql" + "github.com/CircleCI-Public/circleci-cli/settings" "github.com/onsi/gomega/gexec" "github.com/onsi/gomega/ghttp" "github.com/onsi/gomega/types" + "gopkg.in/yaml.v3" "github.com/onsi/gomega" ) @@ -70,6 +72,14 @@ func WithTempSettings() *TempSettings { tempSettings.Config = OpenTmpFile(settingsPath, "cli.yml") tempSettings.Telemetry = OpenTmpFile(settingsPath, "telemetry.yml") + content, err := yaml.Marshal(settings.TelemetrySettings{ + IsActive: false, + HasAnsweredPrompt: true, + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + _, err = tempSettings.Telemetry.File.Write(content) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + tempSettings.Update = OpenTmpFile(settingsPath, "update_check.yml") tempSettings.TestServer = ghttp.NewServer() diff --git a/cmd/create_telemetry.go b/cmd/create_telemetry.go index 95e54105d..f5eaf3efc 100644 --- a/cmd/create_telemetry.go +++ b/cmd/create_telemetry.go @@ -46,14 +46,27 @@ func (client telemetryCircleCIAPI) getMyUserId() (string, error) { return me.ID, nil } +type nullTelemetryAPIClient struct{} + +func (client nullTelemetryAPIClient) getMyUserId() (string, error) { + panic("Should not be called") +} + // Make sure the user gave their approval for the telemetry and func createTelemetry(config *settings.Config) telemetry.Client { + if config.MockTelemetry != "" { + return telemetry.CreateFileTelemetry(config.MockTelemetry) + } + if config.IsTelemetryDisabled { return telemetry.CreateClient(telemetry.User{}, false) } - apiClient := telemetryCircleCIAPI{ - cli: rest.NewFromConfig(config.Host, config), + var apiClient telemetryAPIClient = nullTelemetryAPIClient{} + if config.HTTPClient != nil { + apiClient = telemetryCircleCIAPI{ + cli: rest.NewFromConfig(config.Host, config), + } } ui := telemetryInteractiveUI{} @@ -150,6 +163,4 @@ func loadTelemetrySettings(settings *settings.TelemetrySettings, user *telemetry if err := settings.Write(); err != nil { fmt.Printf("Error writing telemetry settings to disk: %s\n", err) } - - return } diff --git a/cmd/root.go b/cmd/root.go index 8139a2bd2..c85759851 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -186,8 +186,9 @@ func MakeCommands() *cobra.Command { flags.StringVar(&rootOptions.GitHubAPI, "github-api", "https://api.github.com/", "Change the default endpoint to GitHub API for retrieving updates") flags.BoolVar(&rootOptions.SkipUpdateCheck, "skip-update-check", skipUpdateByDefault(), "Skip the check for updates check run before every command.") flags.BoolVar(&rootOptions.IsTelemetryDisabled, "disable-telemetry", false, "Do not show telemetry for the actual command") + flags.StringVar(&rootOptions.MockTelemetry, "mock-telemetry", "", "The path where telemetry must be written") - hidden := []string{"github-api", "debug", "endpoint"} + hidden := []string{"github-api", "debug", "endpoint", "mock-telemetry"} for _, f := range hidden { if err := flags.MarkHidden(f); err != nil { diff --git a/cmd/setup.go b/cmd/setup.go index 6a2d068b5..396413655 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -7,6 +7,7 @@ import ( "github.com/CircleCI-Public/circleci-cli/api/graphql" "github.com/CircleCI-Public/circleci-cli/prompt" "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/telemetry" "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -189,6 +190,12 @@ func setup(opts setupOptions) error { setupDiagnosticCheck(opts) } + telemetryClient := createTelemetry(opts.cfg) + defer telemetryClient.Close() + if err := telemetryClient.Track(telemetry.CreateSetupEvent(opts.cfg.Host == defaultHost)); err != nil { + fmt.Printf("Unable to send telemetry event: %s\n", err) + } + return nil } diff --git a/cmd/setup_test.go b/cmd/setup_test.go index 591a6197d..99e4c6620 100644 --- a/cmd/setup_test.go +++ b/cmd/setup_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "regexp" "runtime" @@ -35,6 +36,41 @@ var _ = Describe("Setup with prompts", func() { tempSettings.Close() }) + Describe("telemetry", func() { + var ( + telemetryDestFilePath string + ) + + BeforeEach(func() { + telemetryDestFilePath = filepath.Join(tempSettings.Home, "telemetry-content") + + command = commandWithHome(pathCLI, tempSettings.Home, + "setup", + "--integration-testing", + "--skip-update-check", + "--mock-telemetry", telemetryDestFilePath, + ) + }) + + AfterEach(func() { + tempSettings.Close() + if _, err := os.Stat(telemetryDestFilePath); err == nil || !os.IsNotExist(err) { + os.Remove(telemetryDestFilePath) + } + }) + + It("should send telemetry event when", func() { + session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) + Expect(err).ShouldNot(HaveOccurred()) + + Eventually(session).Should(gexec.Exit(0)) + content, err := os.ReadFile(telemetryDestFilePath) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(content)).To(Equal(`{"object":"cli-setup","action":"called","properties":{"is_server_customer":false}} +`)) + }) + }) + Describe("new config file", func() { It("should set file permissions to 0600", func() { session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) diff --git a/cmd/version.go b/cmd/version.go index 756e5e6ce..c8039a44d 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/telemetry" "github.com/CircleCI-Public/circleci-cli/version" "github.com/spf13/cobra" ) @@ -17,11 +18,13 @@ func newVersionCommand(config *settings.Config) *cobra.Command { opts := versionOptions{ cfg: config, } + var telemetryClient telemetry.Client return &cobra.Command{ Use: "version", Short: "Display version information", PersistentPreRun: func(_ *cobra.Command, _ []string) { + telemetryClient = createTelemetry(config) opts.cfg.SkipUpdateCheck = true }, PreRun: func(cmd *cobra.Command, args []string) { @@ -30,5 +33,11 @@ func newVersionCommand(config *settings.Config) *cobra.Command { Run: func(_ *cobra.Command, _ []string) { fmt.Printf("%s+%s (%s)\n", version.Version, version.Commit, version.PackageManager()) }, + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + if err := telemetryClient.Track(telemetry.CreateVersionEvent()); err != nil { + return err + } + return telemetryClient.Close() + }, } } diff --git a/settings/settings.go b/settings/settings.go index efde6c893..b31797fe2 100644 --- a/settings/settings.go +++ b/settings/settings.go @@ -28,22 +28,25 @@ var ( // Config is used to represent the current state of a CLI instance. type Config struct { - Host string `yaml:"host"` - DlHost string `yaml:"-"` - Endpoint string `yaml:"endpoint"` - Token string `yaml:"token"` - RestEndpoint string `yaml:"rest_endpoint"` - TLSCert string `yaml:"tls_cert"` - TLSInsecure bool `yaml:"tls_insecure"` - HTTPClient *http.Client `yaml:"-"` - Data *data.DataBag `yaml:"-"` - Debug bool `yaml:"-"` - Address string `yaml:"-"` - FileUsed string `yaml:"-"` - GitHubAPI string `yaml:"-"` - SkipUpdateCheck bool `yaml:"-"` - IsTelemetryDisabled bool `yaml:"-"` - OrbPublishing OrbPublishingInfo `yaml:"orb_publishing"` + Host string `yaml:"host"` + DlHost string `yaml:"-"` + Endpoint string `yaml:"endpoint"` + Token string `yaml:"token"` + RestEndpoint string `yaml:"rest_endpoint"` + TLSCert string `yaml:"tls_cert"` + TLSInsecure bool `yaml:"tls_insecure"` + HTTPClient *http.Client `yaml:"-"` + Data *data.DataBag `yaml:"-"` + Debug bool `yaml:"-"` + Address string `yaml:"-"` + FileUsed string `yaml:"-"` + GitHubAPI string `yaml:"-"` + SkipUpdateCheck bool `yaml:"-"` + IsTelemetryDisabled bool `yaml:"-"` + // If this value is defined, the telemetry will write all its events a file + // The value of this field is the path where the telemetry will be written + MockTelemetry string `yaml:"-"` + OrbPublishing OrbPublishingInfo `yaml:"orb_publishing"` } type OrbPublishingInfo struct { diff --git a/telemetry/events.go b/telemetry/events.go new file mode 100644 index 000000000..41ba03c8f --- /dev/null +++ b/telemetry/events.go @@ -0,0 +1,24 @@ +package telemetry + +// This file contains all the telemetry event constructors +// All the events are referenced in the following file: +// https://circleci.atlassian.net/wiki/spaces/DE/pages/6760694125/CLI+segment+event+tracking +// If you want to add an event, first make sure it appears in this file + +func CreateSetupEvent(isServerCustomer bool) Event { + return Event{ + Object: "cli-setup", + Action: "called", + Properties: map[string]interface{}{ + "is_server_customer": isServerCustomer, + }, + } +} + +func CreateVersionEvent() Event { + return Event{ + Object: "cli-version", + Action: "called", + Properties: map[string]interface{}{}, + } +} diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go index 092c4253f..bb36fb2e9 100644 --- a/telemetry/telemetry.go +++ b/telemetry/telemetry.go @@ -1,8 +1,10 @@ package telemetry import ( + "encoding/json" "fmt" "io" + "os" "github.com/segmentio/analytics-go" ) @@ -26,10 +28,13 @@ type Client interface { Track(event Event) error } +// A segment event to be sent to the telemetry +// Important: this is not meant to be constructed directly apart in tests +// If you want to create a new event, add its constructor in ./events.go type Event struct { - Object string - Action string - Properties map[string]interface{} + Object string `json:"object"` + Action string `json:"action"` + Properties map[string]interface{} `json:"properties"` } type User struct { @@ -43,6 +48,7 @@ type User struct { // Create a telemetry client to be used to send telemetry events func CreateClient(user User, enabled bool) Client { + fmt.Printf("telemetry enabled = %+v\n", enabled) if !enabled { return nullClient{} } @@ -138,3 +144,36 @@ func (segment *segmentClient) Track(event Event) error { func (segment *segmentClient) Close() error { return segment.cli.Close() } + +// File telemetry +// Used for E2E tests + +type fileTelemetry struct { + file *os.File +} + +func CreateFileTelemetry(filePath string) Client { + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY, 0666) + if err != nil { + panic(err) + } + return &fileTelemetry{file} +} + +func (cli *fileTelemetry) Track(event Event) error { + content, err := json.Marshal(&event) + if err != nil { + return err + } + + content = append(content, '\n') + _, err = cli.file.Write(content) + + return err +} + +func (cli *fileTelemetry) Close() error { + file := cli.file + cli.file = nil + return file.Close() +}