github.com/aristanetworks/goarista@v0.0.0-20240514173732-cca2755bbd44/cmd/test2influxdb/main.go (about)

     1  // Copyright (c) 2018 Arista Networks, Inc.
     2  // Use of this source code is governed by the Apache License 2.0
     3  // that can be found in the COPYING file.
     4  
     5  // test2influxdb writes results from 'go test -json' to an influxdb
     6  // database.
     7  //
     8  // Example usage:
     9  //
    10  //	go test -json | test2influxdb [options...]
    11  //
    12  // Points are written to influxdb with tags:
    13  //
    14  //	package
    15  //	type    "package" for a package result; "test" for a test result
    16  //	Additional tags set by -tags flag
    17  //
    18  // And fields:
    19  //
    20  //	test    string  // "NONE" for whole package results
    21  //	elapsed float64 // in seconds
    22  //	pass    float64 // 1 for PASS, 0 for FAIL
    23  //	Additional fields set by -fields flag
    24  //
    25  // "test" is a field instead of a tag to reduce cardinality of data.
    26  package main
    27  
    28  import (
    29  	"bytes"
    30  	"encoding/json"
    31  	"flag"
    32  	"fmt"
    33  	"io"
    34  	"os"
    35  	"strconv"
    36  	"strings"
    37  	"time"
    38  
    39  	"github.com/aristanetworks/glog"
    40  	client "github.com/influxdata/influxdb1-client/v2"
    41  	"golang.org/x/tools/benchmark/parse"
    42  )
    43  
    44  const (
    45  	// Benchmark field names
    46  	fieldNsPerOp           = "nsPerOp"
    47  	fieldAllocedBytesPerOp = "allocedBytesPerOp"
    48  	fieldAllocsPerOp       = "allocsPerOp"
    49  	fieldMBPerS            = "MBPerSec"
    50  )
    51  
    52  type tag struct {
    53  	key   string
    54  	value string
    55  }
    56  
    57  type tags []tag
    58  
    59  func (ts *tags) String() string {
    60  	s := make([]string, len(*ts))
    61  	for i, t := range *ts {
    62  		s[i] = t.key + "=" + t.value
    63  	}
    64  	return strings.Join(s, ",")
    65  }
    66  
    67  func (ts *tags) Set(s string) error {
    68  	for _, fieldString := range strings.Split(s, ",") {
    69  		kv := strings.Split(fieldString, "=")
    70  		if len(kv) != 2 {
    71  			return fmt.Errorf("invalid tag, expecting one '=': %q", fieldString)
    72  		}
    73  		key := strings.TrimSpace(kv[0])
    74  		if key == "" {
    75  			return fmt.Errorf("invalid tag key %q in %q", key, fieldString)
    76  		}
    77  		val := strings.TrimSpace(kv[1])
    78  		if val == "" {
    79  			return fmt.Errorf("invalid tag value %q in %q", val, fieldString)
    80  		}
    81  
    82  		*ts = append(*ts, tag{key: key, value: val})
    83  	}
    84  	return nil
    85  }
    86  
    87  type field struct {
    88  	key   string
    89  	value interface{}
    90  }
    91  
    92  type fields []field
    93  
    94  func (fs *fields) String() string {
    95  	s := make([]string, len(*fs))
    96  	for i, f := range *fs {
    97  		var valString string
    98  		switch v := f.value.(type) {
    99  		case bool:
   100  			valString = strconv.FormatBool(v)
   101  		case float64:
   102  			valString = strconv.FormatFloat(v, 'f', -1, 64)
   103  		case int64:
   104  			valString = strconv.FormatInt(v, 10) + "i"
   105  		case string:
   106  			valString = v
   107  		}
   108  
   109  		s[i] = f.key + "=" + valString
   110  	}
   111  	return strings.Join(s, ",")
   112  }
   113  
   114  func (fs *fields) Set(s string) error {
   115  	for _, fieldString := range strings.Split(s, ",") {
   116  		kv := strings.Split(fieldString, "=")
   117  		if len(kv) != 2 {
   118  			return fmt.Errorf("invalid field, expecting one '=': %q", fieldString)
   119  		}
   120  		key := strings.TrimSpace(kv[0])
   121  		if key == "" {
   122  			return fmt.Errorf("invalid field key %q in %q", key, fieldString)
   123  		}
   124  		val := strings.TrimSpace(kv[1])
   125  		if val == "" {
   126  			return fmt.Errorf("invalid field value %q in %q", val, fieldString)
   127  		}
   128  		var value interface{}
   129  		var err error
   130  		if value, err = strconv.ParseBool(val); err == nil {
   131  			// It's a bool
   132  		} else if value, err = strconv.ParseFloat(val, 64); err == nil {
   133  			// It's a float64
   134  		} else if value, err = strconv.ParseInt(val[:len(val)-1], 0, 64); err == nil &&
   135  			val[len(val)-1] == 'i' {
   136  			// ints are suffixed with an "i"
   137  		} else {
   138  			value = val
   139  		}
   140  
   141  		*fs = append(*fs, field{key: key, value: value})
   142  	}
   143  	return nil
   144  }
   145  
   146  var (
   147  	flagAddr        = flag.String("addr", "http://localhost:8086", "adddress of influxdb database")
   148  	flagDB          = flag.String("db", "gotest", "use `database` in influxdb")
   149  	flagMeasurement = flag.String("m", "result", "`measurement` used in influxdb database")
   150  	flagBenchOnly   = flag.Bool("bench", false, "if true, parses and stores benchmark "+
   151  		"output only while ignoring test results")
   152  
   153  	flagTags   tags
   154  	flagFields fields
   155  )
   156  
   157  type duplicateTestsErr map[string][]string // package to tests
   158  
   159  func (dte duplicateTestsErr) Error() string {
   160  	var b bytes.Buffer
   161  	if _, err := b.WriteString("duplicate tests found:"); err != nil {
   162  		panic(err)
   163  	}
   164  	for pkg, tests := range dte {
   165  		if _, err := b.WriteString(
   166  			fmt.Sprintf("\n\t%s: %s", pkg, strings.Join(tests, " ")),
   167  		); err != nil {
   168  			panic(err)
   169  		}
   170  	}
   171  	return b.String()
   172  }
   173  
   174  func init() {
   175  	flag.Var(&flagTags, "tags", "set additional `tags`. Ex: name=alice,food=pasta")
   176  	flag.Var(&flagFields, "fields", "set additional `fields`. Ex: id=1234i,long=34.123,lat=72.234")
   177  }
   178  
   179  func main() {
   180  	flag.Parse()
   181  
   182  	c, err := client.NewHTTPClient(client.HTTPConfig{
   183  		Addr: *flagAddr,
   184  	})
   185  	if err != nil {
   186  		glog.Fatal(err)
   187  	}
   188  
   189  	if err := run(c, os.Stdin); err != nil {
   190  		glog.Fatal(err)
   191  	}
   192  }
   193  
   194  func run(c client.Client, r io.Reader) error {
   195  	batch, err := client.NewBatchPoints(client.BatchPointsConfig{Database: *flagDB})
   196  	if err != nil {
   197  		return err
   198  	}
   199  
   200  	var parseErr error
   201  	if *flagBenchOnly {
   202  		parseErr = parseBenchmarkOutput(r, batch)
   203  	} else {
   204  		parseErr = parseTestOutput(r, batch)
   205  	}
   206  
   207  	// Partial results can still be published with certain parsing errors like
   208  	// duplicate test names.
   209  	// The process still exits with a non-zero code in this case.
   210  	switch parseErr.(type) {
   211  	case nil, duplicateTestsErr:
   212  		if err := c.Write(batch); err != nil {
   213  			return err
   214  		}
   215  		glog.Infof("wrote %d data points", len(batch.Points()))
   216  	}
   217  
   218  	return parseErr
   219  }
   220  
   221  // See https://golang.org/cmd/test2json/ for a description of 'go test
   222  // -json' output
   223  type testEvent struct {
   224  	Time    time.Time // encodes as an RFC3339-format string
   225  	Action  string
   226  	Package string
   227  	Test    string
   228  	Elapsed float64 // seconds
   229  	Output  string
   230  }
   231  
   232  func createTags(e *testEvent) map[string]string {
   233  	tags := make(map[string]string, len(flagTags)+2)
   234  	for _, t := range flagTags {
   235  		tags[t.key] = t.value
   236  	}
   237  	resultType := "test"
   238  	if e.Test == "" {
   239  		resultType = "package"
   240  	}
   241  	tags["package"] = e.Package
   242  	tags["type"] = resultType
   243  	return tags
   244  }
   245  
   246  func createFields(e *testEvent) map[string]interface{} {
   247  	fields := make(map[string]interface{}, len(flagFields)+3)
   248  	for _, f := range flagFields {
   249  		fields[f.key] = f.value
   250  	}
   251  	// Use a float64 instead of a bool to be able to SUM test
   252  	// successes in influxdb.
   253  	var pass float64
   254  	if e.Action == "pass" {
   255  		pass = 1
   256  	}
   257  	fields["pass"] = pass
   258  	fields["elapsed"] = e.Elapsed
   259  	if e.Test != "" {
   260  		fields["test"] = e.Test
   261  	}
   262  	return fields
   263  }
   264  
   265  func parseTestOutput(r io.Reader, batch client.BatchPoints) error {
   266  	// pkgs holds packages seen in r. Unfortunately, if a test panics,
   267  	// then there is no "fail" result from a package. To detect these
   268  	// kind of failures, keep track of all the packages that never had
   269  	// a "pass" or "fail".
   270  	//
   271  	// The last seen timestamp is stored with the package, so that
   272  	// package result measurement written to influxdb can be later
   273  	// than any test result for that package.
   274  	pkgs := make(map[string]time.Time)
   275  	d := json.NewDecoder(r)
   276  	for {
   277  		e := &testEvent{}
   278  		if err := d.Decode(e); err != nil {
   279  			if err != io.EOF {
   280  				return err
   281  			}
   282  			break
   283  		}
   284  
   285  		switch e.Action {
   286  		case "pass", "fail":
   287  		default:
   288  			continue
   289  		}
   290  
   291  		if e.Test == "" {
   292  			// A package has completed.
   293  			delete(pkgs, e.Package)
   294  		} else {
   295  			pkgs[e.Package] = e.Time
   296  		}
   297  
   298  		point, err := client.NewPoint(
   299  			*flagMeasurement,
   300  			createTags(e),
   301  			createFields(e),
   302  			e.Time,
   303  		)
   304  		if err != nil {
   305  			return err
   306  		}
   307  
   308  		batch.AddPoint(point)
   309  	}
   310  
   311  	for pkg, t := range pkgs {
   312  		pkgFail := &testEvent{
   313  			Action:  "fail",
   314  			Package: pkg,
   315  		}
   316  		point, err := client.NewPoint(
   317  			*flagMeasurement,
   318  			createTags(pkgFail),
   319  			createFields(pkgFail),
   320  			// Fake a timestamp that is later than anything that
   321  			// occurred for this package
   322  			t.Add(time.Millisecond),
   323  		)
   324  		if err != nil {
   325  			return err
   326  		}
   327  
   328  		batch.AddPoint(point)
   329  	}
   330  
   331  	return nil
   332  }
   333  
   334  func createBenchmarkTags(pkg string, b *parse.Benchmark) map[string]string {
   335  	tags := make(map[string]string, len(flagTags)+2)
   336  	for _, t := range flagTags {
   337  		tags[t.key] = t.value
   338  	}
   339  	tags["package"] = pkg
   340  	tags["benchmark"] = b.Name
   341  
   342  	return tags
   343  }
   344  
   345  func createBenchmarkFields(b *parse.Benchmark) map[string]interface{} {
   346  	fields := make(map[string]interface{}, len(flagFields)+4)
   347  	for _, f := range flagFields {
   348  		fields[f.key] = f.value
   349  	}
   350  
   351  	if b.Measured&parse.NsPerOp != 0 {
   352  		fields[fieldNsPerOp] = b.NsPerOp
   353  	}
   354  	if b.Measured&parse.AllocedBytesPerOp != 0 {
   355  		fields[fieldAllocedBytesPerOp] = float64(b.AllocedBytesPerOp)
   356  	}
   357  	if b.Measured&parse.AllocsPerOp != 0 {
   358  		fields[fieldAllocsPerOp] = float64(b.AllocsPerOp)
   359  	}
   360  	if b.Measured&parse.MBPerS != 0 {
   361  		fields[fieldMBPerS] = b.MBPerS
   362  	}
   363  	return fields
   364  }
   365  
   366  func parseBenchmarkOutput(r io.Reader, batch client.BatchPoints) error {
   367  	// Unfortunately, test2json is not very reliable when it comes to benchmarks. At least
   368  	// the following issues exist:
   369  	//
   370  	// - It doesn't guarantee a "pass" action for each successful benchmark test
   371  	// - It might misreport the name of a benchmark (i.e. "Test" field)
   372  	//   See https://github.com/golang/go/issues/27764.
   373  	//   This happens for example when a benchmark panics: it might use the name
   374  	//   of the preceeding benchmark from the same package that run
   375  	//
   376  	// The main useful element of the json data is that it separates the output by package,
   377  	// which complements the features in https://godoc.org/golang.org/x/tools/benchmark/parse
   378  
   379  	// Non-benchmark output from libraries like glog can interfere with benchmark result
   380  	// parsing. filterOutputLine tries to filter out this extraneous info.
   381  	// It returns a tuple with the output to parse and the name of the benchmark
   382  	// if it is in the testEvent.
   383  	filterOutputLine := func(e *testEvent) (string, string) {
   384  		// The benchmark name is in the output of a separate test event.
   385  		// It may be suffixed with non-benchmark-related logged output.
   386  		// So if e.Output is
   387  		//   "BenchmarkFoo  \tIrrelevant output"
   388  		// then here we return
   389  		//   "BenchmarkFoo  \t"
   390  		if strings.HasPrefix(e.Output, "Benchmark") {
   391  			if split := strings.SplitAfterN(e.Output, "\t", 2); len(split) == 2 {
   392  				// Filter out output like "Benchmarking foo\t"
   393  				if words := strings.Fields(split[0]); len(words) == 1 {
   394  					return split[0], words[0]
   395  				}
   396  			}
   397  		}
   398  		if strings.Contains(e.Output, "ns/op\t") {
   399  			return e.Output, ""
   400  		}
   401  		if strings.Contains(e.Output, "B/op\t") {
   402  			return e.Output, ""
   403  		}
   404  		if strings.Contains(e.Output, "allocs/op\t") {
   405  			return e.Output, ""
   406  		}
   407  		if strings.Contains(e.Output, "MB/s\t") {
   408  			return e.Output, ""
   409  		}
   410  		return "", ""
   411  	}
   412  
   413  	// Extract output per package.
   414  	type pkgOutput struct {
   415  		output     bytes.Buffer
   416  		timestamps map[string]time.Time
   417  	}
   418  	outputByPkg := make(map[string]*pkgOutput)
   419  	d := json.NewDecoder(r)
   420  	for {
   421  		e := &testEvent{}
   422  		if err := d.Decode(e); err != nil {
   423  			if err != io.EOF {
   424  				return err
   425  			}
   426  			break
   427  		}
   428  
   429  		if e.Package == "" {
   430  			return fmt.Errorf("empty package name for event %v", e)
   431  		}
   432  		if e.Time.IsZero() {
   433  			return fmt.Errorf("zero timestamp for event %v", e)
   434  		}
   435  
   436  		line, bname := filterOutputLine(e)
   437  		if line == "" {
   438  			continue
   439  		}
   440  
   441  		po, ok := outputByPkg[e.Package]
   442  		if !ok {
   443  			po = &pkgOutput{timestamps: make(map[string]time.Time)}
   444  			outputByPkg[e.Package] = po
   445  		}
   446  		po.output.WriteString(line)
   447  
   448  		if bname != "" {
   449  			po.timestamps[bname] = e.Time
   450  		}
   451  	}
   452  
   453  	// Extract benchmark info from output
   454  	type pkgBenchmarks struct {
   455  		benchmarks []*parse.Benchmark
   456  		timestamps map[string]time.Time
   457  	}
   458  	benchmarksPerPkg := make(map[string]*pkgBenchmarks)
   459  	dups := make(duplicateTestsErr)
   460  	for pkg, po := range outputByPkg {
   461  		glog.V(5).Infof("Package %s output:\n%s", pkg, &po.output)
   462  
   463  		set, err := parse.ParseSet(&po.output)
   464  		if err != nil {
   465  			return fmt.Errorf("error parsing package %s: %s", pkg, err)
   466  		}
   467  
   468  		for name, benchmarks := range set {
   469  			switch len(benchmarks) {
   470  			case 0:
   471  			case 1:
   472  				pb, ok := benchmarksPerPkg[pkg]
   473  				if !ok {
   474  					pb = &pkgBenchmarks{timestamps: po.timestamps}
   475  					benchmarksPerPkg[pkg] = pb
   476  				}
   477  				pb.benchmarks = append(pb.benchmarks, benchmarks[0])
   478  			default:
   479  				dups[pkg] = append(dups[pkg], name)
   480  			}
   481  		}
   482  	}
   483  
   484  	// Add a point per benchmark
   485  	for pkg, pb := range benchmarksPerPkg {
   486  		for _, bm := range pb.benchmarks {
   487  			t, ok := pb.timestamps[bm.Name]
   488  			if !ok {
   489  				return fmt.Errorf("implementation error: no timestamp for benchmark %s "+
   490  					"in package %s", bm.Name, pkg)
   491  			}
   492  
   493  			tags := createBenchmarkTags(pkg, bm)
   494  			fields := createBenchmarkFields(bm)
   495  			point, err := client.NewPoint(
   496  				*flagMeasurement,
   497  				tags,
   498  				fields,
   499  				t,
   500  			)
   501  			if err != nil {
   502  				return err
   503  			}
   504  			batch.AddPoint(point)
   505  			glog.V(5).Infof("point: %s", point)
   506  		}
   507  	}
   508  
   509  	glog.Infof("Parsed %d benchmarks from %d packages",
   510  		len(batch.Points()), len(benchmarksPerPkg))
   511  
   512  	if len(dups) > 0 {
   513  		return dups
   514  	}
   515  	return nil
   516  }