| // Copyright 2023 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // The stacks command finds all gopls stack traces reported by |
| // telemetry in the past 7 days, and reports their associated GitHub |
| // issue, creating new issues as needed. |
| package main |
| |
| import ( |
| "bytes" |
| "encoding/base64" |
| "encoding/json" |
| "flag" |
| "fmt" |
| "hash/fnv" |
| "log" |
| "net/http" |
| "net/url" |
| "sort" |
| "strings" |
| "time" |
| |
| "io" |
| |
| "golang.org/x/telemetry" |
| "golang.org/x/tools/gopls/internal/util/browser" |
| "golang.org/x/tools/gopls/internal/util/maps" |
| ) |
| |
| // flags |
| var ( |
| daysFlag = flag.Int("days", 7, "number of previous days of telemetry data to read") |
| ) |
| |
| func main() { |
| log.SetFlags(0) |
| log.SetPrefix("stacks: ") |
| flag.Parse() |
| |
| // Maps stack text to Version/GoVersion/GOOS/GOARCH string to counter. |
| stacks := make(map[string]map[string]int64) |
| var distinctStacks int |
| |
| // Maps stack to a telemetry URL. |
| stackToURL := make(map[string]string) |
| |
| // Read all recent telemetry reports. |
| t := time.Now() |
| for i := 0; i < *daysFlag; i++ { |
| const DateOnly = "2006-01-02" // TODO(adonovan): use time.DateOnly in go1.20. |
| date := t.Add(-time.Duration(i+1) * 24 * time.Hour).Format(DateOnly) |
| |
| url := fmt.Sprintf("https://storage.googleapis.com/prod-telemetry-merged/%s.json", date) |
| resp, err := http.Get(url) |
| if err != nil { |
| log.Fatalf("can't GET %s: %v", url, err) |
| } |
| defer resp.Body.Close() |
| if resp.StatusCode != 200 { |
| log.Fatalf("GET %s returned %d %s", url, resp.StatusCode, resp.Status) |
| } |
| |
| dec := json.NewDecoder(resp.Body) |
| for { |
| var report telemetry.Report |
| if err := dec.Decode(&report); err != nil { |
| if err == io.EOF { |
| break |
| } |
| log.Fatal(err) |
| } |
| for _, prog := range report.Programs { |
| if prog.Program == "golang.org/x/tools/gopls" && len(prog.Stacks) > 0 { |
| // Include applicable client names (e.g. vscode, eglot). |
| var clients []string |
| var clientSuffix string |
| for key := range prog.Counters { |
| client := strings.TrimPrefix(key, "gopls/client:") |
| if client != key { |
| clients = append(clients, client) |
| } |
| } |
| sort.Strings(clients) |
| if len(clients) > 0 { |
| clientSuffix = " " + strings.Join(clients, ",") |
| } |
| |
| // Ignore @devel versions as they correspond to |
| // ephemeral (and often numerous) variations of |
| // the program as we work on a fix to a bug. |
| if prog.Version == "devel" { |
| continue |
| } |
| |
| distinctStacks++ |
| |
| info := fmt.Sprintf("%s@%s %s %s/%s%s", |
| prog.Program, prog.Version, |
| prog.GoVersion, prog.GOOS, prog.GOARCH, |
| clientSuffix) |
| for stack, count := range prog.Stacks { |
| counts := stacks[stack] |
| if counts == nil { |
| counts = make(map[string]int64) |
| stacks[stack] = counts |
| } |
| counts[info] += count |
| stackToURL[stack] = url |
| } |
| } |
| } |
| } |
| } |
| |
| // Compute IDs of all stacks. |
| var stackIDs []string |
| for stack := range stacks { |
| stackIDs = append(stackIDs, stackID(stack)) |
| } |
| |
| // Query GitHub for existing GitHub issues. |
| // (Note: there may be multiple Issue records |
| // for the same logical issue, i.e. Issue.Number.) |
| issuesByStackID := make(map[string]*Issue) |
| for len(stackIDs) > 0 { |
| // For some reason GitHub returns 422 UnprocessableEntity |
| // if we attempt to read more than 6 at once. |
| batch := stackIDs[:min(6, len(stackIDs))] |
| stackIDs = stackIDs[len(batch):] |
| |
| query := "label:gopls/telemetry-wins in:body " + strings.Join(batch, " OR ") |
| res, err := searchIssues(query) |
| if err != nil { |
| log.Fatalf("GitHub issues query failed: %v", err) |
| } |
| for _, issue := range res.Items { |
| for _, id := range batch { |
| // Matching is a little fuzzy here |
| // but base64 will rarely produce |
| // words that appear in the body |
| // by chance. |
| if strings.Contains(issue.Body, id) { |
| issuesByStackID[id] = issue |
| } |
| } |
| } |
| } |
| |
| fmt.Printf("Found %d distinct stacks in last %v days:\n", distinctStacks, *daysFlag) |
| |
| // For each stack, show existing issue or create a new one. |
| // Aggregate stack IDs by issue summary. |
| var ( |
| // Both vars map the summary line to the stack count. |
| existingIssues = make(map[string]int64) |
| newIssues = make(map[string]int64) |
| ) |
| for stack, counts := range stacks { |
| id := stackID(stack) |
| |
| var total int64 |
| for _, count := range counts { |
| total += count |
| } |
| |
| if issue, ok := issuesByStackID[id]; ok { |
| // existing issue |
| summary := fmt.Sprintf("#%d: %s [%s]", |
| issue.Number, issue.Title, issue.State) |
| existingIssues[summary] += total |
| } else { |
| // new issue |
| title := newIssue(stack, id, stackToURL[stack], counts) |
| summary := fmt.Sprintf("%s: %s [%s]", id, title, "new") |
| newIssues[summary] += total |
| } |
| } |
| print := func(caption string, issues map[string]int64) { |
| // Print items in descending frequency. |
| keys := maps.Keys(issues) |
| sort.Slice(keys, func(i, j int) bool { |
| return issues[keys[i]] > issues[keys[j]] |
| }) |
| fmt.Printf("%s issues:\n", caption) |
| for _, summary := range keys { |
| count := issues[summary] |
| fmt.Printf("%s (n=%d)\n", summary, count) |
| } |
| } |
| print("Existing", existingIssues) |
| print("New", newIssues) |
| } |
| |
| // stackID returns a 32-bit identifier for a stack |
| // suitable for use in GitHub issue titles. |
| func stackID(stack string) string { |
| // Encode it using base64 (6 bytes) for brevity, |
| // as a single issue's body might contain multiple IDs |
| // if separate issues with same cause wre manually de-duped, |
| // e.g. "AAAAAA, BBBBBB" |
| // |
| // https://hbfs.wordpress.com/2012/03/30/finding-collisions: |
| // the chance of a collision is 1 - exp(-n(n-1)/2d) where n |
| // is the number of items and d is the number of distinct values. |
| // So, even with n=10^4 telemetry-reported stacks each identified |
| // by a uint32 (d=2^32), we have a 1% chance of a collision, |
| // which is plenty good enough. |
| h := fnv.New32() |
| io.WriteString(h, stack) |
| return base64.URLEncoding.EncodeToString(h.Sum(nil))[:6] |
| } |
| |
| // newIssue creates a browser tab with a populated GitHub "New issue" |
| // form for the specified stack. (The triage person is expected to |
| // manually de-dup the issue before deciding whether to submit the form.) |
| // |
| // It returns the title. |
| func newIssue(stack, id, jsonURL string, counts map[string]int64) string { |
| // Use a heuristic to find a suitable symbol to blame |
| // in the title: the first public function or method |
| // of a public type, in gopls, to appear in the stack |
| // trace. We can always refine it later. |
| var symbol string |
| for _, line := range strings.Split(stack, "\n") { |
| // Look for: |
| // gopls/.../pkg.Func |
| // gopls/.../pkg.Type.method |
| // gopls/.../pkg.(*Type).method |
| if strings.Contains(line, "internal/util/bug.") { |
| continue // not interesting |
| } |
| if _, rest, ok := strings.Cut(line, "golang.org/x/tools/gopls/"); ok { |
| if i := strings.IndexByte(rest, '.'); i >= 0 { |
| rest = rest[i+1:] |
| rest = strings.TrimPrefix(rest, "(*") |
| if rest != "" && 'A' <= rest[0] && rest[0] <= 'Z' { |
| rest, _, _ = strings.Cut(rest, ":") |
| symbol = " " + rest |
| break |
| } |
| } |
| } |
| } |
| |
| // Populate the form (title, body, label) |
| title := fmt.Sprintf("x/tools/gopls:%s bug reported by telemetry", symbol) |
| body := new(bytes.Buffer) |
| fmt.Fprintf(body, "This stack `%s` was [reported by telemetry](%s):\n\n", |
| id, jsonURL) |
| fmt.Fprintf(body, "```\n%s\n```\n", stack) |
| |
| // Add counts, gopls version, and platform info. |
| // This isn't very precise but should provide clues. |
| // |
| // TODO(adonovan): link each stack (ideally each frame) to source: |
| // https://cs.opensource.google/go/x/tools/+/gopls/VERSION:gopls/FILE;l=LINE |
| // (Requires parsing stack, shallow-cloning gopls module at that tag, and |
| // computing correct line offsets. Would be labor-saving though.) |
| fmt.Fprintf(body, "```\n") |
| for info, count := range counts { |
| fmt.Fprintf(body, "%s (%d)\n", info, count) |
| } |
| fmt.Fprintf(body, "```\n\n") |
| |
| fmt.Fprintf(body, "Issue created by golang.org/x/tools/gopls/internal/telemetry/cmd/stacks.\n") |
| |
| const labels = "gopls,Tools,gopls/telemetry-wins,NeedsInvestigation" |
| |
| // Report it. |
| if !browser.Open("https://github.com/golang/go/issues/new?labels=" + labels + "&title=" + url.QueryEscape(title) + "&body=" + url.QueryEscape(body.String())) { |
| log.Print("Please file a new issue at golang.org/issue/new using this template:\n\n") |
| log.Printf("Title: %s\n", title) |
| log.Printf("Labels: %s\n", labels) |
| log.Printf("Body: %s\n", body) |
| } |
| |
| return title |
| } |
| |
| // -- GitHub search -- |
| |
| // searchIssues queries the GitHub issue tracker. |
| func searchIssues(query string) (*IssuesSearchResult, error) { |
| q := url.QueryEscape(query) |
| resp, err := http.Get(IssuesURL + "?q=" + q) |
| if err != nil { |
| return nil, err |
| } |
| if resp.StatusCode != http.StatusOK { |
| resp.Body.Close() |
| return nil, fmt.Errorf("search query failed: %s", resp.Status) |
| } |
| var result IssuesSearchResult |
| if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { |
| resp.Body.Close() |
| return nil, err |
| } |
| resp.Body.Close() |
| return &result, nil |
| } |
| |
| // See https://developer.github.com/v3/search/#search-issues. |
| |
| const IssuesURL = "https://api.github.com/search/issues" |
| |
| type IssuesSearchResult struct { |
| TotalCount int `json:"total_count"` |
| Items []*Issue |
| } |
| |
| type Issue struct { |
| Number int |
| HTMLURL string `json:"html_url"` |
| Title string |
| State string |
| User *User |
| CreatedAt time.Time `json:"created_at"` |
| Body string // in Markdown format |
| } |
| |
| type User struct { |
| Login string |
| HTMLURL string `json:"html_url"` |
| } |
| |
| // -- helpers -- |
| |
| func min(x, y int) int { |
| if x < y { |
| return x |
| } else { |
| return y |
| } |
| } |