Skip to content

Commit 99f8598

Browse files
authored
Benchmarks middleware (#22)
* add basic benchmarking middleware this thing is collecting and reporting performance metrics, i.e. number of requests, req/sec, max/min resp time, and average resp time for a given (last) duration * add cleanup test * lint: simpler nowFn init * typo in comment * drop unneeded assignment * don't try to aggregate on under 1s range
1 parent 7e5e4ba commit 99f8598

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ Maybe middleware will allow you to change the flow of the middleware stack execu
119119
value of maybeFn(request). This is useful for example if you'd like to skip a middleware handler if
120120
a request does not satisfy the maybeFn logic.
121121

122+
### Benchmarks middleware
123+
124+
Benchmarks middleware allows to measure the time of request handling, number of request per second and report aggregated metrics. This middleware keeps track of the request in the memory and keep up to 900 points (15 minutes, data-point per second).
125+
126+
In order to retrieve the data user should call `Stats(d duration)` method. duration is the time window for which the benchmark data should be returned. It can be any duration from 1s to 15m.
127+
122128
## Helpers
123129

124130
- `rest.Wrap` - converts a list of middlewares to nested handlers calls (in reverse order)

benchmarks.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package rest
2+
3+
import (
4+
"container/list"
5+
"net/http"
6+
"sync"
7+
"time"
8+
)
9+
10+
var maxTimeRange = time.Duration(15) * time.Minute
11+
12+
// Benchmarks is a basic benchmarking middleware collecting and reporting performance metrics
13+
// It keeps track of the requests speeds and counts in 1s benchData buckets ,limiting the number of buckets
14+
// to maxTimeRange. User can request the benchmark for any time duration. This is intended to be used
15+
// for retrieving the benchmark data for the last minute, 5 minutes and up to maxTimeRange.
16+
type Benchmarks struct {
17+
st time.Time
18+
data *list.List
19+
lock sync.RWMutex
20+
21+
nowFn func() time.Time // for testing only
22+
}
23+
24+
type benchData struct {
25+
// 1s aggregates
26+
requests int
27+
respTime time.Duration
28+
minRespTime time.Duration
29+
maxRespTime time.Duration
30+
ts time.Time
31+
}
32+
33+
// BenchmarkStats holds the stats for a given interval
34+
type BenchmarkStats struct {
35+
Requests int `json:"total_requests"`
36+
RequestsSec float64 `json:"total_requests_sec"`
37+
AverageRespTime float64 `json:"average_resp_time"`
38+
MinRespTime float64 `json:"min_resp_time"`
39+
MaxRespTime float64 `json:"max_resp_time"`
40+
}
41+
42+
// NewBenchmarks creates a new benchmark middleware
43+
func NewBenchmarks() *Benchmarks {
44+
res := &Benchmarks{
45+
st: time.Now(),
46+
data: list.New(),
47+
nowFn: time.Now,
48+
}
49+
return res
50+
}
51+
52+
// Handler calculates 1/5/10m request per second and allows to access those values
53+
func (b *Benchmarks) Handler(next http.Handler) http.Handler {
54+
55+
fn := func(w http.ResponseWriter, r *http.Request) {
56+
st := b.nowFn()
57+
defer func() {
58+
b.update(time.Since(st))
59+
}()
60+
next.ServeHTTP(w, r)
61+
}
62+
return http.HandlerFunc(fn)
63+
}
64+
65+
func (b *Benchmarks) update(reqDuration time.Duration) {
66+
now := b.nowFn().Truncate(time.Second)
67+
68+
b.lock.Lock()
69+
defer b.lock.Unlock()
70+
71+
// keep maxTimeRange in the list, drop the rest
72+
for e := b.data.Front(); e != nil; e = e.Next() {
73+
if b.data.Front().Value.(benchData).ts.After(b.nowFn().Add(-maxTimeRange)) {
74+
break
75+
}
76+
b.data.Remove(b.data.Front())
77+
}
78+
79+
last := b.data.Back()
80+
if last == nil || last.Value.(benchData).ts.Before(now) {
81+
b.data.PushBack(benchData{requests: 1, respTime: reqDuration, ts: now,
82+
minRespTime: reqDuration, maxRespTime: reqDuration})
83+
return
84+
}
85+
86+
bd := last.Value.(benchData)
87+
bd.requests++
88+
bd.respTime += reqDuration
89+
90+
if bd.minRespTime == 0 || reqDuration < bd.minRespTime {
91+
bd.minRespTime = reqDuration
92+
}
93+
if bd.maxRespTime == 0 || reqDuration > bd.maxRespTime {
94+
bd.maxRespTime = reqDuration
95+
}
96+
97+
last.Value = bd
98+
}
99+
100+
// Stats returns the current benchmark stats for the given duration
101+
func (b *Benchmarks) Stats(interval time.Duration) BenchmarkStats {
102+
if interval < time.Second { // minimum interval is 1s due to the bucket size
103+
return BenchmarkStats{}
104+
}
105+
106+
b.lock.RLock()
107+
defer b.lock.RUnlock()
108+
109+
var (
110+
requests int
111+
respTime time.Duration
112+
)
113+
114+
stInterval, fnInterval := time.Time{}, time.Time{}
115+
var minRespTime, maxRespTime time.Duration
116+
for e := b.data.Back(); e != nil; e = e.Prev() { // reverse order
117+
bd := e.Value.(benchData)
118+
if bd.ts.Before(b.nowFn().Add(-interval)) {
119+
break
120+
}
121+
if minRespTime == 0 || bd.minRespTime < minRespTime {
122+
minRespTime = bd.minRespTime
123+
}
124+
if maxRespTime == 0 || bd.maxRespTime > maxRespTime {
125+
maxRespTime = bd.maxRespTime
126+
}
127+
requests += bd.requests
128+
respTime += bd.respTime
129+
if fnInterval.IsZero() {
130+
fnInterval = bd.ts.Add(time.Second)
131+
}
132+
stInterval = bd.ts
133+
}
134+
135+
if requests == 0 {
136+
return BenchmarkStats{}
137+
}
138+
139+
return BenchmarkStats{
140+
Requests: requests,
141+
RequestsSec: float64(requests) / (fnInterval.Sub(stInterval).Seconds()),
142+
AverageRespTime: respTime.Seconds() / float64(requests),
143+
MinRespTime: minRespTime.Seconds(),
144+
MaxRespTime: maxRespTime.Seconds(),
145+
}
146+
}

benchmarks_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package rest
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestBenchmark_Stats(t *testing.T) {
14+
bench := NewBenchmarks()
15+
bench.update(time.Millisecond * 50)
16+
bench.update(time.Millisecond * 150)
17+
bench.update(time.Millisecond * 250)
18+
bench.update(time.Millisecond * 100)
19+
20+
{
21+
res := bench.Stats(time.Minute)
22+
t.Logf("%+v", res)
23+
assert.Equal(t, BenchmarkStats{Requests: 4, RequestsSec: 4, AverageRespTime: 0.1375,
24+
MinRespTime: (time.Millisecond * 50).Seconds(), MaxRespTime: (time.Millisecond * 250).Seconds()}, res)
25+
}
26+
27+
{
28+
res := bench.Stats(time.Second * 5)
29+
t.Logf("%+v", res)
30+
assert.Equal(t, BenchmarkStats{Requests: 4, RequestsSec: 4, AverageRespTime: 0.1375,
31+
MinRespTime: (time.Millisecond * 50).Seconds(), MaxRespTime: (time.Millisecond * 250).Seconds()}, res)
32+
}
33+
34+
{
35+
res := bench.Stats(time.Millisecond * 999)
36+
t.Logf("%+v", res)
37+
assert.Equal(t, BenchmarkStats{}, res)
38+
}
39+
}
40+
41+
func TestBenchmark_Stats2s(t *testing.T) {
42+
bench := NewBenchmarks()
43+
bench.update(time.Millisecond * 50)
44+
bench.update(time.Millisecond * 150)
45+
bench.update(time.Millisecond * 250)
46+
time.Sleep(time.Second)
47+
bench.update(time.Millisecond * 100)
48+
49+
res := bench.Stats(time.Minute)
50+
t.Logf("%+v", res)
51+
assert.Equal(t, BenchmarkStats{Requests: 4, RequestsSec: 2, AverageRespTime: 0.1375,
52+
MinRespTime: (time.Millisecond * 50).Seconds(), MaxRespTime: (time.Millisecond * 250).Seconds()}, res)
53+
}
54+
55+
func TestBenchmark_Cleanup(t *testing.T) {
56+
bench := NewBenchmarks()
57+
for i := 0; i < 1000; i++ {
58+
bench.nowFn = func() time.Time {
59+
return time.Date(2022, 5, 15, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Second) // every 2s fake time
60+
}
61+
bench.update(time.Millisecond * 50)
62+
}
63+
64+
{
65+
res := bench.Stats(time.Hour)
66+
t.Logf("%+v", res)
67+
assert.Equal(t, BenchmarkStats{Requests: 900, RequestsSec: 1, AverageRespTime: 0.05,
68+
MinRespTime: (time.Millisecond * 50).Seconds(), MaxRespTime: (time.Millisecond * 50).Seconds()}, res)
69+
}
70+
{
71+
res := bench.Stats(time.Minute)
72+
t.Logf("%+v", res)
73+
assert.Equal(t, BenchmarkStats{Requests: 60, RequestsSec: 1, AverageRespTime: 0.05,
74+
MinRespTime: (time.Millisecond * 50).Seconds(), MaxRespTime: (time.Millisecond * 50).Seconds()}, res)
75+
}
76+
77+
assert.Equal(t, 900, bench.data.Len())
78+
}
79+
80+
func TestBenchmarks_Handler(t *testing.T) {
81+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82+
_, err := w.Write([]byte("blah blah"))
83+
time.Sleep(time.Millisecond * 50)
84+
require.NoError(t, err)
85+
})
86+
87+
bench := NewBenchmarks()
88+
ts := httptest.NewServer(bench.Handler(handler))
89+
defer ts.Close()
90+
91+
for i := 0; i < 100; i++ {
92+
resp, err := ts.Client().Get(ts.URL)
93+
require.NoError(t, err)
94+
assert.Equal(t, http.StatusOK, resp.StatusCode)
95+
}
96+
97+
{
98+
res := bench.Stats(time.Minute)
99+
t.Logf("%+v", res)
100+
assert.Equal(t, 100, res.Requests)
101+
assert.True(t, res.RequestsSec <= 20 && res.RequestsSec >= 10)
102+
assert.InDelta(t, 0.05, res.AverageRespTime, 0.1)
103+
assert.InDelta(t, 0.05, res.MinRespTime, 0.1)
104+
assert.InDelta(t, 0.05, res.MaxRespTime, 0.1)
105+
assert.True(t, res.MaxRespTime >= res.MinRespTime)
106+
}
107+
108+
{
109+
res := bench.Stats(time.Minute * 15)
110+
t.Logf("%+v", res)
111+
assert.Equal(t, 100, res.Requests)
112+
assert.True(t, res.RequestsSec <= 20 && res.RequestsSec >= 10)
113+
assert.InDelta(t, 0.05, res.AverageRespTime, 0.1)
114+
assert.InDelta(t, 0.05, res.MinRespTime, 0.1)
115+
assert.InDelta(t, 0.05, res.MaxRespTime, 0.1)
116+
assert.True(t, res.MaxRespTime >= res.MinRespTime)
117+
}
118+
}

0 commit comments

Comments
 (0)