whatcanGOwrong
This commit is contained in:
+85
@@ -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
|
||||
}
|
||||
+148
@@ -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
|
||||
+219
@@ -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
|
||||
}
|
||||
}
|
||||
+268
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+140
@@ -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
|
||||
}
|
||||
+97
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+536
@@ -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
|
||||
}
|
||||
+256
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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,
|
||||
}
|
||||
+55
@@ -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...)
|
||||
}
|
||||
+81
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -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
|
||||
}
|
||||
+77
@@ -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)
|
||||
}
|
||||
+33
@@ -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,
|
||||
}
|
||||
}
|
||||
+59
@@ -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
|
||||
}
|
||||
+20
@@ -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.
|
||||
+54
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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)
|
||||
}
|
||||
+16
@@ -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}}
|
||||
+173
@@ -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);
|
||||
}
|
||||
+270
@@ -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>
|
||||
+184
@@ -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 {};
|
||||
+14
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
+1
@@ -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 |
+9
@@ -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
|
||||
+7
@@ -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"]
|
||||
}
|
||||
+12
@@ -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);
|
||||
})();
|
||||
+89
@@ -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);
|
||||
}
|
||||
}
|
||||
+352
@@ -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;
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
+36
@@ -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}}
|
||||
+12
@@ -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);
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
+54
@@ -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}}
|
||||
+25
@@ -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>
|
||||
+8
@@ -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 |
+14
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
+15
@@ -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
|
||||
+7
@@ -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"]
|
||||
}
|
||||
+8
@@ -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 */
|
||||
+7
@@ -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": []
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
+8
@@ -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 */
|
||||
+7
@@ -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": []
|
||||
}
|
||||
+9
@@ -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
|
||||
+7
@@ -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"]
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
|
||||
+88
@@ -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);
|
||||
};
|
||||
}
|
||||
+35
@@ -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}}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+37
@@ -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}}
|
||||
+169
@@ -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 {};
|
||||
+40
@@ -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}}
|
||||
+36
@@ -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}}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+61
@@ -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 📊</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}}
|
||||
+88
@@ -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.
|
||||
+13
@@ -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 */
|
||||
+7
@@ -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": []
|
||||
}
|
||||
+74
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
+8
@@ -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 */
|
||||
+7
@@ -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": []
|
||||
}
|
||||
+10
@@ -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.
|
||||
+113
@@ -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
|
||||
}
|
||||
+401
@@ -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
|
||||
}
|
||||
+570
@@ -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}
|
||||
}
|
||||
+814
@@ -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)
|
||||
}
|
||||
+82
@@ -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
|
||||
}
|
||||
+221
@@ -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)
|
||||
}
|
||||
+202
@@ -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")
|
||||
}
|
||||
+17
@@ -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{}) }
|
||||
}
|
||||
+256
@@ -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
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
+214
@@ -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")
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
+25
@@ -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
|
||||
}
|
||||
+179
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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
|
||||
}
|
||||
+52
@@ -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
|
||||
}
|
||||
+144
@@ -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
|
||||
}
|
||||
+109
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+243
@@ -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
|
||||
}
|
||||
+156
@@ -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},
|
||||
}
|
||||
}
|
||||
+9
@@ -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"
|
||||
+163
@@ -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
|
||||
+99
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+58
@@ -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
|
||||
}
|
||||
+115
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
}
|
||||
+98
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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
Reference in New Issue
Block a user