diff --git a/README.md b/README.md index 43c298e0..dc9ef618 100644 --- a/README.md +++ b/README.md @@ -41,78 +41,81 @@ Usage: vegeta [global flags] [command flags] global flags: -cpus int - Number of CPUs to use (default 8) + Number of CPUs to use (default 8) -profile string - Enable profiling of [cpu, heap] + Enable profiling of [cpu, heap] -version - Print version and exit + Print version and exit attack command: -body string - Requests body file + Requests body file -cert string - TLS client PEM encoded certificate file + TLS client PEM encoded certificate file -connections int - Max open idle connections per target host (default 10000) + Max open idle connections per target host (default 10000) -duration duration - Duration of the test [0 = forever] + Duration of the test [0 = forever] -format string - Targets format [http, json] (default "http") + Targets format [http, json] (default "http") -h2c - Send HTTP/2 requests without TLS encryption + Send HTTP/2 requests without TLS encryption -header value - Request header + Request header -http2 - Send HTTP/2 requests when supported by the server (default true) + Send HTTP/2 requests when supported by the server (default true) -insecure - Ignore invalid server TLS certificates + Ignore invalid server TLS certificates -keepalive - Use persistent connections (default true) + Use persistent connections (default true) -key string - TLS client PEM encoded private key file + TLS client PEM encoded private key file -laddr value - Local IP address (default 0.0.0.0) + Local IP address (default 0.0.0.0) -lazy - Read targets lazily + Read targets lazily -name string - Attack name + Attack name -output string - Output file (default "stdout") - -rate uint - Requests per second (default 50) + Output file (default "stdout") + -rate value + Number of requests per time unit (default 50/1s) -redirects int - Number of redirects to follow. -1 will not follow but marks as success (default 10) + Number of redirects to follow. -1 will not follow but marks as success (default 10) -root-certs value - TLS root certificate files (comma separated list) + TLS root certificate files (comma separated list) -targets string - Targets file (default "stdin") + Targets file (default "stdin") -timeout duration - Requests timeout (default 30s) + Requests timeout (default 30s) -workers uint - Initial number of workers (default 10) - -report command: - -inputs string - Input files (comma separated) (default "stdin") - -output string - Output file (default "stdout") - -reporter string - Reporter [text, json, hist[buckets]] (default "text") + Initial number of workers (default 10) encode command: - -from string - Input decoding [csv, gob, json] (default "gob") -output string - Output file (default "stdout") + Output file (default "stdout") -to string - Output encoding [csv, gob, json] (default "json") + Output encoding [csv, gob, json] (default "json") + +plot command: + -output string + Output file (default "stdout") + -threshold int + Threshold of data points above which series are downsampled. (default 4000) + -title string + Title and header of the resulting HTML page (default "Vegeta Plot") + +report command: + -output string + Output file (default "stdout") + -type string + Report type to generate [text, json, hist[buckets]] (default "text") examples: echo "GET http://localhost/" | vegeta attack -duration=5s | tee results.bin | vegeta report - vegeta attack -targets=targets.txt > results.bin - vegeta report -inputs=results.bin -reporter=json > metrics.json + vegeta report -type=json results.bin > metrics.json cat results.bin | vegeta plot > plot.html - cat results.bin | vegeta report -reporter="hist[0,100ms,200ms,300ms]" + cat results.bin | vegeta report -type="hist[0,100ms,200ms,300ms]" ``` #### `-cpus` @@ -239,9 +242,10 @@ 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 +Specifies the request rate per time unit 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. +If no time unit is provided, 1s is used. #### `-redirects` Specifies the max number of redirects followed on each request. The @@ -265,21 +269,29 @@ Specifies the initial number of workers used in the attack. The actual number of workers will increase if necessary in order to sustain the requested rate. -### report command +### `report` command -#### `-inputs` -Specifies the input files to generate the report of, defaulting to stdin. -These are the output of vegeta attack. You can specify more than one (comma -separated) and they will be merged and sorted before being used by the -reports. +``` +Usage: vegeta report [options] [...] -#### `-output` -Specifies the output file to which the report will be written to. +Outputs a report of attack results. + +Arguments: + A file with vegeta attack results encoded with one of + the supported encodings (gob | json | csv) [default: stdin] + +Options: + --type Which report type to generate (text | json | hist[buckets]). + [default: text] + --output Output file [default: stdout] -#### `-reporter` -Specifies the kind of report to be generated. It defaults to text. +Examples: + echo "GET http://:80" | vegeta attack -rate=10/s > results.gob + echo "GET http://:80" | vegeta attack -rate=100/s | vegeta encode > results.json + vegeta report results.* +``` -##### `text` +#### `report -type=text` ```console Requests [total, rate] 1200, 120.00 Duration [total, attack, wait] 10.094965987s, 9.949883921s, 145.082066ms @@ -297,7 +309,7 @@ Get http://localhost:6060: net/http: transport closed before response was receiv Get http://localhost:6060: http: can't write HTTP request on broken connection ``` -##### `json` +#### `report -type=json` ```json { "latencies": { @@ -331,11 +343,11 @@ Get http://localhost:6060: http: can't write HTTP request on broken connection } ``` -##### `hist` +#### `report -type=hist` Computes and prints a text based histogram for the given buckets. Each bucket upper bound is non-inclusive. ```console -cat results.bin | vegeta report -reporter='hist[0,2ms,4ms,6ms]' +cat results.bin | vegeta report -type='hist[0,2ms,4ms,6ms]' Bucket # % Histogram [0, 2ms] 6007 32.65% ######################## [2ms, 4ms] 5505 29.92% ###################### @@ -345,33 +357,38 @@ Bucket # % Histogram ### `encode` command -#### `[...]` -Input files are given as optional list of arguments. Defaults to `stdin`. +``` +Usage: vegeta encode [options] [...] -#### `-to` -Specifies the encoding format of the output. +Encodes vegeta attack results from one encoding to another. +The supported encodings are Gob (binary), CSV and JSON. +Each input file may have a different encoding which is detected +automatically. -#### `-output` -Specifies the file to which the output will be written to. Defaults to -`stdout`. - -##### `csv` -Decodes/encodes results as CSV records with nine columns: -* unix timestamp in nanoseconds since epoch -* HTTP status code -* request latency in nanoseconds -* bytes out -* bytes in -* error if present -* Base64 encoded body -* attack name -* sequence number - -##### `gob` -Decodes/encodes results as Golangs native [binary encoding](https://golang.org/pkg/encoding/gob). - -##### `json` -Decodes/encodes results as JSON objects. +The CSV encoder doesn't write a header. The columns written by it are: + + 1. Unix timestamp in nanoseconds since epoch + 2. HTTP status code + 3. Request latency in nanoseconds + 4. Bytes out + 5. Bytes in + 6. Error + 7. Base64 encoded response body + 8. Attack name + 9. Sequence number of request + +Arguments: + A file with vegeta attack results encoded with one of + the supported encodings (gob | json | csv) [default: stdin] + +Options: + --to Output encoding (gob | json | csv) [default: json] + --output Output file [default: stdout] + +Examples: + echo "GET http://:80" | vegeta attack -rate=1/s > results.gob + cat results.gob | vegeta encode | jq -c 'del(.body)' | vegeta encode -to gob +``` ### `plot` command diff --git a/encode.go b/encode.go index d99e8105..e3997f5c 100644 --- a/encode.go +++ b/encode.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "flag" "fmt" "io" @@ -25,6 +24,18 @@ The supported encodings are Gob (binary), CSV and JSON. Each input file may have a different encoding which is detected automatically. +The CSV encoder doesn't write a header. The columns written by it are: + + 1. Unix timestamp in nanoseconds since epoch + 2. HTTP status code + 3. Request latency in nanoseconds + 4. Bytes out + 5. Bytes in + 6. Error + 7. Base64 encoded response body + 8. Attack name + 9. Sequence number of request + Arguments: A file with vegeta attack results encoded with one of the supported encodings (gob | json | csv) [default: stdin] @@ -59,43 +70,12 @@ func encodeCmd() command { } func encode(files []string, to, output string) error { - srcs := make([]vegeta.Decoder, len(files)) - decs := []func(io.Reader) vegeta.Decoder{ - vegeta.NewDecoder, - vegeta.NewJSONDecoder, - vegeta.NewCSVDecoder, - } - - for i, f := range files { - in, err := file(f, false) - if err != nil { - return err - } - defer in.Close() - - // Auto-detect encoding of each file individually and buffer the read bytes - // so that they can be read in subsequent decoding attempts as well as - // in the final decoder. - - var buf bytes.Buffer - var dec func(io.Reader) vegeta.Decoder - for j := range decs { - rd := io.MultiReader(bytes.NewReader(buf.Bytes()), io.TeeReader(in, &buf)) - if err = decs[j](rd).Decode(&vegeta.Result{}); err == nil { - dec = decs[j] - break - } - } - - if dec == nil { - return fmt.Errorf("encode: can't detect encoding of %q", f) - } - - srcs[i] = dec(io.MultiReader(&buf, in)) + dec, mc, err := decoder(files) + defer mc.Close() + if err != nil { + return err } - dec := vegeta.NewRoundRobinDecoder(srcs...) - out, err := file(output, true) if err != nil { return err diff --git a/file.go b/file.go index 13251d34..9e547f16 100644 --- a/file.go +++ b/file.go @@ -1,7 +1,13 @@ package main import ( + "errors" + "fmt" + "io" "os" + "strings" + + vegeta "github.com/tsenart/vegeta/lib" ) func file(name string, create bool) (*os.File, error) { @@ -17,3 +23,40 @@ func file(name string, create bool) (*os.File, error) { return os.Open(name) } } + +func decoder(files []string) (vegeta.Decoder, io.Closer, error) { + closer := make(multiCloser, 0, len(files)) + decs := make([]vegeta.Decoder, 0, len(files)) + for _, f := range files { + rc, err := file(f, false) + if err != nil { + return nil, closer, err + } + + dec := vegeta.DecoderFor(rc) + if dec == nil { + return nil, closer, fmt.Errorf("encode: can't detect encoding of %q", f) + } + + decs = append(decs, dec) + closer = append(closer, rc) + } + return vegeta.NewRoundRobinDecoder(decs...), closer, nil +} + +type multiCloser []io.Closer + +func (mc multiCloser) Close() error { + var errs []string + for _, c := range mc { + if err := c.Close(); err != nil { + errs = append(errs, err.Error()) + } + } + + if len(errs) > 0 { + return errors.New(strings.Join(errs, "; ")) + } + + return nil +} diff --git a/lib/results.go b/lib/results.go index 5701594f..56e89bd8 100644 --- a/lib/results.go +++ b/lib/results.go @@ -64,9 +64,35 @@ func (rs Results) Swap(i, j int) { rs[i], rs[j] = rs[j], rs[i] } // A Decoder decodes a Result and returns an error in case of failure. type Decoder func(*Result) error +// A DecoderFactory constructs a new Decoder from a given io.Reader. +type DecoderFactory func(io.Reader) Decoder + +// DecoderFor automatically detects the encoding of the first few bytes in +// the given io.Reader and then returns the corresponding Decoder or nil +// in case of failing to detect a supported encoding. +func DecoderFor(r io.Reader) Decoder { + var buf bytes.Buffer + for _, dec := range []DecoderFactory{ + NewDecoder, + NewJSONDecoder, + NewCSVDecoder, + } { + rd := io.MultiReader(bytes.NewReader(buf.Bytes()), io.TeeReader(r, &buf)) + if err := dec(rd).Decode(&Result{}); err == nil { + return dec(io.MultiReader(&buf, r)) + } + } + return nil +} + // NewRoundRobinDecoder returns a new Decoder that round robins across the // given Decoders on every invocation or decoding error. func NewRoundRobinDecoder(dec ...Decoder) Decoder { + // Optimization for single Decoder case. + if len(dec) == 1 { + return dec[0] + } + var seq uint64 return func(r *Result) (err error) { for range dec { diff --git a/lib/results_test.go b/lib/results_test.go index 90084314..ee4ca624 100644 --- a/lib/results_test.go +++ b/lib/results_test.go @@ -53,6 +53,9 @@ func TestEncoding(t *testing.T) { enc func(io.Writer) Encoder dec func(io.Reader) Decoder }{ + {"auto-gob", NewEncoder, DecoderFor}, + {"auto-json", NewJSONEncoder, DecoderFor}, + {"auto-csv", NewCSVEncoder, DecoderFor}, {"gob", NewEncoder, NewDecoder}, {"csv", NewCSVEncoder, NewCSVDecoder}, {"json", NewJSONEncoder, NewJSONDecoder}, @@ -61,9 +64,6 @@ func TestEncoding(t *testing.T) { t.Run(tc.encoding, func(t *testing.T) { t.Parallel() - var buf bytes.Buffer - enc := tc.enc(&buf) - dec := tc.dec(&buf) err := quick.Check(func(code uint16, ts uint32, latency time.Duration, seq, bsIn, bsOut uint64, body []byte, attack, e string) bool { want := Result{ Attack: attack, @@ -77,10 +77,13 @@ func TestEncoding(t *testing.T) { Body: body, } + var buf bytes.Buffer + enc := tc.enc(&buf) if err := enc(&want); err != nil { t.Fatal(err) } + dec := tc.dec(&buf) var got Result if err := dec(&got); err != nil { t.Fatalf("err: %q buffer: %s", err, buf.String()) diff --git a/main.go b/main.go index 530c3093..69087ae2 100644 --- a/main.go +++ b/main.go @@ -101,10 +101,9 @@ var Version, Commit, Date string const examples = ` examples: echo "GET http://localhost/" | vegeta attack -duration=5s | tee results.bin | vegeta report - vegeta attack -targets=targets.txt > results.bin - vegeta report -inputs=results.bin -reporter=json > metrics.json + vegeta report -type=json results.bin > metrics.json cat results.bin | vegeta plot > plot.html - cat results.bin | vegeta report -reporter="hist[0,100ms,200ms,300ms]" + cat results.bin | vegeta report -type="hist[0,100ms,200ms,300ms]" ` type command struct { diff --git a/plot.go b/plot.go index 8efee2e1..f0420b74 100644 --- a/plot.go +++ b/plot.go @@ -23,7 +23,8 @@ Choose a different number on the bottom left corner input field to change the moving average window size (in data points). Arguments: - A file output by running vegeta attack [default: stdin] + A file with vegeta attack results encoded with one of + the supported encodings (gob | json | csv) [default: stdin] Options: --title Title and header of the resulting HTML page. @@ -60,16 +61,11 @@ func plotCmd() command { } func plotRun(files []string, threshold int, title, output string) error { - srcs := make([]vegeta.Decoder, len(files)) - for i, f := range files { - in, err := file(f, false) - if err != nil { - return err - } - defer in.Close() - srcs[i] = vegeta.NewDecoder(in) + dec, mc, err := decoder(files) + defer mc.Close() + if err != nil { + return err } - dec := vegeta.NewRoundRobinDecoder(srcs...) out, err := file(output, true) if err != nil { diff --git a/report.go b/report.go index a2d50c63..17797600 100644 --- a/report.go +++ b/report.go @@ -6,40 +6,58 @@ import ( "io" "os" "os/signal" - "strings" vegeta "github.com/tsenart/vegeta/lib" ) +const reportUsage = `Usage: vegeta report [options] [...] + +Outputs a report of attack results. + +Arguments: + A file with vegeta attack results encoded with one of + the supported encodings (gob | json | csv) [default: stdin] + +Options: + --type Which report type to generate (text | json | hist[buckets]). + [default: text] + --output Output file [default: stdout] + +Examples: + echo "GET http://:80" | vegeta attack -rate=10/s > results.gob + echo "GET http://:80" | vegeta attack -rate=100/s | vegeta encode > results.json + vegeta report results.* +` + func reportCmd() command { fs := flag.NewFlagSet("vegeta report", flag.ExitOnError) - reporter := fs.String("reporter", "text", "Reporter [text, json, hist[buckets]]") - inputs := fs.String("inputs", "stdin", "Input files (comma separated)") + typ := fs.String("type", "text", "Report type to generate [text, json, hist[buckets]]") output := fs.String("output", "stdout", "Output file") + + fs.Usage = func() { + fmt.Fprintln(os.Stderr, reportUsage) + } + return command{fs, func(args []string) error { fs.Parse(args) - return report(*reporter, *inputs, *output) + files := fs.Args() + if len(files) == 0 { + files = append(files, "stdin") + } + return report(files, *typ, *output) }} } -// report validates the report arguments, sets up the required resources -// and writes the report -func report(reporter, inputs, output string) error { - if len(reporter) < 4 { - return fmt.Errorf("bad reporter: %s", reporter) +func report(files []string, typ, output string) error { + if len(typ) < 4 { + return fmt.Errorf("invalid report type: %s", typ) } - files := strings.Split(inputs, ",") - srcs := make([]vegeta.Decoder, len(files)) - for i, f := range files { - in, err := file(f, false) - if err != nil { - return err - } - defer in.Close() - srcs[i] = vegeta.NewDecoder(in) + dec, mc, err := decoder(files) + defer mc.Close() + if err != nil { + return err } - dec := vegeta.NewRoundRobinDecoder(srcs...) out, err := file(output, true) if err != nil { @@ -52,7 +70,7 @@ func report(reporter, inputs, output string) error { report vegeta.Report ) - switch reporter[:4] { + switch typ[:4] { case "plot": return fmt.Errorf("The plot reporter has been deprecated and succeeded by the vegeta plot command") case "text": @@ -62,16 +80,16 @@ func report(reporter, inputs, output string) error { var m vegeta.Metrics rep, report = vegeta.NewJSONReporter(&m), &m case "hist": - if len(reporter) < 6 { - return fmt.Errorf("bad buckets: '%s'", reporter[4:]) + if len(typ) < 6 { + return fmt.Errorf("bad buckets: '%s'", typ[4:]) } var hist vegeta.Histogram - if err := hist.Buckets.UnmarshalText([]byte(reporter[4:])); err != nil { + if err := hist.Buckets.UnmarshalText([]byte(typ[4:])); err != nil { return err } rep, report = vegeta.NewHistogramReporter(&hist), &hist default: - return fmt.Errorf("unknown reporter: %q", reporter) + return fmt.Errorf("unknown report type: %q", typ) } sigch := make(chan os.Signal, 1)