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 }