Skip to content

Commit

Permalink
Merge pull request #23 from kffl/feat/json-output
Browse files Browse the repository at this point in the history
Output load test report data as JSON or YAML
  • Loading branch information
kffl authored Nov 15, 2021
2 parents f024a0b + 9bcc02f commit 1c5adc9
Show file tree
Hide file tree
Showing 17 changed files with 179 additions and 100 deletions.
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ license: "Apache-2.0"
message: "Please use the following citation metadata."
repository-code: "https://github.com/kffl/gocannon"
title: "Gocannon - Performance-focused HTTP benchmarking tool"
version: "1.0.0"
version: "1.1.0"
40 changes: 23 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,32 @@ usage: gocannon [<flags>] <target>
Flags:
--help Show context-sensitive help (also try --help-long and
--help-man).
-d, --duration=10s Load test duration
-c, --connections=50 Maximum number of concurrent connections
-t, --timeout=200ms HTTP client timeout
-m, --mode="reqlog" Statistics collection mode: reqlog (logs each request) or hist
(stores histogram of completed requests latencies)
-o, --output=file.csv File to save the request log in CSV format (reqlog mode) or a
text file with raw histogram data (hist mode)
-i, --interval=250ms Interval for statistics calculation (reqlog mode)
--preallocate=1000 Number of requests in req log to preallocate memory for per
connection (reqlog mode)
--method=GET The HTTP request method (GET, POST, PUT, PATCH or DELETE)
-b, --body="{data..." HTTP request body
-h, --header="k:v" ... HTTP request header(s). You can set more than one header by
repeating this flag.
--trust-all Omit SSL certificate validation
--plugin=/to/p.so Plugin to run Gocannon with
-d, --duration=10s Load test duration.
-c, --connections=50 Maximum number of concurrent connections.
-t, --timeout=200ms HTTP client timeout.
-m, --mode="reqlog" Statistics collection mode: reqlog (logs each request)
or hist (stores histogram of completed requests
latencies).
-o, --output=file.csv File to save the request log in CSV format (reqlog
mode) or a text file with raw histogram data (hist
mode).
-i, --interval=250ms Interval for statistics calculation (reqlog mode).
--preallocate=1000 Number of requests in req log to preallocate memory
for per connection (reqlog mode).
--method=GET The HTTP request method (GET, POST, PUT, PATCH or
DELETE).
-b, --body="{data..." HTTP request body.
--trust-all Omit SSL certificate validation.
-f, --format=default Load test report format. Either 'default' (verbose),
'json' or 'yaml'. When json or yaml is specified,
apart from the load test results, no additional info
will be written to std out.
--plugin=/to/p.so Plugin to run Gocannon with (path to .so file).
--version Show application version.
Args:
<target> HTTP target URL with port (i.e. http://localhost:80/test or https://host:443/x)
<target> HTTP target URL with port (i.e. http://localhost:80/test or
https://host:443/x)
```

Below is an example of a load test conducted using gocannon against an Express.js server (notice the performance improvement over time under sustained load):
Expand Down
32 changes: 18 additions & 14 deletions args.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,46 +19,50 @@ func parseRequestHeaders(s kingpin.Settings) *common.RequestHeaders {
return r
}

var app = kingpin.New("gocannon", "Performance-focused HTTP benchmarking tool")
var app = kingpin.New("gocannon", "Performance-focused HTTP load testing tool.")

var config = common.Config{
Duration: app.Flag("duration", "Load test duration").
Duration: app.Flag("duration", "Load test duration.").
Short('d').
Default("10s").
Duration(),
Connections: app.Flag("connections", "Maximum number of concurrent connections").
Connections: app.Flag("connections", "Maximum number of concurrent connections.").
Short('c').
Default("50").
Int(),
Timeout: app.Flag("timeout", "HTTP client timeout").
Timeout: app.Flag("timeout", "HTTP client timeout.").
Short('t').
Default("200ms").
Duration(),
Mode: app.Flag("mode", "Statistics collection mode: reqlog (logs each request) or hist (stores histogram of completed requests latencies)").
Mode: app.Flag("mode", "Statistics collection mode: reqlog (logs each request) or hist (stores histogram of completed requests latencies).").
Default("reqlog").
Short('m').
String(),
OutputFile: app.Flag("output", "File to save the request log in CSV format (reqlog mode) or a text file with raw histogram data (hist mode)").
OutputFile: app.Flag("output", "File to save the request log in CSV format (reqlog mode) or a text file with raw histogram data (hist mode).").
PlaceHolder("file.csv").
Short('o').
String(),
Interval: app.Flag("interval", "Interval for statistics calculation (reqlog mode)").
Interval: app.Flag("interval", "Interval for statistics calculation (reqlog mode).").
Default("250ms").
Short('i').
Duration(),
Preallocate: app.Flag("preallocate", "Number of requests in req log to preallocate memory for per connection (reqlog mode)").
Preallocate: app.Flag("preallocate", "Number of requests in req log to preallocate memory for per connection (reqlog mode).").
Default("1000").
Int(),
Method: app.Flag("method", "The HTTP request method (GET, POST, PUT, PATCH or DELETE)").Default("GET").Enum("GET", "POST", "PUT", "PATCH", "DELETE"),
Body: parseRequestBody(app.Flag("body", "HTTP request body").Short('b').PlaceHolder("\"{data...\"")),
Method: app.Flag("method", "The HTTP request method (GET, POST, PUT, PATCH or DELETE).").Default("GET").Enum("GET", "POST", "PUT", "PATCH", "DELETE"),
Body: parseRequestBody(app.Flag("body", "HTTP request body.").Short('b').PlaceHolder("\"{data...\"")),
Headers: parseRequestHeaders(kingpin.Flag("header", "HTTP request header(s). You can set more than one header by repeating this flag.").Short('h').PlaceHolder("\"k:v\"")),
TrustAll: app.Flag("trust-all", "Omit SSL certificate validation").Bool(),
Plugin: app.Flag("plugin", "Plugin to run Gocannon with").PlaceHolder("/to/p.so").ExistingFile(),
Target: app.Arg("target", "HTTP target URL with port (i.e. http://localhost:80/test or https://host:443/x)").Required().String(),
TrustAll: app.Flag("trust-all", "Omit SSL certificate validation.").Bool(),
Format: app.Flag("format", "Load test report format. Either 'default' (verbose), 'json' or 'yaml'. When json or yaml is specified, apart from the load test results, no additional info will be written to std out.").
Short('f').
Default("default").
Enum("default", "json", "yaml"),
Plugin: app.Flag("plugin", "Plugin to run Gocannon with (path to .so file).").PlaceHolder("/to/p.so").ExistingFile(),
Target: app.Arg("target", "HTTP target URL with port (i.e. http://localhost:80/test or https://host:443/x)").Required().String(),
}

func parseArgs() error {
app.Version("1.0.0")
app.Version("1.1.0")
_, err := app.Parse(os.Args[1:])
return err
}
1 change: 1 addition & 0 deletions common/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Config struct {
Body *RawRequestBody
Headers *RequestHeaders
TrustAll *bool
Format *string
Plugin *string
Target *string
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ require (
github.com/valyala/fasthttp v1.31.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v2 v2.4.0
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
18 changes: 12 additions & 6 deletions gocannon.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ func runGocannon(cfg common.Config) error {
var gocannonPlugin common.GocannonPlugin
var err error

if *config.Format == "default" {
printHeader(config)
}

if *cfg.Plugin != "" {
gocannonPlugin, err = loadPlugin(*cfg.Plugin)
gocannonPlugin, err = loadPlugin(*cfg.Plugin, *cfg.Format != "default")
if err != nil {
return err
}
Expand Down Expand Up @@ -47,7 +51,9 @@ func runGocannon(cfg common.Config) error {
start := makeTimestamp()
stop := start + cfg.Duration.Nanoseconds()

fmt.Printf("gocannon goes brr...\n")
if *cfg.Format == "default" {
fmt.Printf("gocannon goes brr...\n")
}

for connectionID := 0; connectionID < n; connectionID++ {
go func(c *fasthttp.HostClient, cid int, p common.GocannonPlugin) {
Expand Down Expand Up @@ -79,8 +85,10 @@ func runGocannon(cfg common.Config) error {
return err
}

printSummary(stats)
stats.PrintReport()
if *cfg.Format == "default" {
printSummary(stats)
}
stats.PrintReport(*cfg.Format)

return nil
}
Expand All @@ -91,8 +99,6 @@ func main() {
exitWithError(err)
}

printHeader(config)

err = runGocannon(config)

if err != nil {
Expand Down
52 changes: 37 additions & 15 deletions hist/histogram.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@ package hist

import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"sync/atomic"
"time"

"github.com/kffl/gocannon/rescodes"
"gopkg.in/yaml.v2"
)

type histogram []int64

type summary struct {
reqCount int64
reqPerSec float64
latencyAvg float64
latencyPercentiles []int64
ReqCount int64
ReqPerSec float64
LatencyAvg float64
LatencyPercentiles []int64
}

type requestHist struct {
Expand Down Expand Up @@ -75,16 +77,36 @@ func (h *requestHist) CalculateStats(
return nil
}

func (h *requestHist) PrintReport() {
h.results.print()
if h.didNotFit > 0 {
fmt.Fprintf(
os.Stderr,
"WARNING: some recorded responses (%d) did not fit in the histogram potentially skewing the resulting stats. Consider increasing timeout duration.\n",
func (h *requestHist) PrintReport(format string) {
if format == "default" {
h.results.print()
if h.didNotFit > 0 {
fmt.Fprintf(
os.Stderr,
"WARNING: some recorded responses (%d) did not fit in the histogram potentially skewing the resulting stats. Consider increasing timeout duration.\n",
h.didNotFit,
)
}
h.resCodes.PrintRescodes()
} else {
obj := struct {
Report *summary
DidNotFit int64
ResCodes map[int]int64
}{
&h.results,
h.didNotFit,
)
h.resCodes.AsMap(),
}
var output []byte
if format == "json" {
output, _ = json.MarshalIndent(obj, "", " ")
}
if format == "yaml" {
output, _ = yaml.Marshal(obj)
}
fmt.Printf("%s", output)
}
h.resCodes.PrintRescodes()
}

func (h *requestHist) saveRawData(fileName string) error {
Expand All @@ -109,13 +131,13 @@ func (h *requestHist) saveRawData(fileName string) error {
}

func (h *requestHist) GetReqCount() int64 {
return h.results.reqCount
return h.results.ReqCount
}

func (h *requestHist) GetReqPerSec() float64 {
return h.results.reqPerSec
return h.results.ReqPerSec
}

func (h *requestHist) GetLatencyAvg() float64 {
return h.results.latencyAvg * 1000.0
return h.results.LatencyAvg * 1000.0
}
4 changes: 2 additions & 2 deletions hist/histogram_print.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import "fmt"
func (r *summary) print() {
fmt.Println("|------------------------LATENCY (μs)-----------------------|")
fmt.Println(" AVG P50 P75 P90 P99")
fmt.Printf("%13.2f", r.latencyAvg)
for _, v := range r.latencyPercentiles {
fmt.Printf("%13.2f", r.LatencyAvg)
for _, v := range r.LatencyPercentiles {
fmt.Printf(" %11v", v)
}
fmt.Printf("\n")
Expand Down
4 changes: 4 additions & 0 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ func TestGocannonDefaultValues(t *testing.T) {
body := common.RawRequestBody{}
header := common.RequestHeaders{}
trustAll := false
format := "default"
plugin := ""
target := "http://localhost:3000/hello"

Expand All @@ -120,6 +121,7 @@ func TestGocannonDefaultValues(t *testing.T) {
Body: &body,
Headers: &header,
TrustAll: &trustAll,
Format: &format,
Plugin: &plugin,
Target: &target,
}
Expand All @@ -144,6 +146,7 @@ func TestGocanonWithPlugin(t *testing.T) {
body := common.RawRequestBody{}
header := common.RequestHeaders{}
trustAll := true
format := "json"
plugin := "_example_plugin/plugin.so"
target := "http://localhost:3000/hello"

Expand All @@ -159,6 +162,7 @@ func TestGocanonWithPlugin(t *testing.T) {
Body: &body,
Headers: &header,
TrustAll: &trustAll,
Format: &format,
Plugin: &plugin,
Target: &target,
}
Expand Down
6 changes: 4 additions & 2 deletions plugin_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ var (
ErrPluginInterface = errors.New("module symbol doesn't match GocannonPlugin")
)

func loadPlugin(file string) (common.GocannonPlugin, error) {
func loadPlugin(file string, silentOutput bool) (common.GocannonPlugin, error) {

p, err := plugin.Open(file)
if err != nil {
Expand All @@ -32,7 +32,9 @@ func loadPlugin(file string) (common.GocannonPlugin, error) {
return nil, ErrPluginInterface
}

fmt.Printf("Plugin %s loaded.\n", gocannonPlugin.GetName())
if !silentOutput {
fmt.Printf("Plugin %s loaded.\n", gocannonPlugin.GetName())
}

return gocannonPlugin, nil
}
Loading

0 comments on commit 1c5adc9

Please sign in to comment.