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  }