From 4a4e7ba5b16ce7fc7b9acd22ee30f7db6c28387b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Wed, 18 Jul 2018 15:29:29 +0200 Subject: [PATCH] plot: Highcharts and refactoring WIP --- lib/plot.go | 199 ++++++++++++++++++++++++++--------------------- lib/plot_test.go | 39 ++++++++++ plot.go | 3 +- 3 files changed, 152 insertions(+), 89 deletions(-) diff --git a/lib/plot.go b/lib/plot.go index bea5d22c..5642f8e7 100644 --- a/lib/plot.go +++ b/lib/plot.go @@ -1,14 +1,13 @@ package vegeta import ( - "fmt" + "encoding/json" + "html/template" "io" - "strconv" - "strings" + "sort" "time" lttb "github.com/dgryski/go-lttb" - colorful "github.com/lucasb-eyer/go-colorful" ) // An HTMLPlot represents an interactive HTML time series @@ -16,140 +15,165 @@ import ( type HTMLPlot struct { title string threshold int - series map[string]*points + series []*htmlPlotSeries } -type points struct { - attack string - began time.Time - ok []lttb.Point - err []lttb.Point +type htmlPlotSeries struct { + Attack string + Began time.Time + Ok []lttb.Point + Err []lttb.Point } // NewHTMLPlot returns an HTMLPlot with the given title, // downsampling threshold and latency data points based on the // given Results. func NewHTMLPlot(title string, threshold int, rs Results) *HTMLPlot { - // group by Attack, each split in Error and OK series - series := map[string]*points{} + return &HTMLPlot{ + title: title, + threshold: threshold, + series: newHTMLPlotSeries(rs), + } +} + +// newHTMLPlotSeries groups all Results by attack and then partions each +// attack's results into OK and Error data points to be plotted. The +// X axis is the relative time offset in seconds since the beginning of +// the corresponding attack. The Y axis is the latency in milliseconds. +func newHTMLPlotSeries(rs Results) []*htmlPlotSeries { + if !sort.IsSorted(rs) { + sort.Sort(rs) + } + + groups := map[string]*htmlPlotSeries{} for _, r := range rs { - s, ok := series[r.Attack] + s, ok := groups[r.Attack] if !ok { - s = &points{attack: r.Attack, began: r.Timestamp} - series[r.Attack] = s + s = &htmlPlotSeries{Attack: r.Attack, Began: r.Timestamp} + groups[r.Attack] = s } point := lttb.Point{ - X: r.Timestamp.Sub(s.began).Seconds(), + X: r.Timestamp.Sub(s.Began).Seconds(), Y: r.Latency.Seconds() * 1000, } if r.Error == "" { - s.ok = append(s.ok, point) + s.Ok = append(s.Ok, point) } else { - s.err = append(s.err, point) + s.Err = append(s.Err, point) } } - return &HTMLPlot{ - title: title, - threshold: threshold, - series: series, + ss := make([]*htmlPlotSeries, 0, len(groups)) + for _, s := range groups { + ss = append(ss, s) } + + sort.Slice(ss, func(x, y int) bool { + return ss[x].Began.Before(ss[y].Began) + }) + + return ss } // WriteTo writes the HTML plot to the give io.Writer. -func (p HTMLPlot) WriteTo(w io.Writer) (err error) { - _, err = fmt.Fprintf(w, plotsTemplateHead, p.title, asset(dygraphs), asset(html2canvas)) - if err != nil { - return err +func (p HTMLPlot) WriteTo(w io.Writer) (n int64, err error) { + type chart struct { + Type string `json:"type"` + RenderTo string `json:"renderTo"` } - const count = 2 // OK and Errors - i, offsets := 0, make(map[string]int, len(p.series)) - for name := range p.series { - offsets[name] = 1 + i*count - i++ + type title struct { + Text string `json:"text"` } - const nan = "NaN" + type axis struct { + Type string `json:"type"` + Title title `json:"title"` + } - data := make([]string, 1+len(p.series)*count) - for attack, points := range p.series { - for idx, ps := range [2][]lttb.Point{points.err, points.ok} { - for i, p := range lttb.LTTB(ps, p.threshold) { - for j := range data { - data[j] = nan - } + type data struct { + Name string `json:"name"` + Data [][2]float64 `json:"data"` + } - offset := offsets[attack] + idx - data[0] = strconv.FormatFloat(p.X, 'f', -1, 32) - data[offset] = strconv.FormatFloat(p.Y, 'f', -1, 32) + type highChartOpts struct { + Chart chart `json:"chart"` + Title title `json:"title"` + XAxis axis `json:"xAxis"` + YAxis axis `json:"yAxis"` + Series []data `json:"series"` + } - s := "[" + strings.Join(data, ",") + "]" + type templateData struct { + Title string + HTML2CanvasJS string + HighChartOptsJSON string + } - if i < len(ps)-1 { - s += "," - } + opts := highChartOpts{ + Chart: chart{Type: "line", RenderTo: "latencies"}, + Title: title{Text: p.title}, + XAxis: axis{Title: title{Text: "Time elapsed (s)"}}, + YAxis: axis{ + Title: title{Text: "Latency (ms)"}, + Type: "logarithmic", + }, + } - if _, err = io.WriteString(w, s); err != nil { - return err - } + labels := []string{"OK", "ERROR"} + for _, ss := range p.series { + for i, ps := range [][]lttb.Point{ss.Err, ss.Ok} { + s := data{Name: ss.Attack + ": " + labels[i]} + for _, p := range ps { + s.Data = append(s.Data, [2]float64{p.X, p.Y}) } + opts.Series = append(opts.Series, s) } } - labels := make([]string, len(data)) - labels[0] = strconv.Quote("Seconds") - - for attack, offset := range offsets { - labels[offset] = strconv.Quote(attack + " - ERR") - labels[offset+1] = strconv.Quote(attack + " - OK") - } - - colors := make([]string, 0, len(labels)-1) - palette, err := colorful.HappyPalette(cap(colors)) + bs, err := json.Marshal(&opts) if err != nil { - return err + return 0, err } - for _, color := range palette { - colors = append(colors, strconv.Quote(color.Hex())) - } + cw := countingWriter{w: w} + err = plotTemplate.Execute(&cw, &templateData{ + Title: p.title, + HTML2CanvasJS: string(asset(html2canvas)), + HighChartOptsJSON: string(bs), + }) + + return cw.n, err +} - _, err = fmt.Fprintf(w, plotsTemplateTail, p.title, strings.Join(labels, ","), strings.Join(colors, ",")) - return err +type countingWriter struct { + n int64 + w io.Writer } -const ( - plotsTemplateHead = ` +func (cw *countingWriter) Write(p []byte) (int, error) { + n, err := cw.w.Write(p) + cw.n += int64(n) + return n, err +} + +var plotTemplate = template.Must(template.New("plot").Parse(` + - %s + {{.Title}}
- - + + -` -) +`)) diff --git a/lib/plot_test.go b/lib/plot_test.go index f7fbc6c4..467afb1a 100644 --- a/lib/plot_test.go +++ b/lib/plot_test.go @@ -4,8 +4,47 @@ import ( "io/ioutil" "testing" "time" + + lttb "github.com/dgryski/go-lttb" + "github.com/google/go-cmp/cmp" ) +func TestNewHTMLPlotSeries(t *testing.T) { + for _, tc := range []struct { + name string + in Results + out []*htmlPlotSeries + }{ + { + name: "single attack", + in: Results{ + {Attack: "foo", Timestamp: time.Unix(0, 0), Latency: time.Second}, + {Attack: "foo", Timestamp: time.Unix(1, 0), Latency: time.Millisecond}, + }, + out: []*htmlPlotSeries{ + { + Attack: "foo", + Began: time.Unix(0, 0), + Ok: []lttb.Point{ + {X: 0, Y: 0}, + {X: 1, Y: 0}, + }, + }, + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + diff := cmp.Diff(newHTMLPlotSeries(tc.in), tc.out) + if diff != "" { + t.Error("\n" + diff) + } + }) + } +} + func BenchmarkHTMLPlot(b *testing.B) { b.StopTimer() // Build result set diff --git a/plot.go b/plot.go index c979974a..8bff7f7a 100644 --- a/plot.go +++ b/plot.go @@ -90,5 +90,6 @@ decode: } plot := vegeta.NewHTMLPlot(title, threshold, rs) - return plot.WriteTo(out) + _, err = plot.WriteTo(out) + return err }