whatcanGOwrong

This commit is contained in:
2024-09-19 21:38:24 -04:00
commit d0ae4d841d
17908 changed files with 4096831 additions and 0 deletions
@@ -0,0 +1,85 @@
// 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 chartconfig package defines the ChartConfig type, representing telemetry
// chart configuration, as well as utilities for parsing and validating this
// configuration.
//
// Chart configuration defines the set of aggregations active on the telemetry
// server, and are used to derive which data needs to be uploaded by users.
// See the original blog post for more details:
//
// https://research.swtch.com/telemetry-design#configuration
//
// The record format defined in this package differs slightly from that of the
// blog post. This format is still experimental, and subject to change.
//
// Configuration records consist of fields, comments, and whitespace. A field
// is defined by a line starting with a valid key, followed immediately by ":",
// and then a textual value, which cannot include the comment separator '#'.
//
// Comments start with '#', and extend to the end of the line.
//
// The following keys are supported. Any entry not marked as (optional) must be
// provided.
//
// - title: the chart title.
// - description: (optional) a longer description of the chart.
// - issue: a go issue tracker URL proposing the chart configuration.
// Multiple issues may be provided by including additional 'issue:' lines.
// All proposals must be in the 'accepted' state.
// - type: the chart type: currently only partition, histogram, and stack are
// supported.
// - program: the package path of the program for which this chart applies.
// - version: (optional) the first version for which this chart applies. Must
// be a valid semver value.
// - counter: the primary counter this chart illustrates, including buckets
// for histogram and partition charts.
// - depth: (optional) stack counters only; the maximum stack depth to collect
// - error: (optional) the desired error rate for this chart, which
// determines collection rate
//
// Multiple records are separated by "---" lines.
//
// For example:
//
// # This config defines an ordinary counter.
// counter: gopls/editor:{emacs,vim,vscode,other} # TODO(golang/go#34567): add more editors
// title: Editor Distribution
// description: measure editor distribution for gopls users.
// type: partition
// issue: https://go.dev/issue/12345
// program: golang.org/x/tools/gopls
// version: v1.0.0
// version: [v2.0.0, v2.3.4]
// version: [v3.0.0, ]
//
// ---
//
// # This config defines a stack counter.
// counter: gopls/bug
// title: Gopls bug reports.
// description: Stacks of bugs encountered on the gopls server.
// issue: https://go.dev/12345
// issue: https://go.dev/23456 # increase stack depth
// type: stack
// program: golang.org/x/tools/gopls
// depth: 10
package chartconfig
// A ChartConfig defines the configuration for a single chart/collection on the
// telemetry server.
//
// See the package documentation for field definitions.
type ChartConfig struct {
Title string
Description string
Issue []string
Type string
Program string
Counter string
Depth int
Error float64 // TODO(rfindley) is Error still useful?
Version string
}
@@ -0,0 +1,148 @@
# Note: these are approved chart configs, used to generate the upload config.
# For the chart config file format, see chartconfig.go.
title: Editor Distribution
counter: gopls/client:{vscode,vscodium,vscode-insiders,code-server,eglot,govim,neovim,coc.nvim,sublimetext,other}
description: measure editor distribution for gopls users.
type: partition
issue: https://go.dev/issue/61038
issue: https://go.dev/issue/62214 # add vscode-insiders
program: golang.org/x/tools/gopls
version: v0.13.0 # temporarily back-version to demonstrate config generation.
---
title: Go versions in use for gopls views
counter: gopls/goversion:{1.16,1.17,1.18,1.19,1.20,1.21,1.22,1.23,1.24,1.25,1.26,1.27,1.28,1.29,1.30}
description: measure go version usage distribution.
type: partition
issue: https://go.dev/issue/62248
program: golang.org/x/tools/gopls
version: v0.13.0
---
title: Number of bug report calls
counter: gopls/bug
description: count the bugs reported through gopls/internal/bug APIs.
type: stack
issue: https://go.dev/issue/62249
program: golang.org/x/tools/gopls
depth: 16
version: v0.13.0
---
counter: crash/crash
title: Unexpected Go crashes
description: stacks of goroutines running when the Go program crashed
type: stack
issue: https://go.dev/issue/65696
program: golang.org/x/tools/gopls
depth: 16
version: v0.15.0
---
counter: crash/malformed
title: Failure to parse runtime crash output
description: count of runtime crash messages that failed to parse
type: partition
issue: https://go.dev/issue/65696
program: golang.org/x/tools/gopls
version: v0.15.0
---
counter: crash/no-running-goroutine
title: Failure to identify any running goroutine in the crash output
description: count of runtime crash messages that don't have a running goroutine (e.g. deadlock)
type: partition
issue: https://go.dev/issue/65696
program: golang.org/x/tools/gopls
version: v0.15.0
---
counter: go/invocations
title: cmd/go invocations
description: Number of invocations of the go command
type: partition
issue: https://go.dev/issue/67244
program: cmd/go
version: go1.23rc1
---
counter: go/build/flag:{
buildmode
}
title: cmd/go flags
description: Flag names of flags provided to the go command
type: partition
issue: https://go.dev/issue/67244
program: cmd/go
version: go1.23rc1
---
counter: go/build/flag/buildmode:{
archive,
c-archive,
c-shared,
default,
exe,
pie,
shared,
plugin
}
title: cmd/go buildmode values
description: Buildmode values for the go command
type: partition
issue: https://go.dev/issue/67244
program: cmd/go
version: go1.23rc1
---
counter: compile/invocations
title: cmd/compile invocations
description: Number of invocations of the go compiler
type: partition
issue: https://go.dev/issue/67244
program: cmd/compile
version: go1.23rc1
---
title: Compiler bug report calls
counter: compile/bug
description: count stacks for cases where cmd/compile has a fatal error
type: stack
issue: https://go.dev/issue/67244
program: cmd/compile
depth: 16
version: go1.23rc1
---
counter: govulncheck/scan:{symbol,package,module}
title: Scan Level Distribution
description: measure govulncheck scan level distribution
type: partition
issue: https://go.dev/issue/67678
program: golang.org/x/vuln/cmd/govulncheck
---
counter: govulncheck/mode:{source,binary,extract,query,convert}
title: Scan Mode Distribution
description: measure govulncheck scan mode distribution
type: partition
issue: https://go.dev/issue/67678
program: golang.org/x/vuln/cmd/govulncheck
---
counter: govulncheck/format:{text,json,sarif,openvex}
title: Output Format Distribution
description: measure govulncheck output format distribution
type: partition
issue: https://go.dev/issue/67678
program: golang.org/x/vuln/cmd/govulncheck
---
counter: govulncheck/show:{none,traces,color,verbose,version}
title: Show Options Distribution
description: measure govulncheck show flag distribution
type: partition
issue: https://go.dev/issue/67678
program: golang.org/x/vuln/cmd/govulncheck
---
counter: govulncheck/assumptions:{multi-patterns,no-binary-platform,no-relative-path,no-go-root,local-replace,unknown-pkg-mod-path}
title: Code Invariants Distribution
description: measure distribution of failed govulncheck internal assumptions
type: partition
issue: https://go.dev/issue/67678
program: golang.org/x/vuln/cmd/govulncheck
---
counter: gopls/gotoolchain:{auto,path,local,other}
title: GOTOOLCHAIN types used with gopls
description: measure the types of GOTOOLCHAIN values used with gopls
type: partition
issue: https://go.dev/issue/68771
program: golang.org/x/tools/gopls
version: v0.16.0
@@ -0,0 +1,219 @@
// 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.
package chartconfig
import (
_ "embed"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"unicode"
)
//go:embed config.txt
var chartConfig []byte
func Raw() []byte {
return chartConfig
}
// Load loads and parses the current chart config.
func Load() ([]ChartConfig, error) {
return Parse(chartConfig)
}
// Parse parses ChartConfig records from the provided raw data, returning an
// error if the config has invalid syntax. See the package documentation for a
// description of the record syntax.
//
// Even with correct syntax, the resulting chart config may not meet all the
// requirements described in the package doc. Call [Validate] to check whether
// the config data is coherent.
func Parse(data []byte) ([]ChartConfig, error) {
// Collect field information for the record type.
var (
prefixes []string // for parse errors
fields = make(map[string]reflect.StructField) // key -> struct field
)
{
typ := reflect.TypeOf(ChartConfig{})
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
key := strings.ToLower(f.Name)
if _, ok := fieldParsers[key]; !ok {
panic(fmt.Sprintf("no parser for field %q", f.Name))
}
prefixes = append(prefixes, "'"+key+":'")
fields[key] = f
}
sort.Strings(prefixes)
}
// Read records, separated by '---'
var (
records []ChartConfig
inProgress = new(ChartConfig) // record value currently being parsed
set = make(map[string]bool) // fields that are set so far; empty records are skipped
)
flushRecord := func() {
if len(set) > 0 { // only flush non-empty records
records = append(records, *inProgress)
}
inProgress = new(ChartConfig)
set = make(map[string]bool)
}
// Within bucket braces in counter fields, newlines are ignored.
// if we're in the middle of a multiline counter field, accumulatedCounterText
// contains the joined lines of the field up to the current line. Once
// a line containing an end brace is reached, line will be set to the
// joined lines of accumulatedCounterText and processed as a single line.
var accumulatedCounterText string
for lineNum, line := range strings.Split(string(data), "\n") {
if line == "---" {
if accumulatedCounterText != "" {
return nil, fmt.Errorf("line %d: reached end of record while processing multiline counter field", lineNum)
}
flushRecord()
continue
}
text, _, _ := strings.Cut(line, "#") // trim comments
// Processing of counter fields which can appear across multiple lines.
// See comment on accumulatedCounterText.
if accumulatedCounterText == "" {
if oi := strings.Index(text, "{"); oi >= 0 {
if strings.Contains(text[:oi], "}") {
return nil, fmt.Errorf("line %d: invalid line %q: unexpected '}'", lineNum, line)
}
if strings.Contains(text[oi+len("{"):], "{") {
return nil, fmt.Errorf("line %d: invalid line %q: unexpected '{'", lineNum, line)
}
if !strings.HasPrefix(text, "counter:") {
return nil, fmt.Errorf("line %d: invalid line %q: '{' is only allowed to appear within a counter field", lineNum, line)
}
accumulatedCounterText = strings.TrimRightFunc(text, unicode.IsSpace)
// Don't continue here. If the counter field is a single line
// the check for the close brace below will close the line
// and process it as text. Set text to "" so when it's appended to
// accumulatedCounterText we don't add the line twice.
text = ""
} else if strings.Contains(text, "}") {
return nil, fmt.Errorf("line %d: invalid line %q: unexpected '}'", lineNum, line)
}
}
if accumulatedCounterText != "" {
if strings.Contains(text, "{") {
return nil, fmt.Errorf("line %d: invalid line %q: '{' is only allowed to appear once within a counter field", lineNum, line)
}
accumulatedCounterText += strings.TrimSpace(text)
if ci := strings.Index(accumulatedCounterText, "}"); ci >= 0 {
if strings.Contains(accumulatedCounterText[ci+len("}"):], "}") {
return nil, fmt.Errorf("line %d: invalid line %q: unexpected '}'", lineNum, line)
}
if ci > 0 && strings.HasSuffix(accumulatedCounterText[:ci], ",") {
return nil, fmt.Errorf("line %d: invalid line %q: unexpected '}' after ','", lineNum, line)
}
text = accumulatedCounterText
accumulatedCounterText = ""
} else {
// We're in the middle of a multiline counter field. Continue
// processing.
continue
}
}
var key string
for k := range fields {
prefix := k + ":"
if strings.HasPrefix(text, prefix) {
key = k
text = text[len(prefix):]
break
}
}
text = strings.TrimSpace(text)
if text == "" {
// Check for empty lines before the field == nil check below.
// Lines consisting only of whitespace and comments are OK.
continue
}
if key == "" {
return nil, fmt.Errorf("line %d: invalid line %q: lines must be '---', consist only of whitespace/comments, or start with %s", lineNum, line, strings.Join(prefixes, ", "))
}
field := fields[key]
v := reflect.ValueOf(inProgress).Elem().FieldByName(field.Name)
if set[key] && field.Type.Kind() != reflect.Slice {
return nil, fmt.Errorf("line %d: field %s may not be repeated", lineNum, strings.ToLower(field.Name))
}
parser := fieldParsers[key]
if err := parser(v, text); err != nil {
return nil, fmt.Errorf("line %d: field %q: %v", lineNum, field.Name, err)
}
set[key] = true
}
if accumulatedCounterText != "" {
return nil, fmt.Errorf("reached end of file while processing multiline counter field")
}
flushRecord()
return records, nil
}
// A fieldParser parses the provided input and writes to v, which must be
// addressable.
type fieldParser func(v reflect.Value, input string) error
var fieldParsers = map[string]fieldParser{
"title": parseString,
"description": parseString,
"issue": parseSlice(parseString),
"type": parseString,
"program": parseString,
"counter": parseString,
"depth": parseInt,
"error": parseFloat,
"version": parseString,
}
func parseString(v reflect.Value, input string) error {
v.SetString(input)
return nil
}
func parseInt(v reflect.Value, input string) error {
i, err := strconv.ParseInt(input, 10, 64)
if err != nil {
return fmt.Errorf("invalid int value %q", input)
}
v.SetInt(i)
return nil
}
func parseFloat(v reflect.Value, input string) error {
f, err := strconv.ParseFloat(input, 64)
if err != nil {
return fmt.Errorf("invalid float value %q", input)
}
v.SetFloat(f)
return nil
}
func parseSlice(elemParser fieldParser) fieldParser {
return func(v reflect.Value, input string) error {
elem := reflect.New(v.Type().Elem()).Elem()
v.Set(reflect.Append(v, elem))
elem = v.Index(v.Len() - 1)
if err := elemParser(elem, input); err != nil {
return err
}
return nil
}
}
@@ -0,0 +1,268 @@
// 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.
package chartconfig_test
import (
"reflect"
"testing"
"golang.org/x/telemetry/internal/chartconfig"
)
func TestLoad(t *testing.T) {
// Test that we can actually load the chart config.
if _, err := chartconfig.Load(); err != nil {
t.Errorf("Load() failed: %v", err)
}
}
func TestParse(t *testing.T) {
tests := []struct {
name string
input string
want []chartconfig.ChartConfig
}{
{"empty", "", nil},
{"single field", "title: A", []chartconfig.ChartConfig{{Title: "A"}}},
{
"basic", `
title: A
description: B
type: C
program: D
counter: E
issue: F1
issue: F2
depth: 2
error: 0.1
version: v2.0.0
`,
[]chartconfig.ChartConfig{{
Title: "A",
Description: "B",
Type: "C",
Program: "D",
Counter: "E",
Issue: []string{"F1", "F2"},
Depth: 2,
Error: 0.1,
Version: "v2.0.0",
}},
},
{
"partial", `
title: A
description: B
`,
[]chartconfig.ChartConfig{
{Title: "A", Description: "B"},
},
},
{
"comments and whitespace", `
# A comment
title: A # a line comment
# Another comment
description: B
`,
[]chartconfig.ChartConfig{
{Title: "A", Description: "B"},
},
},
{
"multi-record", `
# Empty records are skipped
---
title: A
description: B
---
title: C
description: D
`,
[]chartconfig.ChartConfig{
{Title: "A", Description: "B"},
{Title: "C", Description: "D"},
},
},
{
"example", `
title: Editor Distribution
counter: gopls/editor:{emacs,vim,vscode,other}
description: measure editor distribution for gopls users.
type: partition
issue: TBD
program: golang.org/x/tools/gopls
`,
[]chartconfig.ChartConfig{
{
Title: "Editor Distribution",
Description: "measure editor distribution for gopls users.",
Counter: "gopls/editor:{emacs,vim,vscode,other}",
Type: "partition",
Issue: []string{"TBD"},
Program: "golang.org/x/tools/gopls",
},
},
},
{
"multiline counter field", `
counter: foo:{
bar,
baz
}
`,
[]chartconfig.ChartConfig{
{Counter: "foo:{bar,baz}"},
},
},
{
"multiline counter field with braces immediately next to text", `
counter: foo:{bar,
baz}
`,
[]chartconfig.ChartConfig{
{Counter: "foo:{bar,baz}"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := chartconfig.Parse([]byte(test.input))
if err != nil {
t.Fatalf("Parse(...) failed: %v", err)
}
if len(got) != len(test.want) {
t.Fatalf("Parse(...) returned %d records, want %d", len(got), len(test.want))
}
for i, got := range got {
want := test.want[i]
if !reflect.DeepEqual(got, want) {
t.Errorf("Parse(...): record %d = %#v, want %#v", i, got, want)
}
}
})
}
}
func TestParseErrors(t *testing.T) {
tests := []struct {
name string
input string
}{
{
"leading space",
`
title: foo
`,
},
{
"unknown key",
`
foo: bar
`,
},
{
"bad separator",
`
title: foo
--- # comments aren't allowed after separators
title: bar
`,
},
{
"invalid depth",
`
depth: notanint
`,
},
{
"open curly brace not in counter field",
`
title: {
`,
},
{
"close curly brace not in counter field",
`
title: }
`,
},
{
"end of record within multiline counter field",
`
counter: foo{
bar
---
title: baz
`,
},
{
"end of file within multiline counter field",
`
counter: foo{
bar
`,
},
{
"close curly before open curly",
`
counter: }foo{
bar
}`,
},
{
"open curly after close curly",
`
counter: foo{
bar
} {`,
},
{
"open curly after open curly same line",
`
counter: foo{{
bar
}`,
},
{
"open curly after open curly different line",
`
counter: foo{
{bar
}`,
},
{
"close curly after close curly",
`
counter: foo{
bar
} }`,
},
{
"comma right before close curly",
`
counter: foo{
bar,
baz,
} }`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := chartconfig.Parse([]byte(test.input))
if err == nil {
t.Fatalf("Parse(...) succeeded unexpectedly")
}
})
}
}
@@ -0,0 +1,140 @@
// 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.
// package config provides methods for loading and querying a
// telemetry upload config file.
package config
import (
"encoding/json"
"os"
"strings"
"golang.org/x/telemetry/internal/telemetry"
)
// Config is a wrapper around telemetry.UploadConfig that provides some
// convenience methods for checking the contents of a report.
type Config struct {
*telemetry.UploadConfig
program map[string]bool
goos map[string]bool
goarch map[string]bool
goversion map[string]bool
pgversion map[pgkey]bool
pgcounter map[pgkey]bool
pgcounterprefix map[pgkey]bool
pgstack map[pgkey]bool
rate map[pgkey]float64
}
type pgkey struct {
program, key string
}
func ReadConfig(file string) (*Config, error) {
data, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var cfg telemetry.UploadConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return NewConfig(&cfg), nil
}
func NewConfig(cfg *telemetry.UploadConfig) *Config {
ucfg := Config{UploadConfig: cfg}
ucfg.goos = set(ucfg.GOOS)
ucfg.goarch = set(ucfg.GOARCH)
ucfg.goversion = set(ucfg.GoVersion)
ucfg.program = make(map[string]bool, len(ucfg.Programs))
ucfg.pgversion = make(map[pgkey]bool, len(ucfg.Programs))
ucfg.pgcounter = make(map[pgkey]bool, len(ucfg.Programs))
ucfg.pgcounterprefix = make(map[pgkey]bool, len(ucfg.Programs))
ucfg.pgstack = make(map[pgkey]bool, len(ucfg.Programs))
ucfg.rate = make(map[pgkey]float64)
for _, p := range ucfg.Programs {
ucfg.program[p.Name] = true
for _, v := range p.Versions {
ucfg.pgversion[pgkey{p.Name, v}] = true
}
for _, c := range p.Counters {
for _, e := range Expand(c.Name) {
ucfg.pgcounter[pgkey{p.Name, e}] = true
ucfg.rate[pgkey{p.Name, e}] = c.Rate
}
prefix, _, found := strings.Cut(c.Name, ":")
if found {
ucfg.pgcounterprefix[pgkey{p.Name, prefix}] = true
}
}
for _, s := range p.Stacks {
ucfg.pgstack[pgkey{p.Name, s.Name}] = true
ucfg.rate[pgkey{p.Name, s.Name}] = s.Rate
}
}
return &ucfg
}
func (r *Config) HasProgram(s string) bool {
return r.program[s]
}
func (r *Config) HasGOOS(s string) bool {
return r.goos[s]
}
func (r *Config) HasGOARCH(s string) bool {
return r.goarch[s]
}
func (r *Config) HasGoVersion(s string) bool {
return r.goversion[s]
}
func (r *Config) HasVersion(program, version string) bool {
return r.pgversion[pgkey{program, version}]
}
func (r *Config) HasCounter(program, counter string) bool {
return r.pgcounter[pgkey{program, counter}]
}
func (r *Config) HasCounterPrefix(program, prefix string) bool {
return r.pgcounterprefix[pgkey{program, prefix}]
}
func (r *Config) HasStack(program, stack string) bool {
return r.pgstack[pgkey{program, stack}]
}
func (r *Config) Rate(program, name string) float64 {
return r.rate[pgkey{program, name}]
}
func set(slice []string) map[string]bool {
s := make(map[string]bool, len(slice))
for _, v := range slice {
s[v] = true
}
return s
}
// Expand takes a counter defined with buckets and expands it into distinct
// strings for each bucket
func Expand(counter string) []string {
prefix, rest, hasBuckets := strings.Cut(counter, "{")
var counters []string
if hasBuckets {
buckets := strings.Split(strings.TrimSuffix(rest, "}"), ",")
for _, b := range buckets {
counters = append(counters, prefix+b)
}
} else {
counters = append(counters, prefix)
}
return counters
}
@@ -0,0 +1,97 @@
// 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.
package config
import (
_ "embed"
"encoding/json"
"os"
"path/filepath"
"testing"
"golang.org/x/telemetry/internal/telemetry"
)
func TestConfig(t *testing.T) {
f, err := os.Open(filepath.FromSlash("../../config/config.json"))
if os.IsNotExist(err) {
t.Skip("config file not found")
}
if err != nil {
t.Fatal(err)
}
defer f.Close()
var cfg telemetry.UploadConfig
d := json.NewDecoder(f)
d.DisallowUnknownFields()
if err := d.Decode(&cfg); err != nil {
t.Fatal(err)
}
}
func TestInternalConfig(t *testing.T) {
got, err := ReadConfig("testdata/config.json")
if err != nil {
t.Fatal(err)
}
wantGOOS := []string{"linux", "darwin"}
wantGOARCH := []string{"amd64", "arm64"}
wantGoVersion := []string{"go1.20", "go1.20.1"}
wantPrograms := []string{"golang.org/x/tools/gopls", "cmd/go"}
wantVersions := [][2]string{
{"golang.org/x/tools/gopls", "v0.10.1"},
{"golang.org/x/tools/gopls", "v0.11.0"},
}
wantCounters := [][2]string{
{"golang.org/x/tools/gopls", "editor:emacs"},
{"golang.org/x/tools/gopls", "editor:vim"},
{"golang.org/x/tools/gopls", "editor:vscode"},
{"golang.org/x/tools/gopls", "editor:other"},
{"cmd/go", "go/buildcache/miss:0"},
{"cmd/go", "go/buildcache/miss:1"},
{"cmd/go", "go/buildcache/miss:10"},
{"cmd/go", "go/buildcache/miss:100"},
}
wantPrefix := [][2]string{
{"golang.org/x/tools/gopls", "editor"},
{"cmd/go", "go/buildcache/miss"},
}
for _, w := range wantGOOS {
if !got.HasGOOS(w) {
t.Errorf("got.HasGOOS(%s) = false: want true", w)
}
}
for _, w := range wantGOARCH {
if !got.HasGOARCH(w) {
t.Errorf("got.HasGOARCH(%s) = false: want true", w)
}
}
for _, w := range wantGoVersion {
if !got.HasGoVersion(w) {
t.Errorf("got.HasGoVersion(%s) = false: want true", w)
}
}
for _, w := range wantPrograms {
if !got.HasProgram(w) {
t.Errorf("got.HasProgram(%s) = false: want true", w)
}
}
for _, w := range wantVersions {
if !got.HasVersion(w[0], w[1]) {
t.Errorf("got.HasVersion(%s, %s) = false: want true", w[0], w)
}
}
for _, w := range wantCounters {
if !got.HasCounter(w[0], w[1]) {
t.Errorf("got.HasCounter(%s, %s) = false: want true", w[0], w[1])
}
}
for _, w := range wantPrefix {
if !got.HasCounterPrefix(w[0], w[1]) {
t.Errorf("got.HasCounterPrefix(%s, %s) = false: want true", w[0], w[1])
}
}
}
@@ -0,0 +1,43 @@
{
"Version": "v0.0.1-test",
"GOOS": [
"linux",
"darwin"
],
"GOARCH": [
"amd64",
"arm64"
],
"GoVersion": [
"go1.20",
"go1.20.1"
],
"Programs": [
{
"Name": "golang.org/x/tools/gopls",
"Versions": [
"v0.10.1",
"v0.11.0"
],
"Counters": [
{
"Name": "editor:{emacs,vim,vscode,other}",
"Rate": 0.01
}
]
},
{
"Name": "cmd/go",
"Versions": [
"v0.10.1",
"v0.11.0"
],
"Counters": [
{
"Name": "go/buildcache/miss:{0,0.1,0.2,0.5,1,10,100,2,20,5,50}",
"Rate": 0.01
}
]
}
]
}
@@ -0,0 +1,536 @@
// 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.
//go:generate go run . -w
//go:build go1.22
// Package configgen generates the upload config file stored in the config.json
// file of golang.org/x/telemetry/config based on the chartconfig stored in
// config.txt.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"go/version"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"golang.org/x/mod/semver"
"golang.org/x/telemetry/internal/chartconfig"
"golang.org/x/telemetry/internal/telemetry"
)
var (
write = flag.Bool("w", false, "if set, write the config file; otherwise, print to stdout")
force = flag.Bool("f", false, "if set, force the write of the config file even if the current content is still valid")
// SamplingRate is the fraction of otherwise uploadable reports that will be uploaded
SamplingRate = 1.0
)
func main() {
flag.Parse()
gcfgs, err := chartconfig.Load()
if err != nil {
log.Fatal(err)
}
// The padding heuristics below are based on the example of gopls.
//
// The goal is to pad enough versions for a quarter.
uCfg, err := generate(gcfgs, padding{
// 6 releases into the future translates to approximately three months for gopls.
releases: 6,
// We may release gopls 1.0, but won't release 2.0 in a three month timespan!
maj: 1,
// We don't usually do more than one minor release a month.
majmin: 3,
// Since golang/go#55267, which committed to adhering to semver, gopls
// hasn't had more than 5 patches per minor version.
patch: 6,
// Gopls has never had more than 4 prereleases.
pre: 4,
})
if err != nil {
log.Fatal(err)
}
cfgJSON, err := json.MarshalIndent(uCfg, "", "\t")
if err != nil {
log.Fatal(err)
}
if !*write {
fmt.Println(string(cfgJSON))
os.Exit(0)
}
configFile, err := configFile()
if err != nil {
log.Fatalf("finding config file: %v", err)
}
if !*force {
currentCfg, err := readConfig(configFile)
if err != nil {
log.Fatal(err)
}
// Guarantee that we have enough padding to do two patches releases tomorrow.
minCfg, err := generate(gcfgs, padding{
releases: 2,
maj: 1,
majmin: 1, // we're not ever going to do more than one major/minor release in a day
patch: 2,
pre: 2, // in a single day, we wouldn't prep more than two prereleases per version
})
if err != nil {
log.Fatal(err)
}
if contains(currentCfg, minCfg) {
fmt.Fprintln(os.Stderr, "not writing the config file as it is still valid; use -f to force")
os.Exit(0)
}
}
if err := os.WriteFile(configFile, cfgJSON, 0666); err != nil {
log.Fatal(err)
}
}
// configFile returns the path to the x/telemetry/config config.json file in
// this repo.
//
// The file must already exist: this won't be a valid location if running from
// the module cache; this functionality only works when executed from the
// telemetry repo.
func configFile() (string, error) {
out, err := exec.Command("go", "list", "-f", "{{.Dir}}", "golang.org/x/telemetry/internal/configgen").Output()
if err != nil {
return "", err
}
dir := strings.TrimSpace(string(out))
configFile := filepath.Join(dir, "..", "..", "config", "config.json")
if _, err := os.Stat(configFile); err != nil {
return "", err
}
return configFile, nil
}
func readConfig(file string) (*telemetry.UploadConfig, error) {
data, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("reading config file: %v", err)
}
cfg := new(telemetry.UploadConfig)
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("unmarshalling config file: %v", err)
}
return cfg, nil
}
// generate computes the upload config from chart configs and module
// information, returning the resulting formatted JSON.
func generate(gcfgs []chartconfig.ChartConfig, padding padding) (*telemetry.UploadConfig, error) {
ucfg := &telemetry.UploadConfig{
GOOS: goos(),
GOARCH: goarch(),
// the probability of uploading a report
SampleRate: SamplingRate,
}
var err error
ucfg.GoVersion, err = goVersions()
if err != nil {
return nil, fmt.Errorf("querying go info: %v", err)
}
for i, r := range gcfgs {
if err := ValidateChartConfig(r); err != nil {
// TODO(rfindley): this is a poor way to identify the faulty record. We
// should probably store position information in the ChartConfig.
return nil, fmt.Errorf("chart config #%d (%q): %v", i, r.Title, err)
}
}
var (
programs = make(map[string]*telemetry.ProgramConfig) // package path -> config
minVersions = make(map[string]string) // package path -> min version required, or "" for all
)
for _, gcfg := range gcfgs {
pcfg := programs[gcfg.Program]
if pcfg == nil {
pcfg = &telemetry.ProgramConfig{
Name: gcfg.Program,
}
programs[gcfg.Program] = pcfg
minVersions[gcfg.Program] = gcfg.Version
}
minVersions[gcfg.Program] = minVersion(minVersions[gcfg.Program], gcfg.Version)
ccfg := telemetry.CounterConfig{
Name: gcfg.Counter,
Rate: 1.0, // TODO(rfindley): how should rate be configured?
Depth: gcfg.Depth,
}
if gcfg.Depth > 0 {
pcfg.Stacks = append(pcfg.Stacks, ccfg)
} else {
pcfg.Counters = append(pcfg.Counters, ccfg)
}
}
for _, p := range programs {
minVersion := minVersions[p.Name]
// Collect eligible program versions. If p is a toolchain tool (cmd/go,
// cmd/compile, etc), these come out of the Go versions queried above.
// Otherwise, they come from the proxy.
//
// In both of these cases, the versions should be valid, but we verify
// anyway as otherwise the version comparison is meaningless.
// (and in fact, there is an invalid go1.9.2rc2 version in the proxy)
if telemetry.IsToolchainProgram(p.Name) {
// Note: no need to pad versions for toolchain programs, since the
// toolchain is released infrequently.
// (and in any case, version padding only works for semantic versions)
for _, v := range ucfg.GoVersion {
if !version.IsValid(v) {
// The proxy toolchain versions list go1.9.2rc2, which is invalid.
// Skip it.
continue
}
if minVersion == "" || version.Compare(minVersion, v) <= 0 {
p.Versions = append(p.Versions, v)
}
}
} else {
versions, err := listProxyVersions(p.Name)
if err != nil {
return nil, fmt.Errorf("listing versions for %q: %v", p.Name, err)
}
// Filter proxy versions in place.
i := 0
for _, v := range versions {
if !semver.IsValid(v) {
return nil, fmt.Errorf("invalid semver %q returned from proxy for %q", v, p.Name)
}
if minVersion == "" || semver.Compare(minVersion, v) <= 0 {
versions[i] = v
i++
}
}
p.Versions = padVersions(versions[:i], prereleasesForProgram(p.Name), padding)
}
ucfg.Programs = append(ucfg.Programs, p)
}
sort.Slice(ucfg.Programs, func(i, j int) bool {
return ucfg.Programs[i].Name < ucfg.Programs[j].Name
})
return ucfg, nil
}
// contains reports whether outer contains all program versions of inner, and
// is otherwise equivalent to inner.
func contains(outer, inner *telemetry.UploadConfig) bool {
if !slices.Equal(outer.GOARCH, inner.GOARCH) {
return false
}
if !slices.Equal(outer.GOOS, inner.GOOS) {
return false
}
if !slices.Equal(outer.GoVersion, inner.GoVersion) {
return false
}
for _, pi := range inner.Programs {
i := slices.IndexFunc(outer.Programs, func(po *telemetry.ProgramConfig) bool {
return po.Name == pi.Name
})
if i < 0 {
return false
}
po := outer.Programs[i]
if !sliceContains(po.Versions, pi.Versions) {
return false
}
if !slices.Equal(po.Counters, pi.Counters) {
return false
}
if !slices.Equal(po.Stacks, pi.Stacks) {
return false
}
}
for _, po := range outer.Programs {
if !slices.ContainsFunc(inner.Programs, func(pi *telemetry.ProgramConfig) bool {
return pi.Name == po.Name
}) {
return false
}
}
return true
}
func sliceContains[T comparable](outer, inner []T) bool {
m := toMap(outer)
for _, v := range inner {
if !m[v] {
return false
}
}
return true
}
func toMap[T comparable](s []T) map[T]bool {
m := make(map[T]bool)
for _, v := range s {
m[v] = true
}
return m
}
// prereleasesForProgram returns the set of prereleases to use for the provided
// program. We may need to customize this for the conventions of different
// programs.
func prereleasesForProgram(program string) []string {
// Surely eight prereleases is enough for any program... :)
return []string{"pre.1", "pre.2", "pre.3", "pre.4", "pre.5", "pre.6", "pre.7", "pre.8"}
}
// minVersion returns the lesser semantic version of v1 and v2.
//
// As a special case, the empty string is treated as an absolute minimum
// (empty => all versions are greater).
func minVersion(v1, v2 string) string {
if v1 == "" || v2 == "" {
return ""
}
if semver.Compare(v1, v2) > 0 {
return v2
}
return v1
}
// goos returns a sorted slice of known GOOS values.
func goos() []string {
var gooses []string
for goos := range knownOS {
gooses = append(gooses, goos)
}
sort.Strings(gooses)
return gooses
}
// goarch returns a sorted slice of known GOARCH values.
func goarch() []string {
var arches []string
for arch := range knownArch {
arches = append(arches, arch)
}
sort.Strings(arches)
return arches
}
// goInfo queries the proxy for information about go distributions, including
// versions, GOOS, and GOARCH values.
func goVersions() ([]string, error) {
// Trick: read Go distribution information from the module versions of
// golang.org/toolchain. These define the set of valid toolchains, and
// therefore are a reasonable source for version information.
//
// A more authoritative source for this information may be
// https://go.dev/dl?mode=json&include=all.
proxyVersions, err := listProxyVersions("golang.org/toolchain")
if err != nil {
return nil, fmt.Errorf("listing toolchain versions: %v", err)
}
var goVersionRx = regexp.MustCompile(`^-(go.+)\.[^.]+-[^.]+$`)
verSet := make(map[string]struct{})
for _, v := range proxyVersions {
pre := semver.Prerelease(v)
match := goVersionRx.FindStringSubmatch(pre)
if match == nil {
return nil, fmt.Errorf("proxy version %q does not match prerelease regexp %q", v, goVersionRx)
}
verSet[match[1]] = struct{}{}
}
var vers []string
for v := range verSet {
vers = append(vers, v)
}
sort.Sort(byGoVersion(vers))
return vers, nil
}
type byGoVersion []string
func (vs byGoVersion) Len() int { return len(vs) }
func (vs byGoVersion) Swap(i, j int) { vs[i], vs[j] = vs[j], vs[i] }
func (vs byGoVersion) Less(i, j int) bool {
cmp := version.Compare(vs[i], vs[j])
if cmp != 0 {
return cmp < 0
}
// To ensure that we have a stable sort, order equivalent Go versions lexically.
return vs[i] < vs[j]
}
// versionsForTesting contains versions to use for testing, rather than
// querying the proxy.
var versionsForTesting map[string][]string
// listProxyVersions queries the Go module mirror for published versions of the
// given modulePath.
//
// modulePath must be lower-case (or already escaped): this function doesn't do
// any escaping of upper-cased letters, as is required by the proxy prototol
// (https://go.dev/ref/mod#goproxy-protocol).
func listProxyVersions(modulePath string) ([]string, error) {
if vers, ok := versionsForTesting[modulePath]; ok {
return vers, nil
}
cmd := exec.Command("go", "list", "-m", "--versions", modulePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("listing versions: %v (stderr: %v)", err, stderr.String())
}
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) == 0 {
return nil, fmt.Errorf("invalid version list output: %q", string(out))
}
return fields[1:], nil
}
// padding defines constraints on additional versions to pad.
//
// These constraints help restrict version padding to "reasonable" versions,
// based on heuristics such as "we never do more than 3 prereleases for a given
// version" or "we never have more than 5 patch versions" or "we can't do more
// than 10 total releases over that time period". See the field documentation
// for details.
type padding struct {
releases int // bounds on the total number of releases
maj int // bounds the number of new major versions
majmin int // bounds the number of new major+minor versions
patch int // bounds the number of new patch versions
pre int // the number of prereleases to pad per release
}
// padVersions pads the existing version list with potential next versions, so
// that we don't have to wait an additional day to start getting reports for a
// newly tagged version.
//
// The prereleases argument may be supplied to provide a set of potential
// prerelease candidates. For example, if the program releases prereleases of
// the form "-pre.N", prereleases should be {"pre.1", "pre.2", ...}. For each
// potential next release version, the next two prerelease versions will be
// selected out of the provided set of prereleases.
func padVersions(versions []string, prereleasePatterns []string, padding padding) []string {
versions = slices.Clone(versions)
semver.Sort(versions)
latestRelease := "v0.0.0"
all := make(map[string]bool) // for de-duplicating padded versions
for _, v := range versions {
cv := semver.Canonical(v)
all[cv] = true
if semver.Prerelease(cv) == "" && semver.Compare(latestRelease, cv) < 0 {
latestRelease = cv
}
}
parsedLatest, ok := parseSemver(latestRelease)
if !ok {
// "can't happen", since the latest release version should always be canonical.
panic(fmt.Sprintf("unable to parse latest release version %q", latestRelease))
}
// Pad the latest version only.
//
// This assumes that the program in question doesn't patch older releases
// (as is the case with gopls). If that assumption ever changes, we may need
// to apply padding to older versions as well.
versionsToPad := []semversion{parsedLatest}
var maj, min, patch int
for _, toPad := range versionsToPad {
for majPadding := 0; majPadding <= padding.maj; majPadding++ {
maj = toPad.major + majPadding
for minPadding := 0; minPadding+majPadding <= padding.majmin; minPadding++ {
if majPadding == 0 {
min = toPad.minor + minPadding
} else {
min = minPadding
}
for patchPadding := 0; patchPadding <= padding.patch; patchPadding++ {
releases := majPadding + minPadding + patchPadding
if releases == 0 || releases > padding.releases {
continue
}
if majPadding == 0 && minPadding == 0 {
patch = toPad.patch + patchPadding
} else {
patch = patchPadding
}
v := fmt.Sprintf("v%d.%d.%d", maj, min, patch)
if all[v] {
// This guard is future proofing: we may have seen this version
// before if we are ever padding something other than the latest
// version.
continue
}
versions = append(versions, v)
// We may already have prereleases at this version. Don't pad
// additional prereleases, under the assumption that we don't
// typically have more than padding.pre prereleases.
nextPrerelease := 0
for i, patt := range prereleasePatterns {
pre := fmt.Sprintf("%s-%s", v, patt)
if all[pre] {
nextPrerelease = i + 1
}
}
for i := nextPrerelease; i < len(prereleasePatterns) && i < padding.pre; i++ {
pre := fmt.Sprintf("%s-%s", v, prereleasePatterns[i])
versions = append(versions, pre)
}
}
}
}
}
semver.Sort(versions)
return versions
}
// version is a parsed semantic version.
type semversion struct {
major, minor, patch int
pre string
}
// parseSemver attempts to parse semver components out of the provided semver
// v. If v is not valid semver in canonical form, parseSemver returns _, _, _,
// _, false.
func parseSemver(v string) (_ semversion, ok bool) {
var parsed semversion
v, parsed.pre, _ = strings.Cut(v, "-")
if _, err := fmt.Sscanf(v, "v%d.%d.%d", &parsed.major, &parsed.minor, &parsed.patch); err == nil {
ok = true
}
return parsed, ok
}
@@ -0,0 +1,256 @@
// 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.
//go:build go1.22
package main
import (
_ "embed"
"reflect"
"sort"
"testing"
"golang.org/x/telemetry/internal/chartconfig"
"golang.org/x/telemetry/internal/telemetry"
)
func TestGenerate(t *testing.T) {
defer func(vers map[string][]string) {
versionsForTesting = vers
}(versionsForTesting)
versionsForTesting = map[string][]string{
"golang.org/toolchain": {"v0.0.1-go1.21.0.linux-arm", "v0.0.1-go1.20.linux-arm"},
"golang.org/x/tools/gopls": {"v0.13.0", "v0.14.0", "v0.15.0-pre.1", "v0.15.0"},
}
const raw = `
title: Editor Distribution
counter: gopls/editor:{emacs,vim,vscode,other}
description: measure editor distribution for gopls users.
type: partition
issue: https://go.dev/issue/61038
program: golang.org/x/tools/gopls
version: v0.14.0
`
gcfgs, err := chartconfig.Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
got, err := generate(gcfgs, padding{2, 1, 1, 2, 2})
if err != nil {
t.Fatal(err)
}
want := telemetry.UploadConfig{
GOOS: goos(),
GOARCH: goarch(),
SampleRate: SamplingRate,
GoVersion: []string{"go1.20", "go1.21.0"},
Programs: []*telemetry.ProgramConfig{{
Name: "golang.org/x/tools/gopls",
Versions: []string{
"v0.14.0",
"v0.15.0-pre.1",
"v0.15.0",
"v0.15.1-pre.1",
"v0.15.1-pre.2",
"v0.15.1",
"v0.15.2-pre.1",
"v0.15.2-pre.2",
"v0.15.2",
"v0.16.0-pre.1",
"v0.16.0-pre.2",
"v0.16.0",
"v0.16.1-pre.1",
"v0.16.1-pre.2",
"v0.16.1",
"v1.0.0-pre.1",
"v1.0.0-pre.2",
"v1.0.0",
"v1.0.1-pre.1",
"v1.0.1-pre.2",
"v1.0.1",
},
Counters: []telemetry.CounterConfig{{
Name: "gopls/editor:{emacs,vim,vscode,other}",
Rate: 1.0,
}},
}},
}
if !reflect.DeepEqual(*got, want) {
if len(got.Programs) != len(want.Programs) {
t.Errorf("generate(): got %d programs, want %d", len(got.Programs), len(want.Programs))
} else {
for i, gotp := range got.Programs {
want := *want.Programs[i]
if !reflect.DeepEqual(*gotp, want) {
t.Errorf("generate() program #%d =\n%+v\nwant:\n%+v", i, *gotp, want)
}
}
}
t.Errorf("generate() =\n%+v\nwant:\n%+v", *got, want)
}
}
func TestByGoVersion_Less(t *testing.T) {
got := []string{
"go1.21.0",
"go1.21rc1",
"go1.9",
"go1.9rc1",
"go1.6",
"go1.6beta1",
}
want := []string{
"go1.6beta1",
"go1.6",
"go1.9rc1",
"go1.9",
"go1.21rc1",
"go1.21.0",
}
sort.Sort(byGoVersion(got))
if !reflect.DeepEqual(got, want) {
t.Errorf("sort.Sort(byGoVersion(got)) = %v, want %v", got, want)
}
}
func TestContains(t *testing.T) {
baseline := func() *telemetry.UploadConfig {
return &telemetry.UploadConfig{
GOOS: goos(),
GOARCH: goarch(),
GoVersion: []string{"go1.20", "go1.21.0"},
Programs: []*telemetry.ProgramConfig{{
Name: "golang.org/x/tools/gopls",
Versions: []string{
"v0.14.0",
"v0.15.0-pre.1",
"v0.15.0",
"v0.15.1-pre.1",
"v0.15.1-pre.2",
"v0.15.1",
},
Counters: []telemetry.CounterConfig{{
Name: "gopls/editor:{emacs,vim,vscode,other}",
Rate: 1.0,
}},
}},
}
}
tests := []struct {
name string
outerMut, innerMut func(*telemetry.UploadConfig)
want bool
}{
{
"additional arch",
func(cfg *telemetry.UploadConfig) { cfg.GOARCH = append(cfg.GOARCH, "fake") },
func(cfg *telemetry.UploadConfig) {},
false,
},
{
"additional program",
func(cfg *telemetry.UploadConfig) { cfg.Programs = append(cfg.Programs, new(telemetry.ProgramConfig)) },
func(cfg *telemetry.UploadConfig) {},
false,
},
{
"additional counter",
func(cfg *telemetry.UploadConfig) {
cfg.Programs[0].Counters = append(cfg.Programs[0].Counters, telemetry.CounterConfig{})
},
func(cfg *telemetry.UploadConfig) {},
false,
},
{
"additional version",
func(cfg *telemetry.UploadConfig) {
cfg.Programs[0].Versions = append(cfg.Programs[0].Versions, "v99.99.99")
},
func(cfg *telemetry.UploadConfig) {},
true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
outer := baseline()
test.outerMut(outer)
inner := baseline()
test.innerMut(inner)
if got := contains(outer, inner); got != test.want {
t.Errorf("contains(...) = %v, want %v", got, test.want)
}
})
}
}
func TestPadVersions(t *testing.T) {
tests := []struct {
versions []string
prereleasePatterns []string
padding padding
want []string
}{
{
nil,
[]string{"pre.1"},
padding{1, 1, 1, 1, 2},
[]string{
"v0.0.1-pre.1",
"v0.0.1",
"v0.1.0-pre.1",
"v0.1.0",
"v1.0.0-pre.1",
"v1.0.0",
},
},
{
[]string{"v0.8.3", "v0.9.1", "v0.9.2", "v1.0.0", "v1.0.1", "v1.0.2-pre.1", "v1.0.2-pre.2", "v1.0.2-pre.3"},
[]string{"pre.1", "pre.2", "pre.3", "pre.4"},
padding{2, 1, 2, 2, 2},
[]string{
"v0.8.3",
"v0.9.1",
"v0.9.2",
"v1.0.0",
"v1.0.1",
"v1.0.2-pre.1",
"v1.0.2-pre.2",
"v1.0.2-pre.3",
"v1.0.2",
"v1.0.3-pre.1",
"v1.0.3-pre.2",
"v1.0.3",
"v1.1.0-pre.1",
"v1.1.0-pre.2",
"v1.1.0",
"v1.1.1-pre.1",
"v1.1.1-pre.2",
"v1.1.1",
"v1.2.0-pre.1",
"v1.2.0-pre.2",
"v1.2.0",
"v2.0.0-pre.1",
"v2.0.0-pre.2",
"v2.0.0",
"v2.0.1-pre.1",
"v2.0.1-pre.2",
"v2.0.1",
"v2.1.0-pre.1",
"v2.1.0-pre.2",
"v2.1.0",
},
},
}
for _, test := range tests {
got := padVersions(test.versions, test.prereleasePatterns, test.padding)
if !reflect.DeepEqual(got, test.want) {
t.Errorf("padVersions(%v, %v) =\n%v\nwant:\n%v", test.versions, test.prereleasePatterns, got, test.want)
}
}
}
@@ -0,0 +1,60 @@
// 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.
//go:build go1.22
package main
// knownOS is the list of past, present, and future known GOOS values.
// Do not remove from this list, as it is used for filename matching.
// If you add an entry to this list, look at unixOS, below.
var knownOS = map[string]bool{
"aix": true,
"android": true,
"darwin": true,
"dragonfly": true,
"freebsd": true,
"hurd": true,
"illumos": true,
"ios": true,
"js": true,
"linux": true,
"nacl": true,
"netbsd": true,
"openbsd": true,
"plan9": true,
"solaris": true,
"wasip1": true,
"windows": true,
"zos": true,
}
// knownArch is the list of past, present, and future known GOARCH values.
// Do not remove from this list, as it is used for filename matching.
var knownArch = map[string]bool{
"386": true,
"amd64": true,
"amd64p32": true,
"arm": true,
"armbe": true,
"arm64": true,
"arm64be": true,
"loong64": true,
"mips": true,
"mipsle": true,
"mips64": true,
"mips64le": true,
"mips64p32": true,
"mips64p32le": true,
"ppc": true,
"ppc64": true,
"ppc64le": true,
"riscv": true,
"riscv64": true,
"s390": true,
"s390x": true,
"sparc": true,
"sparc64": true,
"wasm": true,
}
@@ -0,0 +1,55 @@
// 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.
//go:build go1.22
package main
import (
"errors"
"fmt"
"go/version"
"golang.org/x/mod/semver"
"golang.org/x/telemetry/internal/chartconfig"
"golang.org/x/telemetry/internal/telemetry"
)
// ValidateChartConfig checks that a ChartConfig is complete and coherent,
// returning an error describing all problems encountered, or nil.
func ValidateChartConfig(cfg chartconfig.ChartConfig) error {
var errs []error
reportf := func(format string, args ...any) {
errs = append(errs, fmt.Errorf(format, args...))
}
if cfg.Title == "" {
reportf("title must be set")
}
if len(cfg.Issue) == 0 {
reportf("at least one issue is required")
}
if cfg.Program == "" {
reportf("program must be set")
}
if cfg.Counter == "" {
reportf("counter must be set")
}
if cfg.Type == "" {
reportf("type must be set")
}
if cfg.Depth < 0 {
reportf("invalid depth %d: must be non-negative", cfg.Depth)
}
if cfg.Depth != 0 && cfg.Type != "stack" {
reportf("depth can only be set for \"stack\" chart types")
}
valid := semver.IsValid
if telemetry.IsToolchainProgram(cfg.Program) {
valid = version.IsValid
}
if cfg.Version != "" && !valid(cfg.Version) {
reportf("%q is not a valid version (must be a go version or semver)", cfg.Version)
}
return errors.Join(errs...)
}
@@ -0,0 +1,81 @@
// 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.
//go:build go1.22
package main
import (
"strings"
"testing"
"golang.org/x/telemetry/internal/chartconfig"
)
func TestLoadedChartsAreValid(t *testing.T) {
// Test that we can actually load the chart config.
charts, err := chartconfig.Load()
if err != nil {
t.Errorf("Load() failed: %v", err)
}
for i, chart := range charts {
if err := ValidateChartConfig(chart); err != nil {
t.Errorf("Chart %d is invalid: %v", i, err)
}
}
}
func TestValidateOK(t *testing.T) {
// A minimally valid chart config.
const input = `
title: Editor Distribution
counter: gopls/editor:{emacs,vim,vscode,other}
type: partition
issue: https://go.dev/issue/12345
program: golang.org/x/tools/gopls
`
records, err := chartconfig.Parse([]byte(input))
if err != nil {
t.Fatal(err)
}
if len(records) != 1 {
t.Fatalf("Parse(%q) returned %d records, want exactly 1", input, len(records))
}
if err := ValidateChartConfig(records[0]); err != nil {
t.Errorf("Validate(%q) = %v, want nil", input, err)
}
}
func TestValidate(t *testing.T) {
tests := map[string][]string{ // input -> want errors
// validation of mandatory fields
"description:bar": {"title", "program", "issue", "counter", "type"},
// validation of semver intervals
"version:1.2.3.4": {"semver"},
// valid of stack configuration
"depth:-1": {"non-negative", "stack"},
}
for input, wantErrs := range tests {
records, err := chartconfig.Parse([]byte(input))
if err != nil {
t.Fatal(err)
}
if len(records) != 1 {
t.Fatalf("Parse(%q) returned %d records, want exactly 1", input, len(records))
}
err = ValidateChartConfig(records[0])
if err == nil {
t.Fatalf("Validate(%q) succeeded unexpectedly", input)
}
errs := err.Error()
for _, want := range wantErrs {
if !strings.Contains(errs, want) {
t.Errorf("Validate(%q) = %v, want containing %q", input, err, want)
}
}
}
}
@@ -0,0 +1,86 @@
// 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.
// Package configstore abstracts interaction with the telemetry config server.
// Telemetry config (golang.org/x/telemetry/config) is distributed as a go
// module containing go.mod and config.json. Programs that upload collected
// counters download the latest config using `go mod download`. This provides
// verification of downloaded configuration and cacheability.
package configstore
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync/atomic"
"golang.org/x/telemetry/internal/telemetry"
)
const (
ModulePath = "golang.org/x/telemetry/config"
configFileName = "config.json"
)
// needNoConsole is used on windows to set the windows.CREATE_NO_WINDOW
// creation flag.
var needNoConsole = func(cmd *exec.Cmd) {}
var downloads int64
// Downloads reports, for testing purposes, the number of times [Download] has
// been called.
func Downloads() int64 {
return atomic.LoadInt64(&downloads)
}
// Download fetches the requested telemetry UploadConfig using "go mod
// download". If envOverlay is provided, it is appended to the environment used
// for invoking the go command.
//
// The second result is the canonical version of the requested configuration.
func Download(version string, envOverlay []string) (*telemetry.UploadConfig, string, error) {
atomic.AddInt64(&downloads, 1)
if version == "" {
version = "latest"
}
modVer := ModulePath + "@" + version
var stdout, stderr bytes.Buffer
cmd := exec.Command("go", "mod", "download", "-json", modVer)
needNoConsole(cmd)
cmd.Env = append(os.Environ(), envOverlay...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
var info struct {
Error string
}
if err := json.Unmarshal(stdout.Bytes(), &info); err == nil && info.Error != "" {
return nil, "", fmt.Errorf("failed to download config module: %v", info.Error)
}
return nil, "", fmt.Errorf("failed to download config module: %w\n%s", err, &stderr)
}
var info struct {
Dir string
Version string
Error string
}
if err := json.Unmarshal(stdout.Bytes(), &info); err != nil || info.Dir == "" {
return nil, "", fmt.Errorf("failed to download config module (invalid JSON): %w", err)
}
data, err := os.ReadFile(filepath.Join(info.Dir, configFileName))
if err != nil {
return nil, "", fmt.Errorf("invalid config module: %w", err)
}
cfg := new(telemetry.UploadConfig)
if err := json.Unmarshal(data, cfg); err != nil {
return nil, "", fmt.Errorf("invalid config: %w", err)
}
return cfg, info.Version, nil
}
@@ -0,0 +1,77 @@
// 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.
package configstore_test
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"golang.org/x/telemetry/internal/configstore"
"golang.org/x/telemetry/internal/configtest"
"golang.org/x/telemetry/internal/telemetry"
"golang.org/x/telemetry/internal/testenv"
)
func TestDownload(t *testing.T) {
testenv.NeedsGo(t)
configVersion := "v0.1.0"
in := &telemetry.UploadConfig{
GOOS: []string{"darwin"},
GOARCH: []string{"amd64", "arm64"},
GoVersion: []string{"1.20.3", "1.20.4"},
Programs: []*telemetry.ProgramConfig{{
Name: "gopls",
Versions: []string{"v0.11.0"},
Counters: []telemetry.CounterConfig{{
Name: "foobar",
Rate: 2,
}},
}},
}
env := configtest.LocalProxyEnv(t, in, configVersion)
testCases := []struct {
version string
want telemetry.UploadConfig
}{
{version: configVersion, want: *in},
{version: "latest", want: *in},
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
got, _, err := configstore.Download(tc.version, env)
if err != nil {
t.Fatal("failed to download:", err)
}
want := tc.want
if !reflect.DeepEqual(*got, want) {
t.Errorf("Download(latest, _) = %v\nwant %v", stringify(got), stringify(want))
}
})
}
t.Run("invalidversion", func(t *testing.T) {
got, ver, err := configstore.Download("nonexisting", env)
if err == nil {
t.Fatalf("download succeeded unexpectedly: %v %+v", ver, got)
}
if !strings.Contains(err.Error(), "invalid version") {
t.Errorf("unexpected error message: %v", err)
}
})
}
func stringify(x any) string {
ret, err := json.MarshalIndent(x, "", " ")
if err != nil {
return fmt.Sprintf("json.Marshal failed - %v", err)
}
return string(ret)
}
@@ -0,0 +1,33 @@
// Copyright 2024 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 windows
package configstore
import (
"os/exec"
"syscall"
"golang.org/x/sys/windows"
)
func init() {
needNoConsole = needNoConsoleWindows
}
func needNoConsoleWindows(cmd *exec.Cmd) {
// The uploader main process is likely a daemonized process with no console.
// (see x/telemetry/start_windows.go) The console creation behavior when
// a parent is a console process without console is not clearly documented
// but empirically we observed the new console is created and attached to the
// subprocess in the default setup.
//
// Ensure no new console is attached to the subprocess by setting CREATE_NO_WINDOW.
// https://learn.microsoft.com/en-us/windows/console/creation-of-a-console
// https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.CREATE_NO_WINDOW,
}
}
@@ -0,0 +1,59 @@
// Copyright 2024 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.
// Package configtest provides a helper for testing using a local proxy
// containing a fake upload config.
package configtest
import (
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"testing"
"golang.org/x/telemetry/internal/configstore"
"golang.org/x/telemetry/internal/proxy"
"golang.org/x/telemetry/internal/telemetry"
)
// LocalProxyEnv writes a proxy directory for the given upload config, and
// returns a go environment to use for fetching that config from a local
// file-based proxy.
//
// This environment should be passed to [configstore.Download].
func LocalProxyEnv(t *testing.T, cfg *telemetry.UploadConfig, version string) []string {
t.Helper()
dir := t.TempDir()
encoded, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshaling config failed: %v", err)
}
dirPath := fmt.Sprintf("%v@%v/", configstore.ModulePath, version)
files := map[string][]byte{
dirPath + "go.mod": []byte("module " + configstore.ModulePath + "\n\ngo 1.20\n"),
dirPath + "config.json": encoded,
}
proxyURI, err := proxy.WriteProxy(filepath.Join(dir, "proxy"), files)
if err != nil {
t.Fatalf("writing proxy failed: %v", err)
}
env := []string{
"GOPROXY=" + proxyURI, // Use the fake proxy.
"GONOSUMDB=*", // Skip verifying checksum against sum.golang.org.
"GOMODCACHE=" + filepath.Join(dir, "modcache"), // Don't pollute system module cache.
}
t.Cleanup(func() {
cmd := exec.Command("go", "clean", "-modcache")
cmd.Env = append(cmd.Environ(), env...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Errorf("go clean -modcache failed: %v\n%s", err, out)
}
})
return env
}
@@ -0,0 +1,20 @@
# Telemetry Content
This directory contains the templates, styles, scripts, and images used in the
telemetry services. Scripts and styles are transformed and minified by the
generator in [content.go](./content.go).
## Scripts & Styles
The generator command will look for entrypoint scripts and styles, i.e. files
that are not prefixed with an underscore, and minfiy their contents. TypeScript
files are also transformed into JavaScript. See
[devtools/cmd/esbuild](../devtools/cmd/esbuild/main.go) for more information.
## Templates
Use the .html extension to create a new route, or put an index.html file in a
directory with the desired path. Partial templates with the extension .tmpl in
the same directory as the requested page are included in the html/template
execution step to allow for sharing and composing multiple templates. See
[internal/content](../internal/content/content.go) for more information.
@@ -0,0 +1,54 @@
// 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.
package content
import (
"embed"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
)
//go:embed *
var FS embed.FS
//go:generate go run generate.go
// RunESBuild runs esbuild for all content directories.
// If watch is set, RunESBuild instructs esbuild to watch the content
// directories, and runs esbuild in a separate goroutine.
func RunESBuild(watch bool) {
_, file, _, _ := runtime.Caller(0)
curDir := filepath.Dir(file)
cmdDir := filepath.Join(curDir, "..", "..", "godev", "devtools", "cmd", "esbuild")
for _, dir := range []string{"gotelemetryview", "shared", "telemetrygodev"} {
d := filepath.Join(curDir, dir)
args := []string{"run", ".", "--outdir", filepath.Join(d, "static")}
if watch {
args = append(args, "--watch", d)
}
args = append(args, d)
cmd := exec.Command("go", args...)
cmd.Dir = cmdDir
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Start(); err != nil {
log.Fatal(err)
}
if watch {
go func() {
if err := cmd.Wait(); err != nil {
log.Fatal(err)
}
}()
} else {
if err := cmd.Wait(); err != nil {
log.Fatal(err)
}
}
}
}
@@ -0,0 +1,13 @@
// 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.
//go:build ignore
package main
import "golang.org/x/telemetry/internal/content"
func main() {
content.RunESBuild(false)
}
@@ -0,0 +1,16 @@
{{define "info-icon"}}
<details class="go-Tooltip js-tooltip" data-gtmc="tooltip">
<summary>
<img
class="go-Icon"
height="20"
width="20"
src="/static/info_black_24dp.svg"
alt=""
/>
</summary>
<p>
{{.}}
</p>
</details>
{{end}}
@@ -0,0 +1,173 @@
/*!
* 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.
*/
@import url("../shared/base.css");
html {
scroll-padding-top: 4rem;
}
/* TODO(rfindley): refactor to share breadcrumb logic with telemetry.go.dev */
.ViewBreadcrumb {
position: sticky;
top: 0;
z-index: 1000;
}
.ViewBreadcrumb ol {
align-items: center;
border-bottom: var(--border);
display: inline-flex;
gap: 1rem;
list-style: none;
margin-block-start: 0;
margin-block-end: 0;
padding-inline-start: 0;
min-height: 3rem;
width: calc(100% - 2rem);
background-color: var(--color-background);
padding: 0 1rem;
font-size: 0.875rem;
position: fixed;
top: 0;
transition: top 0.1s ease-in 0.1s;
}
.ViewBreadcrumb ol:empty {
top: -3.0625rem;
}
.ViewBreadcrumb li:not(:last-child)::after {
content: ">";
margin-left: 1rem;
}
.ViewBreadcrumb li:last-child a {
color: var(--color-text-subtle);
}
.Index {
line-height: 1.5;
}
.Counters {
border: var(--border);
border-radius: 0.25rem;
display: grid;
gap: 1rem 2rem;
margin-top: 1rem;
overflow: auto;
padding: 1rem;
grid-template-areas:
"meta count count"
"stack stack stack"
"summary summary summary";
grid-auto-columns: 1fr 2fr 1fr;
}
.Meta {
grid-area: meta;
display: grid;
grid-auto-rows: min-content;
grid-template-columns: repeat(2, max-content);
gap: 0.5rem;
}
.Stack {
grid-area: stack;
border-top: var(--border);
padding-top: 1rem;
gap: 0.5rem 1rem;
display: flex;
flex-direction: column;
width: 100%;
}
.Stack summary {
display: block;
}
.Stack details .Count-entry:first-child::before {
content: "⏵";
}
.Stack details[open] .Count-entry:first-child::before {
content: "⏷";
}
.Count {
grid-area: count;
display: grid;
flex-grow: 1;
grid-auto-rows: min-content;
grid-template-columns: repeat(auto-fill, minmax(12.5rem, 1fr));
gap: 0.5rem 1rem;
}
.Summary {
border-top: var(--border);
font-size: 0.875rem;
grid-area: summary;
line-height: 1.5;
padding-top: 1rem;
}
.Meta .unknown,
.Count .unknown,
.Stack .unknown {
color: var(--color-text-subtle);
}
.Count-entry {
display: flex;
gap: 0.25rem;
justify-content: space-between;
}
.Count-entry > span:nth-child(odd) {
overflow: hidden;
white-space: nowrap;
}
.Count-entry:not(.unknown) > span:nth-child(even) {
text-align: right;
color: var(--color-code-comment);
}
.Count-entry > span:nth-child(odd)::after {
content: " ----------------------------------------------------------------------------------------------- ";
letter-spacing: 0.125rem;
}
h2::after {
content: "⏷";
padding-left: 0.5rem;
}
html[data-closed-sections*="index"] h2#index::after,
html[data-closed-sections*="config"] h2#config::after,
html[data-closed-sections*="files"] h2#files::after,
html[data-closed-sections*="charts"] h2#charts::after,
html[data-closed-sections*="reports"] h2#reports::after {
content: "⏵";
}
html[data-closed-sections*="index"] h2#index ~ *,
html[data-closed-sections*="config"] h2#config ~ *,
html[data-closed-sections*="files"] h2#files ~ *,
html[data-closed-sections*="charts"] h2#charts ~ *,
html[data-closed-sections*="reports"] h2#reports ~ * {
display: none;
}
div[data-chart-id] {
min-height: 16rem;
}
/* Fix tooltip background for dark theme */
svg g[aria-label="tip"] g {
fill: var(--color-background);
}
@@ -0,0 +1,270 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Go Telemetry</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="/static/index.min.css">
<script src="/static/storage.min.js"></script>
</head>
<body>
<main id="main">
<!-- TODO(rfindley): refactor to share breadcrumbs with telemetry.go.dev -->
<nav class="ViewBreadcrumb js-breadcrumb">
<ol></ol>
</nav>
<div class="Container">
<div class="Content">
<h1 class="Title">Go Telemetry</h1>
<p>
This page allows you to inspect counters collected by Go Toolchain
programs on your machine. It includes counters for submitted and
pending reports. For more information about Go Toolchain telemetry
<a target="_blank" rel="noreferrer" href="https://telemetry.go.dev/privacy">
read the docs here.
</a>
</p>
<section class="Index">
<h2 id="index">Index</h2>
<ul>
<li>
<a href="#charts">Charts</a>
<ul>
{{range .Charts.Programs}}
<li>
<a href="#{{.ID}}">{{.Name}}</a>
<ul>
{{range .Counters}}
<li>
<a href="#{{.ID}}">{{.Name}}</a>
</li>
{{end}}
</ul>
</li>
{{end}}
</ul>
</li>
<li>
<a href="#config">Config</a>
</li>
<li>
<a href="#files">Counters</a>
<ul>
{{range .Files}}
<li>
<a href="#{{.ID}}"> {{.ID}} </a>
</li>
{{end}}
</ul>
</li>
<li>
<a href="#reports">Reports</a>
<ul style="column-count: auto; column-width: 10rem">
{{range .Reports}}
<li>
<a href="#{{.ID}}">{{.Week}}</a>
</li>
{{end}}
</ul>
</li>
</ul>
</section>
<section class="Charts">
<h2 id="charts">Charts</h2>
<p>
Charts are visualizations of the counters from your archived
reports. Counters for different program builds of the same program
are summed together. Use the index to navigate to charts by
counter name.
</p>
{{range .Charts.Programs}}
<div class="Chart">
{{$pname := .Name}}
<h3 id="{{.ID}}" data-label="{{$pname}}">
{{$pname}}
{{if not .Active}}
{{template "info-icon" "This program is not present in the telemetry config."}}
{{end}}
</h3>
{{range .Counters}}
<div>
{{$cname := .Name}}
<h4 id="{{.ID}}" data-label="{{$cname}}">
{{$cname}}
{{if not .Active}}
{{template "info-icon" "This counter is not present in the telemetry config."}}
{{end}}
</h4>
<div data-chart-id="{{.ID}}"></div>
</div>
{{end}}
</div>
{{end}}
</section>
<section class="Config">
<h2 id="config">Config</h2>
<p>
The config contains the list of active counters for each program
and allowed report metadata.
</p>
<label>
Version
<select class="js-selectConfig" name="config">
{{range .ConfigVersions}}
<option value="{{.}}" {{if eq . $.RequestedConfig}}selected{{end}}>
{{.}}
</option>
{{end}}
</select>
</label>
<pre style="max-height: 20rem">{{.PrettyConfig}}</pre>
</section>
<section class="Files">
<h2 id="files">Counters</h2>
<p>
Counters display data from active counter files that has not yet
been uploaded with a report or archived. If a report includes data
that is not registered in the telemetry config, a summary of those
fields and how they'll be handled appears next to the counter
values.
</p>
{{range .Files}}
<div class="File">
<h3 id="{{.ID}}">{{.ID}}</h3>
<div class="Counters">
<div class="Meta">
<span>Program:</span>
<span class="{{if not .ActiveMeta.Program}}unknown{{end}}">
{{.Meta.Program}}
</span>
<span>Version:</span>
<span class="{{if not .ActiveMeta.Version}}unknown{{end}}">
{{.Meta.Version}}
</span>
<span>GOOS:</span>
<span class="{{if not .ActiveMeta.GOOS}}unknown{{end}}">
{{.Meta.GOOS}}
</span>
<span>GOARCH:</span>
<span class="{{if not .ActiveMeta.GOARCH}}unknown{{end}}">
{{.Meta.GOARCH}}
</span>
<span>GoVersion:</span>
<span class="{{if not .ActiveMeta.GoVersion}}unknown{{end}}">
{{.Meta.GoVersion}}
</span>
<span>TimeBegin:</span>
<span>{{.Meta.TimeBegin}}</span>
<span>TimeEnd:</span>
<span>{{.Meta.TimeEnd}}</span>
</div>
{{$file := .}}
{{with .Counts}}
<div class="Count">
{{range .}}
<div class="Count-entry {{if not .Active }}unknown{{end}}">
<span>{{.Name}}</span><span>{{.Value}}</span>
</div>
{{end}}
</div>
{{end}}
{{with .Stacks}}
<div class="Stack">
Call stacks:
{{range .}}
<details>
<summary>
<div class="Count-entry {{if not .Active }}unknown{{end}}">
<span>{{.Name}}</span><span>{{.Value}}</span>
</div>
</summary>
<pre>{{.Trace}}</pre>
</details>
{{end}}
</div>
{{end}}
{{with .Summary}}
<div class="Summary">{{.}}</div>
{{end}}
</div>
</div>
{{end}}
</section>
<section class="Reports">
<h2 id="reports">Reports</h2>
<p>
Reports represent local copies of the data uploaded by the Go
command to telemetry.go.dev. Use the index to navigate to a
report by upload date or program build.
</p>
{{range .Reports}}
<div class="Report">
{{$date := .Week}}
<h3 id="reports:{{$date}}">{{$date}}</h3>
{{range .Programs}}
<div id="{{.ID}}" class="Counters">
<div class="Meta">
<span>Program:</span>
<span class="{{if not ($.Config.HasProgram .Program)}}unknown{{end}}">
{{.Program}}
</span>
<span>Version:</span>
<span class="{{if not ($.Config.HasVersion .Program .Version)}}unknown{{end}}">
{{.Version}}
</span>
<span>GOOS:</span>
<span class="{{if not ($.Config.HasGOOS .GOOS)}}unknown{{end}}">
{{.GOOS}}
</span>
<span>GOARCH:</span>
<span class="{{if not ($.Config.HasGOARCH .GOARCH)}}unknown{{end}}">
{{.GOARCH}}
</span>
<span>GoVersion:</span>
<span class="{{if not ($.Config.HasGoVersion .GoVersion)}}unknown{{end}}">
{{.GoVersion}}
</span>
</div>
{{$report := .}}
{{with .Counters}}
<div class="Count">
{{range $name, $value := .}}
<div class="Count-entry {{if not ($.Config.HasCounter $report.Program $name) }}unknown{{end}}">
<span>{{$name}}</span><span>{{$value}}</span>
</div>
{{end}}
</div>
{{end}}
{{with .Summary}}
<div class="Summary">{{.}}</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
</section>
</div>
</div>
</main>
<script>
window.Page = {{.}};
</script>
<script src="/static/index.min.js"></script>
</body>
</html>
@@ -0,0 +1,184 @@
/**
* @license
* 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.
*/
import * as Plot from "@observablehq/plot";
import { debounce } from "../shared/treenav";
declare global {
interface Page {
Charts: ChartData;
}
interface ChartData {
Programs: Program[];
DateRange: [string, string];
UploadDay: Plot.TimeIntervalName;
}
interface Program {
ID: string;
Name: string;
Counters: Counter[];
Active: boolean;
}
interface Counter {
ID: string;
Name: string;
Data: Datum[];
}
interface Datum {
[key: string]: any;
Week: string;
Program: string;
Version: string;
GOARCH: string;
GOOS: string;
GoVersion: string;
Key: string;
Value: number;
}
const Page: Page;
}
window.onload = function () {
drawCharts();
configSelector();
breadcrumbController();
sectionController();
};
// sectionController adds event listeners to the section headers
// to toggle them open and closed.
function sectionController() {
const html = document.querySelector("html")!;
for (const e of document.querySelectorAll("h2")) {
e.addEventListener("click", function () {
let closed = localStorage.getItem("closed-sections")?.split(",");
if (closed?.includes(this.id)) {
closed = closed.filter((v) => v !== this.id);
const str = closed.join(",");
localStorage.setItem("closed-sections", str);
html.setAttribute("data-closed-sections", str);
} else {
closed = [this.id].concat(closed ?? []);
const str = closed.join(",");
localStorage.setItem("closed-sections", str);
html.setAttribute("data-closed-sections", str);
}
});
}
}
// drawCharts draws the charts using @observable/plot. It is called when
// the page is first rendered and when a facet is selected.
function drawCharts() {
for (const program of Page.Charts.Programs ?? []) {
for (const counter of program.Counters ?? []) {
const rectYOpts: Plot.BinXInputs<Plot.RectYOptions> = {
tip: true,
x: (d: Datum) => new Date(d.Week),
y: (d: Datum) => d.Value,
interval: Page.Charts.UploadDay,
fill: (d: Datum) => {
const n = Number(d.Key);
return isNaN(n) ? d.Key : n;
},
};
const chart = Plot.plot({
nice: true,
x: {
type: "utc",
domain: Page.Charts.DateRange.map((d) => new Date(d)),
label: "Week",
},
y: {
label: "Value",
},
color: {
type: "ordinal",
legend: true,
scheme: "Spectral",
reverse: true,
label: "Counter",
},
height: 256,
style: "overflow:visible;width:100%;background:transparent",
marks: [
Plot.rectY(counter.Data, Plot.binX({ y: "sum" }, rectYOpts)),
Plot.ruleY([0]),
],
});
document
.querySelector(`[data-chart-id="${counter.ID}"]`)
?.replaceChildren(chart);
}
}
}
// configSelector adds an event listener that reloads the page when a config
// version is selected.
function configSelector() {
const el = document.querySelector<HTMLButtonElement>(".js-selectConfig");
el?.addEventListener("change", () => {
const params = new URLSearchParams(location.search);
params.set(el.name, el.value);
history.replaceState(null, "", "?" + params.toString());
location.reload();
});
}
// breadcrumbController updates the navigation header as the user scrolls
// that page displaying information about the content currently in the
// viewport.
function breadcrumbController() {
const headings =
document.querySelectorAll<HTMLHeadingElement>("h1, h2, h3, h4");
const callback = debounce(() => {
let above: HTMLHeadingElement[] = [];
for (const h of headings) {
const rect = h.getBoundingClientRect();
if (rect.height && rect.top < 80) {
above.unshift(h);
}
}
if (above.length < 2) {
above = [];
}
let threshold = Infinity;
const els: HTMLHeadingElement[] = [];
for (const h of above) {
const level = Number(h.tagName[1]);
if (level < threshold) {
threshold = level;
els.unshift(h);
}
}
const breadcrumb = document.querySelector(".js-breadcrumb ol");
const items = [];
for (const h of els) {
breadcrumb?.replaceChildren;
const li = document.createElement("li");
const a = document.createElement("a");
a.href = `#${h.id}`;
a.innerText = h.getAttribute("data-label") ?? h.innerText;
li.appendChild(a);
items.push(li);
}
breadcrumb?.replaceChildren(...items);
}, 100);
const observer = new IntersectionObserver(callback);
for (const h of headings) {
observer.observe(h);
}
}
export {};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1,9 @@
// Code generated by esbuild. DO NOT EDIT.
"use strict";(()=>{(function(){let t=localStorage.getItem("closed-sections")??"";document.querySelector("html")?.setAttribute("data-closed-sections",t)})();})();
/**
* @license
* 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.
*/
//# sourceMappingURL=storage.min.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../storage.ts"],
"sourcesContent": ["/**\n * @license\n * Copyright 2023 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n(function () {\n const closedSections = localStorage.getItem(\"closed-sections\") ?? \"\";\n const html = document.querySelector(\"html\");\n html?.setAttribute(\"data-closed-sections\", closedSections);\n})();\n"],
"mappings": ";oBAOC,UAAY,CACX,IAAMA,EAAiB,aAAa,QAAQ,iBAAiB,GAAK,GACrD,SAAS,cAAc,MAAM,GACpC,aAAa,uBAAwBA,CAAc,CAC3D,GAAG",
"names": ["closedSections"]
}
@@ -0,0 +1,12 @@
/**
* @license
* 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.
*/
(function () {
const closedSections = localStorage.getItem("closed-sections") ?? "";
const html = document.querySelector("html");
html?.setAttribute("data-closed-sections", closedSections);
})();
@@ -0,0 +1,89 @@
/*!
* Copyright 2021 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.
*/
:root {
/* Colors */
--gray-1: #202224;
--gray-2: #3e4042;
--gray-3: #555759;
--gray-4: #6e7072;
--gray-5: #848688;
--gray-6: #aaacae;
--gray-7: #c6c8ca;
--gray-8: #dcdee0;
--gray-9: #f0f1f2;
--gray-10: #f8f8f8;
--turq-light: #5dc9e2;
--turq-med: #50b7e0;
--turq-dark: #007d9c;
--blue: #bfeaf4;
--blue-light: #f2fafd;
--black: #000;
--green: #3a6e11;
--green-light: #5fda64;
--pink: #c85e7a;
--pink-light: #fdecf1;
--purple: #542c7d;
--slate: #253443; /* Footer background. */
--white: #fff;
--yellow: #fceea5;
--yellow-light: #fff8cc;
/* Color Intents */
--color-brand-primary: var(--turq-dark);
--color-background: var(--white);
--color-background-inverted: var(--slate);
--color-background-accented: var(--gray-10);
--color-background-highlighted: var(--blue);
--color-background-highlighted-link: var(--blue-light);
--color-background-info: var(--gray-9);
--color-background-warning: var(--yellow-light);
--color-background-alert: var(--pink-light);
--color-border: var(--gray-7);
--color-text: var(--gray-1);
--color-text-subtle: var(--gray-4);
--color-text-link: var(--turq-dark);
--color-text-inverted: var(--white);
--color-code-comment: var(--green);
/* Interactive Colors */
--color-input: var(--color-background);
--color-input-text: var(--color-text);
--color-button: var(--turq-dark);
--color-button-disabled: var(--gray-9);
--color-button-text: var(--white);
--color-button-text-disabled: var(--gray-3);
--color-button-inverted: var(--color-background);
--color-button-inverted-disabled: var(--color-background);
--color-button-inverted-text: var(--color-brand-primary);
--color-button-inverted-text-disabled: var(--color-text-subtle);
--color-button-accented: var(--yellow);
--color-button-accented-disabled: var(--gray-9);
--color-button-accented-text: var(--gray-1);
--color-button-accented-text-disabled: var(--gray-3);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-brand-primary: var(--turq-med);
--color-background: var(--gray-1);
--color-background-accented: var(--gray-2);
--color-background-highlighted: var(--gray-2);
--color-background-highlighted-link: var(--gray-2);
--color-background-info: var(--gray-3);
--color-background-warning: var(--yellow);
--color-background-alert: var(--pink);
--color-border: var(--gray-4);
--color-text: var(--gray-9);
--color-text-link: var(--turq-med);
--color-text-subtle: var(--gray-7);
--color-code-comment: var(--green-light);
}
img.go-Icon {
filter: invert(1);
}
}
@@ -0,0 +1,352 @@
/* stylelint-disable */
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input {
/* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select {
/* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}
@@ -0,0 +1,41 @@
/*!
* Copyright 2021 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-Tooltip {
border-radius: var(--border-radius);
cursor: pointer;
display: inline-block;
position: relative;
}
.go-Tooltip > summary {
list-style: none;
}
.go-Tooltip > summary::-webkit-details-marker,
.go-Tooltip > summary::marker {
display: none;
}
.go-Tooltip > summary > img {
vertical-align: text-bottom;
}
.go-Tooltip p {
background: var(--color-background) 80%;
border: var(--border);
border-radius: var(--border-radius);
color: var(--color-text);
font-size: 0.75rem;
letter-spacing: 0.0187rem;
line-height: 1rem;
padding: 0.5rem;
position: absolute;
top: 1.5rem;
white-space: normal;
width: 12rem;
z-index: 100;
}
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2021 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.
*/
/**
* ToolTipController handles closing tooltips on external clicks.
*/
export class ToolTipController {
constructor(private el: HTMLDetailsElement) {
document.addEventListener("click", (e) => {
const insideTooltip = this.el.contains(e.target as Element);
if (!insideTooltip) {
this.el.removeAttribute("open");
}
});
}
}
@@ -0,0 +1,69 @@
/*!
* Copyright 2021 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.
*/
body {
background-color: var(--color-background);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 1rem;
line-height: normal;
}
p {
line-height: 1.4375;
max-width: 75ch;
}
hr {
border: none;
border-bottom: var(--border);
margin: 0;
width: 100%;
}
code,
pre,
textarea.code {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.875rem;
line-height: 1.5em;
}
pre,
textarea.code {
background-color: var(--color-background-accented);
border: var(--border);
border-radius: var(--border-radius);
color: var(--color-text);
overflow-x: auto;
padding: 0.625rem;
tab-size: 4;
white-space: pre;
}
button,
input,
select,
textarea {
font: inherit;
}
a,
a:link,
a:visited {
color: var(--color-brand-primary);
text-decoration: none;
}
a:hover {
color: var(--color-brand-primary);
text-decoration: underline;
}
a:hover > * {
text-decoration: underline;
}
@@ -0,0 +1,59 @@
/*!
* 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.
*/
@import url("./_normalize.css");
@import url("./_color.css");
@import url("./_typography.css");
@import url("./_tooltip.css");
:root {
--border: 0.0625rem solid var(--color-border);
--border-radius: 0.25rem;
}
.Breadcrumb {
background-color: var(--color-background-accented);
}
.Breadcrumb ol {
list-style: none;
align-items: center;
padding: 0;
margin: 1.5rem 0;
display: inline-flex;
}
.Breadcrumb li {
display: flex;
font-size: 0.875rem;
}
.Breadcrumb li:not(:last-child):after {
background: url("./arrow-forward.svg") no-repeat;
content: "";
display: block;
height: 1rem;
margin: 0 0.8125rem;
width: 1rem;
text-align: center;
}
.Hero {
background-color: var(--color-background-accented);
padding: 1rem 0;
}
.Hero h1 {
font-size: 2.25rem;
font-weight: normal;
margin: 0;
}
.Container {
margin: 0 0 5rem;
}
.Content {
margin: 0 auto;
max-width: 64rem;
padding: 0 1rem;
}
@@ -0,0 +1,36 @@
<!--
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.
-->
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{block "title" .}}{{.Title}}{{end}}</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="stylesheet" href="/static/base.min.css">
</head>
<body>
{{with .Breadcrumbs}}
<nav class="Breadcrumb">
<div class="Content">
<ol>
{{range .}}
<li>{{if .Link}}<a href="{{.Link}}">{{.Label}}</a>{{else}}{{.Label}}{{end}}</li>
{{end}}
</ol>
</div>
</nav>
{{end}}
<div class="Container">
{{block "content" .}}{{.Content}}{{end}}
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,12 @@
/**
* @license
* 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.
*/
import { ToolTipController } from "./_tooltip";
for (const el of document.querySelectorAll<HTMLDetailsElement>(".js-tooltip")) {
new ToolTipController(el);
}
@@ -0,0 +1,59 @@
/*!
* Copyright 2024 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.
*/
@import url("../shared/treenav.css");
/* Fix tooltip background for dark theme */
svg g[aria-label="tip"] g {
fill: var(--color-background);
}
.Chartbrowser-view {
display: flex;
flex-direction: row;
}
.Chartbrowser-index {
flex: 1 1;
padding: 0 1.5rem 0 0;
}
.Chartbrowser-heading {
font-weight: bold;
font-size: 1.25rem;
margin: 0 0 0.5rem 0;
}
.Chartbrowser-index-sticky {
position: sticky;
top: 1rem;
width: 10rem;
}
.Chartbrowser-index-sticky > ul {
position: sticky;
top: 1rem;
margin-top: 0;
}
.Chartbrowser-link {
color: var(--color-text-subtle);
font-size: .875rem;
line-height: 1.5rem;
}
.Chartbrowser-program {
font-weight: normal;
margin: 0 0 1rem 0;
}
.Chartbrowser-program:not(:first-of-type) {
margin-top: 2rem;
}
.Chartbrowser-chart {
background-color: var(--color-background);
border: 1px solid transparent;
margin-bottom: 1rem;
padding: 0.875rem;
box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15);
}
.Chartbrowser-chart-name {
text-align: center;
margin: 0;
}
@@ -0,0 +1,54 @@
<!--
Copyright 2024 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.
-->
<!--
A chart browser is a reusable component for displaying a collection of
charts.
-->
{{define "chartbrowser"}}
<div class="Chartbrowser-view js-Tree">
<div class="Chartbrowser-index">
<nav class="Chartbrowser-index-sticky">
<h3 class="Chartbrowser-heading">Charts</h2>
<ul>
{{range .Charts.Programs}}
{{if .Charts}}
<li class="js-Tree-item" data-heading-id="{{.ID}}">
<a class="Chartbrowser-link" href="#{{.ID}}">{{programName .Name}}</a>
<ul>
{{range .Charts}}
{{with .}}
<li class="js-Tree-item" data-heading-id="{{.ID}}">
<a class="Chartbrowser-link" href="#{{.ID}}">{{chartName .Name}}</a>
</li>
{{end}}
{{end}}
</ul>
</li>
{{end}}
{{end}}
</ul>
</nav>
</div>
<div class="Chartbrowser-content">
{{range .Charts.Programs}}
{{if .Charts}}
{{$progName := programName .Name}}
<h3 id="{{.ID}}" class="Chartbrowser-program js-Tree-heading">{{$progName}}</h3>
{{range .Charts}}
{{with .}}
<div class="Chartbrowser-chart">
<h4 id="{{.ID}}" class="Chartbrowser-chart-name js-Tree-heading">{{$progName}} > {{chartName .Name}}</h4>
<div class="Chart-chart" data-chart-id="{{.ID}}"></div>
</div>
{{end}}
{{end}}
{{end}}
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,25 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{block "title" .}}{{.Title}}{{end}}</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="stylesheet" href="/static/base.min.css">
</head>
<body>
<div class="Container">
<div class="Content">
{{block "content" .}}{{.Content}}{{end}}
</div>
</div>
</body>
</html>
@@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="5" y="2" width="7" height="12">
<path d="M5.06 12.3934L6 13.3334L11.3333 8.00002L6 2.66669L5.06 3.60669L9.44666 8.00002" fill="white"/>
</mask>
<g mask="url(#mask0)">
<rect width="16" height="16" fill="#5F6368"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 386 B

