Merge pull request #17 from tsenart/split-stages

Split test pipeline stages
This commit is contained in:
Tomás Senart
2013-09-11 06:46:27 -07:00
14 changed files with 443 additions and 268 deletions

View File

@@ -21,23 +21,42 @@ $ go get github.com/tsenart/vegeta
$ go install github.com/tsenart/vegeta
```
## Usage (CLI)
## Usage examples
```shell
$ echo "GET http://localhost/" | vegeta attack -rate=100 -duration=5s | vegeta report
$ vegeta attack -targets=targets.txt > results.vr
$ vegeta report -input=results.vr -reporter=json > metrics.json
$ vegeta report -input=results.vr -reporter=plot:timings > plot.svg
```
## Usage manual
```shell
$ vegeta -h
Usage of vegeta:
-cpus=n: Number of CPUs to use
-duration=10s: Duration of the test
-ordering="random": Attack ordering [sequential, random]
-output="stdout": Reporter output file
-rate=50: Requests per second
-reporter="text": Reporter to use [text, json, plot:timings]
-targets="targets.txt": Targets file
Usage: vegeta [globals] <command> [options]
Commands:
attack Hit the targets
report Report the results
Globals:
-cpus=8 Number of CPUs to use
```
#### -cpus
Specifies the number of CPUs to be used internally.
It defaults to the amount of CPUs available in the system.
### attack
```shell
$ vegeta attack -h
Usage of attack:
-duration=10s: Duration of the test
-ordering="random": Attack ordering [sequential, random]
-output="stdout": Output file
-rate=50: Requests per second
-targets="stdin": Targets file
```
#### -duration
Specifies the amount of time to issue request to the targets.
The internal concurrency structure's setup has this value as a variable.
@@ -52,18 +71,44 @@ The other option is `sequential` and it does what you would expect it to
do.
#### -output
Specifies the output file to which the report will be written to.
The default is stdout.
Specifies the output file to which the binary results will be written
to. Made to be piped to the report command input. Defaults to stdout.
#### -rate
Specifies the requests per second rate to issue against
the targets. The actual request rate can vary slightly due to things like
garbage collection, but overall it should stay very close to the specified.
#### -targets
Specifies the attack targets in a line sepated file, defaulting to stdin.
The format should be as follows.
```
GET http://goku:9090/path/to/dragon?item=balls
GET http://user:password@goku:9090/path/to
HEAD http://goku:9090/path/to/success
...
```
### report
```
$ vegeta report -h
Usage of report:
-input="stdin": Input file
-output="stdout": Output file
-reporter="text": Reporter [text, json, plot:timings]
```
#### -input
Specifies the input file from which the attack command binary results
are saved. Defaults to stdin.
#### -output
Specifies the output file to which the report will be written to.
#### -reporter
Specifies the reporting type to display the results with.
The default is the text report printed to stdout.
##### -reporter=text
Specifies the kind of report to be generated. It defaults to text.
##### text
```
Time(avg) Requests Success Bytes(rx/tx)
152.341ms 200 17.00% 251.00/0.00
@@ -75,7 +120,7 @@ Error Set:
Server Timeout
Page Not Found
```
##### -reporter=json
##### json
```json
{
"total_requests": 50,
@@ -93,19 +138,9 @@ Page Not Found
"errors": []
}
```
##### -reporter=plot:timings
Plots the request timings in SVG format.
##### plot:timings
![plot](https://dl.dropboxusercontent.com/u/83217940/plot.svg)
#### -targets
Specifies the attack targets in a line sepated file. The format should
be as follows:
```
GET http://goku:9090/path/to/dragon?item=balls
GET http://user:password@goku:9090/path/to
HEAD http://goku:9090/path/to/success
...
```
## Usage (Library)
```go

78
attack.go Normal file
View File

