bosun.org@v0.0.0-20210513094433-e25bc3e69a1f/cmd/scollector/collectors/google_analytics.go (about) 1 package collectors 2 3 import ( 4 "bytes" 5 "encoding/base64" 6 "encoding/gob" 7 "fmt" 8 "net/http" 9 "sort" 10 "strconv" 11 "time" 12 13 analytics "google.golang.org/api/analytics/v3" 14 15 "bosun.org/cmd/scollector/conf" 16 "bosun.org/metadata" 17 "bosun.org/opentsdb" 18 "golang.org/x/net/context" 19 "golang.org/x/oauth2" 20 "golang.org/x/oauth2/google" 21 ) 22 23 const descActiveUsers = "Number of unique users actively visiting the site." 24 25 type multiError []error 26 27 func (m multiError) Error() string { 28 var fullErr string 29 for _, err := range m { 30 fullErr = fmt.Sprintf("%s\n%s", fullErr, err) 31 } 32 return fullErr 33 } 34 35 func init() { 36 registerInit(func(c *conf.Conf) { 37 for _, g := range c.GoogleAnalytics { 38 collectors = append(collectors, &IntervalCollector{ 39 F: func() (opentsdb.MultiDataPoint, error) { 40 return c_google_analytics(g.ClientID, g.Secret, g.Token, g.JSONToken, g.Sites) 41 }, 42 name: "c_google_analytics", 43 Interval: time.Minute * 1, 44 }) 45 } 46 }) 47 } 48 49 func c_google_analytics(clientid string, secret string, tokenstr string, jsonToken string, sites []conf.GoogleAnalyticsSite) (opentsdb.MultiDataPoint, error) { 50 var md opentsdb.MultiDataPoint 51 var mErr multiError 52 53 c, err := googleAPIClient(clientid, secret, tokenstr, jsonToken, []string{analytics.AnalyticsScope}) 54 if err != nil { 55 return nil, err 56 } 57 svc, err := analytics.New(c) 58 if err != nil { 59 return nil, err 60 } 61 62 // dimension: max records we want to fetch 63 // "source" has a very long tail so we limit it to something sane 64 // TODO: Dimensions we want and associated attributes should eventually be 65 // setup in configuration. 66 dimensions := map[string]int{"browser": -1, "trafficType": -1, "source": 10, "deviceCategory": -1, "operatingSystem": -1} 67 for _, site := range sites { 68 getPageviews(&md, svc, site) 69 if site.Detailed { 70 if err = getActiveUsers(&md, svc, site); err != nil { 71 mErr = append(mErr, err) 72 } 73 for dimension, topN := range dimensions { 74 if err = getActiveUsersByDimension(&md, svc, site, dimension, topN); err != nil { 75 mErr = append(mErr, err) 76 } 77 } 78 } 79 } 80 81 if len(mErr) == 0 { 82 return md, nil 83 } else { 84 return md, mErr 85 } 86 } 87 88 type kv struct { 89 key string 90 value int 91 } 92 93 type kvList []kv 94 95 func (p kvList) Len() int { return len(p) } 96 func (p kvList) Less(i, j int) bool { return p[i].value < p[j].value } 97 func (p kvList) Swap(i, j int) { p[i], p[j] = p[j], p[i] } 98 99 func getActiveUsersByDimension(md *opentsdb.MultiDataPoint, svc *analytics.Service, site conf.GoogleAnalyticsSite, dimension string, topN int) error { 100 call := svc.Data.Realtime.Get("ga:"+site.Profile, "rt:activeusers").Dimensions("rt:" + dimension) 101 data, err := call.Do() 102 if err != nil { 103 return err 104 } 105 tags := opentsdb.TagSet{"site": site.Name} 106 rows := make(kvList, len(data.Rows)) 107 for i, row := range data.Rows { 108 // key will always be an string of the dimension we care about. 109 // For example, 'Chrome' would be a key for the 'browser' dimension. 110 key, _ := opentsdb.Clean(row[0]) 111 if key == "" { 112 key = "__blank__" 113 } 114 value, err := strconv.Atoi(row[1]) 115 if err != nil { 116 return fmt.Errorf("Error parsing GA data: %s", err) 117 } 118 rows[i] = kv{key: key, value: value} 119 } 120 sort.Sort(sort.Reverse(rows)) 121 if topN != -1 && topN < len(rows) { 122 topRows := make(kvList, topN) 123 topRows = rows[:topN] 124 rows = topRows 125 } 126 127 for _, row := range rows { 128 Add(md, "google.analytics.realtime.activeusers.by_"+dimension, row.value, opentsdb.TagSet{dimension: row.key}.Merge(tags), metadata.Gauge, metadata.ActiveUsers, descActiveUsers) 129 } 130 return nil 131 } 132 133 func getActiveUsers(md *opentsdb.MultiDataPoint, svc *analytics.Service, site conf.GoogleAnalyticsSite) error { 134 call := svc.Data.Realtime.Get("ga:"+site.Profile, "rt:activeusers") 135 data, err := call.Do() 136 if err != nil { 137 return err 138 } 139 tags := opentsdb.TagSet{"site": site.Name} 140 if len(data.Rows) < 1 || len(data.Rows[0]) < 1 { 141 return fmt.Errorf("no active user data in response for site %v", site.Name) 142 } 143 value, err := strconv.Atoi(data.Rows[0][0]) 144 if err != nil { 145 return fmt.Errorf("Error parsing GA data: %s", err) 146 } 147 148 Add(md, "google.analytics.realtime.activeusers", value, tags, metadata.Gauge, metadata.ActiveUsers, descActiveUsers) 149 return nil 150 } 151 152 func getPageviews(md *opentsdb.MultiDataPoint, svc *analytics.Service, site conf.GoogleAnalyticsSite) error { 153 call := svc.Data.Realtime.Get("ga:"+site.Profile, "rt:pageviews").Dimensions("rt:minutesAgo") 154 data, err := call.Do() 155 if err != nil { 156 return err 157 } 158 159 // If no offset was specified, the minute we care about is '1', or the most 160 // recently gathered, completed datapoint. Minute '0' is the current minute, 161 // and as such is incomplete. 162 offset := site.Offset 163 if offset == 0 { 164 offset = 1 165 } 166 time := time.Now().Add(time.Duration(-1*offset) * time.Minute).Unix() 167 pageviews := 0 168 // Iterates through the response data and returns the time slice we 169 // actually care about when we find it. 170 for _, row := range data.Rows { 171 // row == [2]string{"0", "123"} 172 // First item is the minute, second is the data (pageviews in this case) 173 minute, err := strconv.Atoi(row[0]) 174 if err != nil { 175 return fmt.Errorf("Error parsing GA data: %s", err) 176 } 177 if minute == offset { 178 if pageviews, err = strconv.Atoi(row[1]); err != nil { 179 return fmt.Errorf("Error parsing GA data: %s", err) 180 } 181 break 182 } 183 } 184 AddTS(md, "google.analytics.realtime.pageviews", time, pageviews, opentsdb.TagSet{"site": site.Name}, metadata.Gauge, metadata.Count, "Number of pageviews tracked by GA in one minute") 185 return nil 186 } 187 188 // googleAPIClient() takes in a clientid, secret, a base64'd gob representing 189 // the cached oauth token, and a list of oauth scopes. Generating the token is 190 // left as an exercise to the reader. 191 // Or use a base 64 encoded service account json key. Provide json key OR oauth client info. 192 func googleAPIClient(clientid string, secret string, tokenstr string, jsonToken string, scopes []string) (*http.Client, error) { 193 194 if jsonToken != "" && clientid+secret+tokenstr != "" { 195 return nil, fmt.Errorf("For google, provide a json token OR oauth client info and token. Not both") 196 } 197 198 ctx := context.Background() 199 if jsonToken != "" { 200 by, err := base64.StdEncoding.DecodeString(jsonToken) 201 if err != nil { 202 return nil, err 203 } 204 config, err := google.JWTConfigFromJSON(by, scopes...) 205 if err != nil { 206 return nil, err 207 } 208 return config.Client(ctx), nil 209 } 210 211 config := &oauth2.Config{ 212 ClientID: clientid, 213 ClientSecret: secret, 214 Endpoint: google.Endpoint, 215 Scopes: scopes, 216 } 217 token := new(oauth2.Token) 218 // Decode the base64'd gob 219 by, err := base64.StdEncoding.DecodeString(tokenstr) 220 if err != nil { 221 return nil, err 222 } 223 b := bytes.Buffer{} 224 b.Write(by) 225 d := gob.NewDecoder(&b) 226 err = d.Decode(&token) 227 return config.Client(ctx, token), nil 228 }