golang.org/x/build@v0.0.0-20240506185731-218518f32b70/perf/app/dashboard.go (about)

     1  // Copyright 2022 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package app
     6  
     7  import (
     8  	"compress/gzip"
     9  	"context"
    10  	"embed"
    11  	"encoding/json"
    12  	"errors"
    13  	"fmt"
    14  	"log"
    15  	"math"
    16  	"net/http"
    17  	"regexp"
    18  	"sort"
    19  	"strconv"
    20  	"strings"
    21  	"time"
    22  
    23  	"github.com/influxdata/influxdb-client-go/v2/api"
    24  	"github.com/influxdata/influxdb-client-go/v2/api/query"
    25  	"golang.org/x/build/internal/influx"
    26  	"golang.org/x/build/third_party/bandchart"
    27  )
    28  
    29  // /dashboard/ displays a dashboard of benchmark results over time for
    30  // performance monitoring.
    31  
    32  //go:embed dashboard/*
    33  var dashboardFS embed.FS
    34  
    35  // dashboardRegisterOnMux registers the dashboard URLs on mux.
    36  func (a *App) dashboardRegisterOnMux(mux *http.ServeMux) {
    37  	mux.Handle("/dashboard/", http.FileServer(http.FS(dashboardFS)))
    38  	mux.Handle("/dashboard/third_party/bandchart/", http.StripPrefix("/dashboard/third_party/bandchart/", http.FileServer(http.FS(bandchart.FS))))
    39  	mux.HandleFunc("/dashboard/data.json", a.dashboardData)
    40  }
    41  
    42  // BenchmarkJSON contains the timeseries values for a single benchmark name +
    43  // unit.
    44  //
    45  // We could try to shoehorn this into benchfmt.Result, but that isn't really
    46  // the best fit for a graph.
    47  type BenchmarkJSON struct {
    48  	Name           string
    49  	Unit           string
    50  	HigherIsBetter bool
    51  
    52  	// These will be sorted by CommitDate.
    53  	Values []ValueJSON
    54  
    55  	Regression *RegressionJSON
    56  }
    57  
    58  type ValueJSON struct {
    59  	CommitHash           string
    60  	CommitDate           time.Time
    61  	BaselineCommitHash   string
    62  	BenchmarksCommitHash string
    63  
    64  	// These are pre-formatted as percent change.
    65  	Low    float64
    66  	Center float64
    67  	High   float64
    68  }
    69  
    70  func fluxRecordToValue(rec *query.FluxRecord) (ValueJSON, error) {
    71  	low, ok := rec.ValueByKey("low").(float64)
    72  	if !ok {
    73  		return ValueJSON{}, fmt.Errorf("record %s low value got type %T want float64", rec, rec.ValueByKey("low"))
    74  	}
    75  
    76  	center, ok := rec.ValueByKey("center").(float64)
    77  	if !ok {
    78  		return ValueJSON{}, fmt.Errorf("record %s center value got type %T want float64", rec, rec.ValueByKey("center"))
    79  	}
    80  
    81  	high, ok := rec.ValueByKey("high").(float64)
    82  	if !ok {
    83  		return ValueJSON{}, fmt.Errorf("record %s high value got type %T want float64", rec, rec.ValueByKey("high"))
    84  	}
    85  
    86  	commit, ok := rec.ValueByKey("experiment-commit").(string)
    87  	if !ok {
    88  		return ValueJSON{}, fmt.Errorf("record %s experiment-commit value got type %T want float64", rec, rec.ValueByKey("experiment-commit"))
    89  	}
    90  
    91  	baselineCommit, ok := rec.ValueByKey("baseline-commit").(string)
    92  	if !ok {
    93  		return ValueJSON{}, fmt.Errorf("record %s experiment-commit value got type %T want float64", rec, rec.ValueByKey("baseline-commit"))
    94  	}
    95  
    96  	benchmarksCommit, ok := rec.ValueByKey("benchmarks-commit").(string)
    97  	if !ok {
    98  		return ValueJSON{}, fmt.Errorf("record %s experiment-commit value got type %T want float64", rec, rec.ValueByKey("benchmarks-commit"))
    99  	}
   100  
   101  	return ValueJSON{
   102  		CommitDate:           rec.Time(),
   103  		CommitHash:           commit,
   104  		BaselineCommitHash:   baselineCommit,
   105  		BenchmarksCommitHash: benchmarksCommit,
   106  		Low:                  low - 1,
   107  		Center:               center - 1,
   108  		High:                 high - 1,
   109  	}, nil
   110  }
   111  
   112  // validateRe is an allowlist of characters for a Flux string literal. The
   113  // string will be quoted, so we must not allow ending the quote sequence.
   114  var validateRe = regexp.MustCompile(`^[a-zA-Z0-9(),=/_:;.-]+$`)
   115  
   116  func validateFluxString(s string) error {
   117  	if !validateRe.MatchString(s) {
   118  		return fmt.Errorf("malformed value %q", s)
   119  	}
   120  	return nil
   121  }
   122  
   123  func influxQuery(ctx context.Context, qc api.QueryAPI, query string) (*api.QueryTableResult, error) {
   124  	log.Printf("InfluxDB query: %s", query)
   125  	return qc.Query(ctx, query)
   126  }
   127  
   128  var errBenchmarkNotFound = errors.New("benchmark not found")
   129  
   130  // fetchNamedUnitBenchmark queries Influx for a specific name + unit benchmark.
   131  func fetchNamedUnitBenchmark(ctx context.Context, qc api.QueryAPI, start, end time.Time, repository, branch, name, unit string) (*BenchmarkJSON, error) {
   132  	if err := validateFluxString(repository); err != nil {
   133  		return nil, fmt.Errorf("invalid repository name: %w", err)
   134  	}
   135  	if err := validateFluxString(branch); err != nil {
   136  		return nil, fmt.Errorf("invalid branch name: %w", err)
   137  	}
   138  	if err := validateFluxString(name); err != nil {
   139  		return nil, fmt.Errorf("invalid benchmark name: %w", err)
   140  	}
   141  	if err := validateFluxString(unit); err != nil {
   142  		return nil, fmt.Errorf("invalid unit name: %w", err)
   143  	}
   144  
   145  	// Note that very old points are missing the "repository" field. fill()
   146  	// sets repository=go on all points missing that field, as they were
   147  	// all runs of the go repo.
   148  	query := fmt.Sprintf(`
   149  from(bucket: "perf")
   150    |> range(start: %s, stop: %s)
   151    |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
   152    |> filter(fn: (r) => r["name"] == "%s")
   153    |> filter(fn: (r) => r["unit"] == "%s")
   154    |> filter(fn: (r) => r["branch"] == "%s")
   155    |> filter(fn: (r) => r["goos"] == "linux")
   156    |> filter(fn: (r) => r["goarch"] == "amd64")
   157    |> fill(column: "repository", value: "go")
   158    |> filter(fn: (r) => r["repository"] == "%s")
   159    |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
   160    |> yield(name: "last")
   161  `, start.Format(time.RFC3339), end.Format(time.RFC3339), name, unit, branch, repository)
   162  
   163  	res, err := influxQuery(ctx, qc, query)
   164  	if err != nil {
   165  		return nil, fmt.Errorf("error performing query: %w", err)
   166  	}
   167  
   168  	b, err := groupBenchmarkResults(res, false)
   169  	if err != nil {
   170  		return nil, err
   171  	}
   172  	if len(b) == 0 {
   173  		return nil, errBenchmarkNotFound
   174  	}
   175  	if len(b) > 1 {
   176  		return nil, fmt.Errorf("query returned too many benchmarks: %+v", b)
   177  	}
   178  	return b[0], nil
   179  }
   180  
   181  // fetchDefaultBenchmarks queries Influx for the default benchmark set.
   182  func fetchDefaultBenchmarks(ctx context.Context, qc api.QueryAPI, start, end time.Time, repository, branch string) ([]*BenchmarkJSON, error) {
   183  	if repository != "go" {
   184  		// No defaults defined for other subrepos yet, just return an
   185  		// empty set.
   186  		return nil, nil
   187  	}
   188  
   189  	// Keep benchmarks with the same name grouped together, which is
   190  	// assumed by the JS.
   191  	benchmarks := []struct{ name, unit string }{
   192  		{
   193  			name: "Tile38QueryLoad",
   194  			unit: "sec/op",
   195  		},
   196  		{
   197  			name: "Tile38QueryLoad",
   198  			unit: "p50-latency-sec",
   199  		},
   200  		{
   201  			name: "Tile38QueryLoad",
   202  			unit: "p90-latency-sec",
   203  		},
   204  		{
   205  			name: "Tile38QueryLoad",
   206  			unit: "p99-latency-sec",
   207  		},
   208  		{
   209  			name: "Tile38QueryLoad",
   210  			unit: "average-RSS-bytes",
   211  		},
   212  		{
   213  			name: "Tile38QueryLoad",
   214  			unit: "peak-RSS-bytes",
   215  		},
   216  		{
   217  			name: "GoBuildKubelet",
   218  			unit: "sec/op",
   219  		},
   220  		{
   221  			name: "GoBuildKubeletLink",
   222  			unit: "sec/op",
   223  		},
   224  		{
   225  			name: "RegexMatch-16",
   226  			unit: "sec/op",
   227  		},
   228  		{
   229  			name: "BuildJSON-16",
   230  			unit: "sec/op",
   231  		},
   232  		{
   233  			name: "ZapJSON-16",
   234  			unit: "sec/op",
   235  		},
   236  	}
   237  
   238  	ret := make([]*BenchmarkJSON, 0, len(benchmarks))
   239  	for _, bench := range benchmarks {
   240  		b, err := fetchNamedUnitBenchmark(ctx, qc, start, end, repository, branch, bench.name, bench.unit)
   241  		if errors.Is(err, errBenchmarkNotFound) {
   242  			continue
   243  		}
   244  		if err != nil {
   245  			return nil, fmt.Errorf("error fetching benchmark %s/%s: %w", bench.name, bench.unit, err)
   246  		}
   247  		ret = append(ret, b)
   248  	}
   249  
   250  	return ret, nil
   251  }
   252  
   253  // fetchNamedBenchmark queries Influx for all benchmark results with the passed
   254  // name (for all units).
   255  func fetchNamedBenchmark(ctx context.Context, qc api.QueryAPI, start, end time.Time, repository, branch, name string) ([]*BenchmarkJSON, error) {
   256  	if err := validateFluxString(repository); err != nil {
   257  		return nil, fmt.Errorf("invalid repository name: %w", err)
   258  	}
   259  	if err := validateFluxString(branch); err != nil {
   260  		return nil, fmt.Errorf("invalid branch name: %w", err)
   261  	}
   262  	if err := validateFluxString(name); err != nil {
   263  		return nil, fmt.Errorf("invalid benchmark name: %w", err)
   264  	}
   265  
   266  	// Note that very old points are missing the "repository" field. fill()
   267  	// sets repository=go on all points missing that field, as they were
   268  	// all runs of the go repo.
   269  	query := fmt.Sprintf(`
   270  from(bucket: "perf")
   271    |> range(start: %s, stop: %s)
   272    |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
   273    |> filter(fn: (r) => r["name"] == "%s")
   274    |> filter(fn: (r) => r["branch"] == "%s")
   275    |> filter(fn: (r) => r["goos"] == "linux")
   276    |> filter(fn: (r) => r["goarch"] == "amd64")
   277    |> fill(column: "repository", value: "go")
   278    |> filter(fn: (r) => r["repository"] == "%s")
   279    |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
   280    |> yield(name: "last")
   281  `, start.Format(time.RFC3339), end.Format(time.RFC3339), name, branch, repository)
   282  
   283  	res, err := influxQuery(ctx, qc, query)
   284  	if err != nil {
   285  		return nil, fmt.Errorf("error performing query: %w", err)
   286  	}
   287  
   288  	b, err := groupBenchmarkResults(res, false)
   289  	if err != nil {
   290  		return nil, err
   291  	}
   292  	if len(b) == 0 {
   293  		return nil, errBenchmarkNotFound
   294  	}
   295  	return b, nil
   296  }
   297  
   298  // fetchAllBenchmarks queries Influx for all benchmark results.
   299  func fetchAllBenchmarks(ctx context.Context, qc api.QueryAPI, regressions bool, start, end time.Time, repository, branch string) ([]*BenchmarkJSON, error) {
   300  	if err := validateFluxString(repository); err != nil {
   301  		return nil, fmt.Errorf("invalid repository name: %w", err)
   302  	}
   303  	if err := validateFluxString(branch); err != nil {
   304  		return nil, fmt.Errorf("invalid branch name: %w", err)
   305  	}
   306  
   307  	// Note that very old points are missing the "repository" field. fill()
   308  	// sets repository=go on all points missing that field, as they were
   309  	// all runs of the go repo.
   310  	query := fmt.Sprintf(`
   311  from(bucket: "perf")
   312    |> range(start: %s, stop: %s)
   313    |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
   314    |> filter(fn: (r) => r["branch"] == "%s")
   315    |> filter(fn: (r) => r["goos"] == "linux")
   316    |> filter(fn: (r) => r["goarch"] == "amd64")
   317    |> fill(column: "repository", value: "go")
   318    |> filter(fn: (r) => r["repository"] == "%s")
   319    |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
   320    |> yield(name: "last")
   321  `, start.Format(time.RFC3339), end.Format(time.RFC3339), branch, repository)
   322  
   323  	res, err := influxQuery(ctx, qc, query)
   324  	if err != nil {
   325  		return nil, fmt.Errorf("error performing query: %w", err)
   326  	}
   327  
   328  	return groupBenchmarkResults(res, regressions)
   329  }
   330  
   331  type RegressionJSON struct {
   332  	Change         float64 // endpoint regression, if any
   333  	DeltaIndex     int     // index at which largest increase of regression occurs
   334  	Delta          float64 // size of that changes
   335  	IgnoredBecause string
   336  
   337  	deltaScore float64 // score of that change (in 95%ile boxes)
   338  }
   339  
   340  // queryToJson process a QueryTableResult into a slice of BenchmarkJSON,
   341  // with that slice in no particular order (i.e., it needs to be sorted or
   342  // run-to-run results will vary).  For each benchmark in the slice, however,
   343  // results are sorted into commit-date order.
   344  func queryToJson(res *api.QueryTableResult) ([]*BenchmarkJSON, error) {
   345  	type key struct {
   346  		name string
   347  		unit string
   348  	}
   349  
   350  	m := make(map[key]*BenchmarkJSON)
   351  
   352  	for res.Next() {
   353  		rec := res.Record()
   354  
   355  		name, ok := rec.ValueByKey("name").(string)
   356  		if !ok {
   357  			return nil, fmt.Errorf("record %s name value got type %T want string", rec, rec.ValueByKey("name"))
   358  		}
   359  
   360  		unit, ok := rec.ValueByKey("unit").(string)
   361  		if !ok {
   362  			return nil, fmt.Errorf("record %s unit value got type %T want string", rec, rec.ValueByKey("unit"))
   363  		}
   364  
   365  		k := key{name, unit}
   366  		b, ok := m[k]
   367  		if !ok {
   368  			b = &BenchmarkJSON{
   369  				Name:           name,
   370  				Unit:           unit,
   371  				HigherIsBetter: isHigherBetter(unit),
   372  			}
   373  			m[k] = b
   374  		}
   375  
   376  		v, err := fluxRecordToValue(res.Record())
   377  		if err != nil {
   378  			return nil, err
   379  		}
   380  
   381  		b.Values = append(b.Values, v)
   382  	}
   383  
   384  	s := make([]*BenchmarkJSON, 0, len(m))
   385  	for _, b := range m {
   386  		// Ensure that the benchmarks are commit-date ordered.
   387  		sort.Slice(b.Values, func(i, j int) bool {
   388  			return b.Values[i].CommitDate.Before(b.Values[j].CommitDate)
   389  		})
   390  		s = append(s, b)
   391  	}
   392  
   393  	return s, nil
   394  }
   395  
   396  // filterAndSortRegressions filters out benchmarks that didn't regress and sorts the
   397  // benchmarks in s so that those with the largest detectable regressions come first.
   398  func filterAndSortRegressions(s []*BenchmarkJSON) []*BenchmarkJSON {
   399  	// Compute per-benchmark estimates of point where the most interesting regression happened.
   400  	for _, b := range s {
   401  		b.Regression = worstRegression(b)
   402  		// TODO(mknyszek, drchase, mpratt): Filter out benchmarks once we're confident this
   403  		// algorithm works OK.
   404  	}
   405  
   406  	// Sort benchmarks with detectable regressions first, ordered by
   407  	// size of regression at end of sample.  Also sort the remaining
   408  	// benchmarks into end-of-sample regression order.
   409  	sort.Slice(s, func(i, j int) bool {
   410  		ri, rj := s[i].Regression, s[j].Regression
   411  		// regressions w/ a delta index come first
   412  		if (ri.DeltaIndex < 0) != (rj.DeltaIndex < 0) {
   413  			return rj.DeltaIndex < 0
   414  		}
   415  		if ri.Change != rj.Change {
   416  			// put larger regression first.
   417  			return ri.Change > rj.Change
   418  		}
   419  		if s[i].Name == s[j].Name {
   420  			return s[i].Unit < s[j].Unit
   421  		}
   422  		return s[i].Name < s[j].Name
   423  	})
   424  	return s
   425  }
   426  
   427  // groupBenchmarkResults groups all benchmark results from the passed query.
   428  // if byRegression is true, order the benchmarks with largest current regressions
   429  // with detectable points first.
   430  func groupBenchmarkResults(res *api.QueryTableResult, byRegression bool) ([]*BenchmarkJSON, error) {
   431  	s, err := queryToJson(res)
   432  	if err != nil {
   433  		return nil, err
   434  	}
   435  	if byRegression {
   436  		return filterAndSortRegressions(s), nil
   437  	}
   438  	// Keep benchmarks with the same name grouped together, which is
   439  	// assumed by the JS.
   440  	sort.Slice(s, func(i, j int) bool {
   441  		if s[i].Name == s[j].Name {
   442  			return s[i].Unit < s[j].Unit
   443  		}
   444  		return s[i].Name < s[j].Name
   445  	})
   446  	return s, nil
   447  }
   448  
   449  // changeScore returns an indicator of the change and direction.
   450  // This is a heuristic measure of the lack of overlap between
   451  // two confidence intervals; minimum lack of overlap (i.e., same
   452  // confidence intervals) is zero.  Exact non-overlap, meaning
   453  // the high end of one interval is equal to the low end of the
   454  // other, is one.  A gap of size G between the two intervals
   455  // yields a score of 1 + G/M where M is the size of the larger
   456  // interval (this suppresses changescores adjacent to noise).
   457  // A partial overlap of size G yields a score of
   458  // 1 - G/M.
   459  //
   460  // Empty confidence intervals are problematic and produces infinities
   461  // or NaNs.
   462  func changeScore(l1, c1, h1, l2, c2, h2 float64) float64 {
   463  	sign := 1.0
   464  	if c1 > c2 {
   465  		l1, c1, h1, l2, c2, h2 = l2, c2, h2, l1, c1, h1
   466  		sign = -sign
   467  	}
   468  	r := math.Max(h1-l1, h2-l2)
   469  	// we know l1 < c1 < h1, c1 < c2, l2 < c2 < h2
   470  	// therefore l1 < c1 < c2 < h2
   471  	if h1 > l2 { // overlap
   472  		overlapHigh, overlapLow := h1, l2
   473  		if overlapHigh > h2 {
   474  			overlapHigh = h2
   475  		}
   476  		if overlapLow < l1 {
   477  			overlapLow = l1
   478  		}
   479  		return sign * (1 - (overlapHigh-overlapLow)/r) // perfect overlap == 0
   480  	} else { // no overlap
   481  		return sign * (1 + (l2-h1)/r) // just touching, l2 == h1, magnitude == 1, and then increases w/ the gap between intervals.
   482  	}
   483  }
   484  
   485  func isHigherBetter(unit string) bool {
   486  	switch unit {
   487  	case "B/s", "ops/s":
   488  		return true
   489  	}
   490  	return false
   491  }
   492  
   493  func worstRegression(b *BenchmarkJSON) *RegressionJSON {
   494  	values := b.Values
   495  	l := len(values)
   496  	ninf := math.Inf(-1)
   497  
   498  	sign := 1.0
   499  	if b.HigherIsBetter {
   500  		sign = -1.0
   501  	}
   502  
   503  	min := sign * values[l-1].Center
   504  	worst := &RegressionJSON{
   505  		DeltaIndex: -1,
   506  		Change:     min,
   507  		deltaScore: ninf,
   508  	}
   509  
   510  	if len(values) < 4 {
   511  		worst.IgnoredBecause = "too few values"
   512  		return worst
   513  	}
   514  
   515  	scores := []float64{}
   516  
   517  	// First classify benchmarks that are too darn noisy, and get a feel for noisiness.
   518  	for i := l - 1; i > 0; i-- {
   519  		v1, v0 := values[i-1], values[i]
   520  		scores = append(scores, math.Abs(changeScore(v1.Low, v1.Center, v1.High, v0.Low, v0.Center, v0.High)))
   521  	}
   522  
   523  	sort.Float64s(scores)
   524  	median := (scores[len(scores)/2] + scores[(len(scores)-1)/2]) / 2
   525  
   526  	// MAGIC NUMBER "1".  Removing this added 25% to the "detected regressions", but they were all junk.
   527  	if median > 1 {
   528  		worst.IgnoredBecause = "median change score > 1"
   529  		return worst
   530  	}
   531  
   532  	if math.IsNaN(median) {
   533  		worst.IgnoredBecause = "median is NaN"
   534  		return worst
   535  	}
   536  
   537  	// MAGIC NUMBER "1.2".  Smaller than that tends to admit junky benchmarks.
   538  	magicScoreThreshold := math.Max(2*median, 1.2)
   539  
   540  	// Scan backwards looking for most recent outlier regression
   541  	for i := l - 1; i > 0; i-- {
   542  		v1, v0 := values[i-1], values[i]
   543  		score := sign * changeScore(v1.Low, v1.Center, v1.High, v0.Low, v0.Center, v0.High)
   544  
   545  		if score > magicScoreThreshold && sign*v1.Center < min && score > worst.deltaScore {
   546  			worst.DeltaIndex = i
   547  			worst.deltaScore = score
   548  			worst.Delta = sign * (v0.Center - v1.Center)
   549  		}
   550  
   551  		min = math.Min(sign*v0.Center, min)
   552  	}
   553  
   554  	if worst.DeltaIndex == -1 {
   555  		worst.IgnoredBecause = "didn't detect outlier regression"
   556  	}
   557  
   558  	return worst
   559  }
   560  
   561  type gzipResponseWriter struct {
   562  	http.ResponseWriter
   563  	w *gzip.Writer
   564  }
   565  
   566  func (w *gzipResponseWriter) Write(b []byte) (int, error) {
   567  	return w.w.Write(b)
   568  }
   569  
   570  const (
   571  	defaultDays = 30
   572  	maxDays     = 366
   573  )
   574  
   575  // search handles /dashboard/data.json.
   576  //
   577  // TODO(prattmic): Consider caching Influx results in-memory for a few mintures
   578  // to reduce load on Influx.
   579  func (a *App) dashboardData(w http.ResponseWriter, r *http.Request) {
   580  	ctx := r.Context()
   581  
   582  	days := uint64(defaultDays)
   583  	dayParam := r.FormValue("days")
   584  	if dayParam != "" {
   585  		var err error
   586  		days, err = strconv.ParseUint(dayParam, 10, 32)
   587  		if err != nil {
   588  			log.Printf("Error parsing days %q: %v", dayParam, err)
   589  			http.Error(w, fmt.Sprintf("day parameter must be a positive integer less than or equal to %d", maxDays), http.StatusBadRequest)
   590  			return
   591  		}
   592  		if days == 0 || days > maxDays {
   593  			log.Printf("days %d too large", days)
   594  			http.Error(w, fmt.Sprintf("day parameter must be a positive integer less than or equal to %d", maxDays), http.StatusBadRequest)
   595  			return
   596  		}
   597  	}
   598  
   599  	end := time.Now()
   600  	endParam := r.FormValue("end")
   601  	if endParam != "" {
   602  		var err error
   603  		// Quirk: Browsers don't have an easy built-in way to deal with
   604  		// timezone in input boxes. The datetime input type yields a
   605  		// string in this form, with no timezone (either local or UTC).
   606  		// Thus, we just treat this as UTC.
   607  		end, err = time.Parse("2006-01-02T15:04", endParam)
   608  		if err != nil {
   609  			log.Printf("Error parsing end %q: %v", endParam, err)
   610  			http.Error(w, "end parameter must be a timestamp similar to RFC3339 without a time zone, like 2000-12-31T15:00", http.StatusBadRequest)
   611  			return
   612  		}
   613  	}
   614  
   615  	start := end.Add(-24 * time.Hour * time.Duration(days))
   616  
   617  	methStart := time.Now()
   618  	defer func() {
   619  		log.Printf("Dashboard total query time: %s", time.Since(methStart))
   620  	}()
   621  
   622  	ifxc, err := a.influxClient(ctx)
   623  	if err != nil {
   624  		log.Printf("Error getting Influx client: %v", err)
   625  		http.Error(w, "Error connecting to Influx", 500)
   626  		return
   627  	}
   628  	defer ifxc.Close()
   629  
   630  	qc := ifxc.QueryAPI(influx.Org)
   631  
   632  	repository := r.FormValue("repository")
   633  	if repository == "" {
   634  		repository = "go"
   635  	}
   636  	branch := r.FormValue("branch")
   637  	if branch == "" {
   638  		branch = "master"
   639  	}
   640  
   641  	benchmark := r.FormValue("benchmark")
   642  	unit := r.FormValue("unit")
   643  	var benchmarks []*BenchmarkJSON
   644  	if benchmark == "" {
   645  		benchmarks, err = fetchDefaultBenchmarks(ctx, qc, start, end, repository, branch)
   646  	} else if benchmark == "all" {
   647  		benchmarks, err = fetchAllBenchmarks(ctx, qc, false, start, end, repository, branch)
   648  	} else if benchmark == "regressions" {
   649  		benchmarks, err = fetchAllBenchmarks(ctx, qc, true, start, end, repository, branch)
   650  	} else if benchmark != "" && unit == "" {
   651  		benchmarks, err = fetchNamedBenchmark(ctx, qc, start, end, repository, branch, benchmark)
   652  	} else {
   653  		var result *BenchmarkJSON
   654  		result, err = fetchNamedUnitBenchmark(ctx, qc, start, end, repository, branch, benchmark, unit)
   655  		if result != nil && err == nil {
   656  			benchmarks = []*BenchmarkJSON{result}
   657  		}
   658  	}
   659  	if errors.Is(err, errBenchmarkNotFound) {
   660  		log.Printf("Benchmark not found: %q", benchmark)
   661  		http.Error(w, "Benchmark not found", 404)
   662  		return
   663  	}
   664  	if err != nil {
   665  		log.Printf("Error fetching benchmarks: %v", err)
   666  		http.Error(w, "Error fetching benchmarks", 500)
   667  		return
   668  	}
   669  
   670  	w.Header().Set("Content-Type", "application/json")
   671  
   672  	if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
   673  		w.Header().Set("Content-Encoding", "gzip")
   674  		gz := gzip.NewWriter(w)
   675  		defer gz.Close()
   676  		w = &gzipResponseWriter{w: gz, ResponseWriter: w}
   677  	}
   678  
   679  	if err := json.NewEncoder(w).Encode(benchmarks); err != nil {
   680  		log.Printf("Error encoding results: %v", err)
   681  		http.Error(w, "Internal error, see logs", 500)
   682  	}
   683  }