github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/analytics/client/sync/client.go (about) 1 package sync 2 3 import ( 4 "os" 5 "runtime/debug" 6 "strings" 7 "sync" 8 9 "github.com/ActiveState/cli/internal/analytics" 10 "github.com/ActiveState/cli/internal/analytics/client/sync/reporters" 11 anaConsts "github.com/ActiveState/cli/internal/analytics/constants" 12 "github.com/ActiveState/cli/internal/analytics/dimensions" 13 "github.com/ActiveState/cli/internal/condition" 14 "github.com/ActiveState/cli/internal/config" 15 "github.com/ActiveState/cli/internal/constants" 16 "github.com/ActiveState/cli/internal/errs" 17 "github.com/ActiveState/cli/internal/installation/storage" 18 "github.com/ActiveState/cli/internal/instanceid" 19 "github.com/ActiveState/cli/internal/logging" 20 configMediator "github.com/ActiveState/cli/internal/mediators/config" 21 "github.com/ActiveState/cli/internal/multilog" 22 "github.com/ActiveState/cli/internal/osutils" 23 "github.com/ActiveState/cli/internal/output" 24 "github.com/ActiveState/cli/internal/rollbar" 25 "github.com/ActiveState/cli/internal/rtutils/ptr" 26 "github.com/ActiveState/cli/internal/singleton/uniqid" 27 "github.com/ActiveState/cli/internal/updater" 28 "github.com/ActiveState/cli/pkg/platform/authentication" 29 "github.com/ActiveState/cli/pkg/sysinfo" 30 ) 31 32 type Reporter interface { 33 ID() string 34 Event(category, action, source, label string, dimensions *dimensions.Values) error 35 } 36 37 // Client instances send analytics events to GA and S3 endpoints without delay. It is only supposed to be used inside the `state-svc`. All other processes should use the DefaultClient. 38 type Client struct { 39 customDimensions *dimensions.Values 40 cfg *config.Instance 41 eventWaitGroup *sync.WaitGroup 42 sendReports bool 43 reporters []Reporter 44 sequence int 45 auth *authentication.Auth 46 source string 47 } 48 49 var _ analytics.Dispatcher = &Client{} 50 51 // New initializes the analytics instance with all custom dimensions known at this time 52 func New(source string, cfg *config.Instance, auth *authentication.Auth, out output.Outputer) *Client { 53 a := &Client{ 54 eventWaitGroup: &sync.WaitGroup{}, 55 sendReports: true, 56 auth: auth, 57 source: source, 58 } 59 60 installSource, err := storage.InstallSource() 61 if err != nil { 62 multilog.Error("Could not detect installSource: %s", errs.JoinMessage(err)) 63 } 64 65 deviceID := uniqid.Text() 66 67 osName := sysinfo.OS().String() 68 osVersion := "unknown" 69 osvInfo, err := sysinfo.OSVersion() 70 if err != nil { 71 multilog.Log(logging.ErrorNoStacktrace, rollbar.Error)("Could not detect osVersion: %v", err) 72 } 73 if osvInfo != nil { 74 osVersion = osvInfo.Version 75 } 76 77 var sessionToken string 78 var tag string 79 if cfg != nil { 80 sessionToken = cfg.GetString(anaConsts.CfgSessionToken) 81 var ok bool 82 tag, ok = os.LookupEnv(constants.UpdateTagEnvVarName) 83 if !ok { 84 tag = cfg.GetString(updater.CfgUpdateTag) 85 } 86 a.cfg = cfg 87 } 88 89 a.readConfig() 90 configMediator.AddListener(constants.ReportAnalyticsConfig, a.readConfig) 91 92 userID := "" 93 if auth != nil && auth.UserID() != nil { 94 userID = string(*auth.UserID()) 95 } 96 97 interactive := false 98 if out != nil { 99 interactive = out.Config().Interactive 100 } 101 102 customDimensions := &dimensions.Values{ 103 Version: ptr.To(constants.Version), 104 ChannelName: ptr.To(constants.ChannelName), 105 OSName: ptr.To(osName), 106 OSVersion: ptr.To(osVersion), 107 InstallSource: ptr.To(installSource), 108 UniqID: ptr.To(deviceID), 109 SessionToken: ptr.To(sessionToken), 110 UpdateTag: ptr.To(tag), 111 UserID: ptr.To(userID), 112 Flags: ptr.To(dimensions.CalculateFlags()), 113 InstanceID: ptr.To(instanceid.ID()), 114 Command: ptr.To(osutils.ExecutableName()), 115 Sequence: ptr.To(0), 116 CI: ptr.To(condition.OnCI()), 117 Interactive: ptr.To(interactive), 118 ActiveStateCI: ptr.To(condition.InActiveStateCI()), 119 } 120 121 a.customDimensions = customDimensions 122 123 // Register reporters 124 if condition.InTest() { 125 logging.Debug("Using test reporter") 126 a.NewReporter(reporters.NewTestReporter(reporters.TestReportFilepath())) 127 logging.Debug("Using test reporter as instructed by env") 128 } else if v := os.Getenv(constants.AnalyticsLogEnvVarName); v != "" { 129 a.NewReporter(reporters.NewTestReporter(v)) 130 } else { 131 a.NewReporter(reporters.NewPixelReporter()) 132 } 133 134 return a 135 } 136 137 func (a *Client) readConfig() { 138 doNotReport := (!a.cfg.Closed() && !a.cfg.GetBool(constants.ReportAnalyticsConfig)) || 139 strings.ToLower(os.Getenv(constants.DisableAnalyticsEnvVarName)) == "true" 140 a.sendReports = !doNotReport 141 logging.Debug("Sending Google Analytics reports? %v", a.sendReports) 142 } 143 144 func (a *Client) NewReporter(rep Reporter) { 145 a.reporters = append(a.reporters, rep) 146 } 147 148 func (a *Client) Wait() { 149 a.eventWaitGroup.Wait() 150 } 151 152 // Events returns a channel to feed eventData directly to the report loop 153 func (a *Client) report(category, action, source, label string, dimensions *dimensions.Values) { 154 if !a.sendReports { 155 return 156 } 157 158 for _, reporter := range a.reporters { 159 if err := reporter.Event(category, action, source, label, dimensions); err != nil { 160 logging.Debug( 161 "Reporter failed: %s, category: %s, action: %s, error: %s", 162 reporter.ID(), category, action, errs.JoinMessage(err), 163 ) 164 } 165 } 166 } 167 168 func (a *Client) Event(category, action string, dims ...*dimensions.Values) { 169 a.EventWithLabel(category, action, "", dims...) 170 } 171 172 func mergeDimensions(target *dimensions.Values, dims ...*dimensions.Values) *dimensions.Values { 173 actualDims := target.Clone() 174 for _, dim := range dims { 175 if dim == nil { 176 continue 177 } 178 actualDims.Merge(dim) 179 } 180 return actualDims 181 } 182 183 func (a *Client) EventWithLabel(category, action, label string, dims ...*dimensions.Values) { 184 a.EventWithSourceAndLabel(category, action, a.source, label, dims...) 185 } 186 187 // EventWithSource should only be used by clients forwarding events on behalf of another source. 188 // Otherwise, use Event(). 189 func (a *Client) EventWithSource(category, action, source string, dims ...*dimensions.Values) { 190 a.EventWithSourceAndLabel(category, action, source, "", dims...) 191 } 192 193 // EventWithSourceAndLabel should only be used by clients forwarding events on behalf of another 194 // source (for example, state-svc forwarding events on behalf of State Tool or an executor). 195 // Otherwise, use EventWithLabel(). 196 func (a *Client) EventWithSourceAndLabel(category, action, source, label string, dims ...*dimensions.Values) { 197 if a.customDimensions == nil { 198 if condition.InUnitTest() { 199 return 200 } 201 if !condition.BuiltViaCI() { 202 panic("Trying to send analytics without configuring the Analytics instance.") 203 } 204 multilog.Critical("Trying to send analytics event without configuring the Analytics instance.") 205 return 206 } 207 208 if a.auth != nil && a.auth.UserID() != nil { 209 a.customDimensions.UserID = ptr.To(string(*a.auth.UserID())) 210 } 211 212 a.customDimensions.Sequence = ptr.To(a.sequence) 213 214 actualDims := mergeDimensions(a.customDimensions, dims...) 215 216 if a.sequence == *actualDims.Sequence { 217 // Increment the sequence number unless dims overrides it (e.g. heartbeats use -1). 218 a.sequence++ 219 } 220 221 if err := actualDims.PreProcess(); err != nil { 222 multilog.Critical("Analytics dimensions cannot be processed properly: %s", errs.JoinMessage(err)) 223 } 224 225 a.eventWaitGroup.Add(1) 226 // We do not wait for the events to be processed, just scheduling them 227 go func() { 228 defer a.eventWaitGroup.Done() 229 defer func() { handlePanics(recover(), debug.Stack()) }() 230 a.report(category, action, source, label, actualDims) 231 }() 232 } 233 234 func handlePanics(err interface{}, stack []byte) { 235 if err == nil { 236 return 237 } 238 multilog.Error("Panic in state-svc analytics: %v", err) 239 logging.Debug("Stack: %s", string(stack)) 240 } 241 242 func (a *Client) Close() { 243 a.Wait() 244 a.sendReports = false 245 }