File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
// Code generated by esbuild. DO NOT EDIT.
"use strict";(()=>{var e=class{constructor(i){this.el=i;document.addEventListener("click",o=>{this.el.contains(o.target)||this.el.removeAttribute("open")})}};for(let t of document.querySelectorAll(".js-tooltip"))new e(t);})();
/**
* @license
* Copyright 2021 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.
*/
/**
* @license
* 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.
*/
//# sourceMappingURL=base.min.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../_tooltip.ts", "../base.ts"],
"sourcesContent": ["/**\n * @license\n * Copyright 2021 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n/**\n * ToolTipController handles closing tooltips on external clicks.\n */\nexport class ToolTipController {\n constructor(private el: HTMLDetailsElement) {\n document.addEventListener(\"click\", (e) => {\n const insideTooltip = this.el.contains(e.target as Element);\n if (!insideTooltip) {\n this.el.removeAttribute(\"open\");\n }\n });\n }\n}\n", "/**\n * @license\n * Copyright 2023 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\nimport { ToolTipController } from \"./_tooltip\";\n\nfor (const el of document.querySelectorAll<HTMLDetailsElement>(\".js-tooltip\")) {\n new ToolTipController(el);\n}\n"],
"mappings": ";mBAUO,IAAMA,EAAN,KAAwB,CAC7B,YAAoBC,EAAwB,CAAxB,QAAAA,EAClB,SAAS,iBAAiB,QAAUC,GAAM,CAClB,KAAK,GAAG,SAASA,EAAE,MAAiB,GAExD,KAAK,GAAG,gBAAgB,MAAM,CAElC,CAAC,CACH,CACF,ECVA,QAAWC,KAAM,SAAS,iBAAqC,aAAa,EAC1E,IAAIC,EAAkBD,CAAE",
"names": ["ToolTipController", "el", "e", "el", "ToolTipController"]
}
@@ -0,0 +1,8 @@
/* Code generated by esbuild. DO NOT EDIT. */
.js-Tree ul{list-style:none;padding-left:0}.js-Tree-item ul{display:none}.js-Tree-item{overflow:hidden;text-overflow:ellipsis;padding:.125rem 0}.js-Tree-item[aria-expanded=true] ul{display:block}.js-Tree-item .js-Tree-item{position:relative;padding-left:1.25rem}.js-Tree-item .js-Tree-item[aria-selected=true]:before{background-color:var(--color-brand-primary);border-radius:50%;content:"";display:block;height:.3125rem;left:.4688rem;position:absolute;top:.75rem;width:.3125rem}.js-Tree-item>a{color:var(--color-text-subtle);font-size:.875rem}.js-Tree-item[aria-selected=true]>a{color:var(--color-text)}svg g[aria-label=tip] g{fill:var(--color-background)}.Chartbrowser-view{display:flex;flex-direction:row}.Chartbrowser-index{flex:1 1;padding:0 1.5rem 0 0}.Chartbrowser-heading{font-weight:700;font-size:1.25rem;margin:0 0 .5rem}.Chartbrowser-index-sticky{position:sticky;top:1rem;width:10rem}.Chartbrowser-index-sticky>ul{position:sticky;top:1rem;margin-top:0}.Chartbrowser-link{color:var(--color-text-subtle);font-size:.875rem;line-height:1.5rem}.Chartbrowser-program{font-weight:400;margin:0 0 1rem}.Chartbrowser-program:not(:first-of-type){margin-top:2rem}.Chartbrowser-chart{background-color:var(--color-background);border:1px solid transparent;margin-bottom:1rem;padding:.875rem;box-shadow:0 1px 2px #3c40434d,0 1px 3px 1px #3c404326}.Chartbrowser-chart-name{text-align:center;margin:0}
/*!
* Copyright 2024 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.
*/
/*# sourceMappingURL=chartbrowser.min.css.map */
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../treenav.css", "../chartbrowser.css"],
"sourcesContent": ["/*!\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n.js-Tree ul {\n list-style: none;\n padding-left: 0;\n}\n\n.js-Tree-item ul {\n display: none;\n}\n\n.js-Tree-item {\n overflow: hidden;\n text-overflow: ellipsis;\n padding: 0.125rem 0 0.125rem 0;\n}\n\n.js-Tree-item[aria-expanded='true'] ul {\n display: block;\n}\n\n.js-Tree-item .js-Tree-item {\n position: relative;\n padding-left: 1.25rem;\n}\n\n.js-Tree-item .js-Tree-item[aria-selected='true']:before {\n background-color: var(--color-brand-primary);\n border-radius: 50%;\n content: \"\";\n display: block;\n height: .3125rem;\n left: .4688rem;\n position: absolute;\n top: .75rem;\n width: .3125rem;\n}\n\n.js-Tree-item>a {\n color: var(--color-text-subtle);\n font-size: .875rem;\n}\n\n.js-Tree-item[aria-selected='true']>a {\n color: var(--color-text);\n}\n\n", "/*!\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n@import url(\"../shared/treenav.css\");\n\n/* Fix tooltip background for dark theme */\nsvg g[aria-label=\"tip\"] g {\n fill: var(--color-background);\n}\n\n.Chartbrowser-view {\n display: flex;\n flex-direction: row;\n}\n.Chartbrowser-index {\n flex: 1 1;\n padding: 0 1.5rem 0 0;\n}\n.Chartbrowser-heading {\n font-weight: bold;\n font-size: 1.25rem;\n margin: 0 0 0.5rem 0;\n}\n.Chartbrowser-index-sticky {\n position: sticky;\n top: 1rem;\n width: 10rem;\n}\n.Chartbrowser-index-sticky > ul {\n position: sticky;\n top: 1rem;\n margin-top: 0;\n}\n.Chartbrowser-link {\n color: var(--color-text-subtle);\n font-size: .875rem;\n line-height: 1.5rem;\n}\n.Chartbrowser-program {\n font-weight: normal;\n margin: 0 0 1rem 0;\n}\n.Chartbrowser-program:not(:first-of-type) {\n margin-top: 2rem;\n}\n.Chartbrowser-chart {\n background-color: var(--color-background);\n border: 1px solid transparent;\n margin-bottom: 1rem;\n padding: 0.875rem;\n box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15);\n}\n.Chartbrowser-chart-name {\n text-align: center;\n margin: 0;\n}\n"],
"mappings": ";AAMA,YACE,gBACA,eAGF,iBACE,aAGF,cACE,gBACA,uBAjBF,kBAqBA,qCACE,cAGF,4BACE,kBACA,qBAGF,uDACE,4CA/BF,kBAiCE,WACA,cACA,gBACA,cACA,kBACA,WACA,eAGF,gBACE,+BACA,kBAGF,oCACE,wBCvCF,wBACE,6BAGF,mBACE,aACA,mBAEF,oBACE,SAlBF,qBAqBA,sBACE,gBACA,kBAvBF,iBA0BA,2BACE,gBACA,SACA,YAEF,8BACE,gBACA,SACA,aAEF,mBACE,+BACA,kBACA,mBAEF,sBACE,gBA1CF,gBA6CA,0CACE,gBAEF,oBACE,yCACA,6BACA,mBAnDF,gBAqDE,uDAEF,yBACE,kBAxDF",
"names": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1,8 @@
/* Code generated by esbuild. DO NOT EDIT. */
.js-Tree ul{list-style:none;padding-left:0}.js-Tree-item ul{display:none}.js-Tree-item{overflow:hidden;text-overflow:ellipsis;padding:.125rem 0}.js-Tree-item[aria-expanded=true] ul{display:block}.js-Tree-item .js-Tree-item{position:relative;padding-left:1.25rem}.js-Tree-item .js-Tree-item[aria-selected=true]:before{background-color:var(--color-brand-primary);border-radius:50%;content:"";display:block;height:.3125rem;left:.4688rem;position:absolute;top:.75rem;width:.3125rem}.js-Tree-item>a{color:var(--color-text-subtle);font-size:.875rem}.js-Tree-item[aria-selected=true]>a{color:var(--color-text)}
/*!
* Copyright 2024 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.
*/
/*# sourceMappingURL=treenav.min.css.map */
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../treenav.css"],
"sourcesContent": ["/*!\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n.js-Tree ul {\n list-style: none;\n padding-left: 0;\n}\n\n.js-Tree-item ul {\n display: none;\n}\n\n.js-Tree-item {\n overflow: hidden;\n text-overflow: ellipsis;\n padding: 0.125rem 0 0.125rem 0;\n}\n\n.js-Tree-item[aria-expanded='true'] ul {\n display: block;\n}\n\n.js-Tree-item .js-Tree-item {\n position: relative;\n padding-left: 1.25rem;\n}\n\n.js-Tree-item .js-Tree-item[aria-selected='true']:before {\n background-color: var(--color-brand-primary);\n border-radius: 50%;\n content: \"\";\n display: block;\n height: .3125rem;\n left: .4688rem;\n position: absolute;\n top: .75rem;\n width: .3125rem;\n}\n\n.js-Tree-item>a {\n color: var(--color-text-subtle);\n font-size: .875rem;\n}\n\n.js-Tree-item[aria-selected='true']>a {\n color: var(--color-text);\n}\n\n"],
"mappings": ";AAMA,YACE,gBACA,eAGF,iBACE,aAGF,cACE,gBACA,uBAjBF,kBAqBA,qCACE,cAGF,4BACE,kBACA,qBAGF,uDACE,4CA/BF,kBAiCE,WACA,cACA,gBACA,cACA,kBACA,WACA,eAGF,gBACE,+BACA,kBAGF,oCACE",
"names": []
}
@@ -0,0 +1,9 @@
// Code generated by esbuild. DO NOT EDIT.
"use strict";(()=>{function h(r){let n=r.querySelectorAll(".js-Tree-heading"),s=()=>{let o=[];for(let e of n){let t=e.getBoundingClientRect();t.height&&t.top<80&&o.unshift(e)}o.length==0&&n[0]instanceof HTMLHeadingElement&&(o=[n[0]]);let l=1/0,a=[];for(let e of o){let t=Number(e.tagName[1]);t<l&&(l=t,a.push(e))}let d=r.querySelectorAll(".js-Tree-item");for(let e of d){let t=e.dataset.headingId,c=!1,f=!1;for(let u of a)if(u.id===t){u===a[0]?c=!0:f=!0;break}e.setAttribute("aria-selected",c?"true":"false"),e.setAttribute("aria-expanded",f?"true":"false")}},i=new IntersectionObserver(m(s,20));for(let o of n)i.observe(o)}function m(r,n){let s;return(...i)=>{clearTimeout(s),s=setTimeout(()=>r(...i),n)}}})();
/**
* @license
* Copyright 2024 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.
*/
//# sourceMappingURL=treenav.min.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../treenav.ts"],
"sourcesContent": ["/**\n * @license\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n/**\n * A treeNavController adds dynamic expansion and selection of index list\n * elements based on scroll position.\n *\n * Use it as follows:\n * - Add the .js-Tree class to a parent element of your index and content.\n * - Add the .js-Tree-item class to <li> elements of your index.\n * - Add the .js-Tree-heading class to <hN> heading elements of your content.\n *\n * Then, when you scroll content, the 'aria-selected' and 'aria-expanded'\n * attributes of your tree items will be set according to the current content\n * scroll position. The included treenav.css implements styling to expand and\n * highlight index elements according to these attributes.\n */\nexport function treeNavController(el: HTMLElement) {\n const headings = el.querySelectorAll<HTMLHeadingElement>(\".js-Tree-heading\");\n const callback = () => {\n // Collect heading elements above the scroll position.\n let above: HTMLHeadingElement[] = [];\n for (const h of headings) {\n const rect = h.getBoundingClientRect();\n if (rect.height && rect.top < 80) {\n above.unshift(h);\n }\n }\n // Highlight the first heading even if we're not yet scrolled below it.\n if (above.length == 0 && headings[0] instanceof HTMLHeadingElement) {\n above = [headings[0]];\n }\n // Collect the set of heading levels we're immediately below, at most one\n // per heading level, by decresing level.\n // e.g. [<h3 element>, <h2 element>, <h1 element>]\n let threshold = Infinity;\n const active: HTMLHeadingElement[] = [];\n for (const h of above) {\n const level = Number(h.tagName[1]);\n if (level < threshold) {\n threshold = level;\n active.push(h);\n }\n }\n // Update aria-selected and aria-expanded for all items, per the current\n // position.\n const navItems = el.querySelectorAll<HTMLElement>(\".js-Tree-item\");\n for (const item of navItems) {\n const headingId = item.dataset[\"headingId\"];\n let selected = false,\n expanded = false;\n for (const h of active) {\n if (h.id === headingId) {\n if (h === active[0]) {\n selected = true;\n } else {\n expanded = true;\n }\n break;\n }\n }\n item.setAttribute(\"aria-selected\", selected ? \"true\" : \"false\");\n item.setAttribute(\"aria-expanded\", expanded ? \"true\" : \"false\");\n }\n };\n\n // Update on changes to viewport intersection, defensively debouncing to\n // guard against performance issues.\n const observer = new IntersectionObserver(debounce(callback, 20));\n for (const h of headings) {\n observer.observe(h);\n }\n}\n\nexport function debounce<T extends (...args: unknown[]) => unknown>(\n callback: T,\n wait: number\n) {\n let timeout: number;\n return (...args: unknown[]) => {\n clearTimeout(timeout);\n timeout = setTimeout(() => callback(...args), wait);\n };\n}\n"],
"mappings": ";mBAqBO,SAASA,EAAkBC,EAAiB,CACjD,IAAMC,EAAWD,EAAG,iBAAqC,kBAAkB,EACrEE,EAAW,IAAM,CAErB,IAAIC,EAA8B,CAAC,EACnC,QAAWC,KAAKH,EAAU,CACxB,IAAMI,EAAOD,EAAE,sBAAsB,EACjCC,EAAK,QAAUA,EAAK,IAAM,IAC5BF,EAAM,QAAQC,CAAC,EAIfD,EAAM,QAAU,GAAKF,EAAS,CAAC,YAAa,qBAC9CE,EAAQ,CAACF,EAAS,CAAC,CAAC,GAKtB,IAAIK,EAAY,IACVC,EAA+B,CAAC,EACtC,QAAWH,KAAKD,EAAO,CACrB,IAAMK,EAAQ,OAAOJ,EAAE,QAAQ,CAAC,CAAC,EAC7BI,EAAQF,IACVA,EAAYE,EACZD,EAAO,KAAKH,CAAC,GAKjB,IAAMK,EAAWT,EAAG,iBAA8B,eAAe,EACjE,QAAWU,KAAQD,EAAU,CAC3B,IAAME,EAAYD,EAAK,QAAQ,UAC3BE,EAAW,GACbC,EAAW,GACb,QAAWT,KAAKG,EACd,GAAIH,EAAE,KAAOO,EAAW,CAClBP,IAAMG,EAAO,CAAC,EAChBK,EAAW,GAEXC,EAAW,GAEb,MAGJH,EAAK,aAAa,gBAAiBE,EAAW,OAAS,OAAO,EAC9DF,EAAK,aAAa,gBAAiBG,EAAW,OAAS,OAAO,EAElE,EAIMC,EAAW,IAAI,qBAAqBC,EAASb,EAAU,EAAE,CAAC,EAChE,QAAWE,KAAKH,EACda,EAAS,QAAQV,CAAC,CAEtB,CAEO,SAASW,EACdb,EACAc,EACA,CACA,IAAIC,EACJ,MAAO,IAAIC,IAAoB,CAC7B,aAAaD,CAAO,EACpBA,EAAU,WAAW,IAAMf,EAAS,GAAGgB,CAAI,EAAGF,CAAI,CACpD,CACF",
"names": ["treeNavController", "el", "headings", "callback", "above", "h", "rect", "threshold", "active", "level", "navItems", "item", "headingId", "selected", "expanded", "observer", "debounce", "wait", "timeout", "args"]
}
@@ -0,0 +1,51 @@
/*!
* Copyright 2024 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.
*/
.js-Tree ul {
list-style: none;
padding-left: 0;
}
.js-Tree-item ul {
display: none;
}
.js-Tree-item {
overflow: hidden;
text-overflow: ellipsis;
padding: 0.125rem 0 0.125rem 0;
}
.js-Tree-item[aria-expanded='true'] ul {
display: block;
}
.js-Tree-item .js-Tree-item {
position: relative;
padding-left: 1.25rem;
}
.js-Tree-item .js-Tree-item[aria-selected='true']:before {
background-color: var(--color-brand-primary);
border-radius: 50%;
content: "";
display: block;
height: .3125rem;
left: .4688rem;
position: absolute;
top: .75rem;
width: .3125rem;
}
.js-Tree-item>a {
color: var(--color-text-subtle);
font-size: .875rem;
}
.js-Tree-item[aria-selected='true']>a {
color: var(--color-text);
}
@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2024 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.
*/
/**
* A treeNavController adds dynamic expansion and selection of index list
* elements based on scroll position.
*
* Use it as follows:
* - Add the .js-Tree class to a parent element of your index and content.
* - Add the .js-Tree-item class to <li> elements of your index.
* - Add the .js-Tree-heading class to <hN> heading elements of your content.
*
* Then, when you scroll content, the 'aria-selected' and 'aria-expanded'
* attributes of your tree items will be set according to the current content
* scroll position. The included treenav.css implements styling to expand and
* highlight index elements according to these attributes.
*/
export function treeNavController(el: HTMLElement) {
const headings = el.querySelectorAll<HTMLHeadingElement>(".js-Tree-heading");
const callback = () => {
// Collect heading elements above the scroll position.
let above: HTMLHeadingElement[] = [];
for (const h of headings) {
const rect = h.getBoundingClientRect();
if (rect.height && rect.top < 80) {
above.unshift(h);
}
}
// Highlight the first heading even if we're not yet scrolled below it.
if (above.length == 0 && headings[0] instanceof HTMLHeadingElement) {
above = [headings[0]];
}
// Collect the set of heading levels we're immediately below, at most one
// per heading level, by decresing level.
// e.g. [<h3 element>, <h2 element>, <h1 element>]
let threshold = Infinity;
const active: HTMLHeadingElement[] = [];
for (const h of above) {
const level = Number(h.tagName[1]);
if (level < threshold) {
threshold = level;
active.push(h);
}
}
// Update aria-selected and aria-expanded for all items, per the current
// position.
const navItems = el.querySelectorAll<HTMLElement>(".js-Tree-item");
for (const item of navItems) {
const headingId = item.dataset["headingId"];
let selected = false,
expanded = false;
for (const h of active) {
if (h.id === headingId) {
if (h === active[0]) {
selected = true;
} else {
expanded = true;
}
break;
}
}
item.setAttribute("aria-selected", selected ? "true" : "false");
item.setAttribute("aria-expanded", expanded ? "true" : "false");
}
};
// Update on changes to viewport intersection, defensively debouncing to
// guard against performance issues.
const observer = new IntersectionObserver(debounce(callback, 20));
for (const h of headings) {
observer.observe(h);
}
}
export function debounce<T extends (...args: unknown[]) => unknown>(
callback: T,
wait: number
) {
let timeout: number;
return (...args: unknown[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => callback(...args), wait);
};
}
@@ -0,0 +1,35 @@
<!--
Copyright 2024 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.
-->
{{template "base" .}}
{{define "title"}}Go Telemetry / Daily Charts{{end}}
{{define "content"}}
<main id="main">
<section>
<div class="Hero">
<div class="Content">
<h1>Daily Charts</h1>
<p>View charts for telemetry data.</p>
</div>
</div>
</section>
<section>
<div class="Content">
<ul style="column-count: auto; column-width: 10rem">
{{range .}}
<li><a href="/charts/{{.}}">{{.}}</a></li>
{{end}}
</ul>
</div>
</section>
</main>
{{end}}
@@ -0,0 +1,12 @@
/*!
* 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.
*/
@import url("../shared/chartbrowser.css");
.Charts {
margin-top: 1.5rem;
}
@@ -0,0 +1,37 @@
<!--
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.
-->
{{template "base" .}}
{{define "title"}}Go Telemetry / {{index .Charts.DateRange 1}}{{end}}
{{define "content"}}
<link rel="stylesheet" href="/static/charts.min.css">
<main id="main">
<section>
<div class="Hero">
<div class="Content">
<h1>{{.ChartTitle}}</h1>
<p>Generated from {{.Charts.NumReports}} reports.</p>
</div>
</div>
</section>
<section>
<div class="Content">
<div class="Charts">
{{template "chartbrowser" .}}
</div>
</div>
</section>
<script>
window.Page = {{.}};
</script>
<script src="/static/charts.min.js"></script>
{{end}}
@@ -0,0 +1,169 @@
/**
* @license
* 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.
*/
interface Page {
Charts: ChartData | null;
}
interface ChartData {
Programs: Program[] | null;
}
interface Program {
ID: string;
Name: string;
Charts: Chart[] | null;
}
interface Chart {
ID: string;
Name: string;
Type: string;
Data: Datum[] | null;
}
interface Datum {
Key: string;
Value: number;
}
declare const Page: Page;
import * as d3 from "d3";
import * as Plot from "@observablehq/plot";
import { treeNavController } from "../shared/treenav";
for (const program of Page.Charts?.Programs || []) {
for (const counter of program?.Charts || []) {
switch (counter.Type) {
case "partition":
document
.querySelector(`[data-chart-id="${counter.ID}"]`)
?.append(partition(counter));
break;
case "histogram":
document
.querySelector(`[data-chart-id="${counter.ID}"]`)
?.append(histogram(counter));
break;
default:
console.error("unknown chart type");
break;
}
}
}
for (const el of document.querySelectorAll<HTMLElement>(".js-Tree")) {
treeNavController(el);
}
function partition({ Data, Name }: Chart) {
Data ??= [];
const max = Data.map((d) => d.Value).reduce((a, b) => Math.max(a, b), 0);
return Plot.plot({
color: {
type: "categorical",
scheme: "set2",
},
nice: true,
x: {
label: Name,
labelOffset: Number.MAX_SAFE_INTEGER,
tickRotate: 45,
domain: Data.map((d) => d.Key),
},
y: {
label: "Reports", // currently, partition charts count the number of reports, not counter totals.
domain: [0, max + 1], // adjust domain to prevent rendering issues, especially with all-zero data.
},
width: 1024,
style: {
overflow: "visible",
background: "transparent",
marginBottom: "3rem",
fontSize: "0.8rem",
marginTop: "1rem",
},
insetTop: 20, // leave enough space between the axis label and marks
marks: [
Plot.barY(Data, {
tip: true,
fill: (d) => (isNaN(Number(d.Key)) ? d.Key : Number(d.Key)),
x: (d) => d.Key,
y: (d) => d.Value,
}),
Plot.frame(),
],
});
}
function histogram({ Data }: Chart) {
Data ??= [];
const n = 3; // number of facet columns
const fixKey = (k: string) => (isNaN(Number(k)) ? k : Number(k));
const keys = Array.from(d3.union(Data.map((d) => fixKey(d.Key))));
const index = new Map(keys.map((key, i) => [key, i]));
const fx = (key: string | number) => (index.get(key) ?? 0) % n;
const fy = (key: string | number) => Math.floor((index.get(key) ?? 0) / n);
return Plot.plot({
marginLeft: 60,
width: 1024,
grid: true,
nice: true,
x: {
label: "Distribution",
},
color: {
type: "ordinal",
legend: true,
scheme: "Spectral",
label: "Counter",
},
y: {
insetTop: 16,
domain: [0, 1],
},
fx: {
ticks: [],
},
fy: {
ticks: [],
},
style: "background:transparent;",
marks: [
Plot.barY(
Data,
Plot.binX(
{ y: "proportion-facet", x: "x1", interval: 0.1, cumulative: 1 },
{
tip: true,
fill: (d: Datum) => fixKey(d.Key),
x: (d: Datum) => d.Value,
fx: (d: Datum) => fx(fixKey(d.Key)),
fy: (d: Datum) => fy(fixKey(d.Key)),
}
)
),
Plot.text(keys, {
frameAnchor: "top",
dy: 3,
fx,
fy,
}),
Plot.axisX({ anchor: "bottom", tickSpacing: 35 }),
Plot.axisX({ anchor: "top", tickSpacing: 35 }),
Plot.frame(),
],
});
}
export {};
@@ -0,0 +1,40 @@
<!--
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.
-->
{{template "base" .}}
{{define "title"}}Go Telemetry Config{{end}}
{{define "content"}}
<main id="main">
<div class="Content">
<section class="Chart Config">
<h2 id="config">Chart Config</h2>
<p>
The chart config contains the list of approved charts to display on
telemetry.go.dev. The chart config format is documented by the
<a href="https://pkg.go.dev/golang.org/x/telemetry/internal/chartconfig">
<code>chartconfig</code>
</a> package documentation.
</p>
<pre style="max-height: 100rem">{{.ChartConfig}}</pre>
</section>
<section class="Upload Config">
<h2 id="config">Upload Config</h2>
<p>
The upload config contains the list of active counters for each program
and allowed report metadata. This is generated from the chart config
above.
</p>
<label>
Version: {{.Version}}
</label>
<pre style="max-height: 100rem">{{.UploadConfig}}</pre>
</section>
</div>
</main>
{{end}}
@@ -0,0 +1,36 @@
<!--
Copyright 2024 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.
-->
{{template "base" .}}
{{define "title"}}Go Telemetry / Data{{end}}
{{define "content"}}
<main id="main">
<section>
<div class="Hero">
<div class="Content">
<h1>Merged daily reports</h1>
<p>Download raw telemetry data.</p>
</div>
</div>
</section>
<section>
<div class="Content">
<ul style="margin-top: 1.5rem; column-count: auto; column-width: 10rem">
{{$url := .BucketURL}}
{{range .Dates}}
<li><a href="{{$url}}/{{.}}.json">{{.}}</a></li>
{{end}}
</ul>
</div>
</section>
</main>
{{end}}
@@ -0,0 +1,46 @@
/*!
* Copyright 2024 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.
*/
@import url("../shared/chartbrowser.css");
p {
/* Reset from _typography.css */
max-width: none;
}
.Charts {
margin-top: 1rem;
}
.Centered {
display: flex;
justify-content: center;
}
.Charts-heading {
margin: 2.5rem 0 1.5rem;
}
.Charts-heading h2 {
font-weight: normal;
margin: 0;
}
.Charts-heading a {
border: var(--border);
padding: 0.6rem;
border-radius: 0.5rem;
background-color: var(--color-background-highlighted-link);
}
.Charts-heading ul {
margin: 0.5rem 0 0 0;
list-style: none;
display: inline-flex;
padding: 0;
}
.Charts-heading li {
list-style: none;
display: inline-flex;
}
.Charts-heading li:not(:last-child) {
margin-right: 1rem;
}
@@ -0,0 +1,61 @@
<!--
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.
-->
{{template "base" .}}
{{define "title"}}Go Telemetry{{end}}
{{define "content"}}
<link rel="stylesheet" href="/static/index.min.css">
<main id="main">
<section>
<div class="Hero">
<div class="Content">
<h1>Go Telemetry &#128202;</h1>
<p>
<em>Go Telemetry</em> is a way for Go toolchain programs to collect
data about their performance and usage. Uploaded data is used to help
improve the Go toolchain and related tools. Go Telemetry is not built
into users' binaries. Learn more about Go telemetry at
<a href="https://go.dev/doc/telemetry">go.dev/doc/telemetry</a>.
</p>
<p>
Users who have opted in will upload an approved subset of telemetry
data approximately once a week. This subset is determined by the current
<a href="/config">upload configuration</a>.
</p>
<p>
For privacy information about this service, see
<a href="/privacy">telemetry.go.dev/privacy</a>.
</p>
</div>
</div>
</section>
<section class="Charts">
<div class="Content">
<div class="Charts-heading">
<h2>{{.ChartTitle}}</h2>
<ul>
<li><a href="/charts/">All charts</a></li>
<li><a href="/data/">All raw data</a></li>
</ul>
</div>
{{template "chartbrowser" .}}
</div>
</section>
</main>
<script>
window.Page = {{.}};
</script>
<script src="/static/charts.min.js"></script>
{{end}}
@@ -0,0 +1,88 @@
---
Title: Go Telemetry Privacy Policy
Layout: privacy.html
---
# Privacy Policy
_Last updated: January 24, 2024_
Go Telemetry is a way for Go toolchain programs to collect data about their
performance and usage. This data can help developers improve the language and
tools.
## What Go Telemetry Records {#collection}
Go toolchain programs, such as the `go` command and `gopls`, record certain information
about their own execution. This data is stored in local files on your computer,
specifically in the [`os.UserConfigDir()/go/telemetry/local`](https://pkg.go.dev/os#UserConfigDir) directory.
Here is what these files contain:
* Event counters: Information about how Go toolchain programs
are used.
* Stack traces: Details about program execution for troubleshooting.
* Basic system information: Your operating system, CPU architecture, and name and version of the Go tool being executed.
Importantly, these files do not contain personal or other
identifying information about you or your system.
## Data Privacy {#data-privacy}
By default, the data collected by Go Telemetry is kept only locally on your computer.
It is not shared with anyone unless you explicitly decide to enable Go Telemetry.
You can do this by running the command [`gotelemetry on`](#command) or using a command
in your integrated development environment (IDE).
Once enabled, Go Telemetry may decide once a week to upload reports to a Google
server. A local copy of the uploaded reports is kept in the
[`os.UserConfigDir()/go/telemetry/remote`](https://pkg.go.dev/os#UserConfigDir) directory on the user's machine.
These reports include only approved counters and are collected in
accordance with the Google Privacy Policy, which you can find
at [Google Privacy Policy](https://policies.google.com/privacy).
The uploaded reports are also made available as part of a public dataset at
[telemetry.go.dev](https://telemetry.go.dev). Developers working on Go,
both inside and outside of Google, use this dataset to understand
how the Go toolchain is used and if it is performing as expected.
## Using the `gotelemetry` Command Line Tool {#command}
To manage Go Telemetry, you can use the `gotelemetry` command line tool.
go install golang.org/x/telemetry/cmd/gotelemetry@latest
Here are some useful commands:
* `gotelemetry on`: Upload Go Telemetry data weekly.
* `gotelemetry off`: Do not upload Go Telemetry data.
* `gotelemetry view`: View locally collected telemetry data.
* `gotelemetry clear`: Clear locally collected telemetry data at any time.
For the complete usage documentation of the gotelemetry command line tool, visit
[golang.org/x/telemetry/cmd/gotelemetry](https://golang.org/x/telemetry/cmd/gotelemetry).
## Approved Counters {#config}
Go Telemetry only uploads counters that have been approved through the [public proposal process](https://github.com/orgs/golang/projects/29).
You can find the set of approved counters as a Go module at
[golang.org/x/telemetry/config](https://go.googlesource.com/telemetry/+/refs/heads/master/config/config.json) and the [current config in use](https://telemetry.go.dev/config).
## IDE Integration {#integration}
If you're using an integrated development environment (IDE) like Visual Studio
Code, versions
[`v0.14.0`](https://github.com/golang/tools/releases/tag/gopls%2Fv0.14.0) and
later of the Go language server [gopls](https://go.dev/s/gopls) collect
telemetry data. As described above, data is only uploaded after you have opted
in, either by using the command [`gotelemetry on`](#command) as described above
or by accepting a dialog in the IDE.
You can always opt out of uploading at any time by using the
[`gotelemetry local`](#command) or [`gotelemetry off`](#command) commands.
By sharing performance statistics, usage information, and crash reports with Go Telemetry,
you can help improve the Go programming language and its tools while also ensuring
your data privacy.
@@ -0,0 +1,13 @@
/* Code generated by esbuild. DO NOT EDIT. */
.js-Tree ul{list-style:none;padding-left:0}.js-Tree-item ul{display:none}.js-Tree-item{overflow:hidden;text-overflow:ellipsis;padding:.125rem 0}.js-Tree-item[aria-expanded=true] ul{display:block}.js-Tree-item .js-Tree-item{position:relative;padding-left:1.25rem}.js-Tree-item .js-Tree-item[aria-selected=true]:before{background-color:var(--color-brand-primary);border-radius:50%;content:"";display:block;height:.3125rem;left:.4688rem;position:absolute;top:.75rem;width:.3125rem}.js-Tree-item>a{color:var(--color-text-subtle);font-size:.875rem}.js-Tree-item[aria-selected=true]>a{color:var(--color-text)}svg g[aria-label=tip] g{fill:var(--color-background)}.Chartbrowser-view{display:flex;flex-direction:row}.Chartbrowser-index{flex:1 1;padding:0 1.5rem 0 0}.Chartbrowser-heading{font-weight:700;font-size:1.25rem;margin:0 0 .5rem}.Chartbrowser-index-sticky{position:sticky;top:1rem;width:10rem}.Chartbrowser-index-sticky>ul{position:sticky;top:1rem;margin-top:0}.Chartbrowser-link{color:var(--color-text-subtle);font-size:.875rem;line-height:1.5rem}.Chartbrowser-program{font-weight:400;margin:0 0 1rem}.Chartbrowser-program:not(:first-of-type){margin-top:2rem}.Chartbrowser-chart{background-color:var(--color-background);border:1px solid transparent;margin-bottom:1rem;padding:.875rem;box-shadow:0 1px 2px #3c40434d,0 1px 3px 1px #3c404326}.Chartbrowser-chart-name{text-align:center;margin:0}.Charts{margin-top:1.5rem}
/*!
* Copyright 2024 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.
*/
/*!
* 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.
*/
/*# sourceMappingURL=charts.min.css.map */
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../shared/treenav.css", "../../shared/chartbrowser.css", "../charts.css"],
"sourcesContent": ["/*!\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n.js-Tree ul {\n list-style: none;\n padding-left: 0;\n}\n\n.js-Tree-item ul {\n display: none;\n}\n\n.js-Tree-item {\n overflow: hidden;\n text-overflow: ellipsis;\n padding: 0.125rem 0 0.125rem 0;\n}\n\n.js-Tree-item[aria-expanded='true'] ul {\n display: block;\n}\n\n.js-Tree-item .js-Tree-item {\n position: relative;\n padding-left: 1.25rem;\n}\n\n.js-Tree-item .js-Tree-item[aria-selected='true']:before {\n background-color: var(--color-brand-primary);\n border-radius: 50%;\n content: \"\";\n display: block;\n height: .3125rem;\n left: .4688rem;\n position: absolute;\n top: .75rem;\n width: .3125rem;\n}\n\n.js-Tree-item>a {\n color: var(--color-text-subtle);\n font-size: .875rem;\n}\n\n.js-Tree-item[aria-selected='true']>a {\n color: var(--color-text);\n}\n\n", "/*!\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n@import url(\"../shared/treenav.css\");\n\n/* Fix tooltip background for dark theme */\nsvg g[aria-label=\"tip\"] g {\n fill: var(--color-background);\n}\n\n.Chartbrowser-view {\n display: flex;\n flex-direction: row;\n}\n.Chartbrowser-index {\n flex: 1 1;\n padding: 0 1.5rem 0 0;\n}\n.Chartbrowser-heading {\n font-weight: bold;\n font-size: 1.25rem;\n margin: 0 0 0.5rem 0;\n}\n.Chartbrowser-index-sticky {\n position: sticky;\n top: 1rem;\n width: 10rem;\n}\n.Chartbrowser-index-sticky > ul {\n position: sticky;\n top: 1rem;\n margin-top: 0;\n}\n.Chartbrowser-link {\n color: var(--color-text-subtle);\n font-size: .875rem;\n line-height: 1.5rem;\n}\n.Chartbrowser-program {\n font-weight: normal;\n margin: 0 0 1rem 0;\n}\n.Chartbrowser-program:not(:first-of-type) {\n margin-top: 2rem;\n}\n.Chartbrowser-chart {\n background-color: var(--color-background);\n border: 1px solid transparent;\n margin-bottom: 1rem;\n padding: 0.875rem;\n box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15);\n}\n.Chartbrowser-chart-name {\n text-align: center;\n margin: 0;\n}\n", "/*!\n * Copyright 2023 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n\n@import url(\"../shared/chartbrowser.css\");\n\n.Charts {\n margin-top: 1.5rem;\n}\n"],
"mappings": ";AAMA,YACE,gBACA,eAGF,iBACE,aAGF,cACE,gBACA,uBAjBF,kBAqBA,qCACE,cAGF,4BACE,kBACA,qBAGF,uDACE,4CA/BF,kBAiCE,WACA,cACA,gBACA,cACA,kBACA,WACA,eAGF,gBACE,+BACA,kBAGF,oCACE,wBCvCF,wBACE,6BAGF,mBACE,aACA,mBAEF,oBACE,SAlBF,qBAqBA,sBACE,gBACA,kBAvBF,iBA0BA,2BACE,gBACA,SACA,YAEF,8BACE,gBACA,SACA,aAEF,mBACE,+BACA,kBACA,mBAEF,sBACE,gBA1CF,gBA6CA,0CACE,gBAEF,oBACE,yCACA,6BACA,mBAnDF,gBAqDE,uDAEF,yBACE,kBAxDF,SCSA,QACE",
"names": []
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/* Code generated by esbuild. DO NOT EDIT. */
.js-Tree ul{list-style:none;padding-left:0}.js-Tree-item ul{display:none}.js-Tree-item{overflow:hidden;text-overflow:ellipsis;padding:.125rem 0}.js-Tree-item[aria-expanded=true] ul{display:block}.js-Tree-item .js-Tree-item{position:relative;padding-left:1.25rem}.js-Tree-item .js-Tree-item[aria-selected=true]:before{background-color:var(--color-brand-primary);border-radius:50%;content:"";display:block;height:.3125rem;left:.4688rem;position:absolute;top:.75rem;width:.3125rem}.js-Tree-item>a{color:var(--color-text-subtle);font-size:.875rem}.js-Tree-item[aria-selected=true]>a{color:var(--color-text)}svg g[aria-label=tip] g{fill:var(--color-background)}.Chartbrowser-view{display:flex;flex-direction:row}.Chartbrowser-index{flex:1 1;padding:0 1.5rem 0 0}.Chartbrowser-heading{font-weight:700;font-size:1.25rem;margin:0 0 .5rem}.Chartbrowser-index-sticky{position:sticky;top:1rem;width:10rem}.Chartbrowser-index-sticky>ul{position:sticky;top:1rem;margin-top:0}.Chartbrowser-link{color:var(--color-text-subtle);font-size:.875rem;line-height:1.5rem}.Chartbrowser-program{font-weight:400;margin:0 0 1rem}.Chartbrowser-program:not(:first-of-type){margin-top:2rem}.Chartbrowser-chart{background-color:var(--color-background);border:1px solid transparent;margin-bottom:1rem;padding:.875rem;box-shadow:0 1px 2px #3c40434d,0 1px 3px 1px #3c404326}.Chartbrowser-chart-name{text-align:center;margin:0}p{max-width:none}.Charts{margin-top:1rem}.Centered{display:flex;justify-content:center}.Charts-heading{margin:2.5rem 0 1.5rem}.Charts-heading h2{font-weight:400;margin:0}.Charts-heading a{border:var(--border);padding:.6rem;border-radius:.5rem;background-color:var(--color-background-highlighted-link)}.Charts-heading ul{margin:.5rem 0 0;list-style:none;display:inline-flex;padding:0}.Charts-heading li{list-style:none;display:inline-flex}.Charts-heading li:not(:last-child){margin-right:1rem}
/*!
* Copyright 2024 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.
*/
/*# sourceMappingURL=index.min.css.map */
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../shared/treenav.css", "../../shared/chartbrowser.css", "../index.css"],
"sourcesContent": ["/*!\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n.js-Tree ul {\n list-style: none;\n padding-left: 0;\n}\n\n.js-Tree-item ul {\n display: none;\n}\n\n.js-Tree-item {\n overflow: hidden;\n text-overflow: ellipsis;\n padding: 0.125rem 0 0.125rem 0;\n}\n\n.js-Tree-item[aria-expanded='true'] ul {\n display: block;\n}\n\n.js-Tree-item .js-Tree-item {\n position: relative;\n padding-left: 1.25rem;\n}\n\n.js-Tree-item .js-Tree-item[aria-selected='true']:before {\n background-color: var(--color-brand-primary);\n border-radius: 50%;\n content: \"\";\n display: block;\n height: .3125rem;\n left: .4688rem;\n position: absolute;\n top: .75rem;\n width: .3125rem;\n}\n\n.js-Tree-item>a {\n color: var(--color-text-subtle);\n font-size: .875rem;\n}\n\n.js-Tree-item[aria-selected='true']>a {\n color: var(--color-text);\n}\n\n", "/*!\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n@import url(\"../shared/treenav.css\");\n\n/* Fix tooltip background for dark theme */\nsvg g[aria-label=\"tip\"] g {\n fill: var(--color-background);\n}\n\n.Chartbrowser-view {\n display: flex;\n flex-direction: row;\n}\n.Chartbrowser-index {\n flex: 1 1;\n padding: 0 1.5rem 0 0;\n}\n.Chartbrowser-heading {\n font-weight: bold;\n font-size: 1.25rem;\n margin: 0 0 0.5rem 0;\n}\n.Chartbrowser-index-sticky {\n position: sticky;\n top: 1rem;\n width: 10rem;\n}\n.Chartbrowser-index-sticky > ul {\n position: sticky;\n top: 1rem;\n margin-top: 0;\n}\n.Chartbrowser-link {\n color: var(--color-text-subtle);\n font-size: .875rem;\n line-height: 1.5rem;\n}\n.Chartbrowser-program {\n font-weight: normal;\n margin: 0 0 1rem 0;\n}\n.Chartbrowser-program:not(:first-of-type) {\n margin-top: 2rem;\n}\n.Chartbrowser-chart {\n background-color: var(--color-background);\n border: 1px solid transparent;\n margin-bottom: 1rem;\n padding: 0.875rem;\n box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15);\n}\n.Chartbrowser-chart-name {\n text-align: center;\n margin: 0;\n}\n", "/*!\n * Copyright 2024 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n@import url(\"../shared/chartbrowser.css\");\n\np {\n /* Reset from _typography.css */\n max-width: none;\n}\n\n.Charts {\n margin-top: 1rem;\n}\n.Centered {\n display: flex;\n justify-content: center;\n}\n.Charts-heading {\n margin: 2.5rem 0 1.5rem;\n}\n.Charts-heading h2 {\n font-weight: normal;\n margin: 0;\n}\n.Charts-heading a {\n border: var(--border);\n padding: 0.6rem;\n border-radius: 0.5rem;\n background-color: var(--color-background-highlighted-link);\n}\n.Charts-heading ul {\n margin: 0.5rem 0 0 0;\n list-style: none;\n display: inline-flex;\n padding: 0;\n}\n.Charts-heading li {\n list-style: none;\n display: inline-flex;\n}\n.Charts-heading li:not(:last-child) {\n margin-right: 1rem;\n}\n"],
"mappings": ";AAMA,YACE,gBACA,eAGF,iBACE,aAGF,cACE,gBACA,uBAjBF,kBAqBA,qCACE,cAGF,4BACE,kBACA,qBAGF,uDACE,4CA/BF,kBAiCE,WACA,cACA,gBACA,cACA,kBACA,WACA,eAGF,gBACE,+BACA,kBAGF,oCACE,wBCvCF,wBACE,6BAGF,mBACE,aACA,mBAEF,oBACE,SAlBF,qBAqBA,sBACE,gBACA,kBAvBF,iBA0BA,2BACE,gBACA,SACA,YAEF,8BACE,gBACA,SACA,aAEF,mBACE,+BACA,kBACA,mBAEF,sBACE,gBA1CF,gBA6CA,0CACE,gBAEF,oBACE,yCACA,6BACA,mBAnDF,gBAqDE,uDAEF,yBACE,kBAxDF,SCQA,EAEE,eAGF,QACE,gBAEF,UACE,aACA,uBAEF,gBApBA,uBAuBA,mBACE,gBAxBF,SA2BA,kBACI,qBA5BJ,kCA+BI,0DAEJ,mBAjCA,iBAmCE,gBACA,oBApCF,UAuCA,mBACE,gBACA,oBAEF,oCACE",
"names": []
}
@@ -0,0 +1,10 @@
---
Title: Go Telemetry Worker
Layout: base.html
---
# Go Telemetry Worker
## Overview
This page will provide information about telemetry the telemetry worker.
@@ -0,0 +1,113 @@
// Copyright 2024 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.
package counter_test
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"golang.org/x/telemetry/counter/countertest"
"golang.org/x/telemetry/internal/counter"
"golang.org/x/telemetry/internal/regtest"
"golang.org/x/telemetry/internal/telemetry"
"golang.org/x/telemetry/internal/testenv"
)
func init() {
// Catch any bugs encountered while mapping counters.
counter.CrashOnBugs = true
}
func TestConcurrentExtension(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
// This test verifies that files may be concurrently extended: when one file
// discovers that its entries exceed the mapped data, it remaps the data.
// Both programs populate enough new records to extend the file multiple
// times.
const numCounters = 50000
prog1 := regtest.NewProgram(t, "inc1", func() int {
for i := 0; i < numCounters; i++ {
counter.New(fmt.Sprint("gophers", i)).Inc()
}
return 0
})
prog2 := regtest.NewProgram(t, "inc2", func() int {
for i := numCounters; i < 2*numCounters; i++ {
counter.New(fmt.Sprint("gophers", i)).Inc()
}
return 0
})
dir := t.TempDir()
now := time.Now().UTC()
// Run a no-op program in the telemetry dir to ensure that the weekends file
// exists, and avoid the race described in golang/go#68390.
// (We could also call countertest.Open here, but better to avoid mutating
// state in the current process for a test that is otherwise hermetic)
prog0 := regtest.NewProgram(t, "init", func() int { return 0 })
if _, err := regtest.RunProgAsOf(t, dir, now, prog0); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
wg.Add(2)
// Run the programs concurrently.
go func() {
defer wg.Done()
if out, err := regtest.RunProgAsOf(t, dir, now, prog1); err != nil {
t.Errorf("prog1 failed: %v; output:\n%s", err, out)
}
}()
go func() {
defer wg.Done()
if out, err := regtest.RunProgAsOf(t, dir, now, prog2); err != nil {
t.Errorf("prog2 failed: %v; output:\n%s", err, out)
}
}()
wg.Wait()
counts := readCountsForDir(t, telemetry.NewDir(dir).LocalDir())
if got, want := len(counts), 2*numCounters; got != want {
t.Errorf("Got %d counters, want %d", got, want)
}
for name, value := range counts {
if value != 1 {
t.Errorf("count(%s) = %d, want 1", name, value)
}
}
}
func readCountsForDir(t *testing.T, dir string) map[string]uint64 {
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var countFiles []string
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".count") {
countFiles = append(countFiles, filepath.Join(dir, entry.Name()))
}
}
if len(countFiles) != 1 {
t.Fatalf("found %d count files, want 1; directory contents: %v", len(countFiles), entries)
}
counters, _, err := countertest.ReadFile(countFiles[0])
if err != nil {
t.Fatal(err)
}
return counters
}
@@ -0,0 +1,401 @@
// 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.
// Package internal/counter implements the internals of the public counter package.
// In addition to the public API, this package also includes APIs to parse and
// manage the counter files, needed by the upload package.
package counter
import (
"fmt"
"os"
"runtime"
"strings"
"sync/atomic"
)
var (
// Note: not using internal/godebug, so that internal/godebug can use
// internal/counter.
debugCounter = strings.Contains(os.Getenv("GODEBUG"), "countertrace=1")
CrashOnBugs = false // for testing; if set, exit on fatal log messages
)
// debugPrintf formats a debug message if GODEBUG=countertrace=1.
func debugPrintf(format string, args ...any) {
if debugCounter {
if len(format) == 0 || format[len(format)-1] != '\n' {
format += "\n"
}
fmt.Fprintf(os.Stderr, "counter: "+format, args...)
}
}
// debugFatalf logs a fatal error if GODEBUG=countertrace=1.
func debugFatalf(format string, args ...any) {
if debugCounter || CrashOnBugs {
if len(format) == 0 || format[len(format)-1] != '\n' {
format += "\n"
}
fmt.Fprintf(os.Stderr, "counter bug: "+format, args...)
os.Exit(1)
}
}
// A Counter is a single named event counter.
// A Counter is safe for use by multiple goroutines simultaneously.
//
// Counters should typically be created using New
// and stored as global variables, like:
//
// package mypackage
// var errorCount = counter.New("mypackage/errors")
//
// (The initialization of errorCount in this example is handled
// entirely by the compiler and linker; this line executes no code
// at program startup.)
//
// Then code can call Add to increment the counter
// each time the corresponding event is observed.
//
// Although it is possible to use New to create
// a Counter each time a particular event needs to be recorded,
// that usage fails to amortize the construction cost over
// multiple calls to Add, so it is more expensive and not recommended.
type Counter struct {
name string
file *file
next atomic.Pointer[Counter]
state counterState
ptr counterPtr
}
func (c *Counter) Name() string {
return c.name
}
type counterPtr struct {
m *mappedFile
count *atomic.Uint64
}
type counterState struct {
bits atomic.Uint64
}
func (s *counterState) load() counterStateBits {
return counterStateBits(s.bits.Load())
}
func (s *counterState) update(old *counterStateBits, new counterStateBits) bool {
if s.bits.CompareAndSwap(uint64(*old), uint64(new)) {
*old = new
return true
}
return false
}
type counterStateBits uint64
const (
stateReaders counterStateBits = 1<<30 - 1
stateLocked counterStateBits = stateReaders
stateHavePtr counterStateBits = 1 << 30
stateExtraShift = 31
stateExtra counterStateBits = 1<<64 - 1<<stateExtraShift
)
func (b counterStateBits) readers() int { return int(b & stateReaders) }
func (b counterStateBits) locked() bool { return b&stateReaders == stateLocked }
func (b counterStateBits) havePtr() bool { return b&stateHavePtr != 0 }
func (b counterStateBits) extra() uint64 { return uint64(b&stateExtra) >> stateExtraShift }
func (b counterStateBits) incReader() counterStateBits { return b + 1 }
func (b counterStateBits) decReader() counterStateBits { return b - 1 }
func (b counterStateBits) setLocked() counterStateBits { return b | stateLocked }
func (b counterStateBits) clearLocked() counterStateBits { return b &^ stateLocked }
func (b counterStateBits) setHavePtr() counterStateBits { return b | stateHavePtr }
func (b counterStateBits) clearHavePtr() counterStateBits { return b &^ stateHavePtr }
func (b counterStateBits) clearExtra() counterStateBits { return b &^ stateExtra }
func (b counterStateBits) addExtra(n uint64) counterStateBits {
const maxExtra = uint64(stateExtra) >> stateExtraShift // 0x1ffffffff
x := b.extra()
if x+n < x || x+n > maxExtra {
x = maxExtra
} else {
x += n
}
return b.clearExtra() | counterStateBits(x)<<stateExtraShift
}
// New returns a counter with the given name.
// New can be called in global initializers and will be compiled down to
// linker-initialized data. That is, calling New to initialize a global
// has no cost at program startup.
func New(name string) *Counter {
// Note: not calling defaultFile.New in order to keep this
// function something the compiler can inline and convert
// into static data initializations, with no init-time footprint.
return &Counter{name: name, file: &defaultFile}
}
// Inc adds 1 to the counter.
func (c *Counter) Inc() {
c.Add(1)
}
// Add adds n to the counter. n cannot be negative, as counts cannot decrease.
func (c *Counter) Add(n int64) {
debugPrintf("Add %q += %d", c.name, n)
if n < 0 {
panic("Counter.Add negative")
}
if n == 0 {
return
}
c.file.register(c)
state := c.state.load()
for ; ; state = c.state.load() {
switch {
case !state.locked() && state.havePtr():
if !c.state.update(&state, state.incReader()) {
continue
}
// Counter unlocked or counter shared; has an initialized count pointer; acquired shared lock.
if c.ptr.count == nil {
for !c.state.update(&state, state.addExtra(uint64(n))) {
// keep trying - we already took the reader lock
state = c.state.load()
}
debugPrintf("Add %q += %d: nil extra=%d\n", c.name, n, state.extra())
} else {
sum := c.add(uint64(n))
debugPrintf("Add %q += %d: count=%d\n", c.name, n, sum)
}
c.releaseReader(state)
return
case state.locked():
if !c.state.update(&state, state.addExtra(uint64(n))) {
continue
}
debugPrintf("Add %q += %d: locked extra=%d\n", c.name, n, state.extra())
return
case !state.havePtr():
if !c.state.update(&state, state.addExtra(uint64(n)).setLocked()) {
continue
}
debugPrintf("Add %q += %d: noptr extra=%d\n", c.name, n, state.extra())
c.releaseLock(state)
return
}
}
}
func (c *Counter) releaseReader(state counterStateBits) {
for ; ; state = c.state.load() {
// If we are the last reader and havePtr was cleared
// while this batch of readers was using c.ptr,
// it's our job to update c.ptr by upgrading to a full lock
// and letting releaseLock do the work.
// Note: no new reader will attempt to add itself now that havePtr is clear,
// so we are only racing against possible additions to extra.
if state.readers() == 1 && !state.havePtr() {
if !c.state.update(&state, state.setLocked()) {
continue
}
debugPrintf("releaseReader %s: last reader, need ptr\n", c.name)
c.releaseLock(state)
return
}
// Release reader.
if !c.state.update(&state, state.decReader()) {
continue
}
debugPrintf("releaseReader %s: released (%d readers now)\n", c.name, state.readers())
return
}
}
func (c *Counter) releaseLock(state counterStateBits) {
for ; ; state = c.state.load() {
if !state.havePtr() {
// Set havePtr before updating ptr,
// to avoid race with the next clear of havePtr.
if !c.state.update(&state, state.setHavePtr()) {
continue
}
debugPrintf("releaseLock %s: reset havePtr (extra=%d)\n", c.name, state.extra())
// Optimization: only bother loading a new pointer
// if we have a value to add to it.
c.ptr = counterPtr{nil, nil}
if state.extra() != 0 {
c.ptr = c.file.lookup(c.name)
debugPrintf("releaseLock %s: ptr=%v\n", c.name, c.ptr)
}
}
if extra := state.extra(); extra != 0 && c.ptr.count != nil {
if !c.state.update(&state, state.clearExtra()) {
continue
}
sum := c.add(extra)
debugPrintf("releaseLock %s: flush extra=%d -> count=%d\n", c.name, extra, sum)
}
// Took care of refreshing ptr and flushing extra.
// Now we can release the lock, unless of course
// another goroutine cleared havePtr or added to extra,
// in which case we go around again.
if !c.state.update(&state, state.clearLocked()) {
continue
}
debugPrintf("releaseLock %s: unlocked\n", c.name)
return
}
}
// add wraps the atomic.Uint64.Add operation to handle integer overflow.
func (c *Counter) add(n uint64) uint64 {
count := c.ptr.count
for {
old := count.Load()
sum := old + n
if sum < old {
sum = ^uint64(0)
}
if count.CompareAndSwap(old, sum) {
runtime.KeepAlive(c.ptr.m)
return sum
}
}
}
func (c *Counter) invalidate() {
for {
state := c.state.load()
if !state.havePtr() {
debugPrintf("invalidate %s: no ptr\n", c.name)
return
}
if c.state.update(&state, state.clearHavePtr()) {
debugPrintf("invalidate %s: cleared havePtr\n", c.name)
return
}
}
}
func (c *Counter) refresh() {
for {
state := c.state.load()
if state.havePtr() || state.readers() > 0 || state.extra() == 0 {
debugPrintf("refresh %s: havePtr=%v readers=%d extra=%d\n", c.name, state.havePtr(), state.readers(), state.extra())
return
}
if c.state.update(&state, state.setLocked()) {
debugPrintf("refresh %s: locked havePtr=%v readers=%d extra=%d\n", c.name, state.havePtr(), state.readers(), state.extra())
c.releaseLock(state)
return
}
}
}
// Read reads the given counter.
// This is the implementation of x/telemetry/counter/countertest.ReadCounter.
func Read(c *Counter) (uint64, error) {
if c.file.current.Load() == nil {
return c.state.load().extra(), nil
}
pf, err := readFile(c.file)
if err != nil {
return 0, err
}
v, ok := pf.Count[DecodeStack(c.Name())]
if !ok {
return v, fmt.Errorf("not found:%q", DecodeStack(c.Name()))
}
return v, nil
}
func readFile(f *file) (*File, error) {
if f == nil {
debugPrintf("No file")
return nil, fmt.Errorf("counter is not initialized - was Open called?")
}
// Note: don't call f.rotate here as this will enqueue a follow-up rotation.
f.rotate1()
if f.err != nil {
return nil, fmt.Errorf("failed to rotate mapped file - %v", f.err)
}
current := f.current.Load()
if current == nil {
return nil, fmt.Errorf("counter has no mapped file")
}
name := current.f.Name()
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("failed to read from file: %v", err)
}
pf, err := Parse(name, data)
if err != nil {
return nil, fmt.Errorf("failed to parse: %v", err)
}
return pf, nil
}
// ReadFile reads the counters and stack counters from the given file.
// This is the implementation of x/telemetry/counter/countertest.ReadFile.
func ReadFile(name string) (counters, stackCounters map[string]uint64, _ error) {
// TODO: Document the format of the stackCounters names.
data, err := ReadMapped(name)
if err != nil {
return nil, nil, fmt.Errorf("failed to read from file: %v", err)
}
pf, err := Parse(name, data)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse: %v", err)
}
counters = make(map[string]uint64)
stackCounters = make(map[string]uint64)
for k, v := range pf.Count {
if IsStackCounter(k) {
stackCounters[DecodeStack(k)] = v
} else {
counters[k] = v
}
}
return counters, stackCounters, nil
}
// ReadMapped reads the contents of the given file by memory mapping.
//
// This avoids file synchronization issues.
func ReadMapped(name string) ([]byte, error) {
f, err := os.OpenFile(name, os.O_RDWR, 0666)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
mapping, err := memmap(f)
if err != nil {
return nil, err
}
data := make([]byte, fi.Size())
copy(data, mapping.Data)
munmap(mapping)
return data, nil
}
@@ -0,0 +1,570 @@
// 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.
package counter
// Builders at
// https://build.golang.org/?repo=golang.org%2fx%2ftelemetry
// there are troubles with tests in Windows. all open files have to
// be closed by the test so the test directory can be removed.
// Once defaultFile is closed, no more tests can be run as
// Open() will fault. (This is mysterious.)
import (
"bytes"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"testing"
"time"
"golang.org/x/telemetry/internal/telemetry"
"golang.org/x/telemetry/internal/testenv"
)
func TestBasic(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
t.Logf("GOOS %s GOARCH %s", runtime.GOOS, runtime.GOARCH)
setup(t)
var f file
defer close(&f)
c := f.New("gophers")
c.Add(9)
f.rotate()
if f.err != nil {
t.Fatal(f.err)
}
current := f.current.Load()
if current == nil {
t.Fatal("no mapped file")
}
c.Add(0x90)
name := current.f.Name()
t.Logf("wrote %s:\n%s", name, hexDump(current.mapping.Data))
data, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
pf, err := Parse(name, data)
if err != nil {
t.Fatal(err)
}
want := map[string]uint64{"gophers": 0x99}
if !reflect.DeepEqual(pf.Count, want) {
t.Errorf("pf.Count = %v, want %v", pf.Count, want)
}
}
func TestMissingLocalDir(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
err := os.RemoveAll(telemetry.Default.LocalDir())
if err != nil {
t.Fatal(err)
}
TestBasic(t)
}
func TestParallel(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
t.Logf("GOOS %s GOARCH %s", runtime.GOOS, runtime.GOARCH)
setup(t)
var f file
defer close(&f)
c := f.New("manygophers")
var wg sync.WaitGroup
wg.Add(100)
for i := 0; i < 100; i++ {
go func() {
c.Inc()
wg.Done()
}()
}
wg.Wait()
f.rotate()
if f.err != nil {
t.Fatal(f.err)
}
current := f.current.Load()
if current == nil {
t.Fatal("no mapped file")
}
name := current.f.Name()
t.Logf("wrote %s:\n%s", name, hexDump(current.mapping.Data))
data, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
pf, err := Parse(name, data)
if err != nil {
t.Fatal(err)
}
want := map[string]uint64{"manygophers": 100}
if !reflect.DeepEqual(pf.Count, want) {
t.Errorf("pf.Count = %v, want %v", pf.Count, want)
}
}
// close ensures that the given mapped file is closed. On Windows, this is
// necessary prior to test cleanup.
// TODO(rfindley): rename.
func close(f *file) {
mf := f.current.Load()
if mf == nil {
// telemetry might have been off
return
}
mf.close()
}
func TestLarge(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
t.Logf("GOOS %s GOARCH %s", runtime.GOOS, runtime.GOARCH)
setup(t)
var f file
defer close(&f)
f.rotate()
for i := int64(0); i < 10000; i++ {
c := f.New(fmt.Sprint("gophers", i))
c.Add(i*i + 1)
}
for i := int64(0); i < 10000; i++ {
c := f.New(fmt.Sprint("gophers", i))
c.Add(i / 2)
}
current := f.current.Load()
if current == nil {
t.Fatal("no mapped file")
}
name := current.f.Name()
data, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
pf, err := Parse(name, data)
if err != nil {
t.Fatal(err)
}
var errcnt int
for i := uint64(0); i < 10000; i++ {
key := fmt.Sprint("gophers", i)
want := 1 + i*i + i/2
if n := pf.Count[key]; n != want {
// print out the first few errors
t.Errorf("Count[%s] = %d, want %d", key, n, want)
errcnt++
if errcnt > 5 {
return
}
}
}
}
func TestCorruption_Truncation(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
if runtime.GOOS == "windows" {
t.Skip("windows does not permit truncating a file that is mapped")
}
defer func(crash bool) {
CrashOnBugs = crash
}(CrashOnBugs)
CrashOnBugs = false // we're intentionally introducing corruption below
// In golang/go#68311, it appeared that telemetry became stuck in an infinite
// loop of re-mapping as a result of a corrupt counter file.
//
// While the specific conditions that led to corruption are not understood,
// the infinite loop was reproducible by truncating the counter file after
// extension.
setup(t)
var f file
defer close(&f)
f.rotate1()
// Populate enough data to extend the file beyond its minimum length.
const numCounters = 1000
for i := int64(0); i < numCounters; i++ {
f.New(fmt.Sprint("gophers", i)).Inc()
}
current := f.current.Load()
if current == nil {
t.Fatal("no mapped file")
}
if err := current.f.Truncate(minFileLen); err != nil {
t.Fatalf("truncating %q: %v", current.f.Name(), err)
}
// Increment the same counters that were created above. This should exercise
// the corruption, as counter heads will point to file locations that no
// longer exist.
var f2 file
defer close(&f2)
f2.rotate1()
for i := int64(0); i < numCounters; i++ {
f2.New(fmt.Sprint("gophers", i)).Inc()
}
}
func TestRepeatedNew(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
t.Logf("GOOS %s GOARCH %s", runtime.GOOS, runtime.GOARCH)
setup(t)
var f file
defer close(&f)
f.rotate()
f.New("gophers")
c1ptr := f.lookup("gophers")
f.New("gophers")
c2ptr := f.lookup("gophers")
if c1ptr != c2ptr {
t.Errorf("c1ptr = %p, c2ptr = %p, want same", c1ptr, c2ptr)
}
}
func hexDump(data []byte) string {
lines := strings.SplitAfter(hex.Dump(data), "\n")
var keep []string
for len(lines) > 0 {
line := lines[0]
keep = append(keep, line)
lines = lines[1:]
const allZeros = "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
if strings.Contains(line, allZeros) {
i := 0
for i < len(lines) && strings.Contains(lines[i], allZeros) {
i++
}
if i > 2 {
keep = append(keep, "*\n", lines[i-1])
lines = lines[i:]
}
}
}
return strings.Join(keep, "")
}
func TestNewFile(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
t.Logf("GOOS %s GOARCH %s", runtime.GOOS, runtime.GOARCH)
setup(t)
now := CounterTime().UTC()
year, month, day := now.Date()
// preserve time location as done in (*file).filename.
testStartTime := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
// test that completely new files have dates well in the future
// Try 20 times to get 20 different random numbers.
for i := 0; i < 20; i++ {
var f file
c := f.New("gophers")
// shouldn't see a file yet
fi, err := os.ReadDir(telemetry.Default.LocalDir())
if err != nil {
t.Fatal(err)
}
if len(fi) != 0 {
t.Fatalf("len(fi) = %d, want 0", len(fi))
}
c.Add(9)
// still shouldn't see a file
fi, err = os.ReadDir(telemetry.Default.LocalDir())
if err != nil {
close(&f)
t.Fatal(err)
}
if len(fi) != 0 {
close(&f)
t.Fatalf("len(fi) = %d, want 0", len(fi))
}
f.rotate()
// now we should see a count file and a weekends file
fi, _ = os.ReadDir(telemetry.Default.LocalDir())
if len(fi) != 2 {
close(&f)
t.Fatalf("len(fi) = %d, want 2", len(fi))
}
var countFile, weekendsFile string
for _, f := range fi {
switch f.Name() {
case "weekends":
weekendsFile = f.Name()
// while we're here, check that is ok
buf, err := os.ReadFile(filepath.Join(telemetry.Default.LocalDir(), weekendsFile))
if err != nil {
t.Fatal(err)
}
buf = bytes.TrimSpace(buf)
if len(buf) == 0 || buf[0] < '0' || buf[0] >= '7' {
t.Errorf("weekends file has bad data: %q", buf)
}
default:
countFile = f.Name()
}
}
buf, err := os.ReadFile(filepath.Join(telemetry.Default.LocalDir(), countFile))
if err != nil {
close(&f)
t.Fatal(err)
}
cf, err := Parse(countFile, buf)
if err != nil {
close(&f)
t.Fatal(err)
}
timeEnd, err := time.Parse(time.RFC3339, cf.Meta["TimeEnd"])
if err != nil {
close(&f)
t.Fatal(err)
}
days := (timeEnd.Sub(testStartTime)) / (24 * time.Hour)
if days <= 0 || days > 7 {
timeBegin, _ := time.Parse(time.RFC3339, cf.Meta["TimeBegin"])
t.Logf("testStartTime: %v file: %v TimeBegin: %v TimeEnd: %v", testStartTime, fi[0].Name(), timeBegin, timeEnd)
t.Errorf("%d: days = %d, want 7 < days <= 14", i, days)
}
close(&f)
// remove the file for the next iteration of the loop
os.Remove(filepath.Join(telemetry.Default.LocalDir(), countFile))
os.Remove(filepath.Join(telemetry.Default.LocalDir(), weekendsFile))
}
}
func TestWeekends(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
setup(t)
// get all the 49 combinations of today and when the week ends
for i := 0; i < 7; i++ {
CounterTime = future(i)
for index := range "0123456" {
os.WriteFile(filepath.Join(telemetry.Default.LocalDir(), "weekends"), []byte{byte(index + '0')}, 0666)
var f file
c := f.New("gophers")
c.Add(7)
f.rotate1()
fis, err := os.ReadDir(telemetry.Default.LocalDir())
if err != nil {
t.Fatal(err)
}
weekends := time.Weekday(-1)
var begins, ends time.Time
for _, fi := range fis {
// ignore errors for brevity: something else will fail
if fi.Name() == "weekends" {
buf, _ := os.ReadFile(filepath.Join(telemetry.Default.LocalDir(), fi.Name()))
buf = bytes.TrimSpace(buf)
weekends = time.Weekday(buf[0] - '0')
} else if strings.HasSuffix(fi.Name(), ".count") {
buf, _ := os.ReadFile(filepath.Join(telemetry.Default.LocalDir(), fi.Name()))
parsed, _ := Parse(fi.Name(), buf)
begins, _ = time.Parse(time.RFC3339, parsed.Meta["TimeBegin"])
ends, _ = time.Parse(time.RFC3339, parsed.Meta["TimeEnd"])
}
}
if weekends < 0 {
for _, f := range fis {
t.Errorf("in %s, weekends is %d", f.Name(), weekends)
}
continue
}
delta := int(ends.Sub(begins) / (24 * time.Hour))
// if we're an old user, we should have a <=7 day report
// if we're a new user, we should have a <=7+7 day report
more := 0
if delta <= 0+more || delta > 7+more {
t.Errorf("delta %d, expected %d<delta<=%d",
delta, more, more+7)
}
if weekends != ends.Weekday() {
t.Errorf("weekends %s unexpecteledy not end day %s", weekends, ends.Weekday())
}
// On Windows, we must unmap f.current before removing files below.
close(&f)
// remove files for the next iteration of the loop
for _, f := range fis {
os.Remove(filepath.Join(telemetry.Default.LocalDir(), f.Name()))
}
}
}
}
func future(days int) func() time.Time {
return func() time.Time {
return time.Now().UTC().AddDate(0, 0, days)
}
}
func TestStack(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
t.Logf("GOOS %s GOARCH %s", runtime.GOOS, runtime.GOARCH)
setup(t)
var f file
defer close(&f)
f.rotate()
c := f.NewStack("foo", 5)
c.Inc()
c.Inc()
names := c.Names()
if len(names) != 2 {
t.Fatalf("got %d names, want 2", len(names))
}
// each name should be 4 lines, and the two names should
// differ only in the second line.
n0 := strings.Split(names[0], "\n")
n1 := strings.Split(names[1], "\n")
if len(n0) != 4 || len(n1) != 4 {
t.Errorf("got %d and %d lines, want 4 (%q,%q)", len(n0), len(n1), n0, n1)
}
for i := 0; i < 4 && i < len(n0) && i < len(n1); i++ {
if i == 1 {
continue
}
if n0[i] != n1[i] {
t.Errorf("line %d differs:\n%s\n%s", i, n0[i], n1[i])
}
}
// check that ReadStack gives the same results
mp, err := ReadStack(c)
if len(mp) != 2 {
t.Errorf("ReadStack returned %d values, expected 2", len(mp))
}
for k, v := range mp {
if v != 1 {
t.Errorf("got %d for %q, expected 1", v, k)
}
}
oldnames := make(map[string]bool)
for _, nm := range names {
oldnames[nm] = true
}
for i := 0; i < 2; i++ {
fn(t, 4, c)
}
newnames := make(map[string]bool)
for _, nm := range c.Names() {
if !oldnames[nm] {
newnames[nm] = true
}
}
// expect 5 new names, one for each level of recursion
if len(newnames) != 5 {
t.Errorf("got %d new names, want 5", len(newnames))
}
// make sure the new names contain compression
for k := range newnames {
if !strings.Contains(k, "\"") {
t.Errorf("new name %q does not contain \"", k)
}
}
// look inside. old names should have a count of 1, new ones 2
for _, ct := range c.Counters() {
if ct == nil {
t.Fatal("nil counter")
}
_, err := Read(ct)
if err != nil {
t.Errorf("failed to read known counter %v", err)
}
if ct.ptr.count == nil {
t.Errorf("%q has nil ptr.count", ct.Name())
continue
}
if oldnames[ct.Name()] && ct.ptr.count.Load() != 1 {
t.Errorf("old name %q has count %d, want 1", ct.Name(), ct.ptr.count.Load())
}
if newnames[ct.Name()] && ct.ptr.count.Load() != 2 {
t.Errorf("new name %q has count %d, want 2", ct.Name(), ct.ptr.count.Load())
}
}
// check that Parse expands compressed counter names
current := f.current.Load()
if current == nil {
t.Fatal("no mapped file")
}
data := current.mapping.Data
fname := "2023-01-01.v1.count" // bogus file name required by Parse.
theFile, err := Parse(fname, data)
if err != nil {
t.Fatal(err)
}
// We know what lines should appear in the stack counter names,
// although line numbers outside our control might change.
// A less fragile test would just check that " doesn't appear
known := map[string]bool{
"foo": true,
"golang.org/x/telemetry/internal/counter.fn": true,
"golang.org/x/telemetry/internal/counter.TestStack": true,
"runtime.goexit": true,
"testing.tRunner": true,
}
counts := theFile.Count
for k := range counts {
ll := strings.Split(k, "\n")
for _, line := range ll {
ix := strings.LastIndex(line, ":")
if ix < 0 {
continue // foo, for instance
}
line = line[:ix]
if !known[line] {
t.Errorf("unexpected line %q", line)
}
}
}
}
// fn calls itself n times recursively while incrementing the stack counter.
func fn(t *testing.T, n int, c *StackCounter) {
c.Inc()
if n > 0 {
fn(t, n-1, c)
}
}
func setup(t *testing.T) {
log.SetFlags(log.Lshortfile)
telemetry.Default = telemetry.NewDir(t.TempDir()) // new dir for each test
os.MkdirAll(telemetry.Default.LocalDir(), 0777)
os.MkdirAll(telemetry.Default.UploadDir(), 0777)
t.Cleanup(func() {
CounterTime = func() time.Time { return time.Now().UTC() }
})
}
func (f *file) New(name string) *Counter {
return &Counter{name: name, file: f}
}
func (f *file) NewStack(name string, depth int) *StackCounter {
return &StackCounter{name: name, depth: depth, file: f}
}
@@ -0,0 +1,814 @@
// 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.
package counter
import (
"bytes"
"errors"
"fmt"
"math/rand"
"os"
"path"
"path/filepath"
"runtime"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"unsafe"
"golang.org/x/telemetry/internal/mmap"
"golang.org/x/telemetry/internal/telemetry"
)
// A file is a counter file.
type file struct {
// Linked list of all known counters.
// (Linked list insertion is easy to make lock-free,
// and we don't want the initial counters incremented
// by a program to cause significant contention.)
counters atomic.Pointer[Counter] // head of list
end Counter // list ends at &end instead of nil
mu sync.Mutex
buildInfo *debug.BuildInfo
timeBegin, timeEnd time.Time
err error
// current holds the current file mapping, which may change when the file is
// rotated or extended.
//
// current may be read without holding mu, but may be nil.
//
// The cleanup logic for file mappings is complicated, because invalidating
// counter pointers is reentrant: [file.invalidateCounters] may call
// [file.lookup], which acquires mu. Therefore, writing current must be done
// as follows:
// 1. record the previous value of current
// 2. Store a new value in current
// 3. unlock mu
// 4. call invalidateCounters
// 5. close the previous mapped value from (1)
// TODO(rfindley): simplify
current atomic.Pointer[mappedFile]
}
var defaultFile file
// register ensures that the counter c is registered with the file.
func (f *file) register(c *Counter) {
debugPrintf("register %s %p\n", c.Name(), c)
// If counter is not registered with file, register it.
// Doing this lazily avoids init-time work
// as well as any execution cost at all for counters
// that are not used in a given program.
wroteNext := false
for wroteNext || c.next.Load() == nil {
head := f.counters.Load()
next := head
if next == nil {
next = &f.end
}
debugPrintf("register %s next %p\n", c.Name(), next)
if !wroteNext {
if !c.next.CompareAndSwap(nil, next) {
debugPrintf("register %s cas failed %p\n", c.Name(), c.next.Load())
continue
}
wroteNext = true
} else {
c.next.Store(next)
}
if f.counters.CompareAndSwap(head, c) {
debugPrintf("registered %s %p\n", c.Name(), f.counters.Load())
return
}
debugPrintf("register %s cas2 failed %p %p\n", c.Name(), f.counters.Load(), head)
}
}
// invalidateCounters marks as invalid all the pointers
// held by f's counters and then refreshes them.
//
// invalidateCounters cannot be called while holding f.mu,
// because a counter refresh may call f.lookup.
func (f *file) invalidateCounters() {
// Mark every counter as needing to refresh its count pointer.
if head := f.counters.Load(); head != nil {
for c := head; c != &f.end; c = c.next.Load() {
c.invalidate()
}
for c := head; c != &f.end; c = c.next.Load() {
c.refresh()
}
}
}
// lookup looks up the counter with the given name in the file,
// allocating it if needed, and returns a pointer to the atomic.Uint64
// containing the counter data.
// If the file has not been opened yet, lookup returns nil.
func (f *file) lookup(name string) counterPtr {
current := f.current.Load()
if current == nil {
debugPrintf("lookup %s - no mapped file\n", name)
return counterPtr{}
}
ptr := f.newCounter(name)
if ptr == nil {
return counterPtr{}
}
return counterPtr{current, ptr}
}
// ErrDisabled is the error returned when telemetry is disabled.
var ErrDisabled = errors.New("counter: disabled as Go telemetry is off")
var (
errNoBuildInfo = errors.New("counter: missing build info")
errCorrupt = errors.New("counter: corrupt counter file")
)
// weekEnd returns the day of the week on which uploads occur (and therefore
// counters expire).
//
// Reads the weekends file, creating one if none exists.
func weekEnd() (time.Weekday, error) {
// If there is no 'weekends' file create it and initialize it
// to a random day of the week. There is a short interval for
// a race.
weekends := filepath.Join(telemetry.Default.LocalDir(), "weekends")
day := fmt.Sprintf("%d\n", rand.Intn(7))
if _, err := os.ReadFile(weekends); err != nil {
if err := os.MkdirAll(telemetry.Default.LocalDir(), 0777); err != nil {
debugPrintf("%v: could not create telemetry.LocalDir %s", err, telemetry.Default.LocalDir())
return 0, err
}
if err = os.WriteFile(weekends, []byte(day), 0666); err != nil {
return 0, err
}
}
// race is over, read the file
buf, err := os.ReadFile(weekends)
// There is no reasonable way of recovering from errors
// so we just fail
if err != nil {
return 0, err
}
buf = bytes.TrimSpace(buf)
if len(buf) == 0 {
return 0, fmt.Errorf("empty weekends file")
}
weekend := time.Weekday(buf[0] - '0') // 0 is Sunday
// paranoia to make sure the value is legal
weekend %= 7
if weekend < 0 {
weekend += 7
}
return weekend, nil
}
// rotate checks to see whether the file f needs to be rotated,
// meaning to start a new counter file with a different date in the name.
// rotate is also used to open the file initially, meaning f.current can be nil.
// In general rotate should be called just once for each file.
// rotate will arrange a timer to call itself again when necessary.
func (f *file) rotate() {
expiry := f.rotate1()
if !expiry.IsZero() {
delay := time.Until(expiry)
// Some tests set CounterTime to a time in the past, causing delay to be
// negative. Avoid infinite loops by delaying at least a short interval.
//
// TODO(rfindley): instead, just also mock AfterFunc.
const minDelay = 1 * time.Minute
if delay < minDelay {
delay = minDelay
}
// TODO(rsc): Does this do the right thing for laptops closing?
time.AfterFunc(delay, f.rotate)
}
}
func nop() {}
// CounterTime returns the current UTC time.
// Mutable for testing.
var CounterTime = func() time.Time {
return time.Now().UTC()
}
// counterSpan returns the current time span for a counter file, as determined
// by [CounterTime] and the [weekEnd].
func counterSpan() (begin, end time.Time, _ error) {
year, month, day := CounterTime().Date()
begin = time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
// files always begin today, but expire on the next day of the week
// from the 'weekends' file.
weekend, err := weekEnd()
if err != nil {
return time.Time{}, time.Time{}, err
}
incr := int(weekend - begin.Weekday())
if incr <= 0 {
incr += 7 // ensure that end is later than begin
}
end = time.Date(year, month, day+incr, 0, 0, 0, 0, time.UTC)
return begin, end, nil
}
// rotate1 rotates the current counter file, returning its expiry, or the zero
// time if rotation failed.
func (f *file) rotate1() time.Time {
// Cleanup must be performed while unlocked, since invalidateCounters may
// involve calls to f.lookup.
var previous *mappedFile // read below while holding the f.mu.
defer func() {
// Counters must be invalidated whenever the mapped file changes.
if next := f.current.Load(); next != previous {
f.invalidateCounters()
// Ensure that the previous counter mapped file is closed.
if previous != nil {
previous.close() // safe to call multiple times
}
}
}()
f.mu.Lock()
defer f.mu.Unlock()
previous = f.current.Load()
if f.err != nil {
return time.Time{} // already in failed state; nothing to do
}
fail := func(err error) {
debugPrintf("rotate: %v", err)
f.err = err
f.current.Store(nil)
}
if mode, _ := telemetry.Default.Mode(); mode == "off" {
// TODO(rfindley): do we ever want to make ErrDisabled recoverable?
// Specifically, if f.err is ErrDisabled, should we check again during when
// rotating?
fail(ErrDisabled)
return time.Time{}
}
if f.buildInfo == nil {
bi, ok := debug.ReadBuildInfo()
if !ok {
fail(errNoBuildInfo)
return time.Time{}
}
f.buildInfo = bi
}
begin, end, err := counterSpan()
if err != nil {
fail(err)
return time.Time{}
}
if f.timeBegin.Equal(begin) && f.timeEnd.Equal(end) {
return f.timeEnd // nothing to do
}
f.timeBegin, f.timeEnd = begin, end
goVers, progPath, progVers := telemetry.ProgramInfo(f.buildInfo)
meta := fmt.Sprintf("TimeBegin: %s\nTimeEnd: %s\nProgram: %s\nVersion: %s\nGoVersion: %s\nGOOS: %s\nGOARCH: %s\n\n",
f.timeBegin.Format(time.RFC3339), f.timeEnd.Format(time.RFC3339),
progPath, progVers, goVers, runtime.GOOS, runtime.GOARCH)
if len(meta) > maxMetaLen { // should be impossible for our use
fail(fmt.Errorf("metadata too long"))
return time.Time{}
}
if progVers != "" {
progVers = "@" + progVers
}
baseName := fmt.Sprintf("%s%s-%s-%s-%s-%s.%s.count",
path.Base(progPath),
progVers,
goVers,
runtime.GOOS,
runtime.GOARCH,
f.timeBegin.Format(telemetry.DateOnly),
FileVersion,
)
dir := telemetry.Default.LocalDir()
if err := os.MkdirAll(dir, 0777); err != nil {
fail(fmt.Errorf("making local dir: %v", err))
return time.Time{}
}
name := filepath.Join(dir, baseName)
m, err := openMapped(name, meta)
if err != nil {
// Mapping failed:
// If there used to be a mapped file, after cleanup
// incrementing counters will only change their internal state.
// (before cleanup the existing mapped file would be updated)
fail(fmt.Errorf("openMapped: %v", err))
return time.Time{}
}
debugPrintf("using %v", m.f.Name())
f.current.Store(m)
return f.timeEnd
}
func (f *file) newCounter(name string) *atomic.Uint64 {
v, cleanup := f.newCounter1(name)
cleanup()
return v
}
func (f *file) newCounter1(name string) (v *atomic.Uint64, cleanup func()) {
f.mu.Lock()
defer f.mu.Unlock()
current := f.current.Load()
if current == nil {
return nil, nop
}
debugPrintf("newCounter %s in %s\n", name, current.f.Name())
if v, _, _, _ := current.lookup(name); v != nil {
return v, nop
}
v, newM, err := current.newCounter(name)
if err != nil {
debugPrintf("newCounter %s: %v\n", name, err)
return nil, nop
}
cleanup = nop
if newM != nil {
f.current.Store(newM)
cleanup = func() {
f.invalidateCounters()
current.close()
}
}
return v, cleanup
}
var (
openOnce sync.Once
// rotating reports whether the call to Open had rotate = true.
//
// In golang/go#68497, we observed that file rotation can break runtime
// deadlock detection. To minimize the fix for 1.23, we are splitting the
// Open API into one version that rotates the counter file, and another that
// does not. The rotating variable guards against use of both APIs from the
// same process.
rotating bool
)
// Open associates counting with the defaultFile.
// The returned function is for testing only, and should
// be called after all Inc()s are finished, but before
// any reports are generated.
// (Otherwise expired count files will not be deleted on Windows.)
func Open(rotate bool) func() {
if telemetry.DisabledOnPlatform {
return func() {}
}
close := func() {}
openOnce.Do(func() {
rotating = rotate
if mode, _ := telemetry.Default.Mode(); mode == "off" {
// Don't open the file when telemetry is off.
defaultFile.err = ErrDisabled
// No need to clean up.
return
}
debugPrintf("Open(%v)", rotate)
if rotate {
defaultFile.rotate() // calls rotate1 and schedules a rotation
} else {
defaultFile.rotate1()
}
close = func() {
// Once this has been called, the defaultFile is no longer usable.
mf := defaultFile.current.Load()
if mf == nil {
// telemetry might have been off
return
}
mf.close()
}
})
if rotating != rotate {
panic("BUG: Open called with inconsistent values for 'rotate'")
}
return close
}
const (
FileVersion = "v1"
hdrPrefix = "# telemetry/counter file " + FileVersion + "\n"
recordUnit = 32
maxMetaLen = 512
numHash = 512 // 2kB for hash table
maxNameLen = 4 * 1024
limitOff = 0
hashOff = 4
pageSize = 16 * 1024
minFileLen = 16 * 1024
)
// A mappedFile is a counter file mmapped into memory.
//
// The file layout for a mappedFile m is as follows:
//
// offset, byte size: description
// ------------------ -----------
// 0, hdrLen: header, containing metadata; see [mappedHeader]
// hdrLen+limitOff, 4: uint32 allocation limit (byte offset of the end of counter records)
// hdrLen+hashOff, 4*numHash: hash table, stores uint32 heads of a linked list of records, keyed by name hash
// hdrLen+hashOff+4*numHash to limit: counter records: see record syntax below
//
// The record layout is as follows:
//
// offset, byte size: description
// ------------------ -----------
// 0, 8: uint64 counter value
// 8, 12: uint32 name length
// 12, 16: uint32 offset of next record in linked list
// 16, name length: counter name
type mappedFile struct {
meta string
hdrLen uint32
zero [4]byte
closeOnce sync.Once
f *os.File
mapping *mmap.Data
}
// openMapped opens and memory maps a file.
//
// name is the path to the file.
//
// meta is the file metadata, which must match the metadata of the file on disk
// exactly.
//
// existing should be nil the first time this is called for a file,
// and when remapping, should be the previous mappedFile.
func openMapped(name, meta string) (_ *mappedFile, err error) {
hdr, err := mappedHeader(meta)
if err != nil {
return nil, err
}
f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return nil, err
}
// Note: using local variable m here, not return value,
// so that return nil, err does not set m = nil and break the code in the defer.
m := &mappedFile{
f: f,
meta: meta,
}
defer func() {
if err != nil {
m.close()
}
}()
info, err := f.Stat()
if err != nil {
return nil, err
}
// Establish file header and initial data area if not already present.
if info.Size() < minFileLen {
if _, err := f.WriteAt(hdr, 0); err != nil {
return nil, err
}
// Write zeros at the end of the file to extend it to minFileLen.
if _, err := f.WriteAt(m.zero[:], int64(minFileLen-len(m.zero))); err != nil {
return nil, err
}
info, err = f.Stat()
if err != nil {
return nil, err
}
if info.Size() < minFileLen {
return nil, fmt.Errorf("counter: writing file did not extend it")
}
}
// Map into memory.
mapping, err := memmap(f)
if err != nil {
return nil, err
}
m.mapping = mapping
if !bytes.HasPrefix(m.mapping.Data, hdr) {
// TODO(rfindley): we can and should do better here, reading the mapped
// header length and comparing headers exactly.
return nil, fmt.Errorf("counter: header mismatch")
}
m.hdrLen = uint32(len(hdr))
return m, nil
}
func mappedHeader(meta string) ([]byte, error) {
if len(meta) > maxMetaLen {
return nil, fmt.Errorf("counter: metadata too large")
}
np := round(len(hdrPrefix), 4)
n := round(np+4+len(meta), 32)
hdr := make([]byte, n)
copy(hdr, hdrPrefix)
*(*uint32)(unsafe.Pointer(&hdr[np])) = uint32(n)
copy(hdr[np+4:], meta)
return hdr, nil
}
func (m *mappedFile) place(limit uint32, name string) (start, end uint32) {
if limit == 0 {
// first record in file
limit = m.hdrLen + hashOff + 4*numHash
}
n := round(uint32(16+len(name)), recordUnit)
start = round(limit, recordUnit) // should already be rounded but just in case
// Note: Checking for crossing a page boundary would be
// start/pageSize != (start+n-1)/pageSize,
// but we are checking for reaching the page end, so no -1.
// The page end is reserved for use by extend.
// See the comment in m.extend.
if start/pageSize != (start+n)/pageSize {
// bump start to next page
start = round(limit, pageSize)
}
return start, start + n
}
var memmap = mmap.Mmap
var munmap = mmap.Munmap
func (m *mappedFile) close() {
m.closeOnce.Do(func() {
if m.mapping != nil {
munmap(m.mapping)
m.mapping = nil
}
if m.f != nil {
m.f.Close() // best effort
m.f = nil
}
})
}
// hash returns the hash code for name.
// The implementation is FNV-1a.
// This hash function is a fixed detail of the file format.
// It cannot be changed without also changing the file format version.
func hash(name string) uint32 {
const (
offset32 = 2166136261
prime32 = 16777619
)
h := uint32(offset32)
for i := 0; i < len(name); i++ {
c := name[i]
h = (h ^ uint32(c)) * prime32
}
return (h ^ (h >> 16)) % numHash
}
func (m *mappedFile) load32(off uint32) uint32 {
if int64(off) >= int64(len(m.mapping.Data)) {
return 0
}
return (*atomic.Uint32)(unsafe.Pointer(&m.mapping.Data[off])).Load()
}
func (m *mappedFile) cas32(off, old, new uint32) bool {
if int64(off) >= int64(len(m.mapping.Data)) {
panic("bad cas32") // return false would probably loop
}
return (*atomic.Uint32)(unsafe.Pointer(&m.mapping.Data[off])).CompareAndSwap(old, new)
}
// entryAt reads a counter record at the given byte offset.
//
// See the documentation for [mappedFile] for a description of the counter record layout.
func (m *mappedFile) entryAt(off uint32) (name []byte, next uint32, v *atomic.Uint64, ok bool) {
if off < m.hdrLen+hashOff || int64(off)+16 > int64(len(m.mapping.Data)) {
return nil, 0, nil, false
}
nameLen := m.load32(off+8) & 0x00ffffff
if nameLen == 0 || int64(off)+16+int64(nameLen) > int64(len(m.mapping.Data)) {
return nil, 0, nil, false
}
name = m.mapping.Data[off+16 : off+16+nameLen]
next = m.load32(off + 12)
v = (*atomic.Uint64)(unsafe.Pointer(&m.mapping.Data[off]))
return name, next, v, true
}
// writeEntryAt writes a new counter record at the given offset.
//
// See the documentation for [mappedFile] for a description of the counter record layout.
//
// writeEntryAt only returns false in the presence of some form of corruption:
// an offset outside the bounds of the record region in the mapped file.
func (m *mappedFile) writeEntryAt(off uint32, name string) (next *atomic.Uint32, v *atomic.Uint64, ok bool) {
// TODO(rfindley): shouldn't this first condition be off < m.hdrLen+hashOff+4*numHash?
if off < m.hdrLen+hashOff || int64(off)+16+int64(len(name)) > int64(len(m.mapping.Data)) {
return nil, nil, false
}
copy(m.mapping.Data[off+16:], name)
atomic.StoreUint32((*uint32)(unsafe.Pointer(&m.mapping.Data[off+8])), uint32(len(name))|0xff000000)
next = (*atomic.Uint32)(unsafe.Pointer(&m.mapping.Data[off+12]))
v = (*atomic.Uint64)(unsafe.Pointer(&m.mapping.Data[off]))
return next, v, true
}
// lookup searches the mapped file for a counter record with the given name, returning:
// - v: the mapped counter value
// - headOff: the offset of the head pointer (see [mappedFile])
// - head: the value of the head pointer
// - ok: whether lookup succeeded
func (m *mappedFile) lookup(name string) (v *atomic.Uint64, headOff, head uint32, ok bool) {
h := hash(name)
headOff = m.hdrLen + hashOff + h*4
head = m.load32(headOff)
off := head
for off != 0 {
ename, next, v, ok := m.entryAt(off)
if !ok {
return nil, 0, 0, false
}
if string(ename) == name {
return v, headOff, head, true
}
off = next
}
return nil, headOff, head, true
}
// newCounter allocates and writes a new counter record with the given name.
//
// If name is already recorded in the file, newCounter returns the existing counter.
func (m *mappedFile) newCounter(name string) (v *atomic.Uint64, m1 *mappedFile, err error) {
if len(name) > maxNameLen {
return nil, nil, fmt.Errorf("counter name too long")
}
orig := m
defer func() {
if m != orig {
if err != nil {
m.close()
} else {
m1 = m
}
}
}()
v, headOff, head, ok := m.lookup(name)
for tries := 0; !ok; tries++ {
if tries >= 10 {
debugFatalf("corrupt: failed to remap after 10 tries")
return nil, nil, errCorrupt
}
// Lookup found an invalid pointer,
// perhaps because the file has grown larger than the mapping.
limit := m.load32(m.hdrLen + limitOff)
if limit, datalen := int64(limit), int64(len(m.mapping.Data)); limit <= datalen {
// Mapping doesn't need to grow, so lookup found actual corruption,
// in the form of an entry pointer that exceeds the recorded allocation
// limit. This should never happen, unless the actual file contents are
// corrupt.
debugFatalf("corrupt: limit %d is within mapping length %d", limit, datalen)
return nil, nil, errCorrupt
}
// That the recorded limit is greater than the mapped data indicates that
// an external process has extended the file. Re-map to pick up this extension.
newM, err := openMapped(m.f.Name(), m.meta)
if err != nil {
return nil, nil, err
}
if limit, datalen := int64(limit), int64(len(newM.mapping.Data)); limit > datalen {
// We've re-mapped, yet limit still exceeds the data length. This
// indicates that the underlying file was somehow truncated, or the
// recorded limit is corrupt.
debugFatalf("corrupt: limit %d exceeds file size %d", limit, datalen)
return nil, nil, errCorrupt
}
// If m != orig, this is at least the second time around the loop
// trying to open the mapping. Close the previous attempt.
if m != orig {
m.close()
}
m = newM
v, headOff, head, ok = m.lookup(name)
}
if v != nil {
return v, nil, nil
}
// Reserve space for new record.
// We are competing against other programs using the same file,
// so we use a compare-and-swap on the allocation limit in the header.
var start, end uint32
for {
// Determine where record should end, and grow file if needed.
limit := m.load32(m.hdrLen + limitOff)
start, end = m.place(limit, name)
debugPrintf("place %s at %#x-%#x\n", name, start, end)
if int64(end) > int64(len(m.mapping.Data)) {
newM, err := m.extend(end)
if err != nil {
return nil, nil, err
}
if m != orig {
m.close()
}
m = newM
continue
}
// Attempt to reserve that space for our record.
if m.cas32(m.hdrLen+limitOff, limit, end) {
break
}
}
// Write record.
next, v, ok := m.writeEntryAt(start, name)
if !ok {
debugFatalf("corrupt: failed to write entry: %#x+%d vs %#x\n", start, len(name), len(m.mapping.Data))
return nil, nil, errCorrupt // more likely our math is wrong
}
// Link record into hash chain, making sure not to introduce a duplicate.
// We know name does not appear in the chain starting at head.
for {
next.Store(head)
if m.cas32(headOff, head, start) {
return v, nil, nil
}
// Check new elements in chain for duplicates.
old := head
head = m.load32(headOff)
for off := head; off != old; {
ename, enext, v, ok := m.entryAt(off)
if !ok {
return nil, nil, errCorrupt
}
if string(ename) == name {
next.Store(^uint32(0)) // mark ours as dead
return v, nil, nil
}
off = enext
}
}
}
func (m *mappedFile) extend(end uint32) (*mappedFile, error) {
end = round(end, pageSize)
info, err := m.f.Stat()
if err != nil {
return nil, err
}
if info.Size() < int64(end) {
// Note: multiple processes could be calling extend at the same time,
// but this write only writes the last 4 bytes of the page.
// The last 4 bytes of the page are reserved for this purpose and hold no data.
// (In m.place, if a new record would extend to the very end of the page,
// it is placed in the next page instead.)
// So it is fine if multiple processes extend at the same time.
if _, err := m.f.WriteAt(m.zero[:], int64(end)-int64(len(m.zero))); err != nil {
return nil, err
}
}
newM, err := openMapped(m.f.Name(), m.meta)
if err != nil {
return nil, err
}
if int64(len(newM.mapping.Data)) < int64(end) {
// File system or logic bug: new file is somehow not extended.
// See go.dev/issue/68311, where this appears to have been happening.
newM.close()
return nil, errCorrupt
}
return newM, err
}
// round returns x rounded up to the next multiple of unit,
// which must be a power of two.
func round[T int | uint32](x T, unit T) T {
return (x + unit - 1) &^ (unit - 1)
}
@@ -0,0 +1,82 @@
// 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.
package counter
import (
"bytes"
"fmt"
"strings"
"unsafe"
"golang.org/x/telemetry/internal/mmap"
)
type File struct {
Meta map[string]string
Count map[string]uint64
}
func Parse(filename string, data []byte) (*File, error) {
if !bytes.HasPrefix(data, []byte(hdrPrefix)) || len(data) < pageSize {
if len(data) < pageSize {
return nil, fmt.Errorf("%s: file too short (%d<%d)", filename, len(data), pageSize)
}
return nil, fmt.Errorf("%s: wrong hdr (not %q)", filename, hdrPrefix)
}
corrupt := func() (*File, error) {
// TODO(rfindley): return a useful error message.
return nil, fmt.Errorf("%s: corrupt counter file", filename)
}
f := &File{
Meta: make(map[string]string),
Count: make(map[string]uint64),
}
np := round(len(hdrPrefix), 4)
hdrLen := *(*uint32)(unsafe.Pointer(&data[np]))
if hdrLen > pageSize {
return corrupt()
}
meta := data[np+4 : hdrLen]
if i := bytes.IndexByte(meta, 0); i >= 0 {
meta = meta[:i]
}
m := &mappedFile{
meta: string(meta),
hdrLen: hdrLen,
mapping: &mmap.Data{Data: data},
}
lines := strings.Split(m.meta, "\n")
for _, line := range lines {
if line == "" {
continue
}
k, v, ok := strings.Cut(line, ": ")
if !ok {
return corrupt()
}
f.Meta[k] = v
}
for i := uint32(0); i < numHash; i++ {
headOff := hdrLen + hashOff + i*4
head := m.load32(headOff)
off := head
for off != 0 {
ename, next, v, ok := m.entryAt(off)
if !ok {
return corrupt()
}
if _, ok := f.Count[string(ename)]; ok {
return corrupt()
}
ctrName := DecodeStack(string(ename))
f.Count[ctrName] = v.Load()
off = next
}
}
return f, nil
}
@@ -0,0 +1,221 @@
// 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.
package counter
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"golang.org/x/telemetry/internal/mmap"
"golang.org/x/telemetry/internal/telemetry"
"golang.org/x/telemetry/internal/testenv"
)
// this test traces the life of a counter from creation
// through two file.rotate()s, followed by a failure to rotate
func TestRotateCounters(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
t.Logf("GOOS %s GOARCH %s", runtime.GOOS, runtime.GOARCH)
setup(t)
now := getnow()
CounterTime = func() time.Time { return now }
var f file
defer close(&f)
c := f.New("gophers")
if c.ptr.count != nil {
t.Error("new counter has non-nil ptr.count")
}
c.Inc() // make sure neither hits Counter.add()
c.Inc() // second use takes a different code path
// at this point c.file is not mapped so c's value is stored in extra.
if c.ptr.count != nil {
t.Error("counter without mapped file has non-nil ptr.count")
}
if c.file.current.Load() != nil {
t.Error("counter has mapped file unexpectedly")
}
state := c.state.load()
if state.extra() != 2 {
// the value of c is in its extra field
t.Errorf("got %d, expected 2", state.extra())
}
// Read should give the same answer
if v, err := Read(c); err != nil || v != 2 {
t.Errorf("Read got %d, %v, expected 2, nil", v, err)
}
f.rotate()
c.Inc() // this goes through counter.add() safely
if c.file.current.Load() == nil {
t.Error("rotated file has no mapping")
}
// rotate called c.releaseLock(), moving c's value from extra to the file
state = c.state.load()
if state.extra() != 0 {
t.Errorf("got %d, expected 0", state.extra())
}
if c.ptr.count == nil {
t.Errorf("c has unexpected nil ptr")
} else if c.ptr.count.Load() != 3 {
// the value of c is in the mapped file
t.Errorf("got %d, expected 3", c.ptr.count.Load())
}
// and Read should give the same result
if v, err := Read(c); err != nil || v != 3 {
t.Errorf("Read gave %d, %v, expected 3, nil", v, err)
}
// move into the future and rotate the file, remapping it
now = now.Add(7 * 24 * time.Hour)
f.rotate()
if got, want := f.timeBegin.Format(telemetry.DateOnly), now.Format(telemetry.DateOnly); got != want {
t.Errorf("f.timeBegin = %q, want %q", got, want)
}
// c has value 0 in the new file
// but c won't have a pointer until the next Inc()
state = c.state.load()
if c.ptr.count == nil {
t.Errorf("c unexpedtedly has nil ptr")
} else if state.havePtr() {
t.Error("unexpected pointer")
}
if state.extra() != 0 {
t.Errorf("got %d, expected 0", state.extra())
}
c.Inc()
state = c.state.load()
if state.extra() != 0 {
// as expected
t.Errorf("got %d, expected 0", state.extra())
}
if !state.havePtr() {
t.Errorf("expectd havePtr")
}
if c.ptr.count == nil || c.ptr.count.Load() != 1 {
t.Errorf("c has wrong value")
}
// add a counter
y := f.New("counter")
// simulate failure to remap
oldmap := memmap
now = now.Add(7 * 24 * time.Hour)
memmap = func(*os.File) (*mmap.Data, error) { return nil, fmt.Errorf("too bad") }
f.rotate()
memmap = oldmap
// no mapping
if f.current.Load() != nil {
t.Errorf("unexpected mapping")
}
c.Inc()
// c should not have a pointer, but its internal
// count should have been incremented
if c.ptr.count != nil {
t.Error("expected nil ptr")
}
if c.state.load().extra() != 1 {
t.Errorf("got %d, but expected extra to be 1", c.state.load().extra())
}
// make sure a new counter doesn't fault
x := f.New("newcounter")
x.Inc()
if x.state.load().extra() != 1 {
t.Errorf("got %d, but expected extra to be 1", c.state.load().extra())
}
// make sure an existing unused counter doesn't fault
// (it's incremented, but not visible externally)
y.Inc()
if y.state.load().extra() != 1 {
t.Errorf("got %d, but expected extra to be 1", c.state.load().extra())
}
}
// return the current date according to counterTime()
func getnow() time.Time {
year, month, day := CounterTime().Date()
now := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
return now
}
func TestRotate(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
t.Logf("GOOS %s GOARCH %s", runtime.GOOS, runtime.GOARCH)
now := getnow()
setup(t)
// pretend something was uploaded
os.WriteFile(filepath.Join(telemetry.Default.UploadDir(), "anything"), []byte{}, 0666)
var f file
defer close(&f)
c := f.New("gophers")
c.Inc()
var modified int
for i := 0; i < 2; i++ {
// nothing should change on the second rotate
f.rotate()
fi, err := os.ReadDir(telemetry.Default.LocalDir())
if err != nil || len(fi) != 2 {
t.Fatalf("err=%v, len(fi) = %d, want 2", err, len(fi))
}
x := fi[0].Name()
y := x[len(x)-len(telemetry.DateOnly)-len(".v1.count") : len(x)-len(".v1.count")]
us, err := time.ParseInLocation(telemetry.DateOnly, y, time.UTC)
if err != nil {
t.Fatal(err)
}
// we expect today's date?
if us != now {
t.Errorf("us = %v, want %v, i=%d y=%s", us, now, i, y)
}
fd, err := os.Open(filepath.Join(telemetry.Default.LocalDir(), fi[0].Name()))
if err != nil {
t.Fatal(err)
}
stat, err := fd.Stat()
if err != nil {
t.Fatal(err)
}
mt := stat.ModTime().Nanosecond()
if modified == 0 {
modified = mt
}
if modified != mt {
t.Errorf("modified = %v, want %v", mt, modified)
}
fd.Close()
}
CounterTime = func() time.Time { return now.Add(7 * 24 * time.Hour) }
f.rotate()
fi, err := os.ReadDir(telemetry.Default.LocalDir())
if err != nil || len(fi) != 3 {
t.Fatalf("err=%v, len(fi) = %d, want 3", err, len(fi))
}
}
// These were useful while debugging failed mapping
func (s *counterState) String() string {
if s == nil {
return "nil"
}
return s.load().String()
}
func (b counterStateBits) String() string {
rdrs := b.readers()
locked := b.locked()
if locked {
rdrs = 0 // rdrs == 1<<30 - 1
}
havePtr := b&stateHavePtr != 0
extra := uint64(b&stateExtra) >> stateExtraShift
return fmt.Sprintf("rdrs:0x%x locked:%v\thavePtr:%v\textra:%d", rdrs, locked, havePtr, extra)
}
@@ -0,0 +1,202 @@
// 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.
package counter
import (
"fmt"
"runtime"
"strings"
"sync"
)
// On the disk, and upstream, stack counters look like sets of
// regular counters with names that include newlines.
// a StackCounter is the in-memory knowledge about a stack counter.
// StackCounters are more expensive to use than regular Counters,
// requiring, at a minimum, a call to runtime.Callers.
type StackCounter struct {
name string
depth int
file *file
mu sync.Mutex
// as this is a detail of the implementation, it could be replaced
// by a more efficient mechanism
stacks []stack
}
type stack struct {
pcs []uintptr
counter *Counter
}
func NewStack(name string, depth int) *StackCounter {
return &StackCounter{name: name, depth: depth, file: &defaultFile}
}
// Inc increments a stack counter. It computes the caller's stack and
// looks up the corresponding counter. It then increments that counter,
// creating it if necessary.
func (c *StackCounter) Inc() {
pcs := make([]uintptr, c.depth)
n := runtime.Callers(2, pcs) // caller of Inc
pcs = pcs[:n]
c.mu.Lock()
defer c.mu.Unlock()
// Existing counter?
var ctr *Counter
for _, s := range c.stacks {
if eq(s.pcs, pcs) {
if s.counter != nil {
ctr = s.counter
break
}
}
}
if ctr == nil {
// Create new counter.
ctr = &Counter{
name: EncodeStack(pcs, c.name),
file: c.file,
}
c.stacks = append(c.stacks, stack{pcs: pcs, counter: ctr})
}
ctr.Inc()
}
// EncodeStack returns the name of the counter to
// use for the given stack of program counters.
// The name encodes the stack.
func EncodeStack(pcs []uintptr, prefix string) string {
var locs []string
lastImport := ""
frs := runtime.CallersFrames(pcs)
for {
fr, more := frs.Next()
// TODO(adonovan): this CutLast(".") operation isn't
// appropriate for generic function symbols.
path, fname := cutLastDot(fr.Function)
if path == lastImport {
path = `"` // (a ditto mark)
} else {
lastImport = path
}
var loc string
if fr.Func != nil {
// Use function-relative line numbering.
// f:+2 means two lines into function f.
// f:-1 should never happen, but be conservative.
_, entryLine := fr.Func.FileLine(fr.Entry)
loc = fmt.Sprintf("%s.%s:%+d", path, fname, fr.Line-entryLine)
} else {
// The function is non-Go code or is fully inlined:
// use absolute line number within enclosing file.
loc = fmt.Sprintf("%s.%s:=%d", path, fname, fr.Line)
}
locs = append(locs, loc)
if !more {
break
}
}
name := prefix + "\n" + strings.Join(locs, "\n")
if len(name) > maxNameLen {
const bad = "\ntruncated\n"
name = name[:maxNameLen-len(bad)] + bad
}
return name
}
// DecodeStack expands the (compressed) stack encoded in the counter name.
func DecodeStack(ename string) string {
if !strings.Contains(ename, "\n") {
return ename // not a stack counter
}
lines := strings.Split(ename, "\n")
var lastPath string // empty or ends with .
for i, line := range lines {
path, rest := cutLastDot(line)
if len(path) == 0 {
continue // unchanged
}
if len(path) == 1 && path[0] == '"' {
lines[i] = lastPath + rest
} else {
lastPath = path + "."
// line unchanged
}
}
return strings.Join(lines, "\n") // trailing \n?
}
// input is <import path>.<function name>
// output is (import path, function name)
func cutLastDot(x string) (before, after string) {
i := strings.LastIndex(x, ".")
if i < 0 {
return "", x
}
return x[:i], x[i+1:]
}
// Names reports all the counter names associated with a StackCounter.
func (c *StackCounter) Names() []string {
c.mu.Lock()
defer c.mu.Unlock()
names := make([]string, len(c.stacks))
for i, s := range c.stacks {
names[i] = s.counter.Name()
}
return names
}
// Counters returns the known Counters for a StackCounter.
// There may be more in the count file.
func (c *StackCounter) Counters() []*Counter {
c.mu.Lock()
defer c.mu.Unlock()
counters := make([]*Counter, len(c.stacks))
for i, s := range c.stacks {
counters[i] = s.counter
}
return counters
}
func eq(a, b []uintptr) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// ReadStack reads the given stack counter.
// This is the implementation of
// golang.org/x/telemetry/counter/countertest.ReadStackCounter.
func ReadStack(c *StackCounter) (map[string]uint64, error) {
ret := map[string]uint64{}
for _, ctr := range c.Counters() {
v, err := Read(ctr)
if err != nil {
return nil, err
}
ret[DecodeStack(ctr.Name())] = v
}
return ret, nil
}
// IsStackCounter reports whether the counter name is for a stack counter.
func IsStackCounter(name string) bool {
return strings.Contains(name, "\n")
}
@@ -0,0 +1,17 @@
// Copyright 2024 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 go1.23
// +build go1.23
package crashmonitor
import (
"os"
"runtime/debug"
)
func init() {
setCrashOutput = func(f *os.File) error { return debug.SetCrashOutput(f, debug.CrashOptions{}) }
}
@@ -0,0 +1,256 @@
// Copyright 2024 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.
package crashmonitor
// This file defines a monitor that reports arbitrary Go runtime
// crashes to telemetry.
import (
"bytes"
"fmt"
"io"
"log"
"os"
"reflect"
"runtime/debug"
"strconv"
"strings"
"golang.org/x/telemetry/internal/counter"
)
// Supported reports whether the runtime supports [runtime/debug.SetCrashOutput].
//
// TODO(adonovan): eliminate once go1.23+ is assured.
func Supported() bool { return setCrashOutput != nil }
var setCrashOutput func(*os.File) error // = runtime/debug.SetCrashOutput on go1.23+
// Parent sets up the parent side of the crashmonitor. It requires
// exclusive use of a writable pipe connected to the child process's stdin.
func Parent(pipe *os.File) {
writeSentinel(pipe)
// Ensure that we get pc=0x%x values in the traceback.
debug.SetTraceback("system")
setCrashOutput(pipe)
}
// Child runs the part of the crashmonitor that runs in the child process.
// It expects its stdin to be connected via a pipe to the parent which has
// run Parent.
func Child() {
// Wait for parent process's dying gasp.
// If the parent dies for any reason this read will return.
data, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalf("failed to read from input pipe: %v", err)
}
// If the only line is the sentinel, it wasn't a crash.
if bytes.Count(data, []byte("\n")) < 2 {
childExitHook()
os.Exit(0) // parent exited without crash report
}
log.Printf("parent reported crash:\n%s", data)
// Parse the stack out of the crash report
// and record a telemetry count for it.
name, err := telemetryCounterName(data)
if err != nil {
// Keep count of how often this happens
// so that we can investigate if necessary.
incrementCounter("crash/malformed")
// Something went wrong.
// Save the crash securely in the file system.
f, err := os.CreateTemp(os.TempDir(), "*.crash")
if err != nil {
log.Fatal(err)
}
if _, err := f.Write(data); err != nil {
log.Fatal(err)
}
if err := f.Close(); err != nil {
log.Fatal(err)
}
log.Printf("failed to report crash to telemetry: %v", err)
log.Fatalf("crash report saved at %s", f.Name())
}
incrementCounter(name)
childExitHook()
log.Fatalf("telemetry crash recorded")
}
// (stubbed by test)
var (
incrementCounter = func(name string) { counter.New(name).Inc() }
childExitHook = func() {}
)
// The sentinel function returns its address. The difference between
// this value as observed by calls in two different processes of the
// same executable tells us the relative offset of their text segments.
//
// It would be nice if SetCrashOutput took care of this as it's fiddly
// and likely to confuse every user at first.
func sentinel() uint64 {
return uint64(reflect.ValueOf(sentinel).Pointer())
}
func writeSentinel(out io.Writer) {
fmt.Fprintf(out, "sentinel %x\n", sentinel())
}
// telemetryCounterName parses a crash report produced by the Go
// runtime, extracts the stack of the first runnable goroutine,
// converts each line into telemetry form ("symbol:relative-line"),
// and returns this as the name of a counter.
func telemetryCounterName(crash []byte) (string, error) {
pcs, err := parseStackPCs(string(crash))
if err != nil {
return "", err
}
// Limit the number of frames we request.
pcs = pcs[:min(len(pcs), 16)]
if len(pcs) == 0 {
// This can occur if all goroutines are idle, as when
// caught in a deadlock, or killed by an async signal
// while blocked.
//
// TODO(adonovan): consider how to report such
// situations. Reporting a goroutine in [sleep] or
// [select] state could be quite confusing without
// further information about the nature of the crash,
// as the problem is not local to the code location.
//
// For now, we keep count of this situation so that we
// can access whether it needs a more involved solution.
return "crash/no-running-goroutine", nil
}
// This string appears at the start of all
// crashmonitor-generated counter names.
//
// It is tempting to expose this as a parameter of Start, but
// it is not without risk. What value should most programs
// provide? There's no point giving the name of the executable
// as this is already recorded by telemetry. What if the
// application runs in multiple modes? Then it might be useful
// to record the mode. The problem is that an application with
// multiple modes probably doesn't know its mode by line 1 of
// main.main: it might require flag or argument parsing, or
// even validation of an environment variable, and we really
// want to steer users aware from any logic before Start. The
// flags and arguments will be wrong in the child process, and
// every extra conditional branch creates a risk that the
// recursively executed child program will behave not like the
// monitor but like the application. If the child process
// exits before calling Start, then the parent application
// will not have a monitor, and its crash reports will be
// discarded (written in to a pipe that is never read).
//
// So for now, we use this constant string.
const prefix = "crash/crash"
return counter.EncodeStack(pcs, prefix), nil
}
// parseStackPCs parses the parent process's program counters for the
// first running goroutine out of a GOTRACEBACK=system traceback,
// adjusting them so that they are valid for the child process's text
// segment.
//
// This function returns only program counter values, ensuring that
// there is no possibility of strings from the crash report (which may
// contain PII) leaking into the telemetry system.
func parseStackPCs(crash string) ([]uintptr, error) {
// getPC parses the PC out of a line of the form:
// \tFILE:LINE +0xRELPC sp=... fp=... pc=...
getPC := func(line string) (uint64, error) {
_, pcstr, ok := strings.Cut(line, " pc=") // e.g. pc=0x%x
if !ok {
return 0, fmt.Errorf("no pc= for stack frame: %s", line)
}
return strconv.ParseUint(pcstr, 0, 64) // 0 => allow 0x prefix
}
var (
pcs []uintptr
parentSentinel uint64
childSentinel = sentinel()
on = false // are we in the first running goroutine?
lines = strings.Split(crash, "\n")
)
for i := 0; i < len(lines); i++ {
line := lines[i]
// Read sentinel value.
if parentSentinel == 0 && strings.HasPrefix(line, "sentinel ") {
_, err := fmt.Sscanf(line, "sentinel %x", &parentSentinel)
if err != nil {
return nil, fmt.Errorf("can't read sentinel line")
}
continue
}
// Search for "goroutine GID [STATUS]"
if !on {
if strings.HasPrefix(line, "goroutine ") &&
strings.Contains(line, " [running]:") {
on = true
if parentSentinel == 0 {
return nil, fmt.Errorf("no sentinel value in crash report")
}
}
continue
}
// A blank line marks end of a goroutine stack.
if line == "" {
break
}
// Skip the final "created by SYMBOL in goroutine GID" part.
if strings.HasPrefix(line, "created by ") {
break
}
// Expect a pair of lines:
// SYMBOL(ARGS)
// \tFILE:LINE +0xRELPC sp=0x%x fp=0x%x pc=0x%x
// Note: SYMBOL may contain parens "pkg.(*T).method"
// The RELPC is sometimes missing.
// Skip the symbol(args) line.
i++
if i == len(lines) {
break
}
line = lines[i]
// Parse the PC, and correct for the parent and child's
// different mappings of the text section.
pc, err := getPC(line)
if err != nil {
// Inlined frame, perhaps; skip it.
continue
}
pcs = append(pcs, uintptr(pc-parentSentinel+childSentinel))
}
return pcs, nil
}
func min(x, y int) int {
if x < y {
return x
} else {
return y
}
}
@@ -0,0 +1,20 @@
// Copyright 2024 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.
package crashmonitor
// This file opens back doors for testing.
var (
WriteSentinel = writeSentinel
TelemetryCounterName = telemetryCounterName
)
func SetIncrementCounter(f func(name string)) {
incrementCounter = f
}
func SetChildExitHook(f func()) {
childExitHook = f
}
@@ -0,0 +1,214 @@
// Copyright 2024 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.
package crashmonitor_test
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"strings"
"testing"
"time"
"golang.org/x/telemetry"
"golang.org/x/telemetry/internal/counter"
"golang.org/x/telemetry/internal/crashmonitor"
"golang.org/x/telemetry/internal/testenv"
)
func TestMain(m *testing.M) {
entry := os.Getenv("CRASHMONITOR_TEST_ENTRYPOINT")
switch entry {
case "via-stderr":
// This mode bypasses Start and debug.SetCrashOutput;
// the crash is printed to stderr.
debug.SetTraceback("system")
crashmonitor.WriteSentinel(os.Stderr)
child() // this line is "TestMain:+9"
panic("unreachable")
case "start.panic", "start.exit":
// These modes uses Start and debug.SetCrashOutput.
// We stub the actual telemetry by instead writing to a file.
crashmonitor.SetIncrementCounter(func(name string) {
os.WriteFile(os.Getenv("CRASHMONITOR_TELEMETRY_FILE"), []byte(name), 0666)
})
crashmonitor.SetChildExitHook(func() {
os.WriteFile(os.Getenv("CRASHMONITOR_TELEMETRY_EXIT_FILE"), nil, 0666)
})
telemetry.Start(telemetry.Config{
ReportCrashes: true,
TelemetryDir: os.Getenv("CRASHMONITOR_TELEMETRY_DIR"),
})
if entry == "start.panic" {
go func() {
child() // this line is "TestMain.func2:1"
}()
select {} // deadlocks when reached
} else {
os.Exit(42)
}
default:
os.Exit(m.Run()) // run tests as normal
}
}
func child() {
fmt.Println("hello")
grandchild() // this line is "child:+2"
}
func grandchild() {
panic("oops") // this line is "grandchild:=92" (the call from child is inlined)
}
// TestViaStderr is an internal test that asserts that the telemetry
// stack generated by the panic in grandchild is correct. It uses
// stderr, and does not rely on [start.Start] or [debug.SetCrashOutput].
func TestViaStderr(t *testing.T) {
_, _, stderr := runSelf(t, "via-stderr")
got, err := crashmonitor.TelemetryCounterName(stderr)
if err != nil {
t.Fatal(err)
}
got = sanitize(counter.DecodeStack(got))
want := "crash/crash\n" +
"runtime.gopanic:--\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.grandchild:=69\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.child:+2\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.TestMain:+9\n" +
"main.main:--\n" +
"runtime.main:--\n" +
"runtime.goexit:--"
if !crashmonitor.Supported() { // !go1.23
// Before go1.23, the traceback excluded PCs for inlined frames.
want = strings.ReplaceAll(want, "golang.org/x/telemetry/internal/crashmonitor_test.child:+2\n", "")
}
if got != want {
t.Errorf("got counter name <<%s>>, want <<%s>>", got, want)
}
}
func waitForExitFile(t *testing.T, exitFile string) {
deadline := time.Now().Add(10 * time.Second)
for {
_, err := os.ReadFile(exitFile)
if err == nil {
break // success
}
if !os.IsNotExist(err) {
t.Fatalf("failed to read exit file: %v", err)
}
// The crashmonitor has not written the file yet.
// Allow it more time.
if time.Now().After(deadline) {
t.Fatalf("crashmonitor failed to write file in a timely manner")
}
time.Sleep(10 * time.Millisecond)
}
}
// TestStart is an integration test of the crashmonitor feature of [telemetry.Start].
// Requires go1.23+.
func TestStart(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
if !crashmonitor.Supported() {
t.Skip("crashmonitor not supported")
}
// Assert that the crash monitor does nothing when the child
// process merely exits.
t.Run("exit", func(t *testing.T) {
telemetryFile, exitFile, _ := runSelf(t, "start.exit")
waitForExitFile(t, exitFile)
data, err := os.ReadFile(telemetryFile)
if err == nil {
t.Fatalf("telemetry counter <<%s>> was unexpectedly incremented", data)
}
})
// Assert that the crash monitor increments a telemetry
// counter of the correct name when the child process panics.
t.Run("panic", func(t *testing.T) {
// Gather a stack trace from executing the panic statement above.
telemetryFile, exitFile, _ := runSelf(t, "start.panic")
waitForExitFile(t, exitFile)
data, err := os.ReadFile(telemetryFile)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
got := sanitize(counter.DecodeStack(string(data)))
want := "crash/crash\n" +
"runtime.gopanic:--\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.grandchild:=69\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.child:+2\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.TestMain.func3:+1\n" +
"runtime.goexit:--"
if got != want {
t.Errorf("got counter name <<%s>>, want <<%s>>", got, want)
}
})
}
// runSelf fork+exec's this test executable using an alternate entry point.
// It returns the child's stderr, the name of the file
// to which any incremented counter name will be written, and
// the name of the file that will be written to when the crashmonitor
// exits.
func runSelf(t *testing.T, entrypoint string) (string, string, []byte) {
testenv.MustHaveExec(t)
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
tmpdir := t.TempDir()
// Provide the names via the environment of the files the child is stubbed
// to write to.
// The exit file is created by the crashmonitor when it is finished.
telemetryExitFile := filepath.Join(tmpdir, "exit.telemetry")
// The telemetry file will contain the name of the incremented counter.
telemetryFile := filepath.Join(tmpdir, "fake.telemetry")
cmd := exec.Command(exe)
cmd.Env = append(os.Environ(),
"CRASHMONITOR_TEST_ENTRYPOINT="+entrypoint,
"CRASHMONITOR_TELEMETRY_FILE="+telemetryFile,
"CRASHMONITOR_TELEMETRY_EXIT_FILE="+telemetryExitFile,
"CRASHMONITOR_TELEMETRY_DIR="+t.TempDir(),
)
cmd.Stderr = new(bytes.Buffer)
cmd.Run() // failure is expected
stderr := cmd.Stderr.(*bytes.Buffer).Bytes()
if true { // debugging
t.Logf("stderr: %s", stderr)
}
return telemetryFile, telemetryExitFile, stderr
}
// sanitize redacts the line numbers that we don't control from a counter name.
func sanitize(name string) string {
lines := strings.Split(name, "\n")
for i, line := range lines {
if symbol, _, ok := strings.Cut(line, ":"); ok &&
!strings.HasPrefix(line, "golang.org/x/telemetry/internal/crashmonitor") {
lines[i] = symbol + ":--"
}
}
return strings.Join(lines, "\n")
}
@@ -0,0 +1,36 @@
// Copyright 2011 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.
// This package is a lightly modified version of the mmap code
// in github.com/google/codesearch/index.
// The mmap package provides an abstraction for memory mapping files
// on different platforms.
package mmap
import (
"os"
)
// The backing file is never closed, so Data
// remains valid for the lifetime of the process.
type Data struct {
// TODO(pjw): might be better to define versions of Data
// for the 3 specializations
f *os.File
Data []byte
// Some windows magic
Windows interface{}
}
// Mmap maps the given file into memory.
// When remapping a file, pass the most recently returned Data.
func Mmap(f *os.File) (*Data, error) {
return mmapFile(f)
}
// Munmap unmaps the given file from memory.
func Munmap(d *Data) error {
return munmapFile(d)
}
@@ -0,0 +1,25 @@
// Copyright 2022 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 (js && wasm) || wasip1 || plan9 || (solaris && !go1.20)
package mmap
import (
"io"
"os"
)
// mmapFile on other systems doesn't mmap the file. It just reads everything.
func mmapFile(f *os.File) (*Data, error) {
b, err := io.ReadAll(f)
if err != nil {
return nil, err
}
return &Data{f, b, nil}, nil
}
func munmapFile(_ *Data) error {
return nil
}
@@ -0,0 +1,179 @@
// Copyright 2024 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.
package mmap_test
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"testing"
"unsafe"
"golang.org/x/telemetry/internal/counter"
"golang.org/x/telemetry/internal/mmap"
"golang.org/x/telemetry/internal/testenv"
)
// If the sharedFileEnv environment variable is set,
// increment an atomic value in that file rather than
// run the test.
const sharedFileEnv = "MMAP_TEST_SHARED_FILE"
func TestMain(m *testing.M) {
if name := os.Getenv(sharedFileEnv); name != "" {
_, mapping, err := openMapped(name)
if err != nil {
log.Fatalf("openMapped failed: %v", err)
}
v := (*atomic.Uint64)(unsafe.Pointer(&mapping.Data[0]))
v.Add(1)
// Exit without explicitly calling munmap/close.
os.Exit(0)
}
os.Exit(m.Run())
}
func openMapped(name string) (*os.File, *mmap.Data, error) {
f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return nil, nil, fmt.Errorf("open failed: %v", err)
}
data, err := mmap.Mmap(f)
if err != nil {
return nil, nil, fmt.Errorf("Mmap failed: %v", err)
}
return f, data, nil
}
// Via golang/go#68389 and golang/go#68458, we learned that 64-bit atomics were
// unreliable on linux/arm in Go 1.21. This was fixed in
// https://go.dev/cl/525637, but only for ARMv7 and later.
func skipIfLinuxArm(t *testing.T) {
if runtime.GOOS == "linux" && runtime.GOARCH == "arm" {
t.Skipf("64-bit atomics may not work on linux/arm")
}
}
func TestSharedMemory(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
skipIfLinuxArm(t)
// This test verifies that Mmap'ed files are usable for concurrent
// cross-process atomic operations.
dir := t.TempDir()
name := filepath.Join(dir, "shared.count")
var zero [8]byte
if err := os.WriteFile(name, zero[:], 0666); err != nil {
t.Fatal(err)
}
// Fork+exec the current test process.
// Child processes atomically increment the counter file in shared memory.
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
const concurrency = 100
var wg sync.WaitGroup
env := append(os.Environ(), sharedFileEnv+"="+name)
for i := 0; i < concurrency; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
cmd := exec.Command(exe)
cmd.Env = env
if err := cmd.Run(); err != nil {
t.Errorf("subcommand #%d failed: %v", i, err)
}
}()
}
wg.Wait()
data, err := counter.ReadMapped(name)
if err != nil {
t.Fatalf("final read failed: %v", err)
}
v := (*atomic.Uint64)(unsafe.Pointer(&data[0]))
if got := v.Load(); got != concurrency {
t.Errorf("incremented %d times, want %d", got, concurrency)
}
}
func TestMultipleMaps(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
skipIfLinuxArm(t)
// This test verifies that multiple views of an mmapp'ed file may
// simultaneously exist for the current process. This is relied upon by
// counter concurrency logic.
dir := t.TempDir()
name := filepath.Join(dir, "shared.count")
var zero [8]byte
if err := os.WriteFile(name, zero[:], 0666); err != nil {
t.Fatal(err)
}
var (
mappings []*mmap.Data
values []*atomic.Uint64 // mapped counts
)
const nMaps = 3
for i := 0; i < nMaps; i++ {
f, mapping, err := openMapped(name)
if err != nil {
t.Fatal(err)
}
mappings = append(mappings, mapping)
i := i
defer func() {
if i > 0 {
mmap.Munmap(mapping)
}
f.Close()
}()
values = append(values, (*atomic.Uint64)(unsafe.Pointer(&mapping.Data[0])))
}
var wg sync.WaitGroup
const nAdds = 100
for _, v := range values {
v := v
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
v.Add(1)
}
}()
}
wg.Wait()
for i, v := range values {
if got, want := v.Load(), uint64(nMaps*nAdds); got != want {
t.Errorf("counter %d has value %d, want %d", i, got, want)
}
}
mmap.Munmap(mappings[0]) // other mappings should remain valid
for i, v := range values[1:] {
if got, want := v.Load(), uint64(nMaps*nAdds); got != want {
t.Errorf("counter %d has value %d, want %d", i, got, want)
}
}
}
@@ -0,0 +1,47 @@
// Copyright 2011 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 unix && (!solaris || go1.20)
package mmap
import (
"fmt"
"io/fs"
"os"
"syscall"
)
func mmapFile(f *os.File) (*Data, error) {
st, err := f.Stat()
if err != nil {
return nil, err
}
size := st.Size()
pagesize := int64(os.Getpagesize())
if int64(int(size+(pagesize-1))) != size+(pagesize-1) {
return nil, fmt.Errorf("%s: too large for mmap", f.Name())
}
n := int(size)
if n == 0 {
return &Data{f, nil, nil}, nil
}
mmapLength := int(((size + pagesize - 1) / pagesize) * pagesize) // round up to page size
data, err := syscall.Mmap(int(f.Fd()), 0, mmapLength, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
if err != nil {
return nil, &fs.PathError{Op: "mmap", Path: f.Name(), Err: err}
}
return &Data{f, data[:n], nil}, nil
}
func munmapFile(d *Data) error {
if len(d.Data) == 0 {
return nil
}
err := syscall.Munmap(d.Data)
if err != nil {
return &fs.PathError{Op: "munmap", Path: d.f.Name(), Err: err}
}
return nil
}
@@ -0,0 +1,52 @@
// Copyright 2011 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.
package mmap
import (
"fmt"
"os"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
func mmapFile(f *os.File) (*Data, error) {
st, err := f.Stat()
if err != nil {
return nil, err
}
size := st.Size()
if size == 0 {
return &Data{f, nil, nil}, nil
}
// set the min and max sizes to zero to map the whole file, as described in
// https://learn.microsoft.com/en-us/windows/win32/memory/creating-a-file-mapping-object#file-mapping-size
h, err := windows.CreateFileMapping(windows.Handle(f.Fd()), nil, syscall.PAGE_READWRITE, 0, 0, nil)
if err != nil {
return nil, fmt.Errorf("CreateFileMapping %s: %w", f.Name(), err)
}
// the mapping extends from zero to the end of the file mapping
// https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-mapviewoffile
addr, err := windows.MapViewOfFile(h, syscall.FILE_MAP_READ|syscall.FILE_MAP_WRITE, 0, 0, 0)
if err != nil {
return nil, fmt.Errorf("MapViewOfFile %s: %w", f.Name(), err)
}
// Note: previously, we called windows.VirtualQuery here to get the exact
// size of the memory mapped region, but VirtualQuery reported sizes smaller
// than the actual file size (hypothesis: VirtualQuery only reports pages in
// a certain state, and newly written pages may not be counted).
return &Data{f, unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), h}, nil
}
func munmapFile(d *Data) error {
err := windows.UnmapViewOfFile(uintptr(unsafe.Pointer(&d.Data[0])))
x, ok := d.Windows.(windows.Handle)
if ok {
windows.CloseHandle(x)
}
d.f.Close()
return err
}
@@ -0,0 +1,144 @@
// 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.
// Package proxy provides functions for writing module data to a directory
// in proxy format, so that it can be used as a module proxy by setting
// GOPROXY="file://<dir>".
// This is copied from golang.org/x/tools/gopls/internal/{proxydir,proxy}.
package proxy
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"golang.org/x/mod/module"
)
// WriteProxy creates a new proxy file tree using the provided content,
// and returns its URL.
func WriteProxy(tmpdir string, files map[string][]byte) (string, error) {
type moduleVersion struct {
modulePath, version string
}
// Transform into the format expected by the proxydir package.
filesByModule := make(map[moduleVersion]map[string][]byte)
for name, data := range files {
modulePath, version, suffix := splitModuleVersionPath(name)
mv := moduleVersion{modulePath, version}
if _, ok := filesByModule[mv]; !ok {
filesByModule[mv] = make(map[string][]byte)
}
filesByModule[mv][suffix] = data
}
for mv, files := range filesByModule {
if err := writeModuleVersion(tmpdir, mv.modulePath, mv.version, files); err != nil {
return "", fmt.Errorf("error writing %s@%s: %v", mv.modulePath, mv.version, err)
}
}
return toURL(tmpdir), nil
}
// splitModuleVersionPath extracts module information from files stored in the
// directory structure modulePath@version/suffix.
// For example:
//
// splitModuleVersionPath("mod.com@v1.2.3/package") = ("mod.com", "v1.2.3", "package")
func splitModuleVersionPath(path string) (modulePath, version, suffix string) {
parts := strings.Split(path, "/")
var modulePathParts []string
for i, p := range parts {
if strings.Contains(p, "@") {
mv := strings.SplitN(p, "@", 2)
modulePathParts = append(modulePathParts, mv[0])
return strings.Join(modulePathParts, "/"), mv[1], strings.Join(parts[i+1:], "/")
}
modulePathParts = append(modulePathParts, p)
}
// Default behavior: this is just a module path.
return path, "", ""
}
// writeModuleVersion creates a directory in the proxy dir for a module.
func writeModuleVersion(rootDir, mod, ver string, files map[string][]byte) (rerr error) {
dir := filepath.Join(rootDir, mod, "@v")
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// The go command checks for versions by looking at the "list" file. Since
// we are supporting multiple versions, create this file if it does not exist
// or append the version number to the preexisting file.
f, err := os.OpenFile(filepath.Join(dir, "list"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer checkClose("list file", f, &rerr)
if _, err := f.WriteString(ver + "\n"); err != nil {
return err
}
// Serve the go.mod file on the <version>.mod url, if it exists. Otherwise,
// serve a stub.
modContents, ok := files["go.mod"]
if !ok {
modContents = []byte("module " + mod)
}
if err := os.WriteFile(filepath.Join(dir, ver+".mod"), modContents, 0644); err != nil {
return err
}
// info file, just the bare bones.
infoContents := []byte(fmt.Sprintf(`{"Version": "%v", "Time":"2017-12-14T13:08:43Z"}`, ver))
if err := os.WriteFile(filepath.Join(dir, ver+".info"), infoContents, 0644); err != nil {
return err
}
// zip of all the source files.
f, err = os.OpenFile(filepath.Join(dir, ver+".zip"), os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer checkClose("zip file", f, &rerr)
z := zip.NewWriter(f)
defer checkClose("zip writer", z, &rerr)
for name, contents := range files {
zf, err := z.Create(mod + "@" + ver + "/" + name)
if err != nil {
return err
}
if _, err := zf.Write(contents); err != nil {
return err
}
}
// Populate the /module/path/@latest that is used by @latest query.
if module.IsPseudoVersion(ver) {
latestFile := filepath.Join(rootDir, mod, "@latest")
if err := os.WriteFile(latestFile, infoContents, 0644); err != nil {
return err
}
}
return nil
}
func checkClose(name string, closer io.Closer, err *error) {
if cerr := closer.Close(); cerr != nil && *err == nil {
*err = fmt.Errorf("closing %s: %v", name, cerr)
}
}
// toURL returns the file uri for a proxy directory.
func toURL(dir string) string {
// file URLs on Windows must start with file:///. See golang.org/issue/6027.
path := filepath.ToSlash(dir)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return "file://" + path
}
@@ -0,0 +1,109 @@
// 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.
package proxy
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteModuleVersion(t *testing.T) {
tests := []struct {
modulePath, version string
files map[string][]byte
}{
{
modulePath: "mod.test/module",
version: "v1.2.3",
files: map[string][]byte{
"go.mod": []byte("module mod.com\n\ngo 1.12"),
"const.go": []byte("package module\n\nconst Answer = 42"),
},
},
{
modulePath: "mod.test/module",
version: "v1.2.4",
files: map[string][]byte{
"go.mod": []byte("module mod.com\n\ngo 1.12"),
"const.go": []byte("package module\n\nconst Answer = 43"),
},
},
{
modulePath: "mod.test/nogomod",
version: "v0.9.0",
files: map[string][]byte{
"const.go": []byte("package module\n\nconst Other = \"Other\""),
},
},
}
dir := t.TempDir()
defer os.RemoveAll(dir)
for _, test := range tests {
// Since we later assert on the contents of /list, don't use subtests.
if err := writeModuleVersion(dir, test.modulePath, test.version, test.files); err != nil {
t.Fatal(err)
}
rootDir := filepath.Join(dir, filepath.FromSlash(test.modulePath), "@v")
gomod, err := os.ReadFile(filepath.Join(rootDir, test.version+".mod"))
if err != nil {
t.Fatal(err)
}
wantMod, ok := test.files["go.mod"]
if !ok {
wantMod = []byte("module " + test.modulePath)
}
if got, want := string(gomod), string(wantMod); got != want {
t.Errorf("reading %s/@v/%s.mod: got %q, want %q", test.modulePath, test.version, got, want)
}
zr, err := zip.OpenReader(filepath.Join(rootDir, test.version+".zip"))
if err != nil {
t.Fatal(err)
}
defer zr.Close()
for _, zf := range zr.File {
r, err := zf.Open()
if err != nil {
t.Fatal(err)
}
defer r.Close()
content, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
name := strings.TrimPrefix(zf.Name, fmt.Sprintf("%s@%s/", test.modulePath, test.version))
if got, want := string(content), string(test.files[name]); got != want {
t.Errorf("unzipping %q: got %q, want %q", zf.Name, got, want)
}
delete(test.files, name)
}
for name := range test.files {
t.Errorf("file %q not present in the module zip", name)
}
}
lists := []struct {
modulePath, want string
}{
{"mod.test/module", "v1.2.3\nv1.2.4\n"},
{"mod.test/nogomod", "v0.9.0\n"},
}
for _, test := range lists {
fp := filepath.Join(dir, filepath.FromSlash(test.modulePath), "@v", "list")
list, err := os.ReadFile(fp)
if err != nil {
t.Fatal(err)
}
if got := string(list); got != test.want {
t.Errorf("%q/@v/list: got %q, want %q", test.modulePath, got, test.want)
}
}
}
@@ -0,0 +1,243 @@
// 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.
package regtest
import (
"bytes"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"golang.org/x/telemetry/counter"
"golang.org/x/telemetry/internal/config"
icounter "golang.org/x/telemetry/internal/counter"
"golang.org/x/telemetry/internal/telemetry"
"golang.org/x/telemetry/internal/testenv"
)
func TestRunProg(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
testenv.MustHaveExec(t)
prog1 := NewProgram(t, "prog1", func() int {
fmt.Println("FuncB")
return 0
})
prog2 := NewProgram(t, "prog2", func() int {
fmt.Println("FuncC")
return 1
})
telemetryDir := t.TempDir()
if out, err := RunProg(t, telemetryDir, prog1); err != nil || !bytes.Contains(out, []byte("FuncB")) || bytes.Contains(out, []byte("FuncC")) {
t.Errorf("first RunProg = (%s, %v), want FuncB' and succeed", out, err)
}
t.Run("in subtest", func(t *testing.T) {
if out, err := RunProg(t, telemetryDir, prog2); err == nil || bytes.Contains(out, []byte("FuncB")) || !bytes.Contains(out, []byte("FuncC")) {
t.Errorf("second RunProg = (%s, %v), want 'FuncC' and fail", out, err)
}
})
}
func programIncCounters() int {
counter.Inc("counter")
counter.Inc("counter:surprise")
counter.New("gopls/editor:expected").Inc()
counter.New("gopls/editor:surprise").Inc()
counter.NewStack("stack/expected", 1).Inc()
counter.NewStack("stack-surprise", 1).Inc()
return 0
}
func TestE2E_off(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
testenv.MustHaveExec(t)
log.Printf("GOOS=%s GOARCH=%s", runtime.GOOS, runtime.GOARCH)
prog := NewProgram(t, "prog", programIncCounters)
tests := []struct {
mode string // if empty, don't set the mode
wantLocalDir bool
}{
{"", true},
{"local", true},
{"on", true},
{"off", false},
}
for _, test := range tests {
t.Run(fmt.Sprintf("mode=%s", test.mode), func(t *testing.T) {
dir := telemetry.NewDir(t.TempDir())
if test.mode != "" {
if err := dir.SetMode(test.mode); err != nil {
t.Fatalf("SetMode failed: %v", err)
}
}
out, err := RunProg(t, dir.Dir(), prog)
if err != nil {
t.Fatalf("program failed unexpectedly (%v)\n%s", err, out)
}
_, err = os.Stat(dir.LocalDir())
if err != nil && !os.IsNotExist(err) {
t.Fatalf("os.Stat(%q): unexpected error: %v", dir.LocalDir(), err)
}
if gotLocalDir := err == nil; gotLocalDir != test.wantLocalDir {
t.Errorf("got /local dir: %v, want %v; out:\n%s", gotLocalDir, test.wantLocalDir, string(out))
}
})
}
}
func TestE2E(t *testing.T) {
testenv.SkipIfUnsupportedPlatform(t)
testenv.MustHaveExec(t)
programIncCounters := NewProgram(t, "prog", programIncCounters)
telemetryDir := t.TempDir()
goVers, progPath, progVers := ProgramInfo(t)
out, err := RunProg(t, telemetryDir, programIncCounters)
if err != nil {
t.Fatalf("program failed unexpectedly (%v)\n%s", err, out)
}
// TODO: retrieve config through a module proxy so we test internal/configstore code path.
cfg := &telemetry.UploadConfig{
GOOS: []string{runtime.GOOS},
GOARCH: []string{runtime.GOARCH},
GoVersion: []string{goVers},
Programs: []*telemetry.ProgramConfig{
{
Name: progPath,
Versions: []string{progVers},
Counters: []telemetry.CounterConfig{
{Name: "counter", Rate: 1},
{Name: "gopls/editor:{expected, other}", Rate: 1},
},
Stacks: []telemetry.CounterConfig{
{Name: "stack/expected", Rate: 1, Depth: 1},
},
},
},
}
// TODO: check if weekday file exists.
// TODO: test upload path.
// - change the global clock (maybe internal/clock package?)
// - start an upload server
// - Run(t, telemetryDir, func() int { upload.Run(...) })
// - check if the upload server received expected data
// - check if the local and upload directories in the expected state
uploaded, notUploaded, err := parseCounters(cfg, telemetryDir)
if err != nil {
t.Fatalf("failed to simulate upload: %v", err)
}
wantUpload := map[string]uint64{
"counter": 1,
"gopls/editor:expected": 1,
"stack/expected\n": 1, // prefix of the stack counter name + "\n". see parseCounters.
}
testCounterUploadStatus(t, uploaded, wantUpload, false)
wantNotUpload := map[string]uint64{
"counter:surprise": 1,
"gopls/editor:surprise": 1,
"stack-surprise\n": 1, // prefix of the stack counter name + "\n". see parseCounters.
}
testCounterUploadStatus(t, notUploaded, wantNotUpload, true)
}
// testCounterUploadStatus checks if got and want counter maps match.
// If allowUnexpected is true, it checks if got is a superset of want.
func testCounterUploadStatus(t *testing.T, got, want map[string]uint64, allowUnexpected bool) {
t.Helper()
seen := got
if allowUnexpected {
// filter out unexpected counters from got, for comparison.
m := make(map[string]uint64, len(want))
for k, v := range got {
if _, ok := want[k]; ok {
m[k] = v
}
}
seen = m
}
if !reflect.DeepEqual(seen, want) {
// TODO(hyangah) implement diff or copy Go project's internal/diff for pretty printing of diff.
// Or, move internal/regtest to the godev module where we can depend on go-cmp.
t.Errorf("unmet expectation:\ngot %v\nwant %v", stringify(got), stringify(want))
}
}
func stringify(a any) string {
encoded, err := json.MarshalIndent(a, "\t", " ")
if err != nil {
return fmt.Sprintf("unmarshallable - %v", err)
}
return string(encoded)
}
// parseCounters reads all counter files in the local telemetry directory, and
// returns all observed counters grouped by whether the counter names are included
// in the specified configuration.
// For simplicity in the comparison code, the returned maps represent a stack counter
// with its counter name prefix and "\n". For example, if there are "stackcounter\npkg.F:..."
// and "stackcounter\npkg.G:..", "stackcounter\n" will hold the sum of those counters.
func parseCounters(uc *telemetry.UploadConfig, telemetryDir string) (uploadable, notUploadable map[string]uint64, _ error) {
cfg := config.NewConfig(uc)
localDir := filepath.Join(telemetryDir, "local")
entries, err := os.ReadDir(localDir)
if err != nil {
return nil, nil, err
}
uploadable, notUploadable = make(map[string]uint64), make(map[string]uint64)
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".count" {
continue
}
data, err := os.ReadFile(filepath.Join(localDir, entry.Name()))
if err != nil { // ignore unreadable file.
continue
}
// TODO(hyangah): how about exposing "Parse" to public for testing? (i.e. countertest.Parse)?
parsed, err := icounter.Parse(entry.Name(), data)
if err != nil { // ignore unparsable file
continue
}
// The following is temporary until the upload package implements the exact same logic.
// TODO(hyangah): replace with the shared logic between the uploader and the local viewer.
maybeUploadable := true &&
cfg.HasGOOS(parsed.Meta["GOOS"]) &&
cfg.HasGOARCH(parsed.Meta["GOARCH"]) &&
cfg.HasGoVersion(parsed.Meta["GoVersion"]) &&
cfg.HasProgram(parsed.Meta["Program"]) &&
cfg.HasVersion(parsed.Meta["Program"], parsed.Meta["Version"])
for k, v := range parsed.Count {
counterPrefix, _, isStackCounter := strings.Cut(k, "\n")
isUploadable := maybeUploadable
key := k
if isStackCounter {
isUploadable = isUploadable && cfg.HasStack(parsed.Meta["Program"], counterPrefix)
key = counterPrefix + "\n"
} else {
isUploadable = isUploadable && cfg.HasCounter(parsed.Meta["Program"], k)
}
if isUploadable {
uploadable[key] = uploadable[key] + v
} else {
notUploadable[key] = notUploadable[key] + v
}
}
}
return uploadable, notUploadable, nil
}
@@ -0,0 +1,156 @@
// 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.
// Package regtest provides helpers for end-to-end testing
// involving counter and upload packages.
package regtest
import (
"fmt"
"log"
"os"
"os/exec"
"runtime"
"runtime/debug"
"strings"
"testing"
"time"
"golang.org/x/telemetry/counter"
"golang.org/x/telemetry/counter/countertest"
internalcounter "golang.org/x/telemetry/internal/counter"
"golang.org/x/telemetry/internal/telemetry"
)
const (
telemetryDirEnvVar = "_COUNTERTEST_RUN_TELEMETRY_DIR"
asofEnvVar = "_COUNTERTEST_ASOF"
entryPointEnvVar = "_COUNTERTEST_ENTRYPOINT"
)
var (
telemetryDirEnvVarValue = os.Getenv(telemetryDirEnvVar)
asofEnvVarValue = os.Getenv(asofEnvVar)
entryPointEnvVarValue = os.Getenv(entryPointEnvVar)
)
// Program is a value that can be used to identify a program in the test.
type Program string
// NewProgram returns a Program value that can be used to identify a program
// to run by RunProg. The program must be registered with NewProgram before
// the first call to RunProg in the test function.
//
// RunProg runs this binary in a separate process with special environment
// variables that specify the entry point. When this binary runs with the
// environment variables that match the specified name, NewProgram calls
// the given fn and exits with the return value. Note that all the code
// before NewProgram is executed in both the main process and the subprocess.
func NewProgram(t *testing.T, name string, fn func() int) Program {
if telemetryDirEnvVarValue != "" && entryPointEnvVarValue == name {
// We are running the separate process that was spawned by RunProg.
fmt.Fprintf(os.Stderr, "running program %q\n", name)
if asofEnvVarValue != "" {
asof, err := time.Parse(telemetry.DateOnly, asofEnvVarValue)
if err != nil {
log.Fatalf("error parsing asof time %q: %v", asof, err)
}
fmt.Fprintf(os.Stderr, "setting counter time to %s\n", name)
internalcounter.CounterTime = func() time.Time {
return asof
}
}
countertest.Open(telemetryDirEnvVarValue)
os.Exit(fn())
}
testName, _, _ := strings.Cut(t.Name(), "/")
registered, ok := registeredPrograms[testName]
if !ok {
registered = make(map[string]bool)
}
if registered[name] {
t.Fatalf("program %q was already registered", name)
}
registered[name] = true
return Program(name)
}
// NewIncProgram returns a basic program that increments the given counters and
// exits with status 0.
func NewIncProgram(t *testing.T, name string, counters ...string) Program {
return NewProgram(t, name, func() int {
for _, c := range counters {
counter.Inc(c)
}
return 0
})
}
// registeredPrograms stores all registered program names to detect duplicate registrations.
var registeredPrograms = make(map[string]map[string]bool) // test name -> program name -> exist
// RunProg runs the program prog in a separate process with the specified
// telemetry directory. RunProg can be called multiple times in the same test,
// but all the programs must be registered with NewProgram before the first
// call to RunProg.
func RunProg(t *testing.T, telemetryDir string, prog Program) ([]byte, error) {
return RunProgAsOf(t, telemetryDir, time.Time{}, prog)
}
// RunProgAsOf is like RunProg, but executes the program as of a specific
// counter time.
func RunProgAsOf(t *testing.T, telemetryDir string, asof time.Time, prog Program) ([]byte, error) {
if telemetryDirEnvVarValue != "" {
fmt.Fprintf(os.Stderr, "unknown program %q\n %s %s", prog, telemetryDirEnvVarValue, entryPointEnvVarValue)
os.Exit(2)
}
testName, _, _ := strings.Cut(t.Name(), "/")
testBin, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("cannot determine the current process's executable name: %v", err)
}
// Spawn a subprocess to run the 'prog' by setting telemetryDirEnvVar.
cmd := exec.Command(testBin, "-test.run", fmt.Sprintf("^%s$", testName))
cmd.Env = append(os.Environ(), telemetryDirEnvVar+"="+telemetryDir, entryPointEnvVar+"="+string(prog))
if !asof.IsZero() {
cmd.Env = append(cmd.Env, asofEnvVar+"="+asof.Format(telemetry.DateOnly))
}
return cmd.CombinedOutput()
}
// ProgramInfo returns the go version, program name and version info the
// process would record in its counter file.
func ProgramInfo(t *testing.T) (goVersion, progPath, progVersion string) {
info, ok := debug.ReadBuildInfo()
if !ok {
t.Fatal("cannot read build info - it's likely this setup is unsupported by the counter package")
}
return telemetry.ProgramInfo(info)
}
// CreateTestUploadConfig creates a new upload config for the current program,
// permitting the given counters.
func CreateTestUploadConfig(t *testing.T, counterNames, stackCounterNames []string) *telemetry.UploadConfig {
goVersion, progPath, progVersion := ProgramInfo(t)
GOOS, GOARCH := runtime.GOOS, runtime.GOARCH
programConfig := &telemetry.ProgramConfig{
Name: progPath,
Versions: []string{progVersion},
}
for _, c := range counterNames {
programConfig.Counters = append(programConfig.Counters, telemetry.CounterConfig{Name: c, Rate: 1})
}
for _, c := range stackCounterNames {
programConfig.Stacks = append(programConfig.Stacks, telemetry.CounterConfig{Name: c, Rate: 1, Depth: 16})
}
return &telemetry.UploadConfig{
GOOS: []string{GOOS},
GOARCH: []string{GOARCH},
SampleRate: 1.0,
GoVersion: []string{goVersion},
Programs: []*telemetry.ProgramConfig{programConfig},
}
}
@@ -0,0 +1,9 @@
// Copyright 2024 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.
package telemetry
// TODO(rfindley): replace uses of DateOnly with time.DateOnly once we no
// longer support building gopls with go 1.19.
const DateOnly = "2006-01-02"
@@ -0,0 +1,163 @@
// 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.
// Package telemetry manages the telemetry mode file.
package telemetry
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
// Default is the default directory containing Go telemetry configuration and
// data.
//
// If Default is uninitialized, Default.Mode will be "off". As a consequence,
// no data should be written to the directory, and so the path values of
// LocalDir, UploadDir, etc. must not matter.
//
// Default is a global for convenience and testing, but should not be mutated
// outside of tests.
//
// TODO(rfindley): it would be nice to completely eliminate this global state,
// or at least push it in the golang.org/x/telemetry package
var Default Dir
// A Dir holds paths to telemetry data inside a directory.
type Dir struct {
dir, local, upload, debug, modefile string
}
// NewDir creates a new Dir encapsulating paths in the given dir.
//
// NewDir does not create any new directories or files--it merely encapsulates
// the telemetry directory layout.
func NewDir(dir string) Dir {
return Dir{
dir: dir,
local: filepath.Join(dir, "local"),
upload: filepath.Join(dir, "upload"),
debug: filepath.Join(dir, "debug"),
modefile: filepath.Join(dir, "mode"),
}
}
func init() {
cfgDir, err := os.UserConfigDir()
if err != nil {
return
}
Default = NewDir(filepath.Join(cfgDir, "go", "telemetry"))
}
func (d Dir) Dir() string {
return d.dir
}
func (d Dir) LocalDir() string {
return d.local
}
func (d Dir) UploadDir() string {
return d.upload
}
func (d Dir) DebugDir() string {
return d.debug
}
func (d Dir) ModeFile() string {
return d.modefile
}
// SetMode updates the telemetry mode with the given mode.
// Acceptable values for mode are "on", "off", or "local".
//
// SetMode always writes the mode file, and explicitly records the date at
// which the modefile was updated. This means that calling SetMode with "on"
// effectively resets the timeout before the next telemetry report is uploaded.
func (d Dir) SetMode(mode string) error {
return d.SetModeAsOf(mode, time.Now())
}
// SetModeAsOf is like SetMode, but accepts an explicit time to use to
// back-date the mode state. This exists only for testing purposes.
func (d Dir) SetModeAsOf(mode string, asofTime time.Time) error {
mode = strings.TrimSpace(mode)
switch mode {
case "on", "off", "local":
default:
return fmt.Errorf("invalid telemetry mode: %q", mode)
}
if d.modefile == "" {
return fmt.Errorf("cannot determine telemetry mode file name")
}
// TODO(rfindley): why is this not 777, consistent with the use of 666 below?
if err := os.MkdirAll(filepath.Dir(d.modefile), 0755); err != nil {
return fmt.Errorf("cannot create a telemetry mode file: %w", err)
}
asof := asofTime.UTC().Format(DateOnly)
// Defensively guarantee that we can parse the asof time.
if _, err := time.Parse(DateOnly, asof); err != nil {
return fmt.Errorf("internal error: invalid mode date %q: %v", asof, err)
}
data := []byte(mode + " " + asof)
return os.WriteFile(d.modefile, data, 0666)
}
// Mode returns the current telemetry mode, as well as the time that the mode
// was effective.
//
// If there is no effective time, the second result is the zero time.
//
// If Mode is "off", no data should be written to the telemetry directory, and
// the other paths values referenced by Dir should be considered undefined.
// This accounts for the case where initializing [Default] fails, and therefore
// local telemetry paths are unknown.
func (d Dir) Mode() (string, time.Time) {
if d.modefile == "" {
return "off", time.Time{} // it's likely LocalDir/UploadDir are empty too. Turn off telemetry.
}
data, err := os.ReadFile(d.modefile)
if err != nil {
return "local", time.Time{} // default
}
mode := string(data)
mode = strings.TrimSpace(mode)
// Forward compatibility for https://go.dev/issue/63142#issuecomment-1734025130
//
// If the modefile contains a date, return it.
if idx := strings.Index(mode, " "); idx >= 0 {
d, err := time.Parse(DateOnly, mode[idx+1:])
if err != nil {
d = time.Time{}
}
return mode[:idx], d
}
return mode, time.Time{}
}
// DisabledOnPlatform indicates whether telemetry is disabled
// due to bugs in the current platform.
//
// TODO(rfindley): move to a more appropriate file.
const DisabledOnPlatform = false ||
// The following platforms could potentially be supported in the future:
runtime.GOOS == "openbsd" || // #60614
runtime.GOOS == "solaris" || // #60968 #60970
runtime.GOOS == "android" || // #60967
runtime.GOOS == "illumos" || // #65544
// These platforms fundamentally can't be supported:
runtime.GOOS == "js" || // #60971
runtime.GOOS == "wasip1" || // #60971
runtime.GOOS == "plan9" || // https://github.com/golang/go/issues/57540#issuecomment-1470766639
runtime.GOARCH == "mips" || runtime.GOARCH == "mipsle" // mips lacks cross-process 64-bit atomics
@@ -0,0 +1,99 @@
// 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.
// Package telemetrymode manages the telemetry mode file.
package telemetry
import (
"os"
"testing"
"time"
)
func TestDefaults(t *testing.T) {
defaultDirMissing := false
if _, err := os.UserConfigDir(); err != nil {
defaultDirMissing = true
}
if defaultDirMissing {
if Default.LocalDir() != "" || Default.UploadDir() != "" || Default.ModeFile() != "" {
t.Errorf("DefaultSetting: (%q, %q, %q), want empty LocalDir/UploadDir/ModeFile", Default.LocalDir(), Default.UploadDir(), Default.ModeFile())
}
} else {
if Default.LocalDir() == "" || Default.UploadDir() == "" || Default.ModeFile() == "" {
t.Errorf("DefaultSetting: (%q, %q, %q), want non-empty LocalDir/UploadDir/ModeFile", Default.LocalDir(), Default.UploadDir(), Default.ModeFile())
}
}
}
func TestTelemetryModeWithNoModeConfig(t *testing.T) {
tests := []struct {
dir Dir
want string
}{
{NewDir(t.TempDir()), "local"},
{Dir{}, "off"},
}
for _, tt := range tests {
if got, _ := tt.dir.Mode(); got != tt.want {
t.Errorf("Dir{modefile=%q}.Mode() = %v, want %v", tt.dir.ModeFile(), got, tt.want)
}
}
}
func TestSetMode(t *testing.T) {
tests := []struct {
in string
wantErr bool // want error when setting.
}{
{"on", false},
{"off", false},
{"local", false},
{"https://mytelemetry.com", true},
{"http://insecure.com", true},
{"bogus", true},
{"", true},
}
for _, tt := range tests {
t.Run("mode="+tt.in, func(t *testing.T) {
dir := NewDir(t.TempDir())
setErr := dir.SetMode(tt.in)
if (setErr != nil) != tt.wantErr {
t.Fatalf("Set() error = %v, wantErr %v", setErr, tt.wantErr)
}
if setErr != nil {
return
}
if got, _ := dir.Mode(); got != tt.in {
t.Errorf("LookupMode() = %q, want %q", got, tt.in)
}
})
}
}
func TestMode(t *testing.T) {
tests := []struct {
in string
wantMode string
wantTime time.Time
}{
{"on", "on", time.Time{}},
{"on 2023-09-26", "on", time.Date(2023, time.September, 26, 0, 0, 0, 0, time.UTC)},
{"off", "off", time.Time{}},
{"local", "local", time.Time{}},
}
for _, tt := range tests {
t.Run("mode="+tt.in, func(t *testing.T) {
dir := NewDir(t.TempDir())
if err := os.WriteFile(dir.ModeFile(), []byte(tt.in), 0666); err != nil {
t.Fatal(err)
}
// Note: the checks below intentionally do not use time.Equal:
// we want this exact representation of time.
if gotMode, gotTime := dir.Mode(); gotMode != tt.wantMode || gotTime != tt.wantTime {
t.Errorf("ModeFilePath(contents=%s).Mode() = %q, %v, want %q, %v", tt.in, gotMode, gotTime, tt.wantMode, tt.wantTime)
}
})
}
}
@@ -0,0 +1,58 @@
// Copyright 2024 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.
package telemetry
import (
"os"
"path/filepath"
"runtime/debug"
"strings"
)
// IsToolchainProgram reports whether a program with the given path is a Go
// toolchain program.
func IsToolchainProgram(progPath string) bool {
return strings.HasPrefix(progPath, "cmd/")
}
// ProgramInfo extracts the go version, program package path, and program
// version to use for counter files.
//
// For programs in the Go toolchain, the program version will be the same as
// the Go version, and will typically be of the form "go1.2.3", not a semantic
// version of the form "v1.2.3". Go versions may also include spaces and
// special characters.
func ProgramInfo(info *debug.BuildInfo) (goVers, progPath, progVers string) {
goVers = info.GoVersion
// TODO(matloob): Use go/version.IsValid instead of checking for X: once the telemetry
// module can be upgraded to require Go 1.22.
if strings.Contains(goVers, "devel") || strings.Contains(goVers, "-") || strings.Contains(goVers, "X:") {
goVers = "devel"
}
progPath = info.Path
if progPath == "" {
progPath = strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe")
}
// Main module version information is not populated for the cmd module, but
// we can re-use the Go version here.
if IsToolchainProgram(progPath) {
progVers = goVers
} else {
progVers = info.Main.Version
if strings.Contains(progVers, "devel") || strings.Count(progVers, "-") > 1 {
// Heuristically mark all pseudo-version-like version strings as "devel"
// to avoid creating too many counter files.
// We should not use regexp that pulls in large dependencies.
// Pseudo-versions have at least three parts (https://go.dev/ref/mod#pseudo-versions).
// This heuristic still allows use to track prerelease
// versions (e.g. gopls@v0.16.0-pre.1, vscgo@v0.42.0-rc.1).
progVers = "devel"
}
}
return goVers, progPath, progVers
}
@@ -0,0 +1,115 @@
// 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.
package telemetry_test
import (
"fmt"
"path"
"runtime/debug"
"testing"
"golang.org/x/telemetry/internal/telemetry"
)
func TestProgramInfo_ProgramVersion(t *testing.T) {
tests := []struct {
path string
version string
want string
}{
{
path: "golang.org/x/tools/gopls",
version: "(devel)",
want: "devel",
},
{
path: "golang.org/x/tools/gopls",
version: "",
want: "",
},
{
path: "golang.org/x/tools/gopls",
version: "v0.14.0-pre.1",
want: "v0.14.0-pre.1",
},
{
path: "golang.org/x/tools/gopls",
version: "v0.0.0-20231207172801-3c8b0df0c3fd",
want: "devel",
},
{
path: "cmd/go",
version: "",
want: "go1.23.0", // hard-coded below
},
{
path: "cmd/compile",
version: "",
want: "go1.23.0", // hard-coded below
},
}
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
t.Fatal("cannot use debug.ReadBuildInfo")
}
for _, tt := range tests {
name := fmt.Sprintf("%s@%s", path.Base(tt.path), tt.version)
t.Run(name, func(t *testing.T) {
in := *buildInfo
in.GoVersion = "go1.23.0"
in.Path = tt.path
in.Main.Version = tt.version
_, _, got := telemetry.ProgramInfo(&in)
if got != tt.want {
t.Errorf("program version = %q, want %q", got, tt.want)
}
})
}
}
func TestProgramInfo_GoVersion(t *testing.T) {
tests := []struct {
goVersion string
wantGoVers string
}{
{
"go1.23.0-bigcorp",
"devel",
},
{
"go1.23.0",
"go1.23.0",
},
{
"devel go1.24-0d6bb68f48 Thu Jul 25 23:27:41 2024 -0600",
"devel",
},
{
"go1.23rc2 X:aliastypeparams",
"devel",
},
}
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
t.Fatal("cannot use debug.ReadBuildInfo")
}
for _, tt := range tests {
t.Run(tt.goVersion, func(t *testing.T) {
in := *buildInfo
in.GoVersion = tt.goVersion
in.Path = "cmd/go"
in.Main.Version = tt.goVersion
gotGoVers, _, gotProgVers := telemetry.ProgramInfo((&in))
if gotGoVers != tt.wantGoVers {
t.Errorf("go version = %q, want %q", gotGoVers, tt.wantGoVers)
}
if gotProgVers != tt.wantGoVers {
t.Errorf("program version = %q, want %q", gotProgVers, tt.wantGoVers)
}
})
}
}
@@ -0,0 +1,51 @@
// 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.
package telemetry
// Common types and directories used by multiple packages.
// An UploadConfig controls what data is uploaded.
type UploadConfig struct {
GOOS []string
GOARCH []string
GoVersion []string
SampleRate float64
Programs []*ProgramConfig
}
type ProgramConfig struct {
// the counter names may have to be
// repeated for each program. (e.g., if the counters are in a package
// that is used in more than one program.)
Name string
Versions []string // versions present in a counterconfig
Counters []CounterConfig `json:",omitempty"`
Stacks []CounterConfig `json:",omitempty"`
}
type CounterConfig struct {
Name string
Rate float64 // If X <= Rate, report this counter
Depth int `json:",omitempty"` // for stack counters
}
// A Report is the weekly aggregate of counters.
type Report struct {
Week string // End day this report covers (YYYY-MM-DD)
LastWeek string // Week field from latest previous report uploaded
X float64 // A random probability used to determine which counters are uploaded
Programs []*ProgramReport
Config string // version of UploadConfig used
}
type ProgramReport struct {
Program string // Package path of the program.
Version string // Program version. Go version if the program is part of the go distribution. Module version, otherwise.
GoVersion string // Go version used to build the program.
GOOS string
GOARCH string
Counters map[string]int64
Stacks map[string]int64
}
@@ -0,0 +1,98 @@
// 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.
// Package testenv contains helper functions for skipping tests
// based on which tools are present in the environment.
package testenv
import (
"bytes"
"fmt"
"go/build"
"os/exec"
"runtime"
"sync"
"testing"
"golang.org/x/telemetry/internal/telemetry"
)
// NeedsLocalhostNet skips t if networking does not work for ports opened
// with "localhost".
func NeedsLocalhostNet(t testing.TB) {
switch runtime.GOOS {
case "js", "wasip1":
t.Skipf(`Listening on "localhost" fails on %s; see https://go.dev/issue/59718`, runtime.GOOS)
}
}
var (
hasGoOnce sync.Once
hasGoErr error
)
// HasGo checks whether the current system has 'go'.
func HasGo() error {
hasGoOnce.Do(func() {
cmd := exec.Command("go", "env", "GOROOT")
out, err := cmd.Output()
if err != nil { // cannot run go.
hasGoErr = fmt.Errorf("%v: %v", cmd, err)
return
}
out = bytes.TrimSpace(out)
if len(out) == 0 { // unusual, incomplete go installation.
hasGoErr = fmt.Errorf("%v: no GOROOT - incomplete go installation", cmd)
}
})
return hasGoErr
}
// NeedsGo skips t if the current system does not have 'go' or
// cannot run them with exec.Command.
func NeedsGo(t testing.TB) {
if err := HasGo(); err != nil {
t.Skipf("skipping test: go is not available - %v", err)
}
}
// SkipIfUnsupportedPlatform skips test if the current os/arch
// are not support.
func SkipIfUnsupportedPlatform(t testing.TB) {
t.Helper()
if telemetry.DisabledOnPlatform {
t.Skip("telemetry is unsupported on this platform")
}
}
// MustHaveExec checks that the current system can start new processes
// using os.StartProcess or (more commonly) exec.Command.
// If not, MustHaveExec calls t.Skip with an explanation.
func MustHaveExec(t testing.TB) {
switch runtime.GOOS {
case "wasip1", "js", "ios":
t.Skipf("skipping test: may not be able to exec subprocess on %s/%s", runtime.GOOS, runtime.GOARCH)
}
}
// Go1Point returns the x in Go 1.x.
func Go1Point() int {
for i := len(build.Default.ReleaseTags) - 1; i >= 0; i-- {
var version int
if _, err := fmt.Sscanf(build.Default.ReleaseTags[i], "go1.%d", &version); err != nil {
continue
}
return version
}
panic("bad release tags")
}
// NeedsGo1Point skips t if the Go version used to run the test is older than
// 1.x.
func NeedsGo1Point(t testing.TB, x int) {
if Go1Point() < x {
t.Helper()
t.Skipf("running Go version %q is version 1.%d, older than required 1.%d", runtime.Version(), Go1Point(), x)
}
}
@@ -0,0 +1,20 @@
// 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.
package testenv
import (
"bytes"
"os/exec"
"testing"
)
func TestNeedsGo(t *testing.T) {
NeedsGo(t)
out, err := exec.Command("go", "version").Output()
out = bytes.TrimSpace(out)
if err != nil || !bytes.HasPrefix(out, []byte("go version ")) {
t.Errorf("go version failed - %v: %s", err, out)
}
}

Some files were not shown because too many files have changed in this diff Show More