Skip to content

Commit 471e696

Browse files
first commit
0 parents  commit 471e696

File tree

15 files changed

+1388
-0
lines changed

15 files changed

+1388
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ripley

LICENSE

Lines changed: 674 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# ripley - replay HTTP
2+
3+
Ripley replays HTTP traffic at multiples of the original rate. It simulates traffic ramp up or down by specifying rate phases for each run. For example, you can replay HTTP requests at twice the original rate for ten minutes, then three times the original rate for five minutes, then ten times the original rate for an hour and so on. Ripley's original use case is load testing by replaying HTTP access logs from production applications.
4+
5+
## Quickstart
6+
7+
Clone and build ripley
8+
9+
```bash
10+
git clone [email protected]:loveholidays/ripley.git
11+
cd ripley
12+
go build -o ripley main.go
13+
```
14+
15+
Run a web server to replay traffic against
16+
17+
```bash
18+
go run etc/dummyweb.go
19+
```
20+
21+
Loop 10 times over a set of HTTP requests at 1x rate for 10 seconds, then at 5x for 10 seconds, then at 10x for the remaining requests
22+
23+
```bash
24+
seq 10 | xargs -i cat etc/requests.jsonl | ./ripley -pace "10s@1 10s@5 1h@10"
25+
```
26+
27+
## Replaying HTTP traffic
28+
29+
Ripley reads a representation of HTTP requests in [JSON Lines format](https://jsonlines.org/) from `STDIN` and replays them at different rates in phases as specified by the `-pace` flag.
30+
31+
An example ripley request:
32+
33+
```JSON
34+
{
35+
"url": "http://localhost:8080/",
36+
"verb": "GET",
37+
"timestamp": "2021-11-08T18:59:59.9Z",
38+
"headers": {"Accept": "text/plain"}
39+
}
40+
```
41+
42+
`url`, `verb` and `timestamp` are required, `headers` are optional.
43+
44+
`-pace` specifies rate phases in `[duration]@[rate]` format. For example, `10s@5 5m@10 1h30m@100` means replay traffic at 5x for 10 seconds, 10x for 5 minutes and 100x for one and a half hours. The run will stop either when ripley stops receiving requests from `STDIN` or when the last phase elapses, whichever happens first.
45+
46+
Ripley writes request results as JSON Lines to `STDOUT`
47+
48+
```bash
49+
echo '{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:50.9Z"}' | ./ripley | jq
50+
```
51+
52+
produces
53+
54+
```JSON
55+
{
56+
"statusCode": 200,
57+
"latency": 3915447,
58+
"request": {
59+
"verb": "GET",
60+
"url": "http://localhost:8080/",
61+
"body": "",
62+
"timestamp": "2021-11-08T18:59:50.9Z",
63+
"headers": null
64+
}
65+
}
66+
```
67+
68+
Results output can be suppressed using the `-silent` flag.
69+
70+
It is possible to collect and print a run's statistics:
71+
72+
```bash
73+
seq 10 | xargs -i cat etc/requests.jsonl | ./ripley -pace "10s@1 10s@5 1h@10" -silent -stats | jq
74+
```
75+
76+
```JSON
77+
{
78+
"totalRequests": 100,
79+
"statusCodes": {
80+
"200": 100
81+
},
82+
"latencyMicroseconds": {
83+
"max": 2960,
84+
"mean": 2008.25,
85+
"median": 2085.5,
86+
"min": 815,
87+
"p95": 2577,
88+
"p99": 2876,
89+
"stdDev": 449.1945986986041
90+
}
91+
}
92+
```
93+
94+
## Running the tests
95+
96+
```bash
97+
go test pkg/*go
98+
```

etc/dummyweb.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package main
2+
3+
import (
4+
"log"
5+
"net/http"
6+
"time"
7+
)
8+
9+
func handler(w http.ResponseWriter, r *http.Request) {
10+
log.Printf("%v\n", time.Now().Format(time.UnixDate))
11+
w.Write([]byte("hi\n"))
12+
}
13+
14+
func main() {
15+
http.HandleFunc("/", handler)
16+
log.Fatal(http.ListenAndServe(":8080", nil))
17+
}

etc/requests.jsonl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:50.9Z"}
2+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:51.9Z"}
3+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:52.9Z"}
4+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:53.9Z"}
5+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:54.9Z"}
6+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:55.9Z"}
7+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:56.9Z"}
8+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:57.9Z"}
9+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:58.9Z"}
10+
{"url": "http://localhost:8080/", "verb": "GET", "timestamp": "2021-11-08T18:59:59.9Z", "headers": {"Accept": "text/plain"}}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/loveholidays/ripley
2+
3+
go 1.17
4+
5+
require github.com/montanaflynn/stats v0.6.6

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/montanaflynn/stats v0.6.6 h1:Duep6KMIDpY4Yo11iFsvyqJDyfzLF9+sndUKT+v64GQ=
2+
github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=

main.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"github.com/loveholidays/ripley/pkg"
6+
)
7+
8+
func main() {
9+
paceStr := flag.String("pace", "10s@1", `[duration]@[rate], e.g. "1m@1 [email protected] 1h@2"`)
10+
silent := flag.Bool("silent", false, "Suppress output")
11+
printStats := flag.Bool("stats", false, "Collect and print statistics before the program exits")
12+
13+
flag.Parse()
14+
15+
ripley.Replay(*paceStr, *silent, *printStats)
16+
}

