golang.org/x/tools/gopls@v0.15.3/internal/telemetry/cmd/stacks/stacks.go (about) 1 // Copyright 2023 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // The stacks command finds all gopls stack traces reported by 6 // telemetry in the past 7 days, and reports their associated GitHub 7 // issue, creating new issues as needed. 8 package main 9 10 import ( 11 "bytes" 12 "encoding/base64" 13 "encoding/json" 14 "flag" 15 "fmt" 16 "hash/fnv" 17 "log" 18 "net/http" 19 "net/url" 20 "sort" 21 "strings" 22 "time" 23 24 "io" 25 26 "golang.org/x/telemetry" 27 "golang.org/x/tools/gopls/internal/util/browser" 28 ) 29 30 // flags 31 var ( 32 daysFlag = flag.Int("days", 7, "number of previous days of telemetry data to read") 33 ) 34 35 func main() { 36 log.SetFlags(0) 37 log.SetPrefix("stacks: ") 38 flag.Parse() 39 40 // Maps stack text to Version/GoVersion/GOOS/GOARCH string to counter. 41 stacks := make(map[string]map[string]int64) 42 var total int 43 44 // Maps stack to a telemetry URL. 45 stackToURL := make(map[string]string) 46 47 // Read all recent telemetry reports. 48 t := time.Now() 49 for i := 0; i < *daysFlag; i++ { 50 const DateOnly = "2006-01-02" // TODO(adonovan): use time.DateOnly in go1.20. 51 date := t.Add(-time.Duration(i+1) * 24 * time.Hour).Format(DateOnly) 52 53 url := fmt.Sprintf("https://storage.googleapis.com/prod-telemetry-merged/%s.json", date) 54 resp, err := http.Get(url) 55 if err != nil { 56 log.Fatalf("can't GET %s: %v", url, err) 57 } 58 defer resp.Body.Close() 59 if resp.StatusCode != 200 { 60 log.Fatalf("GET %s returned %d %s", url, resp.StatusCode, resp.Status) 61 } 62 63 dec := json.NewDecoder(resp.Body) 64 for { 65 var report telemetry.Report 66 if err := dec.Decode(&report); err != nil { 67 if err == io.EOF { 68 break 69 } 70 log.Fatal(err) 71 } 72 for _, prog := range report.Programs { 73 if prog.Program == "golang.org/x/tools/gopls" && len(prog.Stacks) > 0 { 74 total++ 75 76 // Include applicable client names (e.g. vscode, eglot). 77 var clients []string 78 var clientSuffix string 79 for key := range prog.Counters { 80 client := strings.TrimPrefix(key, "gopls/client:") 81 if client != key { 82 clients = append(clients, client) 83 } 84 } 85 sort.Strings(clients) 86 if len(clients) > 0 { 87 clientSuffix = " " + strings.Join(clients, ",") 88 } 89 90 // Ignore @devel versions as they correspond to 91 // ephemeral (and often numerous) variations of 92 // the program as we work on a fix to a bug. 93 if prog.Version == "devel" { 94 continue 95 } 96 info := fmt.Sprintf("%s@%s %s %s/%s%s", 97 prog.Program, prog.Version, 98 prog.GoVersion, prog.GOOS, prog.GOARCH, 99 clientSuffix) 100 for stack, count := range prog.Stacks { 101 counts := stacks[stack] 102 if counts == nil { 103 counts = make(map[string]int64) 104 stacks[stack] = counts 105 } 106 counts[info] += count 107 stackToURL[stack] = url 108 } 109 } 110 } 111 } 112 } 113 114 // Compute IDs of all stacks. 115 var stackIDs []string 116 for stack := range stacks { 117 stackIDs = append(stackIDs, stackID(stack)) 118 } 119 120 // Query GitHub for existing GitHub issues. 121 issuesByStackID := make(map[string]*Issue) 122 for len(stackIDs) > 0 { 123 // For some reason GitHub returns 422 UnprocessableEntity 124 // if we attempt to read more than 6 at once. 125 batch := stackIDs[:min(6, len(stackIDs))] 126 stackIDs = stackIDs[len(batch):] 127 128 query := "label:gopls/telemetry-wins in:body " + strings.Join(batch, " OR ") 129 res, err := searchIssues(query) 130 if err != nil { 131 log.Fatalf("GitHub issues query failed: %v", err) 132 } 133 for _, issue := range res.Items { 134 for _, id := range batch { 135 // Matching is a little fuzzy here 136 // but base64 will rarely produce 137 // words that appear in the body 138 // by chance. 139 if strings.Contains(issue.Body, id) { 140 issuesByStackID[id] = issue 141 } 142 } 143 } 144 } 145 146 fmt.Printf("Found %d stacks in last %v days:\n", total, *daysFlag) 147 148 // For each stack, show existing issue or create a new one. 149 for stack, counts := range stacks { 150 id := stackID(stack) 151 152 // Existing issue? 153 issue, ok := issuesByStackID[id] 154 if ok { 155 if issue != nil { 156 fmt.Printf("#%d: %s [%s]\n", 157 issue.Number, issue.Title, issue.State) 158 } else { 159 // We just created a "New issue" browser tab 160 // for this stackID. 161 issuesByStackID[id] = nil // suppress dups 162 } 163 continue 164 } 165 166 // Create new issue. 167 issuesByStackID[id] = nil // suppress dups 168 169 // Use a heuristic to find a suitable symbol to blame 170 // in the title: the first public function or method 171 // of a public type, in gopls, to appear in the stack 172 // trace. We can always refine it later. 173 var symbol string 174 for _, line := range strings.Split(stack, "\n") { 175 // Look for: 176 // gopls/.../pkg.Func 177 // gopls/.../pkg.Type.method 178 // gopls/.../pkg.(*Type).method 179 if strings.Contains(line, "internal/util/bug.") { 180 continue // not interesting 181 } 182 if _, rest, ok := strings.Cut(line, "golang.org/x/tools/gopls/"); ok { 183 if i := strings.IndexByte(rest, '.'); i >= 0 { 184 rest = rest[i+1:] 185 rest = strings.TrimPrefix(rest, "(*") 186 if rest != "" && 'A' <= rest[0] && rest[0] <= 'Z' { 187 rest, _, _ = strings.Cut(rest, ":") 188 symbol = " " + rest 189 break 190 } 191 } 192 } 193 } 194 195 // Populate the form (title, body, label) 196 title := fmt.Sprintf("x/tools/gopls:%s bug reported by telemetry", symbol) 197 body := new(bytes.Buffer) 198 fmt.Fprintf(body, "This stack `%s` was [reported by telemetry](%s):\n\n", 199 id, stackToURL[stack]) 200 fmt.Fprintf(body, "```\n%s\n```\n", stack) 201 202 // Add counts, gopls version, and platform info. 203 // This isn't very precise but should provide clues. 204 // 205 // TODO(adonovan): link each stack (ideally each frame) to source: 206 // https://cs.opensource.google/go/x/tools/+/gopls/VERSION:gopls/FILE;l=LINE 207 // (Requires parsing stack, shallow-cloning gopls module at that tag, and 208 // computing correct line offsets. Would be labor-saving though.) 209 fmt.Fprintf(body, "```\n") 210 for info, count := range counts { 211 fmt.Fprintf(body, "%s (%d)\n", info, count) 212 } 213 fmt.Fprintf(body, "```\n\n") 214 215 fmt.Fprintf(body, "Issue created by golang.org/x/tools/gopls/internal/telemetry/cmd/stacks.\n") 216 217 const labels = "gopls,Tools,gopls/telemetry-wins,NeedsInvestigation" 218 219 // Report it. 220 if !browser.Open("https://github.com/golang/go/issues/new?labels=" + labels + "&title=" + url.QueryEscape(title) + "&body=" + url.QueryEscape(body.String())) { 221 log.Print("Please file a new issue at golang.org/issue/new using this template:\n\n") 222 log.Printf("Title: %s\n", title) 223 log.Printf("Labels: %s\n", labels) 224 log.Printf("Body: %s\n", body) 225 } 226 } 227 } 228 229 // stackID returns a 32-bit identifier for a stack 230 // suitable for use in GitHub issue titles. 231 func stackID(stack string) string { 232 // Encode it using base64 (6 bytes) for brevity, 233 // as a single issue's body might contain multiple IDs 234 // if separate issues with same cause wre manually de-duped, 235 // e.g. "AAAAAA, BBBBBB" 236 // 237 // https://hbfs.wordpress.com/2012/03/30/finding-collisions: 238 // the chance of a collision is 1 - exp(-n(n-1)/2d) where n 239 // is the number of items and d is the number of distinct values. 240 // So, even with n=10^4 telemetry-reported stacks each identified 241 // by a uint32 (d=2^32), we have a 1% chance of a collision, 242 // which is plenty good enough. 243 h := fnv.New32() 244 io.WriteString(h, stack) 245 return base64.URLEncoding.EncodeToString(h.Sum(nil))[:6] 246 } 247 248 // -- GitHub search -- 249 250 // searchIssues queries the GitHub issue tracker. 251 func searchIssues(query string) (*IssuesSearchResult, error) { 252 q := url.QueryEscape(query) 253 resp, err := http.Get(IssuesURL + "?q=" + q) 254 if err != nil { 255 return nil, err 256 } 257 if resp.StatusCode != http.StatusOK { 258 resp.Body.Close() 259 return nil, fmt.Errorf("search query failed: %s", resp.Status) 260 } 261 var result IssuesSearchResult 262 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 263 resp.Body.Close() 264 return nil, err 265 } 266 resp.Body.Close() 267 return &result, nil 268 } 269 270 // See https://developer.github.com/v3/search/#search-issues. 271 272 const IssuesURL = "https://api.github.com/search/issues" 273 274 type IssuesSearchResult struct { 275 TotalCount int `json:"total_count"` 276 Items []*Issue 277 } 278 279 type Issue struct { 280 Number int 281 HTMLURL string `json:"html_url"` 282 Title string 283 State string 284 User *User 285 CreatedAt time.Time `json:"created_at"` 286 Body string // in Markdown format 287 } 288 289 type User struct { 290 Login string 291 HTMLURL string `json:"html_url"` 292 } 293 294 // -- helpers -- 295 296 func min(x, y int) int { 297 if x < y { 298 return x 299 } else { 300 return y 301 } 302 }