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}