pkg/client.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package ripley
2+
3+
import (
4+
"net/http"
5+
"time"
6+
)
7+
8+
type result struct {
9+
StatusCode int `json:"statusCode"`
10+
Latency time.Duration `json:"latency"`
11+
Request *request `json:"request"`
12+
err error
13+
}
14+
15+
func startClientWorkers(numWorkers int, requests <-chan *request, results chan<- *result) {
16+
client := &http.Client{
17+
Timeout: 30 * time.Second,
18+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
19+
return http.ErrUseLastResponse
20+
},
21+
}
22+
23+
for i := 0; i <= numWorkers; i++ {
24+
go doHttpRequest(client, requests, results)
25+
}
26+
}
27+
28+
func doHttpRequest(client *http.Client, requests <-chan *request, results chan<- *result) {
29+
for req := range requests {
30+
latencyStart := time.Now()
31+
httpReq, err := req.httpRequest()
32+
33+
if err != nil {
34+
results <- &result{err: err}
35+
return
36+
}
37+
38+
resp, err := client.Do(httpReq)
39+
40+
if err != nil {
41+
results <- &result{err: err}
42+
return
43+
}
44+
45+
latency := time.Now().Sub(latencyStart)
46+
results <- &result{StatusCode: resp.StatusCode, Latency: latency, Request: req}
47+
}
48+
}

pkg/pace.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package ripley
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
"time"
7+
)
8+
9+
type pacer struct {
10+
phases []*phase
11+
lastRequestTime time.Time
12+
done bool
13+
}
14+
15+
type phase struct {
16+
duration time.Duration
17+
rate float64
18+
}
19+
20+
func newPacer(phasesStr string) (*pacer, error) {
21+
phases, err := parsePhases(phasesStr)
22+
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
return &pacer{phases: phases}, nil
28+
}
29+
30+
func (p *pacer) start() {
31+
// Run a timer for the first phase's duration
32+
time.AfterFunc(p.phases[0].duration, p.onPhaseElapsed)
33+
}
34+
35+
func (p *pacer) onPhaseElapsed() {
36+
// Pop phase
37+
p.phases = p.phases[1:]
38+
39+
if len(p.phases) == 0 {
40+
p.done = true
41+
} else {
42+
// Create a timer with next phase
43+
time.AfterFunc(p.phases[0].duration, p.onPhaseElapsed)
44+
}
45+
}
46+
47+
func (p *pacer) waitDuration(t time.Time) time.Duration {
48+
// If there are no more phases left, continue with the last phase's rate
49+
if p.lastRequestTime.IsZero() {
50+
p.lastRequestTime = t
51+
}
52+
53+
duration := t.Sub(p.lastRequestTime)
54+
p.lastRequestTime = t
55+
return time.Duration(float64(duration) / p.phases[0].rate)
56+
}
57+
58+
// Format is [duration]@[rate] [duration]@[rate]..."
59+
// e.g. "5s@1 10m@2"
60+
func parsePhases(phasesStr string) ([]*phase, error) {
61+
var phases []*phase
62+
63+
for _, durationAtRate := range strings.Split(phasesStr, " ") {
64+
tokens := strings.Split(durationAtRate, "@")
65+
66+
duration, err := time.ParseDuration(tokens[0])
67+
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
rate, err := strconv.ParseFloat(tokens[1], 64)
73+
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
phases = append(phases, &phase{duration, rate})
79+
}
80+
81+
return phases, nil
82+
}

0 commit comments

Comments
 (0)