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))
+}