@@ -0,0 +1,78 @@
package main
import (
"flag"
"fmt"
vegeta "github.com/tsenart/vegeta/lib"
"log"
"time"
)
func attackCmd(args []string) command {
fs := flag.NewFlagSet("attack", flag.ExitOnError)
rate := fs.Uint64("rate", 50, "Requests per second")
targetsf := fs.String("targets", "stdin", "Targets file")
ordering := fs.String("ordering", "random", "Attack ordering [sequential, random]")
duration := fs.Duration("duration", 10*time.Second, "Duration of the test")
output := fs.String("output", "stdout", "Output file")
fs.Parse(args)
return func() error {
return attack(*rate, *duration, *targetsf, *ordering, *output)
}
}
// attack validates the attack arguments, sets up the
// required resources, launches the attack and writes the results
func attack(rate uint64, duration time.Duration, targetsf, ordering, output string) error {
if rate == 0 {
return fmt.Errorf(errRatePrefix + "can't be zero")
}
if duration == 0 {
return fmt.Errorf(errDurationPrefix + "can't be zero")
}
in, err := file(targetsf, false)
if err != nil {
return fmt.Errorf(errTargetsFilePrefix+"(%s): %s", targetsf, err)
}
defer in.Close()
targets, err := vegeta.NewTargetsFrom(in)
if err != nil {
return fmt.Errorf(errTargetsFilePrefix+"(%s): %s", targetsf, err)
}
switch ordering {
case "random":
targets.Shuffle(time.Now().UnixNano())
case "sequential":
break
default:
return fmt.Errorf(errOrderingPrefix+"`%s` is invalid", ordering)
}
out, err := file(output, true)
if err != nil {
return fmt.Errorf(errOutputFilePrefix+"(%s): %s", output, err)
}
defer out.Close()
log.Printf("Vegeta is attacking %d targets in %s order for %s...\n", len(targets), ordering, duration)
results := vegeta.Attack(targets, rate, duration)
log.Println("Done!")
log.Printf("Writing results to '%s'...", output)
if err := results.WriteTo(out); err != nil {
return err
}
return nil
}
const (
errRatePrefix = "Rate: "
errDurationPrefix = "Duration: "
errOutputFilePrefix = "Output file: "
errTargetsFilePrefix = "Targets file: "
errOrderingPrefix = "Ordering: "
errReportingPrefix = "Reporting: "
)

74
attack_test.go Normal file
View File

@@ -0,0 +1,74 @@
package main
import (
"io/ioutil"
"log"
"strings"
"testing"
"time"
)
func init() {
// Discard default log output
log.SetOutput(ioutil.Discard)
}
func TestRateValidation(t *testing.T) {
rate, duration, targetsf, ordering, output := defaultArguments()
rate = 0
err := attack(rate, duration, targetsf, ordering, output)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errRatePrefix)) {
t.Errorf("Rate 0 shouldn't be valid: %s", err)
}
}
func TestDurationValidation(t *testing.T) {
rate, duration, targetsf, ordering, output := defaultArguments()
duration = 0
err := attack(rate, duration, targetsf, ordering, output)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errDurationPrefix)) {
t.Errorf("Duration 0 shouldn't be valid: %s", err)
}
}
func TestTargetsValidation(t *testing.T) {
rate, duration, goodFile, ordering, output := defaultArguments()
// Good case
err := attack(rate, duration, goodFile, ordering, output)
if err != nil {
t.Errorf("Targets file `%s` should be valid: %s", goodFile, err)
}
// Bad case
badFile := "randomInexistingFile12345.txt"
err = attack(rate, duration, badFile, ordering, output)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errTargetsFilePrefix)) {
t.Errorf("Targets file `%s` shouldn't be valid: %s", badFile, err)
}
}
func TestOrderingValidation(t *testing.T) {
rate, duration, targetsf, _, output := defaultArguments()
// Good cases
for _, ordering := range []string{"random", "sequential"} {
err := attack(rate, duration, targetsf, ordering, output)
if err != nil {
t.Errorf("Ordering `%s` should be valid: %s", ordering, err)
}
}
// Bad case
badOrdering := "lolcat"
err := attack(rate, duration, targetsf, badOrdering, output)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errOrderingPrefix)) {
t.Errorf("Ordering `%s` shouldn't be valid: %s", badOrdering, err)
}
}
func defaultArguments() (uint64, time.Duration, string, string, string) {
return uint64(1000), 5 * time.Millisecond, ".targets.txt", "random", "/dev/null"
}

