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 }