v0.24.0 166837a

report.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package main
4
5import (
6	"bytes"
7	"fmt"
8	"io"
9	"maps"
10	"runtime"
11	"slices"
12	"text/tabwriter"
13	"time"
14)
15
16// Report is the result of a run, printed as a table and optionally written as
17// JSON to compare runs.
18type Report struct {
19	Scenario string    `json:"scenario"`
20	URL      string    `json:"url"`
21	Started  time.Time `json:"started"`
22	Mode     string    `json:"mode"` // "closed-loop" or "open-loop"
23	Clients  int       `json:"clients"`
24	Rate     float64   `json:"target_rps,omitempty"`
25	Seconds  float64   `json:"seconds"`
26	Client   string    `json:"client"` // the load generator, to tell runs apart
27	Total    ReqReport `json:"total"`
28	// Missed counts the open-loop requests that found no free client: the
29	// server could not keep up with the target rate.
30	Missed   int64          `json:"missed"`
31	Requests []ReqReport    `json:"requests"`
32	Streams  []StreamReport `json:"streams"`
33}
34
35type ReqReport struct {
36	Name    string         `json:"name"`
37	Path    string         `json:"path,omitempty"`
38	Count   int            `json:"count"`
39	RPS     float64        `json:"rps"`
40	Errors  map[string]int `json:"errors,omitempty"`
41	Bytes   int64          `json:"avg_bytes"`
42	Latency Latency        `json:"latency_ms"`
43}
44
45type StreamReport struct {
46	Name    string  `json:"name"`
47	Wanted  int     `json:"wanted"`
48	Opened  int     `json:"opened"`
49	Refused int     `json:"refused"`
50	Dropped int     `json:"dropped"`
51	Events  int     `json:"events"`
52	Connect Latency `json:"connect_ms"`
53}
54
55type Latency struct {
56	P50 float64 `json:"p50"`
57	P90 float64 `json:"p90"`
58	P99 float64 `json:"p99"`
59	Max float64 `json:"max"`
60}
61
62func summarize(d []time.Duration) Latency {
63	if len(d) == 0 {
64		return Latency{}
65	}
66	s := slices.Clone(d)
67	slices.Sort(s)
68	q := func(p float64) float64 {
69		return float64(s[int(float64(len(s)-1)*p)]) / float64(time.Millisecond)
70	}
71	return Latency{P50: q(.5), P90: q(.9), P99: q(.99), Max: q(1)}
72}
73
74func newReport(cfg config, sc *Scenario, samples []sample, missed int64, elapsed time.Duration, streams []StreamReport) *Report {
75	r := &Report{
76		Scenario: sc.Name, URL: cfg.base, Started: time.Now().Add(-elapsed).UTC(),
77		Mode: "closed-loop", Clients: cfg.clients, Seconds: elapsed.Seconds(),
78		Client:  fmt.Sprintf("%s/%s, %d CPUs", runtime.GOOS, runtime.GOARCH, runtime.NumCPU()),
79		Missed:  missed,
80		Streams: streams,
81	}
82	if cfg.rate > 0 {
83		r.Mode, r.Rate = "open-loop", cfg.rate
84	}
85	sum := func(name, path string, ss []sample) ReqReport {
86		rr := ReqReport{Name: name, Path: path, Count: len(ss), RPS: float64(len(ss)) / elapsed.Seconds()}
87		lat := make([]time.Duration, 0, len(ss))
88		var bytes int64
89		for _, s := range ss {
90			lat = append(lat, s.latency)
91			bytes += s.bytes
92			if s.err != "" {
93				if rr.Errors == nil {
94					rr.Errors = map[string]int{}
95				}
96				rr.Errors[s.err]++
97			}
98		}
99		if len(ss) > 0 {
100			rr.Bytes = bytes / int64(len(ss))
101		}
102		rr.Latency = summarize(lat)
103		return rr
104	}
105	byReq := make([][]sample, len(sc.Requests))
106	for _, s := range samples {
107		byReq[s.req] = append(byReq[s.req], s)
108	}
109	for i, q := range sc.Requests {
110		r.Requests = append(r.Requests, sum(q.Name, q.Path, byReq[i]))
111	}
112	r.Total = sum("total", "", samples)
113	return r
114}
115
116// Failed is true when a request failed, a stream did not open or was
117// dropped, or the server missed the target rate.
118func (r *Report) Failed() bool {
119	if len(r.Total.Errors) > 0 || r.Missed > 0 {
120		return true
121	}
122	for _, s := range r.Streams {
123		if s.Opened < s.Wanted || s.Refused > 0 || s.Dropped > 0 {
124			return true
125		}
126	}
127	return false
128}
129
130// print writes the report as text; it is built in memory (writes to a
131// bytes.Buffer cannot fail) and written once.
132func (r *Report) print(out io.Writer) error {
133	w := &bytes.Buffer{}
134	fmt.Fprintf(w, "scenario %s on %s, %s, %d clients", r.Scenario, r.URL, r.Mode, r.Clients)
135	if r.Rate > 0 {
136		fmt.Fprintf(w, ", target %.0f req/s", r.Rate)
137	}
138	fmt.Fprintf(w, ", %.1fs (load generator: %s)\n\n", r.Seconds, r.Client)
139
140	tw := &bytes.Buffer{}
141	fmt.Fprintln(tw, "request\treq/s\tp50 ms\tp90 ms\tp99 ms\tmax ms\tavg KB\terrors\t")
142	row := func(q ReqReport) {
143		fmt.Fprintf(tw, "%s\t%.0f\t%.2f\t%.2f\t%.2f\t%.1f\t%.1f\t%d\t\n", q.Name, q.RPS,
144			q.Latency.P50, q.Latency.P90, q.Latency.P99, q.Latency.Max, float64(q.Bytes)/1024, errCount(q.Errors))
145	}
146	for _, q := range r.Requests {
147		row(q)
148	}
149	if len(r.Requests) > 1 {
150		row(r.Total)
151	}
152	table(w, tw)
153
154	for _, q := range append(r.Requests, r.Total) {
155		for _, e := range slices.Sorted(maps.Keys(q.Errors)) {
156			if q.Name != "total" || len(r.Requests) == 1 {
157				fmt.Fprintf(w, "  %s: %d × %s\n", q.Name, q.Errors[e], e)
158			}
159		}
160	}
161	if r.Missed > 0 {
162		fmt.Fprintf(w, "\nmissed %d requests: the server did not keep up with %.0f req/s\n", r.Missed, r.Rate)
163	}
164	if len(r.Streams) > 0 {
165		w.WriteByte('\n')
166		tw.Reset()
167		fmt.Fprintln(tw, "stream\topen\trefused\tdropped\tevents\tconnect p99 ms\t")
168		for _, s := range r.Streams {
169			fmt.Fprintf(tw, "%s\t%d/%d\t%d\t%d\t%d\t%.2f\t\n", s.Name, s.Opened, s.Wanted, s.Refused, s.Dropped, s.Events, s.Connect.P99)
170		}
171		table(w, tw)
172	}
173	verdict := "PASS"
174	if r.Failed() {
175		verdict = "FAIL"
176	}
177	fmt.Fprintf(w, "\n%s\n", verdict)
178	_, err := out.Write(w.Bytes())
179	return err
180}
181
182// table aligns the tab-separated rows of src into dst.
183func table(dst, src *bytes.Buffer) {
184	tw := tabwriter.NewWriter(dst, 0, 0, 2, ' ', tabwriter.AlignRight)
185	_, _ = tw.Write(src.Bytes())
186	_ = tw.Flush() // cannot fail: dst is a bytes.Buffer
187}
188
189func errCount(m map[string]int) int {
190	n := 0
191	for _, v := range m {
192		n += v
193	}
194	return n
195}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close