diff --git a/.travis.yml b/.travis.yml index 2c38210..af672be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: go go: - - 1.13.x + - 1.14.x os: - linux install: true diff --git a/README.md b/README.md index 0691fc9..4b1b4e5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Toc * [File Slurp Mode](#file-slurp-mode) * [Exporting Metrics to File](#exporting-metrics-to-file) * [Exporting Metrics to Prometheus](#exporting-metrics-to-prometheus) + * [Exporting Metrics to Cloudwatch](#exporting-metrics-to-cloudwatch) * [Load Test with POST Data](#load-test-with-post-data) * [Specifying a Duration](#specifying-a-duration-for-the-load-test) * [Adding HTTP Headers](#adding-http-headers) @@ -41,7 +42,7 @@ Features - **2 Load Testing modes**: one standard and one spread mode where URL Paths can be specified from a file (ideal if you want to hit several underlying microservices) - **CI Friendly**: Well-suited to be part of a CI pipeline step -- **Flexible metrics**: Prometheus metrics (pushing metrics to Prometheus PushGateway), JSON file +- **Flexible metrics**: Cloudwatch metrics, Prometheus metrics (pushing metrics to Prometheus PushGateway), JSON file - **Configurable**: Able to pass in arbitrary HTTP headers, able to configure the HTTP client - **Supports GET, POST & PUT** - POST and PUT data can be defined in a file - **Cross Platform**: One single pre-built binary for Linux, Mac OSX and Windows @@ -160,6 +161,19 @@ Starting Load Test with 100000 requests using 125 concurrent users ``` +### Exporting Metrics to Cloudwatch +**Cassowary** can export metrics to AWS Cloudwatch just by adding the *--cloudwatch* flag without a value. Take note that you will need to tell Cassoway which AWS Region you want to use. The easiest way is using an environment variable as shown below: + +```bash +$ export AWS_REGION=eu-north-1 && ./cassowary run -u http://localhost:8000 -c 125 -n 100000 --cloudwatch + +Starting Load Test with 100000 requests using 125 concurrent users + +[ omitted for brevity ] + +``` + + ### Load Test with POST Data Example hitting a POST endpoint where POST json data is defined in a file: diff --git a/cmd/cassowary/cli.go b/cmd/cassowary/cli.go index d2028eb..cfc7cae 100644 --- a/cmd/cassowary/cli.go +++ b/cmd/cassowary/cli.go @@ -7,6 +7,8 @@ import ( "os" "strconv" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/fatih/color" "github.com/rogerwelin/cassowary/pkg/client" "github.com/urfave/cli" @@ -64,6 +66,27 @@ func runLoadTest(c *client.Cassowary) error { if c.ExportMetrics { return outPutJSON(c.ExportMetricsFile, metrics) } + + if c.PromExport { + err := c.PushPrometheusMetrics(metrics) + if err != nil { + return err + } + } + + if c.Cloudwatch { + session, err := session.NewSession() + if err != nil { + return err + } + + svc := cloudwatch.New(session) + _, err = c.PutCloudwatchMetrics(svc, metrics) + if err != nil { + return err + } + } + return nil } @@ -137,6 +160,7 @@ func validateCLI(c *cli.Context) error { Duration: duration, PromExport: prometheusEnabled, PromURL: c.String("prompushgwurl"), + Cloudwatch: c.Bool("cloudwatch"), ExportMetrics: c.Bool("json-metrics"), ExportMetricsFile: c.String("json-metrics-file"), DisableKeepAlive: c.Bool("disable-keep-alive"), @@ -184,6 +208,7 @@ func validateCLIFile(c *cli.Context) error { RequestHeader: header, PromExport: prometheusEnabled, PromURL: c.String("prompushgwurl"), + Cloudwatch: c.Bool("cloudwatch"), ExportMetrics: c.Bool("json-metrics"), ExportMetricsFile: c.String("json-metrics-file"), DisableKeepAlive: c.Bool("diable-keep-alive"), @@ -237,6 +262,10 @@ func runCLI(args []string) { Name: "p, prompushgwurl", Usage: "specify prometheus push gateway url to send metrics (optional)", }, + cli.BoolFlag{ + Name: "C, cloudwatch", + Usage: "enable to send metrics to AWS Cloudwatch", + }, cli.StringFlag{ Name: "H, header", Usage: "add arbitrary header, eg. 'Host: www.example.com'", @@ -288,6 +317,10 @@ func runCLI(args []string) { Name: "p, prompushgwurl", Usage: "specify prometheus push gateway url to send metrics (optional)", }, + cli.BoolFlag{ + Name: "C, cloudwatch", + Usage: "enable to send metrics to AWS Cloudwatch", + }, cli.StringFlag{ Name: "H, header", Usage: "add arbitrary header, eg. 'Host: www.example.com'", diff --git a/go.mod b/go.mod index 1ce2091..deac9d2 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ module github.com/rogerwelin/cassowary -go 1.13 +go 1.14 require ( + github.com/aws/aws-sdk-go v1.30.24 github.com/fatih/color v1.7.0 github.com/mattn/go-colorable v0.1.4 // indirect github.com/mattn/go-isatty v0.0.10 // indirect diff --git a/go.sum b/go.sum index 063c2a9..ba99b60 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/aws/aws-sdk-go v1.30.24 h1:y3JPD51VuEmVqN3BEDVm4amGpDma2cKJcDPuAU1OR58= +github.com/aws/aws-sdk-go v1.30.24/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -19,6 +21,7 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -28,6 +31,8 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= +github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= @@ -47,6 +52,7 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -76,12 +82,14 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/client/cloudwatch.go b/pkg/client/cloudwatch.go new file mode 100644 index 0000000..5d2e57d --- /dev/null +++ b/pkg/client/cloudwatch.go @@ -0,0 +1,154 @@ +package client + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" +) + +// PutCloudwatchMetrics exports metrics to AWS Cloudwatch +func (c *Cassowary) PutCloudwatchMetrics(svc cloudwatchiface.CloudWatchAPI, metrics ResultMetrics) (*cloudwatch.PutMetricDataOutput, error) { + resp, err := svc.PutMetricData(&cloudwatch.PutMetricDataInput{ + Namespace: aws.String("Cassowary/Metrics"), + MetricData: []*cloudwatch.MetricDatum{ + &cloudwatch.MetricDatum{ + MetricName: aws.String("tcp_connect_mean"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.TCPStats.TCPMean), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("tcp_connect_median"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.TCPStats.TCPMedian), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("tcp_connect_95p"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.TCPStats.TCP95p), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("server_processing_mean"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.ProcessingStats.ServerProcessingMean), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("server_processing_median"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.ProcessingStats.ServerProcessingMedian), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("server_processing_95p"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.ProcessingStats.ServerProcessing95p), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("content_transfer_mean"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.ContentStats.ContentTransferMean), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("content_transfer_median"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.ContentStats.ContentTransferMedian), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("content_transfer_95p"), + Unit: aws.String("Milliseconds"), + Value: aws.Float64(metrics.ContentStats.ContentTransfer95p), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("total_requests"), + Unit: aws.String("Count"), + Value: aws.Float64(float64(metrics.TotalRequests)), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("failed_requests"), + Unit: aws.String("Count"), + Value: aws.Float64(float64(metrics.FailedRequests)), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + &cloudwatch.MetricDatum{ + MetricName: aws.String("requests_per_second"), + Unit: aws.String("Count/Second"), + Value: aws.Float64(metrics.RequestsPerSecond), + Dimensions: []*cloudwatch.Dimension{ + &cloudwatch.Dimension{ + Name: aws.String("Site"), + Value: aws.String(c.BaseURL), + }, + }, + }, + }, + }) + + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/pkg/client/cloudwatch_test.go b/pkg/client/cloudwatch_test.go new file mode 100644 index 0000000..b80468c --- /dev/null +++ b/pkg/client/cloudwatch_test.go @@ -0,0 +1,43 @@ +package client + +import ( + "testing" + + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" +) + +type mockCloudWatchClient struct { + cloudwatchiface.CloudWatchAPI +} + +func (m *mockCloudWatchClient) PutMetricData(input *cloudwatch.PutMetricDataInput) (*cloudwatch.PutMetricDataOutput, error) { + // mock response/functionality + return nil, nil +} + +func TestPutCloudwatchMetrics(t *testing.T) { + c := Cassowary{} + metrics := ResultMetrics{ + FailedRequests: 1, + TotalRequests: 100, + RequestsPerSecond: 100.10, + TCPStats: tcpStats{ + TCPMean: 10.0, + TCPMedian: 10.0, + TCP95p: 10.0, + }, + ProcessingStats: serverProcessingStats{ + ServerProcessingMean: 1.0, + ServerProcessingMedian: 1.0, + ServerProcessing95p: 1.0, + }, + } + mockSvc := &mockCloudWatchClient{} + _, err := c.PutCloudwatchMetrics(mockSvc, metrics) + + if err != nil { + t.Errorf("Wanted ok but got error: %v", err) + } + +} diff --git a/pkg/client/types.go b/pkg/client/types.go index cfb08e8..5cc94be 100644 --- a/pkg/client/types.go +++ b/pkg/client/types.go @@ -17,6 +17,7 @@ type Cassowary struct { ExportMetrics bool ExportMetricsFile string PromExport bool + Cloudwatch bool PromURL string RequestHeader []string URLPaths []string