blob: dc33380e3739a1408f73fe95be03511861d2c5cb [file] [log] [blame]
// 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
// TODO(adonovan): create a proper package with tests. Much of this
// machinery might find wider use in other x/telemetry clients.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"hash/fnv"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/telemetry"
"golang.org/x/tools/gopls/internal/util/browser"
"golang.org/x/tools/gopls/internal/util/moremaps"
)
// flags
var (
daysFlag = flag.Int("days", 7, "number of previous days of telemetry data to read")
token string // optional GitHub authentication token, to relax the rate limit
)
func main() {
log.SetFlags(0)
log.SetPrefix("stacks: ")
flag.Parse()
// Read GitHub authentication token from $HOME/.stacks.token.
//
// You can create one using the flow at: GitHub > You > Settings >
// Developer Settings > Personal Access Tokens > Fine-grained tokens >
// Generate New Token. Generate the token on behalf of yourself
// (not "golang" or "google"), with no special permissions.
// The token is typically of the form "github_pat_XXX", with 82 hex digits.
// Save it in the file, with mode 0400.
//
// For security, secret tokens should be read from files, not
// command-line flags or environment variables.
{
home, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
tokenFile := filepath.Join(home, ".stacks.token")
content, err := os.ReadFile(tokenFile)
if err != nil {
if !os.IsNotExist(err) {
log.Fatalf("cannot read GitHub authentication token: %v", err)
}
log.Printf("no file %s containing GitHub authentication token; continuing without authentication, which is subject to stricter rate limits (https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).", tokenFile)
}
token = string(bytes.TrimSpace(content))
}
// Maps stack text to Info to count.
stacks := make(map[string]map[Info]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++ {
date := t.Add(-time.Duration(i+1) * 24 * time.Hour).Format(time.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 := Info{
Program: prog.Program,
Version: prog.Version,
GoVersion: prog.GoVersion,
GOOS: prog.GOOS,
GOARCH: prog.GOARCH,
Client: clientSuffix,
}
for stack, count := range prog.Stacks {
counts := stacks[stack]
if counts == nil {
counts = make(map[Info]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 := "is:issue label:gopls/telemetry-wins in:body " + strings.Join(batch, " OR ")
res, err := searchIssues(query)
if err != nil {
log.Fatalf("GitHub issues query %q failed: %v", query, 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
}
}
}
}
// 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 info0 Info // an arbitrary Info for this stack
var total int64
for info, count := range counts {
info0 = info
total += count
}
if issue, ok := issuesByStackID[id]; ok {
// existing issue
// TODO(adonovan): use ANSI tty color codes for Issue.State.
summary := fmt.Sprintf("#%d: %s [%s]",
issue.Number, issue.Title, issue.State)
existingIssues[summary] += total
} else {
// new issue
title := newIssue(stack, id, info0, stackToURL[stack], counts)
summary := fmt.Sprintf("%s: %s [%s]", id, title, "new")
newIssues[summary] += total
}
}
fmt.Printf("Found %d distinct stacks in last %v days:\n", distinctStacks, *daysFlag)
print := func(caption string, issues map[string]int64) {
// Print items in descending frequency.
keys := moremaps.KeySlice(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]
// TODO(adonovan): use ANSI tty codes to show high n values in bold.
fmt.Printf("%s (n=%d)\n", summary, count)
}
}
print("Existing", existingIssues)
print("New", newIssues)
}
type Info struct {
Program string // "golang.org/x/tools/gopls"
Version, GoVersion string // e.g. "gopls/v0.16.1", "go1.23"
GOOS, GOARCH string
Client string // e.g. "vscode"
}
func (info Info) String() string {
return fmt.Sprintf("%s@%s %s %s/%s %s",
info.Program, info.Version,
info.GoVersion, info.GOOS, info.GOARCH,
info.Client)
}
// 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 were 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 string, info Info, jsonURL string, counts map[Info]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.
//
// TODO(adonovan): include in the issue a source snippet ±5
// lines around the PC in this symbol.
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: bug in %s", symbol)
body := new(bytes.Buffer)
fmt.Fprintf(body, "This stack `%s` was [reported by telemetry](%s):\n\n",
id, jsonURL)
// Read the mapping from symbols to file/line.
pclntab, err := readPCLineTable(info)
if err != nil {
log.Fatal(err)
}
// Parse the stack and get the symbol names out.
for _, frame := range strings.Split(stack, "\n") {
if url := frameURL(pclntab, info, frame); url != "" {
fmt.Fprintf(body, "- [`%s`](%s)\n", frame, url)
} else {
fmt.Fprintf(body, "- `%s`\n", frame)
}
}
// Add counts, gopls version, and platform info.
// This isn't very precise but should provide clues.
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
}
// frameURL returns the CodeSearch URL for the stack frame, if known.
func frameURL(pclntab map[string]FileLine, info Info, frame string) string {
// e.g. "golang.org/x/tools/gopls/foo.(*Type).Method.inlined.func3:+5"
symbol, offset, ok := strings.Cut(frame, ":")
if !ok {
// Not a symbol (perhaps stack counter title: "gopls/bug"?)
return ""
}
fileline, ok := pclntab[symbol]
if !ok {
// objdump reports ELF symbol names, which in
// rare cases may be the Go symbols of
// runtime.CallersFrames mangled by (e.g.) the
// addition of .abi0 suffix; see
// https://github.com/golang/go/issues/69390#issuecomment-2343795920
// So this should not be a hard error.
log.Printf("no pclntab info for symbol: %s", symbol)
return ""
}
if offset == "" {
log.Fatalf("missing line offset: %s", frame)
}
offsetNum, err := strconv.Atoi(offset[1:])
if err != nil {
log.Fatalf("invalid line offset: %s", frame)
}
linenum := fileline.line
switch offset[0] {
case '-':
linenum -= offsetNum
case '+':
linenum += offsetNum
case '=':
linenum = offsetNum
}
// Construct CodeSearch URL.
firstSegment, _, _ := strings.Cut(fileline.file, "/")
if !strings.Contains(firstSegment, ".") {
// std
// (First segment is a dir beneath GOROOT/src, not a module domain name.)
return fmt.Sprintf("https://cs.opensource.google/go/go/+/%s:src/%s;l=%d",
info.GoVersion, fileline.file, linenum)
}
if rest, ok := strings.CutPrefix(fileline.file, "golang.org/x/tools"); ok {
// in x/tools repo (tools or gopls module)
if rest[0] == '/' {
// "golang.org/x/tools/gopls" -> "gopls"
rest = rest[1:]
} else if rest[0] == '@' {
// "golang.org/x/tools@version/dir/file.go" -> "dir/file.go"
rest = rest[strings.Index(rest, "/")+1:]
}
return fmt.Sprintf("https://cs.opensource.google/go/x/tools/+/%s:%s;l=%d",
"gopls/"+info.Version, rest, linenum)
}
// TODO(adonovan): support other module dependencies of gopls.
log.Printf("no CodeSearch URL for %q (%s:%d)",
symbol, fileline.file, linenum)
return ""
}
// -- GitHub search --
// searchIssues queries the GitHub issue tracker.
func searchIssues(query string) (*IssuesSearchResult, error) {
q := url.QueryEscape(query)
req, err := http.NewRequest("GET", IssuesURL+"?q="+q, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Add("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("search query failed: %s (body: %s)", resp.Status, body)
}
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
}
}
// -- pclntab --
type FileLine struct {
file string // "module@version/dir/file.go" or path relative to $GOROOT/src
line int
}
// readPCLineTable builds the gopls executable specified by info,
// reads its PC-to-line-number table, and returns the file/line of
// each TEXT symbol.
func readPCLineTable(info Info) (map[string]FileLine, error) {
// The stacks dir will be a semi-durable temp directory
// (i.e. lasts for at least hours) holding source trees
// and executables we have build recently.
//
// Each subdir will hold a specific revision.
stacksDir := "/tmp/gopls-stacks"
if err := os.MkdirAll(stacksDir, 0777); err != nil {
return nil, fmt.Errorf("can't create stacks dir: %v", err)
}
// Fetch the source for the tools repo,
// shallow-cloning just the desired revision.
// (Skip if it's already cloned.)
revDir := filepath.Join(stacksDir, info.Version)
if !fileExists(revDir) {
log.Printf("cloning tools@gopls/%s", info.Version)
if err := shallowClone(revDir, "https://go.googlesource.com/tools", "gopls/"+info.Version); err != nil {
_ = os.RemoveAll(revDir) // ignore errors
return nil, fmt.Errorf("clone: %v", err)
}
}
// Build the executable with the correct GOTOOLCHAIN, GOOS, GOARCH.
// Use -trimpath for normalized file names.
// (Skip if it's already built.)
exe := fmt.Sprintf("exe-%s.%s-%s", info.GoVersion, info.GOOS, info.GOARCH)
cmd := exec.Command("go", "build", "-trimpath", "-o", "../"+exe)
cmd.Dir = filepath.Join(revDir, "gopls")
cmd.Env = append(os.Environ(),
"GOTOOLCHAIN="+info.GoVersion,
"GOOS="+info.GOOS,
"GOARCH="+info.GOARCH,
)
if !fileExists(filepath.Join(revDir, exe)) {
log.Printf("building %s@%s with %s on %s/%s",
info.Program, info.Version, info.GoVersion, info.GOOS, info.GOARCH)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("building: %v", err)
}
}
// Read pclntab of executable.
cmd = exec.Command("go", "tool", "objdump", exe)
cmd.Stdout = new(strings.Builder)
cmd.Stderr = os.Stderr
cmd.Dir = revDir
cmd.Env = append(os.Environ(),
"GOTOOLCHAIN="+info.GoVersion,
"GOOS="+info.GOOS,
"GOARCH="+info.GOARCH,
)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("reading pclntab %v", err)
}
pclntab := make(map[string]FileLine)
lines := strings.Split(fmt.Sprint(cmd.Stdout), "\n")
for i, line := range lines {
// Each function is of this form:
//
// TEXT symbol(SB) filename
// basename.go:line instruction
// ...
if !strings.HasPrefix(line, "TEXT ") {
continue
}
fields := strings.Fields(line)
if len(fields) != 3 {
continue // symbol without file (e.g. go:buildid)
}
symbol := strings.TrimSuffix(fields[1], "(SB)")
filename := fields[2]
_, line, ok := strings.Cut(strings.Fields(lines[i+1])[0], ":")
if !ok {
return nil, fmt.Errorf("can't parse 'basename.go:line' from first instruction of %s:\n%s",
symbol, line)
}
linenum, err := strconv.Atoi(line)
if err != nil {
return nil, fmt.Errorf("can't parse line number of %s: %s", symbol, line)
}
pclntab[symbol] = FileLine{filename, linenum}
}
return pclntab, nil
}
// shallowClone performs a shallow clone of repo into dir at the given
// 'commitish' ref (any commit reference understood by git).
//
// The directory dir must not already exist.
func shallowClone(dir, repo, commitish string) error {
if err := os.Mkdir(dir, 0750); err != nil {
return fmt.Errorf("creating dir for %s: %v", repo, err)
}
// Set a timeout for git fetch. If this proves flaky, it can be removed.
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
// Use a shallow fetch to download just the relevant commit.
shInit := fmt.Sprintf("git init && git fetch --depth=1 %q %q && git checkout FETCH_HEAD", repo, commitish)
initCmd := exec.CommandContext(ctx, "/bin/sh", "-c", shInit)
initCmd.Dir = dir
if output, err := initCmd.CombinedOutput(); err != nil {
return fmt.Errorf("checking out %s: %v\n%s", repo, err, output)
}
return nil
}
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return err == nil
}