| // Copyright 2017 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package app |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "net/http" |
| "net/url" |
| "sort" |
| "strconv" |
| "strings" |
| "unicode" |
| |
| "github.com/google/safehtml" |
| "github.com/google/safehtml/template" |
| "golang.org/x/build/perfdata/query" |
| "golang.org/x/perf/benchstat" |
| "golang.org/x/perf/storage/benchfmt" |
| ) |
| |
| // A resultGroup holds a list of results and tracks the distinct labels found in that list. |
| type resultGroup struct { |
| // The (partial) query string that resulted in this group. |
| Q string |
| // Raw list of results. |
| results []*benchfmt.Result |
| // LabelValues is the count of results found with each distinct (key, value) pair found in labels. |
| // A value of "" counts results missing that key. |
| LabelValues map[string]valueSet |
| } |
| |
| // add adds res to the resultGroup. |
| func (g *resultGroup) add(res *benchfmt.Result) { |
| g.results = append(g.results, res) |
| if g.LabelValues == nil { |
| g.LabelValues = make(map[string]valueSet) |
| } |
| for k, v := range res.Labels { |
| if g.LabelValues[k] == nil { |
| g.LabelValues[k] = make(valueSet) |
| if len(g.results) > 1 { |
| g.LabelValues[k][""] = len(g.results) - 1 |
| } |
| } |
| g.LabelValues[k][v]++ |
| } |
| for k := range g.LabelValues { |
| if res.Labels[k] == "" { |
| g.LabelValues[k][""]++ |
| } |
| } |
| } |
| |
| // splitOn returns a new set of groups sharing a common value for key. |
| func (g *resultGroup) splitOn(key string) []*resultGroup { |
| groups := make(map[string]*resultGroup) |
| var values []string |
| for _, res := range g.results { |
| value := res.Labels[key] |
| if groups[value] == nil { |
| groups[value] = &resultGroup{Q: key + ":" + value} |
| values = append(values, value) |
| } |
| groups[value].add(res) |
| } |
| |
| sort.Strings(values) |
| var out []*resultGroup |
| for _, value := range values { |
| out = append(out, groups[value]) |
| } |
| return out |
| } |
| |
| // valueSet is a set of values and the number of results with each value. |
| type valueSet map[string]int |
| |
| // valueCount and byCount are used for sorting a valueSet |
| type valueCount struct { |
| Value string |
| Count int |
| } |
| type byCount []valueCount |
| |
| func (s byCount) Len() int { return len(s) } |
| func (s byCount) Swap(i, j int) { s[i], s[j] = s[j], s[i] } |
| |
| func (s byCount) Less(i, j int) bool { |
| if s[i].Count != s[j].Count { |
| return s[i].Count > s[j].Count |
| } |
| return s[i].Value < s[j].Value |
| } |
| |
| // TopN returns a slice containing n valueCount entries, and if any labels were omitted, an extra entry with value "…". |
| func (vs valueSet) TopN(n int) []valueCount { |
| var s []valueCount |
| var total int |
| for v, count := range vs { |
| s = append(s, valueCount{v, count}) |
| total += count |
| } |
| sort.Sort(byCount(s)) |
| out := s |
| if len(out) > n { |
| out = s[:n] |
| } |
| if len(out) < len(s) { |
| var outTotal int |
| for _, vc := range out { |
| outTotal += vc.Count |
| } |
| out = append(out, valueCount{"…", total - outTotal}) |
| } |
| return out |
| } |
| |
| // addToQuery returns a new query string with add applied as a filter. |
| func addToQuery(query, add string) string { |
| if strings.ContainsAny(add, " \t\\\"") { |
| add = strings.Replace(add, `\`, `\\`, -1) |
| add = strings.Replace(add, `"`, `\"`, -1) |
| add = `"` + add + `"` |
| } |
| if strings.Contains(query, "|") { |
| return add + " " + query |
| } |
| return add + " | " + query |
| } |
| |
| // linkify returns a link related to the label's value. If no such link exists, it returns an empty string. |
| // For example, "cl: 1234" is linked to golang.org/cl/1234. |
| // string is used as the return type and not template.URL so that html/template will validate the scheme. |
| func linkify(labels benchfmt.Labels, label string) string { |
| switch label { |
| case "cl", "commit": |
| return "https://golang.org/cl/" + url.QueryEscape(labels[label]) |
| case "ps": |
| // TODO(quentin): Figure out how to link to a particular patch set on Gerrit. |
| return "" |
| case "repo": |
| return labels["repo"] |
| case "try": |
| // TODO(quentin): Return link to farmer once farmer has permalinks. |
| return "" |
| } |
| return "" |
| } |
| |
| // compare handles queries that require comparison of the groups in the query. |
| func (a *App) compare(w http.ResponseWriter, r *http.Request) { |
| ctx := r.Context() |
| |
| if err := r.ParseForm(); err != nil { |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| |
| q := r.Form.Get("q") |
| |
| t, err := template.New("compare.html").Funcs(template.FuncMap{ |
| "addToQuery": addToQuery, |
| "linkify": linkify, |
| }).ParseFS(tmplFS, "template/compare.html") |
| if err != nil { |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| |
| data := a.compareQuery(ctx, q) |
| |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| if err := t.Execute(w, data); err != nil { |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| } |
| |
| type compareData struct { |
| Q string |
| Error string |
| Benchstat safehtml.HTML |
| Groups []*resultGroup |
| Labels map[string]bool |
| CommonLabels benchfmt.Labels |
| } |
| |
| // queryKeys returns the keys that are exact-matched by q. |
| func queryKeys(q string) map[string]bool { |
| out := make(map[string]bool) |
| for _, part := range query.SplitWords(q) { |
| // TODO(quentin): This func is shared with db.go; refactor? |
| i := strings.IndexFunc(part, func(r rune) bool { |
| return r == ':' || r == '>' || r == '<' || unicode.IsSpace(r) || unicode.IsUpper(r) |
| }) |
| if i >= 0 && part[i] == ':' { |
| out[part[:i]] = true |
| } |
| } |
| return out |
| } |
| |
| // elideKeyValues returns content, a benchmark format line, with the |
| // values of any keys in keys elided. |
| func elideKeyValues(content string, keys map[string]bool) string { |
| var end string |
| if i := strings.IndexFunc(content, unicode.IsSpace); i >= 0 { |
| content, end = content[:i], content[i:] |
| } |
| // Check for gomaxprocs value |
| if i := strings.LastIndex(content, "-"); i >= 0 { |
| _, err := strconv.Atoi(content[i+1:]) |
| if err == nil { |
| if keys["gomaxprocs"] { |
| content, end = content[:i], "-*"+end |
| } else { |
| content, end = content[:i], content[i:]+end |
| } |
| } |
| } |
| parts := strings.Split(content, "/") |
| for i, part := range parts { |
| if equals := strings.Index(part, "="); equals >= 0 { |
| if keys[part[:equals]] { |
| parts[i] = part[:equals] + "=*" |
| } |
| } else if i == 0 { |
| if keys["name"] { |
| parts[i] = "Benchmark*" |
| } |
| } else if keys[fmt.Sprintf("sub%d", i)] { |
| parts[i] = "*" |
| } |
| } |
| return strings.Join(parts, "/") + end |
| } |
| |
| // fetchCompareResults fetches the matching results for a given query string. |
| // The results will be grouped into one or more groups based on either the query string or heuristics. |
| func (a *App) fetchCompareResults(ctx context.Context, q string) ([]*resultGroup, error) { |
| // Parse query |
| prefix, queries := parseQueryString(q) |
| |
| // Send requests |
| // TODO(quentin): Issue requests in parallel? |
| var groups []*resultGroup |
| var found int |
| for _, qPart := range queries { |
| keys := queryKeys(qPart) |
| group := &resultGroup{Q: qPart} |
| if prefix != "" { |
| qPart = prefix + " " + qPart |
| } |
| s, err := a.StorageClient.Query(ctx, qPart) |
| if err != nil { |
| return nil, err |
| } |
| res := benchfmt.NewReader(s) |
| for res.Next() { |
| result := res.Result() |
| result.Content = elideKeyValues(result.Content, keys) |
| group.add(result) |
| found++ |
| } |
| err = res.Err() |
| s.Close() |
| if err != nil { |
| // TODO: If the query is invalid, surface that to the user. |
| return nil, err |
| } |
| groups = append(groups, group) |
| } |
| |
| if found == 0 { |
| return nil, errors.New("no results matched the query string") |
| } |
| |
| // Attempt to automatically split results. |
| if len(groups) == 1 { |
| group := groups[0] |
| // Matching a single CL -> split by filename |
| switch { |
| case len(group.LabelValues["cl"]) == 1 && len(group.LabelValues["ps"]) == 1 && len(group.LabelValues["upload-file"]) > 1: |
| groups = group.splitOn("upload-file") |
| // Matching a single upload with multiple files -> split by file |
| case len(group.LabelValues["upload"]) == 1 && len(group.LabelValues["upload-part"]) > 1: |
| groups = group.splitOn("upload-part") |
| } |
| } |
| |
| return groups, nil |
| } |
| |
| func (a *App) compareQuery(ctx context.Context, q string) *compareData { |
| if len(q) == 0 { |
| return &compareData{} |
| } |
| |
| groups, err := a.fetchCompareResults(ctx, q) |
| if err != nil { |
| return &compareData{ |
| Q: q, |
| Error: err.Error(), |
| } |
| } |
| |
| // Compute benchstat |
| c := &benchstat.Collection{ |
| AddGeoMean: true, |
| SplitBy: nil, |
| } |
| for _, label := range []string{"buildlet", "pkg", "goos", "goarch"} { |
| for _, g := range groups { |
| if len(g.LabelValues[label]) > 1 { |
| c.SplitBy = append(c.SplitBy, label) |
| break |
| } |
| } |
| } |
| for _, g := range groups { |
| c.AddResults(g.Q, g.results) |
| } |
| tableHTML := benchstat.SafeFormatHTML(c.Tables()) |
| |
| // Prepare struct for template. |
| labels := make(map[string]bool) |
| // commonLabels are the key: value of every label that has an |
| // identical value on every result. |
| commonLabels := make(benchfmt.Labels) |
| // Scan the first group for common labels. |
| for k, vs := range groups[0].LabelValues { |
| if len(vs) == 1 { |
| for v := range vs { |
| commonLabels[k] = v |
| } |
| } |
| } |
| // Remove any labels not common in later groups. |
| for _, g := range groups[1:] { |
| for k, v := range commonLabels { |
| if len(g.LabelValues[k]) != 1 || g.LabelValues[k][v] == 0 { |
| delete(commonLabels, k) |
| } |
| } |
| } |
| // List all labels present and not in commonLabels. |
| for _, g := range groups { |
| for k := range g.LabelValues { |
| if commonLabels[k] != "" { |
| continue |
| } |
| labels[k] = true |
| } |
| } |
| data := &compareData{ |
| Q: q, |
| Benchstat: tableHTML, |
| Groups: groups, |
| Labels: labels, |
| CommonLabels: commonLabels, |
| } |
| return data |
| } |
| |
| // textCompare is called if benchsave is requesting a text-only analysis. |
| func (a *App) textCompare(w http.ResponseWriter, r *http.Request) { |
| ctx := r.Context() |
| |
| if err := r.ParseForm(); err != nil { |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| |
| w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| |
| q := r.Form.Get("q") |
| |
| groups, err := a.fetchCompareResults(ctx, q) |
| if err != nil { |
| // TODO(quentin): Should we serve this with a 500 or 404? This means the query was invalid or had no results. |
| fmt.Fprintf(w, "unable to analyze results: %v", err) |
| } |
| |
| // Compute benchstat |
| c := new(benchstat.Collection) |
| for _, g := range groups { |
| c.AddResults(g.Q, g.results) |
| } |
| benchstat.FormatText(w, c.Tables()) |
| } |