blob: b2f261cf1972f45b3e00cdc59db4da5a76f2bb78 [file] [log] [blame]
// Copyright 2025 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.
//go:build linux || darwin
// 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.
//
// The association of stacks with GitHub issues (labelled
// gopls/telemetry-wins) is represented in two different ways by the
// body (first comment) of the issue:
//
// 1. Each distinct stack is identified by an ID, 6-digit base64
// string such as "TwtkSg". If a stack's ID appears anywhere
// within the issue body, the stack is associated with the issue.
//
// Some problems are highly deterministic, resulting in many
// field reports of the exact same stack. For such problems, a
// single ID in the issue body suffices to record the
// association. But most problems are exhibited in a variety of
// ways, leading to multiple field reports of similar but
// distinct stacks. Hence the following way to associate stacks
// with issues.
//
// 2. Each GitHub issue body may start with a code block of this form:
//
// ```
// #!stacks
// "runtime.sigpanic" && "golang.hover:+170"
// ```
//
// The first line indicates the purpose of the block; the
// remainder is a predicate that matches stacks.
// It is an expression defined by this grammar:
//
// > expr = "string literal"
// > | ( expr )
// > | ! expr
// > | expr && expr
// > | expr || expr
//
// Each string literal must match complete words on the stack;
// the other productions are boolean operations.
// As an example of literal matching, "fu+12" matches "x:fu+12 "
// but not "fu:123" or "snafu+12".
//
// The stacks command gathers all such predicates out of the
// labelled issues and evaluates each one against each new stack.
// If the predicate for an issue matches, the issue is considered
// to have "claimed" the stack: the stack command appends a
// comment containing the new (variant) stack to the issue, and
// appends the stack's ID to the last line of the issue body.
//
// It is an error if two issues' predicates attempt to claim the
// same stack.
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"hash/fnv"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
"unicode"
"golang.org/x/mod/semver"
"golang.org/x/sys/unix"
"golang.org/x/telemetry"
"golang.org/x/telemetry/internal/browser"
)
// flags
var (
programFlag = flag.String("program", "golang.org/x/tools/gopls", "Package path of program to process")
daysFlag = flag.Int("days", 7, "number of previous days of telemetry data to read")
dryRun = flag.Bool("n", false, "dry run, avoid updating issues")
)
// ProgramConfig is the configuration for processing reports for a specific
// program.
type ProgramConfig struct {
// Program is the package path of the program to process.
Program string
// IncludeClient indicates that stack Info should include gopls/client metadata.
IncludeClient bool
// SearchLabel is the GitHub label used to find all existing reports.
SearchLabel string
// NewIssuePrefix is the package prefix to apply to new issue titles.
NewIssuePrefix string
// NewIssueLabels are the labels to apply to new issues.
NewIssueLabels []string
// MatchSymbolPrefix is the prefix of "interesting" symbol names.
//
// A given stack will be "blamed" on the deepest symbol in the stack that:
// 1. Matches MatchSymbolPrefix
// 2. Is an exported function or any method on an exported Type.
// 3. Does _not_ match IgnoreSymbolContains.
MatchSymbolPrefix string
// IgnoreSymbolContains are "uninteresting" symbol substrings. e.g.,
// logging packages.
IgnoreSymbolContains []string
// Repository is the repository where the issues should be created, for example: "golang/go"
Repository string
}
var programs = map[string]ProgramConfig{
"golang.org/x/tools/gopls": {
Program: "golang.org/x/tools/gopls",
IncludeClient: true,
SearchLabel: "gopls/telemetry-wins",
NewIssuePrefix: "x/tools/gopls",
NewIssueLabels: []string{
"gopls",
"Tools",
"gopls/telemetry-wins",
"NeedsInvestigation",
},
MatchSymbolPrefix: "golang.org/x/tools/gopls/",
IgnoreSymbolContains: []string{
"internal/util/bug.",
"internal/bug.", // former name in gopls/0.14.2
},
Repository: "golang/go",
},
"cmd/compile": {
Program: "cmd/compile",
SearchLabel: "compiler/telemetry-wins",
NewIssuePrefix: "cmd/compile",
NewIssueLabels: []string{
"compiler/runtime",
"compiler/telemetry-wins",
"NeedsInvestigation",
},
MatchSymbolPrefix: "cmd/compile",
IgnoreSymbolContains: []string{
// Various "fatal" wrappers.
"Fatal", // base.Fatal*, ssa.Value.Fatal*, etc.
"cmd/compile/internal/base.Assert",
"cmd/compile/internal/noder.assert",
"cmd/compile/internal/ssa.Compile.func1", // basically a Fatalf wrapper.
// Panic recovery.
"cmd/compile/internal/types2.(*Checker).handleBailout",
"cmd/compile/internal/gc.handlePanic",
},
Repository: "golang/go",
},
"github.com/go-delve/delve/cmd/dlv": {
Program: "github.com/go-delve/delve/cmd/dlv",
IncludeClient: false,
SearchLabel: "delve/telemetry-wins",
NewIssuePrefix: "telemetry report",
NewIssueLabels: []string{
"delve/telemetry-wins",
},
MatchSymbolPrefix: "github.com/go-delve/delve",
IgnoreSymbolContains: []string{
"service/dap.(*Session).recoverPanic",
"rpccommon.newInternalError",
"rpccommon.(*ServerImpl).serveJSONCodec",
},
Repository: "go-delve/delve",
},
}
func main() {
log.SetFlags(0)
log.SetPrefix("stacks: ")
flag.Parse()
var ghclient *githubClient
// 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 golang/go
// with R/W access to "Issues".
// 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 {
log.Fatalf("cannot read GitHub authentication token: %v", err)
}
ghclient = &githubClient{authToken: string(bytes.TrimSpace(content))}
}
pcfg, ok := programs[*programFlag]
if !ok {
log.Fatalf("unknown -program %s", *programFlag)
}
// Read all recent telemetry reports.
stacks, distinctStacks, stackToURL, err := readReports(pcfg, *daysFlag)
if err != nil {
log.Fatalf("Error reading reports: %v", err)
}
issues, err := readIssues(ghclient, pcfg)
if err != nil {
log.Fatalf("Error reading issues: %v", err)
}
// Map stacks to existing issues (if any).
claimedBy := claimStacks(issues, stacks)
// Update existing issues that claimed new stacks.
updateIssues(ghclient, pcfg.Repository, issues, stacks, stackToURL)
// 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 := claimedBy[id]; ok {
// existing issue, already updated above, just store
// the summary.
state := issue.State
if issue.State == "closed" && issue.StateReason == "completed" {
state = "completed"
}
summary := fmt.Sprintf("#%d: %s [%s]",
issue.Number, issue.Title, state)
if state == "completed" && issue.Milestone != nil {
summary += " milestone " + strings.TrimPrefix(issue.Milestone.Title, "gopls/")
}
existingIssues[summary] += total
} else {
// new issue, need to create GitHub issue and store
// summary.
title := newIssue(pcfg, stack, id, 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 := 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]
// Show closed issues in "white".
if isTerminal(os.Stdout) && (strings.Contains(summary, "[closed]") || strings.Contains(summary, "[completed]")) {
// ESC + "[" + n + "m" => change color to n
// (37 = white, 0 = default)
summary = "\x1B[37m" + summary + "\x1B[0m"
}
fmt.Printf("%s (n=%d)\n", summary, count)
}
}
print("Existing", existingIssues)
print("New", newIssues)
}
// Info is used as a key for de-duping and aggregating.
// Do not add detail about particular records (e.g. data, telemetry URL).
type Info struct {
Program string // "golang.org/x/tools/gopls"
ProgramVersion string // "v0.16.1"
GoVersion string // "go1.23"
GOOS, GOARCH string
GoplsClient string // e.g. "vscode" (only set if Program == "golang.org/x/tools/gopls")
}
func (info Info) String() string {
s := fmt.Sprintf("%s@%s %s %s/%s",
info.Program, info.ProgramVersion,
info.GoVersion, info.GOOS, info.GOARCH)
if info.GoplsClient != "" {
s += " " + info.GoplsClient
}
return s
}
// readReports downloads telemetry stack reports for a program from the
// specified number of most recent days.
//
// stacks is a map of stack text to program metadata to stack+metadata report
// count.
// distinctStacks is the number of distinct stacks across all reports.
// stackToURL maps the stack text to the oldest telemetry JSON report it was
// included in.
func readReports(pcfg ProgramConfig, days int) (stacks map[string]map[Info]int64, distinctStacks int, stackToURL map[string]string, err error) {
stacks = make(map[string]map[Info]int64)
stackToURL = make(map[string]string)
t := time.Now()
for i := range days {
date := t.Add(-time.Duration(i+1) * 24 * time.Hour).Format(time.DateOnly)
url := fmt.Sprintf("https://telemetry.go.dev/data/%s", date)
resp, err := http.Get(url)
if err != nil {
return nil, 0, nil, fmt.Errorf("error on GET %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, 0, nil, fmt.Errorf("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
}
return nil, 0, nil, fmt.Errorf("error decoding report: %v", err)
}
for _, prog := range report.Programs {
if prog.Program != pcfg.Program {
continue
}
if len(prog.Stacks) == 0 {
continue
}
// 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
}
// Include applicable client names (e.g. vscode, eglot) for gopls.
var clientSuffix string
if pcfg.IncludeClient {
var clients []string
for key := range prog.Counters {
if client, ok := strings.CutPrefix(key, "gopls/client:"); ok {
clients = append(clients, client)
}
}
sort.Strings(clients)
if len(clients) > 0 {
clientSuffix = strings.Join(clients, ",")
}
}
info := Info{
Program: prog.Program,
ProgramVersion: prog.Version,
GoVersion: prog.GoVersion,
GOOS: prog.GOOS,
GOARCH: prog.GOARCH,
GoplsClient: 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
}
distinctStacks += len(prog.Stacks)
}
}
}
return stacks, distinctStacks, stackToURL, nil
}
// readIssues returns all existing issues for the given program and parses any
// predicates.
func readIssues(cli *githubClient, pcfg ProgramConfig) ([]*Issue, error) {
// Query GitHub for all existing GitHub issues with the report label.
issues, err := cli.searchIssues(pcfg.Repository, pcfg.SearchLabel)
if err != nil {
// TODO(jba): return error instead of dying, or doc.
log.Fatalf("GitHub issues label %q search failed: %v", pcfg.SearchLabel, err)
}
// Extract and validate predicate expressions in ```#!stacks...``` code blocks.
// See the package doc comment for the grammar.
for _, issue := range issues {
block := findPredicateBlock(issue.Body)
if block != "" {
pred, err := parsePredicate(block)
if err != nil {
log.Printf("invalid predicate in issue #%d: %v\n<<%s>>",
issue.Number, err, block)
continue
}
issue.predicate = pred
}
}
return issues, nil
}
// parsePredicate parses a predicate expression, returning a function that evaluates
// the predicate on a stack.
// The expression must match this grammar:
//
// expr = "string literal"
// | ( expr )
// | ! expr
// | expr && expr
// | expr || expr
//
// The value of a string literal is whether it is a substring of the stack, respecting word boundaries.
// That is, a literal L behaves like the regular expression \bL'\b, where L' is L with
// regexp metacharacters quoted.
func parsePredicate(s string) (func(string) bool, error) {
expr, err := parser.ParseExpr(s)
if err != nil {
return nil, fmt.Errorf("parse error: %w", err)
}
// Cache compiled regexps since we need them more than once.
literalRegexps := make(map[*ast.BasicLit]*regexp.Regexp)
// Check for errors in the predicate so we can report them now,
// ensuring that evaluation is error-free.
var validate func(ast.Expr) error
validate = func(e ast.Expr) error {
switch e := e.(type) {
case *ast.UnaryExpr:
if e.Op != token.NOT {
return fmt.Errorf("invalid op: %s", e.Op)
}
return validate(e.X)
case *ast.BinaryExpr:
if e.Op != token.LAND && e.Op != token.LOR {
return fmt.Errorf("invalid op: %s", e.Op)
}
if err := validate(e.X); err != nil {
return err
}
return validate(e.Y)
case *ast.ParenExpr:
return validate(e.X)
case *ast.BasicLit:
if e.Kind != token.STRING {
return fmt.Errorf("invalid literal (%s)", e.Kind)
}
lit, err := strconv.Unquote(e.Value)
if err != nil {
return err
}
// The end of the literal (usually "symbol",
// "pkg.symbol", or "pkg.symbol:+1") must
// match a word boundary. However, the start
// of the literal need not: an input line such
// as "domain.name/dir/pkg.symbol:+1" should
// match literal "pkg.symbol", but the slash
// is not a word boundary (witness:
// https://go.dev/play/p/w-8ev_VUBSq).
//
// It may match multiple words if it contains
// non-word runes like whitespace.
//
// The constructed regular expression is always valid.
literalRegexps[e] = regexp.MustCompile(regexp.QuoteMeta(lit) + `\b`)
default:
return fmt.Errorf("syntax error (%T)", e)
}
return nil
}
if err := validate(expr); err != nil {
return nil, err
}
return func(stack string) bool {
var eval func(ast.Expr) bool
eval = func(e ast.Expr) bool {
switch e := e.(type) {
case *ast.UnaryExpr:
return !eval(e.X)
case *ast.BinaryExpr:
if e.Op == token.LAND {
return eval(e.X) && eval(e.Y)
} else {
return eval(e.X) || eval(e.Y)
}
case *ast.ParenExpr:
return eval(e.X)
case *ast.BasicLit:
return literalRegexps[e].MatchString(stack)
}
panic("unreachable")
}
return eval(expr)
}, nil
}
// claimStacks maps each stack ID to its issue (if any).
//
// It returns a map of stack text to the issue that claimed it.
//
// An issue can claim a stack two ways:
//
// 1. if the issue body contains the ID of the stack. Matching
// is a little loose but base64 will rarely produce words
// that appear in the body by chance.
//
// 2. if the issue body contains a ```#!stacks``` predicate
// that matches the stack.
//
// We log an error if two different issues attempt to claim
// the same stack.
func claimStacks(issues []*Issue, stacks map[string]map[Info]int64) map[string]*Issue {
// This is O(new stacks x existing issues).
claimedBy := make(map[string]*Issue)
for stack := range stacks {
id := stackID(stack)
for _, issue := range issues {
byPredicate := false
if strings.Contains(issue.Body, id) {
// nop
} else if issue.predicate != nil && issue.predicate(stack) {
byPredicate = true
} else {
continue
}
if prev := claimedBy[id]; prev != nil && prev != issue {
log.Printf("stack %s is claimed by issues #%d and #%d:%s",
id, prev.Number, issue.Number, strings.ReplaceAll("\n"+stack, "\n", "\n- "))
continue
}
if false {
log.Printf("stack %s claimed by issue #%d",
id, issue.Number)
}
claimedBy[id] = issue
if byPredicate {
// The stack ID matched the predicate but was not
// found in the issue body, so this is a new stack.
issue.newStacks = append(issue.newStacks, stack)
}
}
}
return claimedBy
}
// updateIssues updates existing issues that claimed new stacks by predicate.
func updateIssues(cli *githubClient, repo string, issues []*Issue, stacks map[string]map[Info]int64, stackToURL map[string]string) {
for _, issue := range issues {
if len(issue.newStacks) == 0 {
continue
}
// Add a comment to the existing issue listing all its new stacks.
// (Save the ID of each stack for the second step.)
comment := new(bytes.Buffer)
var newStackIDs []string
for _, stack := range issue.newStacks {
id := stackID(stack)
newStackIDs = append(newStackIDs, id)
writeStackComment(comment, stack, id, stackToURL[stack], stacks[stack])
}
if err := cli.addIssueComment(repo, issue.Number, comment.String()); err != nil {
log.Println(err)
continue
}
// Append to the "Dups: ID ..." list on last line of issue body.
body := strings.TrimSpace(issue.Body)
lastLineStart := strings.LastIndexByte(body, '\n') + 1
lastLine := body[lastLineStart:]
if !strings.HasPrefix(lastLine, "Dups:") {
body += "\nDups:"
}
body += " " + strings.Join(newStackIDs, " ")
update := updateIssue{number: issue.Number, Body: body}
if shouldReopen(issue, stacks) {
update.State = "open"
update.StateReason = "reopened"
}
if err := cli.updateIssue(repo, update); err != nil {
log.Printf("added comment to issue #%d but failed to update: %v",
issue.Number, err)
continue
}
log.Printf("added stacks %s to issue #%d", newStackIDs, issue.Number)
}
}
// An issue should be re-opened if it was closed as fixed, and at least one of the
// new stacks happened since the version containing the fix.
func shouldReopen(issue *Issue, stacks map[string]map[Info]int64) bool {
if !issue.isFixed() {
return false
}
issueProgram, issueVersion, ok := parseMilestone(issue.Milestone)
if !ok {
return false
}
matchProgram := func(infoProg string) bool {
switch issueProgram {
case "gopls":
return path.Base(infoProg) == issueProgram
case "go":
// At present, we only care about compiler stacks.
// Issues should have milestones like "Go1.24".
return infoProg == "cmd/compile"
default:
return false
}
}
for _, stack := range issue.newStacks {
for info := range stacks[stack] {
if matchProgram(info.Program) && semver.Compare(semVer(info.ProgramVersion), issueVersion) >= 0 {
log.Printf("reopening issue #%d: purportedly fixed in %s@%s, but found a new stack from version %s",
issue.Number, issueProgram, issueVersion, info.ProgramVersion)
return true
}
}
}
return false
}
// An issue is fixed if it was closed because it was completed.
func (i *Issue) isFixed() bool {
return i.State == "closed" && i.StateReason == "completed"
}
// parseMilestone parses a the title of a GitHub milestone.
// If it is in the format PROGRAM/VERSION (for example, "gopls/v0.17.0"),
// then it returns PROGRAM and VERSION.
// If it is in the format Go1.X, then it returns "go" as the program and
// "v1.X" or "v1.X.0" as the version.
// Otherwise, the last return value is false.
func parseMilestone(m *Milestone) (program, version string, ok bool) {
if m == nil {
return "", "", false
}
if strings.HasPrefix(m.Title, "Go") {
v := semVer(m.Title)
if !semver.IsValid(v) {
return "", "", false
}
return "go", v, true
}
program, version, ok = cutLast(m.Title, "/")
if !ok || program == "" || version == "" || version[0] != 'v' {
return "", "", false
}
return program, version, true
}
// semVer returns a semantic version for its argument, which may already be
// a semantic version, or may be a Go version.
//
// v1.2.3 => v1.2.3
// go1.24 => v1.24
// Go1.23.5 => v1.23.5
// goHome => vHome
//
// It returns "", false if the go version is in the wrong format.
func semVer(v string) string {
if strings.HasPrefix(v, "go") || strings.HasPrefix(v, "Go") {
return "v" + v[2:]
}
return v
}
// 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(pcfg ProgramConfig, stack, id, 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
// MatchSymbolPrefix, 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
outer:
for _, line := range strings.Split(stack, "\n") {
for _, s := range pcfg.IgnoreSymbolContains {
if strings.Contains(line, s) {
continue outer // not interesting
}
}
// Look for:
// pcfg.MatchSymbolPrefix/.../pkg.Func
// pcfg.MatchSymbolPrefix/.../pkg.Type.method
// pcfg.MatchSymbolPrefix/.../pkg.(*Type).method
if _, rest, ok := strings.Cut(line, pcfg.MatchSymbolPrefix); 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("%s: bug in %s", pcfg.NewIssuePrefix, symbol)
body := new(bytes.Buffer)
// Add a placeholder ```#!stacks``` block since this is a new issue.
body.WriteString("```" + `
#!stacks
"<insert predicate here>"
` + "```\n")
fmt.Fprintf(body, "Issue created by [stacks](https://pkg.go.dev/golang.org/x/telemetry/cmd/stacks).\n\n")
writeStackComment(body, stack, id, jsonURL, counts)
labels := strings.Join(pcfg.NewIssueLabels, ",")
// Report it. The user will interactively finish the task,
// since they will typically de-dup it without even creating a new issue
// by expanding the #!stacks predicate of an existing issue.
if !browser.Open("https://github.com/" + pcfg.Repository + "/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
}
// writeStackComment writes a stack in Markdown form, for a new GitHub
// issue or new comment on an existing one.
func writeStackComment(body *bytes.Buffer, stack, id string, jsonURL string, counts map[Info]int64) {
if len(counts) == 0 {
panic("no counts")
}
var info Info // pick an arbitrary key
for info = range counts {
break
}
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, defaultStacksDir)
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")
}
// 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,+0x123",
symbol, offset, ok := strings.Cut(frame, ":")
if !ok {
// Not a symbol (perhaps stack counter title: "gopls/bug"?)
return ""
}
again:
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.
if symbol != "runtime.goexit" {
log.Printf("no pclntab info for symbol: %s", symbol)
// This can also happen for inlined symbols, e.g.
// golang.org/x/tools/gopls/foo.(*bar).wiz.func1.1
// Such symbol names, chosen by cmd/compile/internal/ir.closureName,
// appear in the pclntab and thus the backtrace,
// but not in the ELF symbol table, and thus not
// in the output of "go tool objdump".
//
// Many such symbols are formed by appending a gensym
// counter ".%d" to a real symbol. If the line offset
// is absolute (e.g. "foo.func1.1:=123"), then we only
// need the filename, which is the same as that of the
// enclosing function ("foo.func1").
// So try stripping off the suffix in that case.
if before, after, ok := cutLast(symbol, "."); ok && strings.Contains(offset, "=") {
if _, err := strconv.Atoi(after); err == nil { // have "fn.%d"
symbol = before
log.Printf("trying without numeric suffix: %s", symbol)
goto again
}
}
}
return ""
}
if offset == "" {
log.Fatalf("missing line offset: %s", frame)
}
if unicode.IsDigit(rune(offset[0])) {
// Fix gopls/v0.14.2 legacy syntax ":%d" -> ":+%d".
offset = "+" + offset
}
// CL 664175 (Apr 2025) changed the format
// to include relative PCs ("+%d,+0x%x") too.
// We can discard the PC part here.
if before, _, ok := strings.Cut(offset, ",+"); ok {
offset = before
}
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.
// std module?
firstSegment, _, _ := strings.Cut(fileline.file, "/")
if !strings.Contains(firstSegment, ".") {
// (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)
}
// x/tools repo (tools or gopls module)?
if rest, ok := strings.CutPrefix(fileline.file, "golang.org/x/tools"); ok {
switch rest[0] {
case '/':
// "golang.org/x/tools/gopls" -> "gopls"
rest = rest[1:]
case '@':
// "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.ProgramVersion, rest, linenum)
}
// other x/ module dependency?
// e.g. golang.org/x/sync@v0.8.0/errgroup/errgroup.go
if rest, ok := strings.CutPrefix(fileline.file, "golang.org/x/"); ok {
if modVer, filename, ok := strings.Cut(rest, "/"); ok {
if mod, version, ok := strings.Cut(modVer, "@"); ok {
return fmt.Sprintf("https://cs.opensource.google/go/x/%s/+/%s:%s;l=%d",
mod, version, filename, linenum)
}
}
}
// Delve
const delveRepo = "github.com/go-delve/delve/"
if strings.HasPrefix(fileline.file, delveRepo) {
filename := fileline.file[len(delveRepo):]
return fmt.Sprintf("https://%sblob/%s/%s#L%d", delveRepo, info.ProgramVersion, filename, linenum)
}
log.Printf("no CodeSearch URL for %q (%s:%d)",
symbol, fileline.file, linenum)
return ""
}
// -- GitHub client --
// A githubClient interacts with GitHub.
// During testing, updates to GitHub are saved in changes instead of being applied.
// Reads from GitHub occur normally.
type githubClient struct {
authToken string // mandatory GitHub authentication token (for R/W issues access)
divertChanges bool // divert attempted GitHub changes to the changes field instead of executing them
changes []any // slice of (addIssueComment | updateIssueBody)
}
func (cli *githubClient) takeChanges() []any {
r := cli.changes
cli.changes = nil
return r
}
// addIssueComment is a change for creating a comment on an issue.
type addIssueComment struct {
number int
comment string
}
// updateIssue is a change for modifying an existing issue.
// It includes the issue number and the fields that can be updated on a GitHub issue.
// A JSON-marshaled updateIssue can be used as the body of the update request sent to GitHub.
// See https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#update-an-issue.
type updateIssue struct {
number int // issue number; must be unexported
Body string `json:"body,omitempty"`
State string `json:"state,omitempty"` // "open" or "closed"
StateReason string `json:"state_reason,omitempty"` // "completed", "not_planned", "reopened"
}
// -- GitHub search --
// searchIssues queries the GitHub issue tracker.
func (cli *githubClient) searchIssues(repo, label string) ([]*Issue, error) {
label = url.QueryEscape(label)
// Slurp all issues with the telemetry label.
//
// The pagination link headers have an annoying format, but ultimately
// are just ?page=1, ?page=2, etc with no extra state. So just keep
// trying new pages until we get no more results.
//
// NOTE: With this scheme, GitHub clearly has no protection against
// race conditions, so presumably we could get duplicate issues or miss
// issues across pages.
getPage := func(page int) ([]*Issue, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/issues?state=all&labels=%s&per_page=100&page=%d", repo, label, page)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", "Bearer "+cli.authToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("search query %s failed: %s (body: %s)", url, resp.Status, body)
}
var r []*Issue
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, err
}
return r, nil
}
var results []*Issue
for page := 1; ; page++ {
r, err := getPage(page)
if err != nil {
return nil, err
}
if len(r) == 0 {
// No more results.
break
}
results = append(results, r...)
}
return results, nil
}
// updateIssue updates the numbered issue.
func (cli *githubClient) updateIssue(repo string, update updateIssue) error {
if cli.divertChanges {
cli.changes = append(cli.changes, update)
return nil
}
data, err := json.Marshal(update)
if err != nil {
return err
}
url := fmt.Sprintf("https://api.github.com/repos/%s/issues/%d", repo, update.number)
if err := cli.requestChange("PATCH", url, data, http.StatusOK); err != nil {
return fmt.Errorf("updating issue: %v", err)
}
return nil
}
// addIssueComment adds a markdown comment to the numbered issue.
func (cli *githubClient) addIssueComment(repo string, number int, comment string) error {
if cli.divertChanges {
cli.changes = append(cli.changes, addIssueComment{number, comment})
return nil
}
// https://docs.github.com/en/rest/issues/comments#create-an-issue-comment
var payload struct {
Body string `json:"body"`
}
payload.Body = comment
data, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("https://api.github.com/repos/%s/issues/%d/comments", repo, number)
if err := cli.requestChange("POST", url, data, http.StatusCreated); err != nil {
return fmt.Errorf("creating issue comment: %v", err)
}
return nil
}
// requestChange sends a request to url using method, which may change the state at the server.
// The data is sent as the request body, and wantStatus is the expected response status code.
func (cli *githubClient) requestChange(method, url string, data []byte, wantStatus int) error {
if *dryRun {
log.Printf("DRY RUN: %s %s", method, url)
return nil
}
req, err := http.NewRequest(method, url, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Add("Authorization", "Bearer "+cli.authToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("request failed: %s (body: %s)", resp.Status, body)
}
return nil
}
// See https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues.
type Issue struct {
Number int
HTMLURL string `json:"html_url"`
Title string
State string
StateReason string `json:"state_reason"`
User *User
CreatedAt time.Time `json:"created_at"`
Body string // in Markdown format
Milestone *Milestone
// Set by readIssues.
predicate func(string) bool // matching predicate over stack text
// Set by claimIssues.
newStacks []string // new stacks to add to existing issue (comments and IDs)
}
func (issue *Issue) String() string { return fmt.Sprintf("#%d", issue.Number) }
type User struct {
Login string
HTMLURL string `json:"html_url"`
}
type Milestone struct {
Title string
}
// -- pclntab --
type FileLine struct {
file string // "module@version/dir/file.go" or path relative to $GOROOT/src
line int
}
const defaultStacksDir = "/tmp/stacks-cache"
// readPCLineTable builds the gopls executable specified by info,
// reads its PC-to-line-number table, and returns the file/line of
// each TEXT symbol.
//
// stacksDir is a semi-durable temp directory (i.e. lasts for at least a few
// hours) to hold recent sources and executables.
func readPCLineTable(info Info, stacksDir string) (map[string]FileLine, error) {
// Telemetry records always have a valid GoVersion field,
// but internal unit tests may not. Reject them, otherwise
// they'll use GOTOOLCHAIN="" and will fail to build
// (for example) older versions of gopls that compile-time
// assert the sizeof(token.FileSet), which changed in go1.25.
if info.GoVersion == "" {
panic("readPCLineTable: missing GoVersion")
}
// The stacks dir will be a semi-durable temp directory
// (i.e. lasts for at least hours) holding source trees
// and executables we have built recently.
//
// Each subdir will hold a specific revision.
if err := os.MkdirAll(stacksDir, 0777); err != nil {
return nil, fmt.Errorf("can't create stacks dir: %v", err)
}
// When building a subrepo tool, we must clone the source of the
// subrepo, and run go build from that checkout.
//
// When building a main repo tool, no need to clone or change
// directories. GOTOOLCHAIN is sufficient to fetch and build the
// appropriate version.
var buildDir string // cwd for "go build", and output directory for exe
switch info.Program {
case "golang.org/x/tools/gopls":
// Fetch the source for the tools repo,
// shallow-cloning just the desired revision.
// (Skip if it's already cloned.)
revDir := filepath.Join(stacksDir, info.ProgramVersion)
if !fileExists(filepath.Join(revDir, "go.mod")) {
// We check for presence of the go.mod file,
// not just the directory itself, as the /tmp reaper
// often removes stale files before removing their directories.
// Remove those stale directories now.
_ = os.RemoveAll(revDir) // ignore errors
// TODO(prattmic): Consider using ProgramConfig
// configuration if we add more configurations.
log.Printf("cloning tools@gopls/%s", info.ProgramVersion)
if err := shallowClone(revDir, "https://go.googlesource.com/tools", "gopls/"+info.ProgramVersion); err != nil {
_ = os.RemoveAll(revDir) // ignore errors
return nil, fmt.Errorf("clone: %v", err)
}
}
// gopls is in its own module, we must build from there.
buildDir = filepath.Join(revDir, "gopls")
case "cmd/compile":
// Nothing to do, GOTOOLCHAIN is sufficient.
// Set output directory (and avoid any dependence on
// the current directory, which may be a Go module
// directory containing a go.mod.)
//
// The exe name is unambiguous so we don't need to use a subdir.
buildDir = stacksDir
case "github.com/go-delve/delve/cmd/dlv":
revDir := filepath.Join(stacksDir, "delve@"+info.ProgramVersion)
if !fileExists(filepath.Join(revDir, "go.mod")) {
_ = os.RemoveAll(revDir)
log.Printf("cloning github.com/go-delve/delve@%s", info.ProgramVersion)
if err := shallowClone(revDir, "https://github.com/go-delve/delve", info.ProgramVersion); err != nil {
_ = os.RemoveAll(revDir)
return nil, fmt.Errorf("clone: %v", err)
}
}
buildDir = revDir
default:
return nil, fmt.Errorf("don't know how to build unknown program %s", info.Program)
}
if !strings.HasPrefix(buildDir, stacksDir) {
log.Fatalf("buildDir %q is not within stack temp dir %q", buildDir, stacksDir)
}
// Build the executable with the correct GOTOOLCHAIN, GOOS, GOARCH.
// (The buildDir implies the ProgramVersion, if relevant.)
// Use -trimpath for normalized file names.
// (Skip if it's already built.)
exe := filepath.Join(buildDir,
fmt.Sprintf("exe-%s-%s.%s-%s",
strings.ReplaceAll(info.Program, "/", "_"),
info.GoVersion,
info.GOOS,
info.GOARCH))
if !fileExists(exe) {
log.Printf("building %s@%s with %s for %s/%s",
info.Program, info.ProgramVersion, info.GoVersion, info.GOOS, info.GOARCH)
cmd := exec.Command("go", "build", "-trimpath", "-o", exe, info.Program)
cmd.Stderr = os.Stderr
cmd.Dir = buildDir
cmd.Env = append(os.Environ(),
"GOTOOLCHAIN="+info.GoVersion,
"GOEXPERIMENT=", // Don't forward GOEXPERIMENT from current environment since the GOTOOLCHAIN selected might not support the same experiments.
"GOOS="+info.GOOS,
"GOARCH="+info.GOARCH,
)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("building: %v (rm -fr %s?)", err, stacksDir)
}
}
// Read pclntab of executable.
cmd := exec.Command("go", "tool", "objdump", exe)
cmd.Stdout = new(strings.Builder)
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"GOTOOLCHAIN="+info.GoVersion,
"GOEXPERIMENT=", // Don't forward GOEXPERIMENT from current environment since the GOTOOLCHAIN selected might not support the same experiments.
"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
}
// findPredicateBlock returns the content (sans "#!stacks") of the
// code block at the start of the issue body.
// Logic plundered from x/build/cmd/watchflakes/github.go.
func findPredicateBlock(body string) string {
// Extract ```-fenced or indented code block at start of issue description (body).
body = strings.ReplaceAll(body, "\r\n", "\n")
lines := strings.SplitAfter(body, "\n")
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
lines = lines[1:]
}
text := ""
// A code quotation is bracketed by sequence of 3+ backticks.
// (More than 3 are permitted so that one can quote 3 backticks.)
if len(lines) > 0 && strings.HasPrefix(lines[0], "```") {
marker := lines[0]
n := 0
for n < len(marker) && marker[n] == '`' {
n++
}
marker = marker[:n]
i := 1
for i := 1; i < len(lines); i++ {
if strings.HasPrefix(lines[i], marker) && strings.TrimSpace(strings.TrimLeft(lines[i], "`")) == "" {
text = strings.Join(lines[1:i], "")
break
}
}
if i < len(lines) {
}
} else if strings.HasPrefix(lines[0], "\t") || strings.HasPrefix(lines[0], " ") {
i := 1
for i < len(lines) && (strings.HasPrefix(lines[i], "\t") || strings.HasPrefix(lines[i], " ")) {
i++
}
text = strings.Join(lines[:i], "")
}
// Must start with #!stacks so we're sure it is for us.
hdr, rest, _ := strings.Cut(text, "\n")
hdr = strings.TrimSpace(hdr)
if hdr != "#!stacks" {
return ""
}
return rest
}
// isTerminal reports whether file is a terminal,
// avoiding a dependency on golang.org/x/term.
func isTerminal(file *os.File) bool {
// Hardwire the constants to avoid the need for build tags.
// The values here are good for our dev machines.
switch runtime.GOOS {
case "darwin":
const TIOCGETA = 0x40487413 // from unix.TIOCGETA
_, err := unix.IoctlGetTermios(int(file.Fd()), TIOCGETA)
return err == nil
case "linux":
const TCGETS = 0x5401 // from unix.TCGETS
_, err := unix.IoctlGetTermios(int(file.Fd()), TCGETS)
return err == nil
}
panic("unreachable")
}
// keySlice returns the keys of the map M, like slices.Collect(maps.Keys(m)).
// (Copied from x/tools/gopls/internal/moremaps.)
func keySlice[M ~map[K]V, K comparable, V any](m M) []K {
r := make([]K, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}
// cutLast is the "last" analogue of [strings.Cut].
// (Copied from x/tools/gopls/internal.morestrings.)
func cutLast(s, sep string) (before, after string, ok bool) {
if i := strings.LastIndex(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}