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, + }}, + }) +}