Skip to content

Commit

Permalink
feat(cmd): add ga (#3599)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update ignite/cmd/ignite/main.go

Co-authored-by: Jerónimo Albi <[email protected]>

* 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 <Pantani>
Co-authored-by: Jerónimo Albi <[email protected]>
Co-authored-by: Julien Robert <[email protected]>
  • Loading branch information
3 people authored Dec 1, 2023
1 parent e510a29 commit c7cc3a5
Show file tree
Hide file tree
Showing 5 changed files with 272 additions and 44 deletions.
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
26 changes: 20 additions & 6 deletions ignite/cmd/ignite/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -64,5 +75,8 @@ func run() int {

return exitCodeError
}

wg.Wait() // waits for all metrics to be sent

return exitCodeOK
}
153 changes: 153 additions & 0 deletions ignite/internal/analytics/analytics.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions ignite/pkg/gacli/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package gacli is a client for Google Analytics to send data points for hint-type=event.
package gacli
134 changes: 96 additions & 38 deletions ignite/pkg/gacli/gacli.go
Original file line number Diff line number Diff line change
@@ -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,
}},
})
}

0 comments on commit c7cc3a5

Please sign in to comment.