perf: all fetch of specific or all benchmarks

The ?benchmark query param specifies the benchmarks to fetch. "all"
fetches all benchmarks. Omitting it returns the default set.

For golang/go#48803.

Change-Id: I221ac3a702eb0cbb263ef86b2723e5d951662cef
Reviewed-on: https://go-review.googlesource.com/c/build/+/412998
Reviewed-by: Michael Knyszek <mknyszek@google.com>
Run-TryBot: Michael Pratt <mpratt@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/perf/app/dashboard.go b/perf/app/dashboard.go
index c917a44..fae9c43 100644
--- a/perf/app/dashboard.go
+++ b/perf/app/dashboard.go
@@ -8,13 +8,16 @@
 	"context"
 	"embed"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"log"
 	"net/http"
+	"regexp"
 	"sort"
 	"time"
 
 	"github.com/influxdata/influxdb-client-go/v2/api"
+	"github.com/influxdata/influxdb-client-go/v2/api/query"
 	"golang.org/x/build/internal/influx"
 	"golang.org/x/build/third_party/bandchart"
 )
@@ -55,19 +58,59 @@
 	High   float64
 }
 
-// fetch queries Influx to fill Values. Name and Unit must be set.
-//
-// WARNING: Name and Unit are not sanitized. DO NOT pass user input.
-func (b *BenchmarkJSON) fetch(ctx context.Context, qc api.QueryAPI) error {
-	if b.Name == "" {
-		return fmt.Errorf("Name must be set")
-	}
-	if b.Unit == "" {
-		return fmt.Errorf("Unit must be set")
+func fluxRecordToValue(rec *query.FluxRecord) (ValueJSON, error) {
+	low, ok := rec.ValueByKey("low").(float64)
+	if !ok {
+		return ValueJSON{}, fmt.Errorf("record %s low value got type %T want float64", rec, rec.ValueByKey("low"))
 	}
 
-	// TODO(prattmic): Adjust UI to comfortably display more than 7d of
-	// data.
+	center, ok := rec.ValueByKey("center").(float64)
+	if !ok {
+		return ValueJSON{}, fmt.Errorf("record %s center value got type %T want float64", rec, rec.ValueByKey("center"))
+	}
+
+	high, ok := rec.ValueByKey("high").(float64)
+	if !ok {
+		return ValueJSON{}, fmt.Errorf("record %s high value got type %T want float64", rec, rec.ValueByKey("high"))
+	}
+
+	commit, ok := rec.ValueByKey("experiment-commit").(string)
+	if !ok {
+		return ValueJSON{}, fmt.Errorf("record %s experiment-commit value got type %T want float64", rec, rec.ValueByKey("experiment-commit"))
+	}
+
+	return ValueJSON{
+		CommitDate: rec.Time(),
+		CommitHash: commit,
+		Low:        low - 1,
+		Center:     center - 1,
+		High:       high - 1,
+	}, nil
+}
+
+// validateRe is an allowlist of characters for a Flux string literal for
+// benchmark names. The string will be quoted, so we must not allow ending the
+// quote sequence.
+var validateRe = regexp.MustCompile(`[a-zA-Z/_:-]+`)
+
+func validateFluxString(s string) error {
+	if !validateRe.MatchString(s) {
+		return fmt.Errorf("malformed value %q", s)
+	}
+	return nil
+}
+
+var errBenchmarkNotFound = errors.New("benchmark not found")
+
+// fetchNamedUnitBenchmark queries Influx for a specific name + unit benchmark.
+func fetchNamedUnitBenchmark(ctx context.Context, qc api.QueryAPI, name, unit string) (*BenchmarkJSON, error) {
+	if err := validateFluxString(name); err != nil {
+		return nil, fmt.Errorf("invalid benchmark name: %w", err)
+	}
+	if err := validateFluxString(unit); err != nil {
+		return nil, fmt.Errorf("invalid benchmark name: %w", err)
+	}
+
 	query := fmt.Sprintf(`
 from(bucket: "perf")
   |> range(start: -30d)
@@ -79,50 +122,183 @@
   |> filter(fn: (r) => r["goarch"] == "amd64")
   |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
   |> yield(name: "last")
-`, b.Name, b.Unit)
+`, name, unit)
 
-	ir, err := qc.Query(ctx, query)
+	res, err := qc.Query(ctx, query)
 	if err != nil {
-		return fmt.Errorf("error performing query: %W", err)
+		return nil, fmt.Errorf("error performing query: %W", err)
 	}
 
-	for ir.Next() {
-		rec := ir.Record()
+	b, err := groupBenchmarkResults(res)
+	if err != nil {
+		return nil, err
+	}
+	if len(b) == 0 {
+		return nil, errBenchmarkNotFound
+	}
+	if len(b) > 1 {
+		return nil, fmt.Errorf("query returned too many benchmarks: %+v", b)
+	}
+	return b[0], nil
+}
 
-		low, ok := rec.ValueByKey("low").(float64)
+// fetchDefaultBenchmarks queries Influx for the default benchmark set.
+func fetchDefaultBenchmarks(ctx context.Context, qc api.QueryAPI) ([]*BenchmarkJSON, error) {
+	// Keep benchmarks with the same name grouped together, which is
+	// assumed by the JS.
+	benchmarks := []struct{ name, unit string }{
+		{
+			name: "Tile38WithinCircle100kmRequest",
+			unit: "sec/op",
+		},
+		{
+			name: "Tile38WithinCircle100kmRequest",
+			unit: "p90-latency-sec",
+		},
+		{
+			name: "Tile38WithinCircle100kmRequest",
+			unit: "average-RSS-bytes",
+		},
+		{
+			name: "Tile38WithinCircle100kmRequest",
+			unit: "peak-RSS-bytes",
+		},
+		{
+			name: "GoBuildKubelet",
+			unit: "sec/op",
+		},
+		{
+			name: "GoBuildKubeletLink",
+			unit: "sec/op",
+		},
+	}
+
+	ret := make([]*BenchmarkJSON, 0, len(benchmarks))
+	for _, bench := range benchmarks {
+		b, err := fetchNamedUnitBenchmark(ctx, qc, bench.name, bench.unit)
+		if err != nil {
+			return nil, fmt.Errorf("error fetching benchmark %s/%s: %w", bench.name, bench.unit, err)
+		}
+		ret = append(ret, b)
+	}
+
+	return ret, nil
+}
+
+// fetchNamedBenchmark queries Influx for all benchmark results with the passed
+// name (for all units).
+func fetchNamedBenchmark(ctx context.Context, qc api.QueryAPI, name string) ([]*BenchmarkJSON, error) {
+	if err := validateFluxString(name); err != nil {
+		return nil, fmt.Errorf("invalid benchmark name: %w", err)
+	}
+
+	query := fmt.Sprintf(`
+from(bucket: "perf")
+  |> range(start: -30d)
+  |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
+  |> filter(fn: (r) => r["name"] == "%s")
+  |> filter(fn: (r) => r["branch"] == "master")
+  |> filter(fn: (r) => r["goos"] == "linux")
+  |> filter(fn: (r) => r["goarch"] == "amd64")
+  |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
+  |> yield(name: "last")
+`, name)
+
+	res, err := qc.Query(ctx, query)
+	if err != nil {
+		return nil, fmt.Errorf("error performing query: %W", err)
+	}
+
+	b, err := groupBenchmarkResults(res)
+	if err != nil {
+		return nil, err
+	}
+	if len(b) == 0 {
+		return nil, errBenchmarkNotFound
+	}
+	return b, nil
+}
+
+// fetchAllBenchmarks queries Influx for all benchmark results.
+func fetchAllBenchmarks(ctx context.Context, qc api.QueryAPI) ([]*BenchmarkJSON, error) {
+	const query = `
+from(bucket: "perf")
+  |> range(start: -30d)
+  |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
+  |> filter(fn: (r) => r["branch"] == "master")
+  |> filter(fn: (r) => r["goos"] == "linux")
+  |> filter(fn: (r) => r["goarch"] == "amd64")
+  |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
+  |> yield(name: "last")
+`
+
+	res, err := qc.Query(ctx, query)
+	if err != nil {
+		return nil, fmt.Errorf("error performing query: %W", err)
+	}
+
+	return groupBenchmarkResults(res)
+}
+
+// groupBenchmarkResults groups all benchmark results from the passed query.
+func groupBenchmarkResults(res *api.QueryTableResult) ([]*BenchmarkJSON, error) {
+	type key struct {
+		name string
+		unit string
+	}
+	m := make(map[key]*BenchmarkJSON)
+
+	for res.Next() {
+		rec := res.Record()
+
+		name, ok := rec.ValueByKey("name").(string)
 		if !ok {
-			return fmt.Errorf("record %s low value got type %T want float64", rec, rec.ValueByKey("low"))
+			return nil, fmt.Errorf("record %s name value got type %T want string", rec, rec.ValueByKey("name"))
 		}
 
-		center, ok := rec.ValueByKey("center").(float64)
+		unit, ok := rec.ValueByKey("unit").(string)
 		if !ok {
-			return fmt.Errorf("record %s center value got type %T want float64", rec, rec.ValueByKey("center"))
+			return nil, fmt.Errorf("record %s unit value got type %T want string", rec, rec.ValueByKey("unit"))
 		}
 
-		high, ok := rec.ValueByKey("high").(float64)
+		k := key{name, unit}
+		b, ok := m[k]
 		if !ok {
-			return fmt.Errorf("record %s high value got type %T want float64", rec, rec.ValueByKey("high"))
+			b = &BenchmarkJSON{
+				Name: name,
+				Unit: unit,
+			}
+			m[k] = b
 		}
 
-		commit, ok := rec.ValueByKey("experiment-commit").(string)
-		if !ok {
-			return fmt.Errorf("record %s experiment-commit value got type %T want float64", rec, rec.ValueByKey("experiment-commit"))
+		v, err := fluxRecordToValue(res.Record())
+		if err != nil {
+			return nil, err
 		}
 
-		b.Values = append(b.Values, ValueJSON{
-			CommitDate: rec.Time(),
-			CommitHash: commit,
-			Low:        low - 1,
-			Center:     center - 1,
-			High:       high - 1,
+		b.Values = append(b.Values, v)
+	}
+
+	s := make([]*BenchmarkJSON, 0, len(m))
+	for _, b := range m {
+		s = append(s, b)
+	}
+	// Keep benchmarks with the same name grouped together, which is
+	// assumed by the JS.
+	sort.Slice(s, func(i, j int) bool {
+		if s[i].Name == s[j].Name {
+			return s[i].Unit < s[j].Unit
+		}
+		return s[i].Name < s[j].Name
+	})
+
+	for _, b := range s {
+		sort.Slice(b.Values, func(i, j int) bool {
+			return b.Values[i].CommitDate.Before(b.Values[j].CommitDate)
 		})
 	}
 
-	sort.Slice(b.Values, func(i, j int) bool {
-		return b.Values[i].CommitDate.Before(b.Values[j].CommitDate)
-	})
-
-	return nil
+	return s, nil
 }
 
 // search handles /dashboard/data.json.
@@ -147,46 +323,24 @@
 
 	qc := ifxc.QueryAPI(influx.Org)
 
-	// Keep benchmarks with the same name grouped together, which is
-	// assumed by the JS.
-	//
-	// WARNING: Name and Unit are not sanitized. DO NOT pass user input.
-	benchmarks := []BenchmarkJSON{
-		{
-			Name: "Tile38WithinCircle100kmRequest",
-			Unit: "sec/op",
-		},
-		{
-			Name: "Tile38WithinCircle100kmRequest",
-			Unit: "p90-latency-sec",
-		},
-		{
-			Name: "Tile38WithinCircle100kmRequest",
-			Unit: "average-RSS-bytes",
-		},
-		{
-			Name: "Tile38WithinCircle100kmRequest",
-			Unit: "peak-RSS-bytes",
-		},
-		{
-			Name: "GoBuildKubelet",
-			Unit: "sec/op",
-		},
-		{
-			Name: "GoBuildKubeletLink",
-			Unit: "sec/op",
-		},
+	benchmark := r.FormValue("benchmark")
+	var benchmarks []*BenchmarkJSON
+	if benchmark == "" {
+		benchmarks, err = fetchDefaultBenchmarks(ctx, qc)
+	} else if benchmark == "all" {
+		benchmarks, err = fetchAllBenchmarks(ctx, qc)
+	} else {
+		benchmarks, err = fetchNamedBenchmark(ctx, qc, benchmark)
 	}
-
-	for i := range benchmarks {
-		b := &benchmarks[i]
-		// WARNING: Name and Unit are not sanitized. DO NOT pass user
-		// input.
-		if err := b.fetch(ctx, qc); err != nil {
-			log.Printf("Error fetching benchmark %s/%s: %v", b.Name, b.Unit, err)
-			http.Error(w, "Error fetching benchmark", 500)
-			return
-		}
+	if err == errBenchmarkNotFound {
+		log.Printf("Benchmark not found: %q", benchmark)
+		http.Error(w, "Benchmark not found", 404)
+		return
+	}
+	if err != nil {
+		log.Printf("Error fetching benchmarks: %v", err)
+		http.Error(w, "Error fetching benchmarks", 500)
+		return
 	}
 
 	w.Header().Set("Content-Type", "application/json")
diff --git a/perf/app/dashboard/index.html b/perf/app/dashboard/index.html
index bda57b7..d83e081 100644
--- a/perf/app/dashboard/index.html
+++ b/perf/app/dashboard/index.html
@@ -41,26 +41,9 @@
 <div id="dashboard"></div>
 
 <script>
-function addContent(name, benchmarks) {
+function addContent(benchmarks) {
 	let dashboard = document.getElementById("dashboard");
 
-	if (name == "" || name == null || name == undefined) {
-		// All benchmarks.
-		// TODO(prattmic): Replace with a simpler overview?
-	} else {
-		// Filter to specified benchmark.
-		benchmarks = benchmarks.filter(function(b) {
-			return b.Name == name;
-		});
-		if (benchmarks.length == 0) {
-			let title = document.createElement("h2");
-			title.classList.add("Dashboard-title");
-			title.innerHTML = "Benchmark \"" + name + "\" not found.";
-			dashboard.appendChild(title);
-			return;
-		}
-	}
-
 	let prevName = "";
 	let grid = null;
 	for (const b in benchmarks) {
@@ -88,9 +71,27 @@
 	}
 }
 
+function failure(name) {
+	let dashboard = document.getElementById("dashboard");
+
+	let title = document.createElement("h2");
+	title.classList.add("Dashboard-title");
+	title.innerHTML = "Benchmark \"" + name + "\" not found.";
+	dashboard.appendChild(title);
+}
+
 let benchmark = (new URLSearchParams(window.location.search)).get('benchmark');
-fetch('./data.json')
-	.then(response => response.json())
+let dataURL = './data.json';
+if (benchmark) {
+	dataURL += '?benchmark=' + benchmark;
+}
+fetch(dataURL)
+	.then(response => {
+		if (!response.ok) {
+			throw new Error("Data fetch failed");
+		}
+		return response.json();
+	})
 	.then(function(benchmarks) {
 		// Convert CommitDate to a proper date.
 		benchmarks.forEach(function(b) {
@@ -99,8 +100,11 @@
 			});
 		});
 
-		addContent(benchmark, benchmarks);
-	});
+		addContent(benchmarks);
+	})
+	// This will look odd if the error isn't a 404 and benchmark is
+	// undefined, but close enough.
+	.catch(error => failure(benchmark));
 </script>
 
 </body>