Skip to content

Commit

Permalink
feat: Created telemetry client
Browse files Browse the repository at this point in the history
  • Loading branch information
JulesFaucherre committed Aug 1, 2023
1 parent 16345fd commit 1ecfa71
Show file tree
Hide file tree
Showing 9 changed files with 360 additions and 0 deletions.
19 changes: 19 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"sort"
"strings"

"github.com/CircleCI-Public/circleci-cli/api/graphql"
"github.com/CircleCI-Public/circleci-cli/api/rest"
"github.com/CircleCI-Public/circleci-cli/references"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/Masterminds/semver"
Expand Down Expand Up @@ -1945,3 +1947,20 @@ func FollowProject(config settings.Config, vcs string, owner string, projectName

return fr, nil
}

type Me struct {
ID string `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
}

func GetMe(client *rest.Client) (Me, error) {
req, err := client.NewRequest("GET", &url.URL{Path: "me"}, nil)
if err != nil {
return Me{}, errors.Wrap(err, "Unable to get user info")
}

var me Me
_, err = client.DoRequest(req, &me)
return me, err
}
2 changes: 2 additions & 0 deletions clitest/clitest.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type TempSettings struct {
Home string
TestServer *ghttp.Server
Config *TmpFile
Telemetry *TmpFile
Update *TmpFile
}

Expand Down Expand Up @@ -68,6 +69,7 @@ func WithTempSettings() *TempSettings {
gomega.Expect(os.Mkdir(settingsPath, 0700)).To(gomega.Succeed())

tempSettings.Config = OpenTmpFile(settingsPath, "cli.yml")
tempSettings.Telemetry = OpenTmpFile(settingsPath, "telemetry.yml")
tempSettings.Update = OpenTmpFile(settingsPath, "update_check.yml")

tempSettings.TestServer = ghttp.NewServer()
Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ func MakeCommands() *cobra.Command {
flags.StringVar(&rootOptions.Endpoint, "endpoint", rootOptions.Endpoint, "URI to your CircleCI GraphQL API endpoint")
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.Telemetry.DisabledFromParams, "disable-telemetry", false, "Do not show telemetry for the actual command")

hidden := []string{"github-api", "debug", "endpoint"}

Expand Down Expand Up @@ -227,6 +228,7 @@ func rootCmdPreRun(rootOptions *settings.Config) error {
fmt.Printf("Error checking for updates: %s\n", err)
fmt.Printf("Please contact support.\n\n")
}

return nil
}

Expand Down
111 changes: 111 additions & 0 deletions cmd/telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package cmd

import (
"fmt"
"os"

"github.com/CircleCI-Public/circleci-cli/api"
"github.com/CircleCI-Public/circleci-cli/api/rest"
"github.com/CircleCI-Public/circleci-cli/prompt"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/telemetry"
"github.com/google/uuid"
"github.com/pkg/errors"
"golang.org/x/term"
)

const (
TelemetryIsActive = "yes"
TelemetryIsInactive = "no"

// This value means telemetry is disabled because we have no access to stdin and so can't ask user approval
TelemetryDefaultDisabled = "default-disabled"
)

type telemetryUI interface {
AskUserToApproveTelemetry(message string) bool
}

type telemetryInteractiveUI struct{}

func (telemetryInteractiveUI) AskUserToApproveTelemetry(message string) bool {
return prompt.AskUserToConfirmWithDefault(message, true)
}

type telemetryTestUI struct {
Approved bool
}

func (ui telemetryTestUI) AskUserToApproveTelemetry(message string) bool {
return ui.Approved
}

// Make sure the user gave their approval for the telemetry and
func checkTelemetry(config *settings.Config, ui telemetryUI) error {
config.Telemetry.Load()

if err := askForTelemetryApproval(config, ui); err != nil {
config.Telemetry.Client = telemetry.CreateClient(telemetry.User{}, false)
return err
}

config.Telemetry.Client = telemetry.CreateClient(telemetry.User{
UniqueID: config.Telemetry.UniqueID,
UserID: config.Telemetry.UserID,
}, config.Telemetry.IsActive)
return nil
}

func askForTelemetryApproval(config *settings.Config, ui telemetryUI) error {
// If we already have telemetry information or that telemetry is explicitly disabled, skip
if config.Telemetry.HasAnsweredPrompt || config.Telemetry.DisabledFromParams {
return nil
}

// If stdin is not available, send telemetry event, disactive telemetry and return
if !term.IsTerminal(int(os.Stdin.Fd())) {
telemetry.SendTelemetryApproval(telemetry.User{}, telemetry.NoStdin)
config.Telemetry.IsActive = false
return nil
}

// Else ask user for telemetry approval
fmt.Println("CircleCI would like to collect CLI usage data for product improvement purposes.")
fmt.Println("")
fmt.Println("Participation is voluntary, and your choice can be changed at any time through the command `cli telemetry enable` and `cli telemetry disable`.")
fmt.Println("For more information, please see our privacy policy at https://circleci.com/legal/privacy/.")
fmt.Println("")
config.Telemetry.IsActive = ui.AskUserToApproveTelemetry("Enable telemetry?")
config.Telemetry.HasAnsweredPrompt = true

// If user allows telemetry, create a telemetry user
user := telemetry.User{}
if config.Telemetry.UniqueID == "" {
user.UniqueID = uuid.New().String()
}

if config.Telemetry.IsActive && config.Token != "" {
me, err := api.GetMe(rest.NewFromConfig(config.Host, config))
if err != nil {
user.UserID = me.ID
}
}
config.Telemetry.UniqueID = user.UniqueID
config.Telemetry.UserID = user.UserID

// Send telemetry approval event
approval := telemetry.Enabled
if !config.Telemetry.IsActive {
approval = telemetry.Disabled
}
if err := telemetry.SendTelemetryApproval(telemetry.User{}, approval); err != nil {
return err
}

// Write telemetry
if err := config.Telemetry.Write(); err != nil {
return errors.Wrap(err, "Writing telemetry to disk")
}

return nil
}
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ require (
github.com/charmbracelet/lipgloss v0.5.0
github.com/erikgeiser/promptkit v0.7.0
github.com/hexops/gotextdiff v1.0.3
github.com/segmentio/analytics-go v3.1.0+incompatible
github.com/stretchr/testify v1.8.3
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
golang.org/x/term v0.10.0
Expand All @@ -50,6 +51,7 @@ require (
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/charmbracelet/bubbles v0.11.0 // indirect
github.com/charmbracelet/bubbletea v0.21.0 // indirect
Expand Down Expand Up @@ -94,13 +96,15 @@ require (
github.com/prometheus/procfs v0.11.0 // indirect
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/segmentio/backo-go v1.0.1 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.1.1 // indirect
github.com/tchap/go-patricia/v2 v2.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
github.com/yashtewari/glob-intersection v0.2.0 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/metric v1.16.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
Expand Down Expand Up @@ -241,6 +243,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/segmentio/analytics-go v3.1.0+incompatible h1:IyiOfUgQFVHvsykKKbdI7ZsH374uv3/DfZUo9+G0Z80=
github.com/segmentio/analytics-go v3.1.0+incompatible/go.mod h1:C7CYBtQWk4vRk2RyLu0qOcbHJ18E3F1HV2C/8JvKN48=
github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4=
github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
Expand Down Expand Up @@ -273,6 +279,8 @@ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMc
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g=
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
Expand Down
11 changes: 11 additions & 0 deletions prompt/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,14 @@ func AskUserToConfirm(message string) bool {
result, err := input.RunPrompt()
return err == nil && result
}

func AskUserToConfirmWithDefault(message string, defaultValue bool) bool {
def := confirmation.No
if defaultValue {
def = confirmation.Yes
}

input := confirmation.New(message, def)
result, err := input.RunPrompt()
return err == nil && result
}
77 changes: 77 additions & 0 deletions settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
yaml "gopkg.in/yaml.v3"

"github.com/CircleCI-Public/circleci-cli/data"
"github.com/CircleCI-Public/circleci-cli/telemetry"
"github.com/CircleCI-Public/circleci-cli/version"
)

// Config is used to represent the current state of a CLI instance.
Expand All @@ -36,6 +38,7 @@ type Config struct {
GitHubAPI string `yaml:"-"`
SkipUpdateCheck bool `yaml:"-"`
OrbPublishing OrbPublishingInfo `yaml:"orb_publishing"`
Telemetry TelemetrySettings `yaml:"-"`
}

type OrbPublishingInfo struct {
Expand All @@ -50,6 +53,17 @@ type UpdateCheck struct {
FileUsed string `yaml:"-"`
}

// TelemetrySettings is used to represent telemetry related settings
type TelemetrySettings struct {
IsActive bool `yaml:"is_active"`
HasAnsweredPrompt bool `yaml:"has_answered_prompt"`
DisabledFromParams bool `yaml:"-"`
UniqueID string `yaml:"unique_id"`
UserID string `yaml:"user_id"`

Client telemetry.Client `yaml:"-"`
}

// Load will read the update check settings from the user's disk and then deserialize it into the current instance.
func (upd *UpdateCheck) Load() error {
path := filepath.Join(SettingsPath(), updateCheckFilename())
Expand Down Expand Up @@ -80,6 +94,64 @@ func (upd *UpdateCheck) WriteToDisk() error {
return err
}

// Load will read the telemetry settings from the user's disk and then deserialize it into the current instance.
func (tel *TelemetrySettings) Load() error {
path := filepath.Join(SettingsPath(), telemetryFilename())

if err := ensureSettingsFileExists(path); err != nil {
return err
}

content, err := os.ReadFile(path) // #nosec
if err != nil {
return err
}

err = yaml.Unmarshal(content, &tel)
return err
}

// WriteToDisk will write the telemetry settings to disk by serializing the YAML
func (tel *TelemetrySettings) Write() error {
enc, err := yaml.Marshal(&tel)
if err != nil {
return err
}

path := filepath.Join(SettingsPath(), telemetryFilename())
err = os.WriteFile(path, enc, 0600)
return err
}

// Track takes a telemetry event, enrich with various data and sends it
// This is the method you must use to send telemetry events
// This will fail if 'checkTelemetry' has not called before
func (cfg *Config) Track(event telemetry.Event) error {
if cfg.Telemetry.Client == nil {
return errors.New("No telemetry client found")
}

if cfg.Telemetry.UniqueID != "" {
event.Properties["UUID"] = cfg.Telemetry.UniqueID
}

if cfg.Telemetry.UserID != "" {
event.Properties["user_id"] = cfg.Telemetry.UserID
}

if cfg.Host != "" {
event.Properties["host"] = cfg.Host
} else {
event.Properties["host"] = "https://circleci.com"
}

event.Properties["os"] = runtime.GOOS
event.Properties["cli_version"] = version.Version
event.Properties["team_name"] = "devex"

return cfg.Telemetry.Client.Track(event)
}

// Load will read the config from the user's disk and then evaluate possible configuration from the environment.
func (cfg *Config) Load() error {
if err := cfg.LoadFromDisk(); err != nil {
Expand Down Expand Up @@ -161,6 +233,11 @@ func configFilename() string {
return "cli.yml"
}

// telemetryFilename returns the name of the cli telemetry file
func telemetryFilename() string {
return "telemetry.yml"
}

// settingsPath returns the path of the CLI settings directory
func SettingsPath() string {
// TODO: Make this configurable
Expand Down
Loading

0 comments on commit 1ecfa71

Please sign in to comment.