26
file.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"os"
)
func file(filename string, create bool) (*os.File, error) {
switch filename {
case "stdin":
return os.Stdin, nil
case "stdout":
return os.Stdout, nil
default:
var file *os.File
var err error
if create {
file, err = os.Create(filename)
} else {
file, err = os.Open(filename)
}
if err != nil {
return nil, err
}
return file, nil
}
}

View File

@@ -1,32 +1,19 @@
package vegeta
import (
"errors"
"io/ioutil"
"net/http"
"sort"
"time"
)
// Result represents the metrics defined out of an http.Response
// generated by each target hit
type Result struct {
Code uint16
Timestamp time.Time
Timing time.Duration
BytesOut uint64
BytesIn uint64
Error error
}
// Attack hits the passed Targets (http.Requests) at the rate specified for
// duration time and then waits for all the requests to come back.
// The results of the attack are put into a slice which is returned.
func Attack(targets Targets, rate uint64, duration time.Duration) []Result {
func Attack(targets Targets, rate uint64, duration time.Duration) Results {
total := rate * uint64(duration.Seconds())
hits := make(chan *http.Request, total)
res := make(chan Result, total)
results := make(results, total)
results := make(Results, total)
// Scatter
go drill(rate, hits, res)
for i := 0; i < cap(hits); i++ {
@@ -39,18 +26,9 @@ func Attack(targets Targets, rate uint64, duration time.Duration) []Result {
}
close(res)
sort.Sort(results)
return results
return results.Sort()
}
// results is a slice of Result defined only to be sortable with sort.Interface
type results []Result
func (r results) Len() int { return len(r) }
func (r results) Less(i, j int) bool { return r[i].Timestamp.Before(r[j].Timestamp) }
func (r results) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
// drill loops over the passed reqs channel and executes each request.
// It is throttled to the rate specified.
func drill(rate uint64, reqs chan *http.Request, res chan Result) {
@@ -71,13 +49,14 @@ func hit(req *http.Request, res chan Result) {
Timestamp: began,
Timing: time.Since(began),
BytesOut: uint64(req.ContentLength),
Error: err,
}
if err == nil {
if err != nil {
result.Error = err.Error()
} else {
result.Code = uint16(r.StatusCode)
if body, err := ioutil.ReadAll(r.Body); err != nil {
if result.Code < 200 || result.Code >= 300 {
result.Error = errors.New(string(body))
result.Error = string(body)
}
} else {
result.BytesIn = uint64(len(body))

View File

@@ -37,8 +37,8 @@ func NewMetrics(results []Result) *Metrics {
if result.Code >= 200 && result.Code < 300 {
m.TotalSuccess++
}
if result.Error != nil {
errorSet[result.Error.Error()] = struct{}{}
if result.Error != "" {
errorSet[result.Error] = struct{}{}
}
}

View File

@@ -1,16 +1,15 @@
package vegeta
import (
"errors"
"testing"
"time"
)
func TestNewMetrics(t *testing.T) {
m := NewMetrics([]Result{
Result{500, time.Now(), 100 * time.Millisecond, 10, 30, errors.New("Internal server error")},
Result{200, time.Now(), 20 * time.Millisecond, 20, 20, nil},
Result{200, time.Now(), 30 * time.Millisecond, 30, 10, nil},
Result{500, time.Now(), 100 * time.Millisecond, 10, 30, "Internal server error"},
Result{200, time.Now(), 20 * time.Millisecond, 20, 20, ""},
Result{200, time.Now(), 30 * time.Millisecond, 30, 10, ""},
})
for field, values := range map[string][]float64{

46
lib/results.go Normal file
View File

@@ -0,0 +1,46 @@
package vegeta
import (
"encoding/gob"
"io"
"sort"
"time"
)
// Result represents the metrics defined out of an http.Response
// generated by each target hit
type Result struct {
Code uint16
Timestamp time.Time
Timing time.Duration
BytesOut uint64
BytesIn uint64
Error string
}
// Results is a slice of Result structs with encoding,
// decoding and sorting behavior attached
type Results []Result
// WriteTo encodes the results and writes it to an io.Writer
// returning an error in case of failure
func (r Results) WriteTo(out io.Writer) error {
return gob.NewEncoder(out).Encode(r)
}
// ReadFrom reads data from an io.Reader and decodes it into a Results struct
// returning an error in case of failure
func (r *Results) ReadFrom(in io.Reader) error {
return gob.NewDecoder(in).Decode(r)
}
// Sort sorts Results by Timestamp in ascending order and returns
// the sorted slice
func (r Results) Sort() Results {
sort.Sort(r)
return r
}
func (r Results) Len() int { return len(r) }
func (r Results) Less(i, j int) bool { return r[i].Timestamp.Before(r[j].Timestamp) }
func (r Results) Swap(i, j int) { r[i], r[j] = r[j], r[i] }

50
lib/results_test.go Normal file
View File

@@ -0,0 +1,50 @@
package vegeta
import (
"bytes"
"sort"
"testing"
"time"
)
func TestEncoding(t *testing.T) {
results := Results{
Result{200, time.Now(), 100 * time.Millisecond, 10, 30, ""},
Result{200, time.Now(), 20 * time.Millisecond, 20, 20, ""},
Result{200, time.Now(), 30 * time.Millisecond, 30, 10, ""},
}
buffer := &bytes.Buffer{}
if err := results.WriteTo(buffer); err != nil {
t.Fatalf("Failed WriteTo: %s", err)
}
decoded := Results{}
if err := decoded.ReadFrom(buffer); err != nil {
t.Fatalf("Failed ReadFrom: %s", err)
}
if len(decoded) != len(results) {
t.Fatalf("Length mismatch. Want: %d, Got: %d", len(results), len(decoded))
}
for i, result := range results {
if decoded[i].Timestamp != result.Timestamp {
t.Fatalf("Expected result with timestamp: %s, got: %s", result.Timestamp, decoded[i].Timestamp)
}
}
}
func TestSort(t *testing.T) {
results := Results{
Result{Timestamp: time.Date(2013, 9, 10, 20, 4, 0, 3, time.UTC)},
Result{Timestamp: time.Date(2013, 9, 10, 20, 4, 0, 2, time.UTC)},
Result{Timestamp: time.Date(2013, 9, 10, 20, 4, 0, 1, time.UTC)},
}
results.Sort()
if !sort.IsSorted(results) {
t.Fatalf("Sort failed: %v", results)
}
}

View File

@@ -6,25 +6,14 @@ import (
"io"
"math/rand"
"net/http"
"os"
"strings"
)
// Targets represents the http.Requests which will be issued during the test
type Targets []*http.Request
// NewTargetsFromFile reads and parses targets from a text file
func NewTargetsFromFile(filename string) (Targets, error) {
file, err := os.Open(filename)
if err != nil {
return Targets{}, err
}
defer file.Close()
return readTargets(file)
}
// readTargets reads targets out of a line separated source skipping empty lines
func readTargets(source io.Reader) (Targets, error) {
// NewTargetsFrom reads targets out of a line separated source skipping empty lines
func NewTargetsFrom(source io.Reader) (Targets, error) {
scanner := bufio.NewScanner(source)
lines := make([]string, 0)
for scanner.Scan() {

View File

@@ -6,9 +6,9 @@ import (
"testing"
)
func TestReadTargets(t *testing.T) {
func TestNewTargetsFrom(t *testing.T) {
lines := bytes.NewBufferString("GET http://lolcathost:9999/\n\n // HEAD http://lolcathost.com this is a comment \nHEAD http://lolcathost:9999/\n")
targets, err := readTargets(lines)
targets, err := NewTargetsFrom(lines)
if err != nil {
t.Fatalf("Couldn't parse valid source: %s", err)
}

122
main.go
View File

@@ -3,104 +3,48 @@ package main
import (
"flag"
"fmt"
vegeta "github.com/tsenart/vegeta/lib"
"io"
"log"
"os"
"runtime"
"time"
)
func main() {
var (
rate = flag.Uint64("rate", 50, "Requests per second")
targetsf = flag.String("targets", "targets.txt", "Targets file")
ordering = flag.String("ordering", "random", "Attack ordering [sequential, random]")
duration = flag.Duration("duration", 10*time.Second, "Duration of the test")
reporter = flag.String("reporter", "text", "Reporter to use [text, json, plot:timings]")
output = flag.String("output", "stdout", "Reporter output file")
cpus = flag.Int("cpus", runtime.NumCPU(), "Number of CPUs to use")
)
flag.Parse()
// command is a closure function which each command constructor
// builds and returns
type command func() error
if flag.NFlag() == 0 {
flag.Usage()
return
var usage = fmt.Sprintf(
`Usage: vegeta [globals] <command> [options]
Commands:
attack Hit the targets
report Report the results
Globals:
-cpus=%d Number of CPUs to use
`, runtime.NumCPU())
func init() {
flag.Usage = func() { fmt.Print(usage) }
cpus := flag.Int("cpus", runtime.NumCPU(), "Number of CPUs to use")
flag.Parse()
runtime.GOMAXPROCS(*cpus)
}
func main() {
commands := map[string]func([]string) command{
"attack": attackCmd,
"report": reportCmd,
}
runtime.GOMAXPROCS(*cpus)
args := flag.Args()
if len(args) == 0 {
flag.Usage()
os.Exit(1)
}
if err := run(*rate, *duration, *targetsf, *ordering, *reporter, *output); err != nil {
if cmd, ok := commands[args[0]]; !ok {
log.Fatalf("Unknown command: %s", args[0])
} else if err := cmd(args[1:])(); err != nil {
log.Fatal(err)
}
}
const (
errRatePrefix = "Rate: "
errDurationPrefix = "Duration: "
errOutputFilePrefix = "Output file: "
errTargetsFilePrefix = "Targets file: "
errOrderingPrefix = "Ordering: "
errReportingPrefix = "Reporting: "
)
// run is an utility function that validates the attack arguments, sets up the
// required resources, launches the attack and reports the results
func run(rate uint64, duration time.Duration, targetsf, ordering, reporter, output string) error {
if rate == 0 {
return fmt.Errorf(errRatePrefix + "can't be zero")
}
if duration == 0 {
return fmt.Errorf(errDurationPrefix + "can't be zero")
}
var out io.Writer
switch output {
case "stdout":
out = os.Stdout
default:
file, err := os.Create(output)
if err != nil {
return fmt.Errorf(errOutputFilePrefix+"(%s): %s", output, err)
}
defer file.Close()
out = file
}
var rep vegeta.Reporter
switch reporter {
case "text":
rep = vegeta.ReportText
case "json":
rep = vegeta.ReportJSON
case "plot:timings":
rep = vegeta.ReportTimingsPlot
default:
log.Println("Reporter provided is not supported. Using text")
rep = vegeta.ReportText
}
targets, err := vegeta.NewTargetsFromFile(targetsf)
if err != nil {
return fmt.Errorf(errTargetsFilePrefix+"(%s): %s", targetsf, err)
}
switch ordering {
case "random":
targets.Shuffle(time.Now().UnixNano())
case "sequential":
break
default:
return fmt.Errorf(errOrderingPrefix+"`%s` is invalid", ordering)
}
log.Printf("Vegeta is attacking %d targets in %s order for %s...\n", len(targets), ordering, duration)
results := vegeta.Attack(targets, rate, duration)
log.Println("Done!")
log.Printf("Writing report to '%s'...", output)
if err = rep(results, out); err != nil {
return fmt.Errorf(errReportingPrefix+"%s", err)
}
return nil
}

View File

@@ -1,104 +0,0 @@
package main
import (
"io/ioutil"
"log"
"strings"
"testing"
"time"
)
func init() {
// Discard default log output
log.SetOutput(ioutil.Discard)
}
func TestRateValidation(t *testing.T) {
rate, duration, targetsf, ordering, reporter, output := defaultArguments()
rate = 0
err := run(rate, duration, targetsf, ordering, reporter, output)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errRatePrefix)) {
t.Errorf("Rate 0 shouldn't be valid: %s", err)
}
}
func TestDurationValidation(t *testing.T) {
rate, duration, targetsf, ordering, reporter, output := defaultArguments()
duration = 0
err := run(rate, duration, targetsf, ordering, reporter, output)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errDurationPrefix)) {
t.Errorf("Duration 0 shouldn't be valid: %s", err)
}
}
func TestOutputValidation(t *testing.T) {
rate, duration, targetsf, ordering, reporter, _ := defaultArguments()
// Good cases
for _, output := range []string{"stdout", "/dev/null"} {
err := run(rate, duration, targetsf, ordering, reporter, output)
if err != nil {
t.Errorf("Output file `%s` should be valid: %s", output, err)
}
}
// Bad case
badOutput := ""
err := run(rate, duration, targetsf, ordering, reporter, badOutput)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errOutputFilePrefix)) {
t.Errorf("Output file `%s` shouldn't be valid: %s", badOutput, err)
}
}
func TestReporter(t *testing.T) {
rate, duration, targetsf, ordering, _, output := defaultArguments()
for _, reporter := range []string{"text", "plot:timings"} {
err := run(rate, duration, targetsf, ordering, reporter, output)
if err != nil {
t.Errorf("Reporter `%s` shouldn't return an error: %s", reporter, err)
}
}
}
func TestTargetsValidation(t *testing.T) {
rate, duration, goodFile, ordering, reporter, output := defaultArguments()
// Good case
err := run(rate, duration, goodFile, ordering, reporter, output)
if err != nil {
t.Errorf("Targets file `%s` should be valid: %s", goodFile, err)
}
// Bad case
badFile := "randomInexistingFile12345.txt"
err = run(rate, duration, badFile, ordering, reporter, output)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errTargetsFilePrefix)) {
t.Errorf("Targets file `%s` shouldn't be valid: %s", badFile, err)
}
}
func TestOrderingValidation(t *testing.T) {
rate, duration, targetsf, _, reporter, output := defaultArguments()
// Good cases
for _, ordering := range []string{"random", "sequential"} {
err := run(rate, duration, targetsf, ordering, reporter, output)
if err != nil {
t.Errorf("Ordering `%s` should be valid: %s", ordering, err)
}
}
// Bad case
badOrdering := "lolcat"
err := run(rate, duration, targetsf, badOrdering, reporter, output)
if err == nil || (err != nil && !strings.HasPrefix(err.Error(), errOrderingPrefix)) {
t.Errorf("Ordering `%s` shouldn't be valid: %s", badOrdering, err)
}
}
func defaultArguments() (uint64, time.Duration, string, string, string, string) {
return uint64(1000), 5 * time.Millisecond, ".targets.txt", "random", "text", "/dev/null"
}

59
report.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"flag"
vegeta "github.com/tsenart/vegeta/lib"
"log"
)
func reportCmd(args []string) command {
fs := flag.NewFlagSet("report", flag.ExitOnError)
reporter := fs.String("reporter", "text", "Reporter [text, json, plot:timings]")
input := fs.String("input", "stdin", "Input file")
output := fs.String("output", "stdout", "Output file")
fs.Parse(args)
return func() error {
return report(*reporter, *input, *output)
}
}
// report validates the report arguments, sets up the required resources
// and writes the report
func report(reporter, input, output string) error {
var rep vegeta.Reporter
switch reporter {
case "text":
rep = vegeta.ReportText
case "json":
rep = vegeta.ReportJSON
case "plot:timings":
rep = vegeta.ReportTimingsPlot
default:
log.Println("Reporter provided is not supported. Using text")
rep = vegeta.ReportText
}
in, err := file(input, false)
if err != nil {
return err
}
defer in.Close()
out, err := file(output, true)
if err != nil {
return err
}
defer out.Close()
results := vegeta.Results{}
if err := results.ReadFrom(in); err != nil {
return err
}
if err := rep(results, out); err != nil {
return err
}
return nil
}