From c7cc3a5ac69e2e4e775e07742ea533c650c4473e Mon Sep 17 00:00:00 2001 From: Danilo Pantani Date: Fri, 1 Dec 2023 14:15:09 +0100 Subject: [PATCH 1/3] feat(cmd): add ga (#3599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add googla analytics * add doNotTrack env var * add changelog * add anlytics into a cobra command * improve analyticcs * run make format * fix file check * fix env var * fix send metrics in parallel * move vars to const * Update ignite/cmd/ignite/analytics.go Co-authored-by: Jerónimo Albi * Update ignite/cmd/ignite/main.go Co-authored-by: Jerónimo Albi * improve analytics msg * update ga pkg * improve analytics init * use http post request instate a libraty * add GA Client ID * use postform instead newrequest * use set instead literals for url.Values * add ga4 http metric send * fix url and values * chore: small improvements gacli (#3789) * chore: small improvements gacli * duplicate * remove sensitive data * remove sensitive data and improve the parameters * refactor: good default http client for ga (#3791) * refactor: good default http client * use client * ilker feedback * lint * remove `Pallinder/go-randomdata` pkg * set the ignite telemetry endpoint * remove the url from the pkg and change some var names * move telemetry to the internal package * use snake case for anon identity file --------- Co-authored-by: Pantani Co-authored-by: Jerónimo Albi Co-authored-by: Julien Robert --- changelog.md | 1 + ignite/cmd/ignite/main.go | 26 ++++- ignite/internal/analytics/analytics.go | 153 +++++++++++++++++++++++++ ignite/pkg/gacli/doc.go | 2 + ignite/pkg/gacli/gacli.go | 134 ++++++++++++++++------ 5 files changed, 272 insertions(+), 44 deletions(-) create mode 100644 ignite/internal/analytics/analytics.go create mode 100644 ignite/pkg/gacli/doc.go diff --git a/changelog.md b/changelog.md index 6211360d24..7c8719031e 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,7 @@ - [#3561](https://github.com/ignite/cli/pull/3561) Add GetChainInfo method to plugin system API - [#3626](https://github.com/ignite/cli/pull/3626) Add logging levels to relayer - [#3476](https://github.com/ignite/cli/pull/3476) Use `buf.build` binary to code generate from proto files +- [#3599](https://github.com/ignite/cli/pull/3599) Add google analytics - [#3614](https://github.com/ignite/cli/pull/3614) feat: use DefaultBaseappOptions for app.New method - [#3536](https://github.com/ignite/cli/pull/3536) Change app.go to v2 and add AppWiring feature - [#3659](https://github.com/ignite/cli/pull/3659) cosmos-sdk `v0.50.x` diff --git a/ignite/cmd/ignite/main.go b/ignite/cmd/ignite/main.go index bee86660da..f61a3f611b 100644 --- a/ignite/cmd/ignite/main.go +++ b/ignite/cmd/ignite/main.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "os" + "sync" ignitecmd "github.com/ignite/cli/ignite/cmd" chainconfig "github.com/ignite/cli/ignite/config/chain" + "github.com/ignite/cli/ignite/internal/analytics" "github.com/ignite/cli/ignite/pkg/clictx" "github.com/ignite/cli/ignite/pkg/cliui/colors" "github.com/ignite/cli/ignite/pkg/cliui/icons" @@ -20,12 +22,22 @@ func main() { } func run() int { - const ( - exitCodeOK = 0 - exitCodeError = 1 - ) - ctx := clictx.From(context.Background()) + const exitCodeOK, exitCodeError = 0, 1 + var wg sync.WaitGroup + + defer func() { + if r := recover(); r != nil { + analytics.SendMetric(&wg, os.Args, analytics.WithError(fmt.Errorf("%v", r))) + fmt.Println(r) + os.Exit(exitCodeError) + } + }() + + if len(os.Args) > 1 { + analytics.SendMetric(&wg, os.Args) + } + ctx := clictx.From(context.Background()) cmd, cleanUp, err := ignitecmd.New(ctx) if err != nil { fmt.Printf("%v\n", err) @@ -34,7 +46,6 @@ func run() int { defer cleanUp() err = cmd.ExecuteContext(ctx) - if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) { fmt.Println("aborted") return exitCodeOK @@ -64,5 +75,8 @@ func run() int { return exitCodeError } + + wg.Wait() // waits for all metrics to be sent + return exitCodeOK } diff --git a/ignite/internal/analytics/analytics.go b/ignite/internal/analytics/analytics.go new file mode 100644 index 0000000000..9da5a77760 --- /dev/null +++ b/ignite/internal/analytics/analytics.go @@ -0,0 +1,153 @@ +package analytics + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/manifoldco/promptui" + + "github.com/ignite/cli/ignite/pkg/gacli" + "github.com/ignite/cli/ignite/pkg/randstr" + "github.com/ignite/cli/ignite/version" +) + +const ( + telemetryEndpoint = "https://telemetry-cli.ignite.com" + envDoNotTrack = "DO_NOT_TRACK" + igniteDir = ".ignite" + igniteAnonIdentity = "anon_identity.json" +) + +var gaclient gacli.Client + +type ( + // metric represents an analytics metric. + options struct { + // err sets metrics type as an error metric. + err error + } + + // anonIdentity represents an analytics identity file. + anonIdentity struct { + // name represents the username. + Name string `json:"name" yaml:"name"` + // doNotTrack represents the user track choice. + DoNotTrack bool `json:"doNotTrack" yaml:"doNotTrack"` + } +) + +func init() { + gaclient = gacli.New(telemetryEndpoint) +} + +// Option configures ChainCmd. +type Option func(*options) + +// WithError with application command error. +func WithError(error error) Option { + return func(m *options) { + m.err = error + } +} + +func SendMetric(wg *sync.WaitGroup, args []string, opts ...Option) { + // only the app name + if len(args) <= 1 { + return + } + + // apply analytics options. + var opt options + for _, o := range opts { + o(&opt) + } + + envDoNotTrackVar := os.Getenv(envDoNotTrack) + if envDoNotTrackVar == "1" || strings.ToLower(envDoNotTrackVar) == "true" { + return + } + + if args[1] == "version" { + return + } + + fullCmd := strings.Join(args[1:], " ") + + dntInfo, err := checkDNT() + if err != nil || dntInfo.DoNotTrack { + return + } + + met := gacli.Metric{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + FullCmd: fullCmd, + SessionID: dntInfo.Name, + Version: version.Version, + } + + switch { + case opt.err == nil: + met.Status = "success" + case opt.err != nil: + met.Status = "error" + met.Error = opt.err.Error() + } + met.Cmd = args[1] + + wg.Add(1) + go func() { + defer wg.Done() + _ = gaclient.SendMetric(met) + }() +} + +func checkDNT() (anonIdentity, error) { + home, err := os.UserHomeDir() + if err != nil { + return anonIdentity{}, err + } + if err := os.Mkdir(filepath.Join(home, igniteDir), 0o700); err != nil && !os.IsExist(err) { + return anonIdentity{}, err + } + identityPath := filepath.Join(home, igniteDir, igniteAnonIdentity) + data, err := os.ReadFile(identityPath) + if err != nil && !os.IsNotExist(err) { + return anonIdentity{}, err + } + + var i anonIdentity + if err := json.Unmarshal(data, &i); err == nil { + return i, nil + } + + i.Name = randstr.Runes(10) + i.DoNotTrack = false + + prompt := promptui.Select{ + Label: "Ignite collects metrics about command usage. " + + "All data is anonymous and helps to improve Ignite. " + + "Ignite respect the DNT rules (consoledonottrack.com). " + + "Would you agree to share these metrics with us?", + Items: []string{"Yes", "No"}, + } + resultID, _, err := prompt.Run() + if err != nil { + return anonIdentity{}, err + } + + if resultID != 0 { + i.DoNotTrack = true + } + + data, err = json.Marshal(&i) + if err != nil { + return i, err + } + + return i, os.WriteFile(identityPath, data, 0o700) +} diff --git a/ignite/pkg/gacli/doc.go b/ignite/pkg/gacli/doc.go new file mode 100644 index 0000000000..9b905a5d3a --- /dev/null +++ b/ignite/pkg/gacli/doc.go @@ -0,0 +1,2 @@ +// Package gacli is a client for Google Analytics to send data points for hint-type=event. +package gacli diff --git a/ignite/pkg/gacli/gacli.go b/ignite/pkg/gacli/gacli.go index 0488a49715..3e1373c125 100644 --- a/ignite/pkg/gacli/gacli.go +++ b/ignite/pkg/gacli/gacli.go @@ -1,63 +1,121 @@ -// Package gacli is a client for Google Analytics to send data points for hint-type=event. package gacli import ( + "bytes" + "encoding/json" + "fmt" "net/http" "net/url" + "time" ) -const ( - endpoint = "https://www.google-analytics.com/collect" +type ( + // Client is an analytics client. + Client struct { + endpoint string + measurementID string // Google Analytics measurement ID. + apiSecret string // Google Analytics API secret. + httpClient http.Client + } + // Body analytics metrics body. + Body struct { + ClientID string `json:"client_id"` + Events []Event `json:"events"` + } + // Event analytics event. + Event struct { + Name string `json:"name"` + Params Metric `json:"params"` + } + // Metric represents a data point. + Metric struct { + Status string `json:"status,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + FullCmd string `json:"full_command,omitempty"` + Cmd string `json:"command,omitempty"` + Error string `json:"error,omitempty"` + Version string `json:"version,omitempty"` + SessionID string `json:"session_id,omitempty"` + EngagementTimeMsec string `json:"engagement_time_msec,omitempty"` + } ) -// Client is an analytics client. -type Client struct { - id string // Google Analytics ID +// Option configures code generation. +type Option func(*Client) + +// WithMeasurementID adds an analytics measurement ID. +func WithMeasurementID(measurementID string) Option { + return func(c *Client) { + c.measurementID = measurementID + } } -// New creates a new analytics client for Segment.io with Segment's -// endpoint and access key. -func New(id string) *Client { - return &Client{ - id: id, +// WithAPISecret adds an analytics API secret. +func WithAPISecret(secret string) Option { + return func(c *Client) { + c.apiSecret = secret } } -// Metric represents a data point. -type Metric struct { - Category string - Action string - Label string - Value string - User string - Version string +// New creates a new analytics client with +// measure id and secret key. +func New(endpoint string, opts ...Option) Client { + c := Client{ + endpoint: endpoint, + httpClient: http.Client{ + Timeout: 1500 * time.Millisecond, + }, + } + // apply analytics options. + for _, o := range opts { + o(&c) + } + return c } -// Send sends metrics to GA. -func (c *Client) Send(metric Metric) error { - v := url.Values{ - "v": {"1"}, - "tid": {c.id}, - "cid": {metric.User}, - "t": {"event"}, - "ec": {metric.Category}, - "ea": {metric.Action}, - "ua": {"Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14"}, +// Send sends metrics to analytics. +func (c Client) Send(body Body) error { + // encode body + encoded, err := json.Marshal(body) + if err != nil { + return err } - if metric.Label != "" { - v.Set("el", metric.Label) + + requestURL, err := url.Parse(c.endpoint) + if err != nil { + return err } - if metric.Value != "" { - v.Set("ev", metric.Value) + v := requestURL.Query() + if c.measurementID != "" { + v.Set("measurement_id", c.measurementID) } - if metric.Version != "" { - v.Set("an", metric.Version) - v.Set("av", metric.Version) + if c.apiSecret != "" { + v.Set("api_secret", c.apiSecret) } - resp, err := http.PostForm(endpoint, v) + requestURL.RawQuery = v.Encode() + + // Create an HTTP request with the payload + resp, err := c.httpClient.Post(requestURL.String(), "application/json", bytes.NewBuffer(encoded)) if err != nil { - return err + return fmt.Errorf("error creating HTTP request: %w", err) } defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && + resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("error sending event. Status code: %d", resp.StatusCode) + } return nil } + +func (c Client) SendMetric(metric Metric) error { + metric.EngagementTimeMsec = "100" + return c.Send(Body{ + ClientID: metric.SessionID, + Events: []Event{{ + Name: metric.Cmd, + Params: metric, + }}, + }) +} From 40013713dddf4dbb2ae4cc9796623a925cd87e6c Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 1 Dec 2023 15:27:39 +0100 Subject: [PATCH 2/3] chore: change default scaffolding branch to main (#3792) --- ignite/pkg/xgit/xgit.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ignite/pkg/xgit/xgit.go b/ignite/pkg/xgit/xgit.go index 919889c98e..88ef52b108 100644 --- a/ignite/pkg/xgit/xgit.go +++ b/ignite/pkg/xgit/xgit.go @@ -33,7 +33,12 @@ func InitAndCommit(path string) error { return fmt.Errorf("open git repo %s: %w", path, err) } // not a git repo, creates a new one - repo, err = git.PlainInit(path, false) + repo, err = git.PlainInitWithOptions(path, &git.PlainInitOptions{ + InitOptions: git.InitOptions{ + DefaultBranch: plumbing.Main, + }, + Bare: false, + }) if err != nil { return fmt.Errorf("init git repo %s: %w", path, err) } From b62a3e70bbd8f745227755aea1961c63f5931416 Mon Sep 17 00:00:00 2001 From: Danilo Pantani Date: Fri, 1 Dec 2023 22:41:06 +0100 Subject: [PATCH 3/3] skip analytics for the integration tests setting the env var DO_NOT_TRACK (#3794) Co-authored-by: Pantani --- ignite/internal/analytics/analytics.go | 17 +++++++++-------- ignite/pkg/gacli/gacli.go | 3 ++- integration/env.go | 11 ++++++++++- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/ignite/internal/analytics/analytics.go b/ignite/internal/analytics/analytics.go index 9da5a77760..6bc8367caa 100644 --- a/ignite/internal/analytics/analytics.go +++ b/ignite/internal/analytics/analytics.go @@ -54,6 +54,7 @@ func WithError(error error) Option { } } +// SendMetric send command metrics to analytics. func SendMetric(wg *sync.WaitGroup, args []string, opts ...Option) { // only the app name if len(args) <= 1 { @@ -66,17 +67,10 @@ func SendMetric(wg *sync.WaitGroup, args []string, opts ...Option) { o(&opt) } - envDoNotTrackVar := os.Getenv(envDoNotTrack) - if envDoNotTrackVar == "1" || strings.ToLower(envDoNotTrackVar) == "true" { - return - } - if args[1] == "version" { return } - fullCmd := strings.Join(args[1:], " ") - dntInfo, err := checkDNT() if err != nil || dntInfo.DoNotTrack { return @@ -85,7 +79,7 @@ func SendMetric(wg *sync.WaitGroup, args []string, opts ...Option) { met := gacli.Metric{ OS: runtime.GOOS, Arch: runtime.GOARCH, - FullCmd: fullCmd, + FullCmd: strings.Join(args[1:], " "), SessionID: dntInfo.Name, Version: version.Version, } @@ -106,7 +100,14 @@ func SendMetric(wg *sync.WaitGroup, args []string, opts ...Option) { }() } +// checkDNT check if the user allow to track data or if the DO_NOT_TRACK +// env var is set https://consoledonottrack.com/ func checkDNT() (anonIdentity, error) { + envDoNotTrackVar := os.Getenv(envDoNotTrack) + if envDoNotTrackVar == "1" || strings.ToLower(envDoNotTrackVar) == "true" { + return anonIdentity{DoNotTrack: true}, nil + } + home, err := os.UserHomeDir() if err != nil { return anonIdentity{}, err diff --git a/ignite/pkg/gacli/gacli.go b/ignite/pkg/gacli/gacli.go index 3e1373c125..7237bcd50b 100644 --- a/ignite/pkg/gacli/gacli.go +++ b/ignite/pkg/gacli/gacli.go @@ -74,7 +74,7 @@ func New(endpoint string, opts ...Option) Client { return c } -// Send sends metrics to analytics. +// Send sends metric event to analytics. func (c Client) Send(body Body) error { // encode body encoded, err := json.Marshal(body) @@ -109,6 +109,7 @@ func (c Client) Send(body Body) error { return nil } +// SendMetric build the metrics and send to analytics. func (c Client) SendMetric(metric Metric) error { metric.EngagementTimeMsec = "100" return c.Send(Body{ diff --git a/integration/env.go b/integration/env.go index 394a818464..477fe00b04 100644 --- a/integration/env.go +++ b/integration/env.go @@ -25,7 +25,7 @@ import ( ) const ( - ConfigYML = "config.yml" + envDoNotTrack = "DO_NOT_TRACK" ) var ( @@ -57,6 +57,7 @@ func New(t *testing.T) Env { // set an other one thanks to env var. cfgDir := path.Join(t.TempDir(), ".ignite") env.SetConfigDir(cfgDir) + enableDoNotTrackEnv() t.Cleanup(cancel) compileBinaryOnce.Do(func() { @@ -167,6 +168,14 @@ func (e Env) RequireExpectations() { e.Must(e.HasFailed()) } +// enableDoNotTrackEnv set true the DO_NOT_TRACK env var. +func enableDoNotTrackEnv() { + err := os.Setenv(envDoNotTrack, "true") + if err != nil { + panic(fmt.Sprintf("error set %s env: %v", envDoNotTrack, err)) + } +} + func Contains(s, partial string) bool { return strings.Contains(s, strings.TrimSpace(partial)) }