blob: e50b1c319c938921c8fe4bb6d38bb5a31b95f7be [file] [log] [blame]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
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}
}