analysis: basic analysis app
The app can a parse query and fetch relevant results. It can run on
both App Engine and locally.
Change-Id: I34a6415750fc29ed468997f05bc6d8c0ad068235
Reviewed-on: https://go-review.googlesource.com/35493
Reviewed-by: Russ Cox <rsc@golang.org>
diff --git a/analysis/app/app.go b/analysis/app/app.go
new file mode 100644
index 0000000..9e362c1
--- /dev/null
+++ b/analysis/app/app.go
@@ -0,0 +1,39 @@
+// 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 implements the performance data analysis server.
+package app
+
+import (
+ "net/http"
+
+ "golang.org/x/perf/storage"
+)
+
+// App manages the analysis server logic.
+// Construct an App instance and call RegisterOnMux to connect it with an HTTP server.
+type App struct {
+ // StorageClient is used to talk to the storage server.
+ StorageClient *storage.Client
+}
+
+// RegisterOnMux registers the app's URLs on mux.
+func (a *App) RegisterOnMux(mux *http.ServeMux) {
+ mux.HandleFunc("/search", a.search)
+ mux.HandleFunc("/compare", a.compare)
+}
+
+// search handles /search.
+// This currently just runs the compare handler, until more analysis methods are implemented.
+func (a *App) search(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ // TODO(quentin): Intelligently choose an analysis method
+ // based on the results from the query, once there is more
+ // than one analysis method.
+ //q := r.Form.Get("q")
+ a.compare(w, r)
+}
diff --git a/analysis/app/compare.go b/analysis/app/compare.go
new file mode 100644
index 0000000..cd57505
--- /dev/null
+++ b/analysis/app/compare.go
@@ -0,0 +1,108 @@
+// 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 (
+ "fmt"
+ "net/http"
+ "sort"
+
+ "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 {
+ // Raw list of results.
+ results []*benchfmt.Result
+ // LabelValues is the count of results found with each distinct (key, value) pair found in labels.
+ LabelValues map[string]map[string]int
+}
+
+// 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]map[string]int)
+ }
+ for k, v := range res.Labels {
+ if g.LabelValues[k] == nil {
+ g.LabelValues[k] = make(map[string]int)
+ }
+ g.LabelValues[k][v]++
+ }
+}
+
+// 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{}
+ 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
+}
+
+// compare handles queries that require comparison of the groups in the query.
+func (a *App) compare(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+
+ q := r.Form.Get("q")
+
+ // Parse query
+ queries := parseQueryString(q)
+
+ // Send requests
+ // TODO(quentin): Issue requests in parallel?
+ var groups []*resultGroup
+ for _, q := range queries {
+ group := &resultGroup{}
+ res := a.StorageClient.Query(q)
+ defer res.Close() // TODO: Should happen each time through the loop
+ for res.Next() {
+ group.add(res.Result())
+ }
+ if err := res.Err(); err != nil {
+ // TODO: If the query is invalid, surface that to the user.
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ groups = append(groups, group)
+ }
+
+ // Attempt to automatically split results.
+ if len(groups) == 1 {
+ group := groups[0]
+ // Matching a single upload with multiple files -> split by file
+ if len(group.LabelValues["upload"]) == 1 && len(group.LabelValues["upload-part"]) > 1 {
+ groups = group.splitOn("upload-part")
+ }
+ }
+
+ // TODO: Compute benchstat
+
+ // TODO: Render template. This is just temporary output to confirm the above works.
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ for i, g := range groups {
+ fmt.Fprintf(w, "Group #%d: %d results\n", i, len(g.results))
+ for k, vs := range g.labelValues {
+ fmt.Fprintf(w, "\t%s: %#v\n", k, vs)
+ }
+ fmt.Fprintf(w, "\n")
+ }
+}
diff --git a/analysis/app/compare_test.go b/analysis/app/compare_test.go
new file mode 100644
index 0000000..55ca938
--- /dev/null
+++ b/analysis/app/compare_test.go
@@ -0,0 +1,48 @@
+// 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 (
+ "reflect"
+ "strings"
+ "testing"
+
+ "golang.org/x/perf/storage/benchfmt"
+)
+
+func TestResultGroup(t *testing.T) {
+ data := `key: value
+BenchmarkName 1 ns/op
+key: value2
+BenchmarkName 1 ns/op`
+ var results []*benchfmt.Result
+ br := benchfmt.NewReader(strings.NewReader(data))
+ g := &resultGroup{}
+ for br.Next() {
+ results = append(results, br.Result())
+ g.add(br.Result())
+ }
+ if err := br.Err(); err != nil {
+ t.Fatalf("Err() = %v, want nil", err)
+ }
+ if !reflect.DeepEqual(g.results, results) {
+ t.Errorf("g.results = %#v, want %#v", g.results, results)
+ }
+ if want := map[string]map[string]int{"key": {"value": 1, "value2": 1}}; !reflect.DeepEqual(g.LabelValues, want) {
+ t.Errorf("g.LabelValues = %#v, want %#v", g.LabelValues, want)
+ }
+ groups := g.splitOn("key")
+ if len(groups) != 2 {
+ t.Fatalf("g.splitOn returned %d groups, want 2", len(groups))
+ }
+ for i, results := range [][]*benchfmt.Result{
+ {results[0]},
+ {results[1]},
+ } {
+ if !reflect.DeepEqual(groups[i].results, results) {
+ t.Errorf("groups[%d].results = %#v, want %#v", i, groups[i].results, results)
+ }
+ }
+}
diff --git a/analysis/app/parse.go b/analysis/app/parse.go
new file mode 100644
index 0000000..dab586c
--- /dev/null
+++ b/analysis/app/parse.go
@@ -0,0 +1,60 @@
+// 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 "strings"
+
+// parseQueryString splits a user-entered query into one or more storage server queries.
+// The supported query formats are:
+// prefix | one vs two - parsed as {"prefix one", "prefix two"}
+// prefix one vs two - parsed as {"prefix one", "two"}
+// anything else - parsed as {"anything else"}
+// The vs and | separators must not be quoted.
+func parseQueryString(q string) []string {
+ var queries []string
+ var parts []string
+ var prefix string
+ quoting := false
+ for r := 0; r < len(q); {
+ switch c := q[r]; {
+ case c == '"' && quoting:
+ quoting = false
+ r++
+ case quoting:
+ if c == '\\' {
+ r++
+ }
+ r++
+ case c == '"':
+ quoting = true
+ r++
+ case c == ' ', c == '\t':
+ switch part := q[:r]; {
+ case part == "|" && prefix == "":
+ prefix = strings.Join(parts, " ") + " "
+ parts = nil
+ case part == "vs":
+ queries = append(queries, prefix+strings.Join(parts, " "))
+ parts = nil
+ default:
+ parts = append(parts, part)
+ }
+ q = q[r+1:]
+ r = 0
+ default:
+ if c == '\\' {
+ r++
+ }
+ r++
+ }
+ }
+ if len(q) > 0 {
+ parts = append(parts, q)
+ }
+ if len(parts) > 0 {
+ queries = append(queries, prefix+strings.Join(parts, " "))
+ }
+ return queries
+}
diff --git a/analysis/app/parse_test.go b/analysis/app/parse_test.go
new file mode 100644
index 0000000..3a892a8
--- /dev/null
+++ b/analysis/app/parse_test.go
@@ -0,0 +1,31 @@
+// 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 (
+ "reflect"
+ "testing"
+)
+
+func TestParseQueryString(t *testing.T) {
+ tests := []struct {
+ q string
+ want []string
+ }{
+ {"prefix | one vs two", []string{"prefix one", "prefix two"}},
+ {"prefix one vs two", []string{"prefix one", "two"}},
+ {"anything else", []string{"anything else"}},
+ {`one vs "two vs three"`, []string{"one", `"two vs three"`}},
+ {"mixed\ttabs \"and\tspaces\"", []string{"mixed tabs \"and\tspaces\""}},
+ }
+ for _, test := range tests {
+ t.Run(test.q, func(t *testing.T) {
+ have := parseQueryString(test.q)
+ if !reflect.DeepEqual(have, test.want) {
+ t.Fatalf("parseQueryString = %#v, want %#v", have, test.want)
+ }
+ })
+ }
+}
diff --git a/analysis/appengine/app.go b/analysis/appengine/app.go
new file mode 100644
index 0000000..2b92c2c
--- /dev/null
+++ b/analysis/appengine/app.go
@@ -0,0 +1,47 @@
+// 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 appengine contains an AppEngine app for perf.golang.org
+package appengine
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "golang.org/x/perf/analysis/app"
+ "golang.org/x/perf/storage"
+ "google.golang.org/appengine"
+ "google.golang.org/appengine/urlfetch"
+)
+
+func mustGetenv(k string) string {
+ v := os.Getenv(k)
+ if v == "" {
+ log.Panicf("%s environment variable not set.", k)
+ }
+ return v
+}
+
+// appHandler is the default handler, registered to serve "/".
+// It creates a new App instance using the appengine Context and then
+// dispatches the request to the App. The environment variable
+// STORAGE_URL_BASE must be set in app.yaml with the name of the bucket to
+// write to.
+func appHandler(w http.ResponseWriter, r *http.Request) {
+ ctx := appengine.NewContext(r)
+ app := &app.App{
+ StorageClient: &storage.Client{
+ BaseURL: mustGetenv("STORAGE_URL_BASE"),
+ HTTPClient: urlfetch.Client(ctx),
+ },
+ }
+ mux := http.NewServeMux()
+ app.RegisterOnMux(mux)
+ mux.ServeHTTP(w, r)
+}
+
+func init() {
+ http.HandleFunc("/", appHandler)
+}
diff --git a/analysis/appengine/app.yaml b/analysis/appengine/app.yaml
new file mode 100644
index 0000000..2ae243c
--- /dev/null
+++ b/analysis/appengine/app.yaml
@@ -0,0 +1,19 @@
+# Update with
+# google_appengine/appcfg.py [-V dev-test] update .
+#
+# Using -V dev-test will run as dev-test.perf.golang.org.
+
+application: golang-org
+module: perf
+version: main
+runtime: go
+api_version: go1
+
+handlers:
+- url: /_ah/remote_api
+ script: _go_app
+- url: /.*
+ script: _go_app
+ secure: always
+env_variables:
+ STORAGE_URL_BASE: "https://perfdata.golang.org"
diff --git a/analysis/localserver/app.go b/analysis/localserver/app.go
new file mode 100644
index 0000000..6076aab
--- /dev/null
+++ b/analysis/localserver/app.go
@@ -0,0 +1,50 @@
+// 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.
+
+// Localserver runs an HTTP server for benchmark analysis.
+//
+// Usage:
+//
+// localserver [-addr address] [-storage url]
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+
+ "golang.org/x/perf/analysis/app"
+ "golang.org/x/perf/storage"
+)
+
+var (
+ addr = flag.String("addr", "localhost:8080", "serve HTTP on `address`")
+ storageURL = flag.String("storage", "https://perfdata.golang.org", "storage server base `url`")
+)
+
+func usage() {
+ fmt.Fprintf(os.Stderr, `Usage of localserver:
+ localserver [flags]
+`)
+ flag.PrintDefaults()
+ os.Exit(2)
+}
+
+func main() {
+ log.SetPrefix("localserver: ")
+ flag.Usage = usage
+ flag.Parse()
+ if flag.NArg() != 0 {
+ flag.Usage()
+ }
+
+ app := &app.App{StorageClient: &storage.Client{BaseURL: *storageURL}}
+ app.RegisterOnMux(http.DefaultServeMux)
+
+ log.Printf("Listening on %s", *addr)
+
+ log.Fatal(http.ListenAndServe(*addr, nil))
+}