diff --git a/config/json.go b/config/json.go index 03fceb55..dd7f9bbe 100644 --- a/config/json.go +++ b/config/json.go @@ -98,13 +98,14 @@ func (s *step) UnmarshalJSON(data []byte) error { } type JsonReader struct { - ReqCount int `json:"request_count"` - LoadType string `json:"load_type"` - Duration int `json:"duration"` - TimeRunCount timeRunCount `json:"manual_load"` - Steps []step `json:"steps"` - Output string `json:"output"` - Proxy string `json:"proxy"` + ReqCount int `json:"request_count"` + LoadType string `json:"load_type"` + Duration int `json:"duration"` + TimeRunCount timeRunCount `json:"manual_load"` + Steps []step `json:"steps"` + Output string `json:"output"` + Proxy string `json:"proxy"` + OutputPercentile bool `json:"output_percentile"` } func (j *JsonReader) UnmarshalJSON(data []byte) error { @@ -182,6 +183,7 @@ func (j *JsonReader) CreateHammer() (h types.Hammer, err error) { Scenario: s, Proxy: p, ReportDestination: j.Output, + ReportPercentiles: j.OutputPercentile, } return } diff --git a/core/engine.go b/core/engine.go index d89c50b0..6573bb15 100644 --- a/core/engine.go +++ b/core/engine.go @@ -99,7 +99,7 @@ func (e *engine) Init() (err error) { return } - if err = e.reportService.Init(); err != nil { + if err = e.reportService.Init(e.hammer.ReportPercentiles); err != nil { return } diff --git a/core/report/aggregator.go b/core/report/aggregator.go index 893bdc7a..218712aa 100644 --- a/core/report/aggregator.go +++ b/core/report/aggregator.go @@ -21,6 +21,8 @@ package report import ( + "math" + "sort" "strings" "time" @@ -39,6 +41,7 @@ func aggregate(result *Result, response *types.Response) { StatusCodeDist: make(map[int]int, 0), ErrorDist: make(map[string]int), Durations: map[string]float32{}, + TotalDurations: map[string][]float32{}, } } item := result.ItemReports[rr.ScenarioItemID] @@ -60,7 +63,12 @@ func aggregate(result *Result, response *types.Response) { } } } + } + for _, report := range result.ItemReports { + for key, duration := range report.Durations { + report.TotalDurations[key] = append(report.TotalDurations[key], duration) + } } // Don't change avg duration if there is a error @@ -96,12 +104,29 @@ func (r *Result) failedPercentage() int { } type ScenarioItemReport struct { - Name string `json:"name"` - StatusCodeDist map[int]int `json:"status_code_dist"` - ErrorDist map[string]int `json:"error_dist"` - Durations map[string]float32 `json:"durations"` - SuccessCount int64 `json:"success_count"` - FailedCount int64 `json:"fail_count"` + Name string `json:"name"` + StatusCodeDist map[int]int `json:"status_code_dist"` + ErrorDist map[string]int `json:"error_dist"` + Durations map[string]float32 `json:"durations"` + TotalDurations map[string][]float32 `json:"total_durations"` + SuccessCount int64 `json:"success_count"` + FailedCount int64 `json:"fail_count"` +} + +func (s *ScenarioItemReport) DurationPercentile(p int) float32 { + if p < 0 || p > 100 { + return 0 + } + + durations, ok := s.TotalDurations["duration"] + if !ok { + return 0 + } + + // todo: it could be optimized by always sorted array being used in TotalDurations so we would not make this call. + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + + return Percentile(durations, p) } func (s *ScenarioItemReport) successPercentage() int { @@ -118,3 +143,17 @@ func (s *ScenarioItemReport) failedPercentage() int { } return 100 - s.successPercentage() } + +func Percentile(list []float32, p int) float32 { + if p < 0 || p > 100 { + return 0 + } + + n := int(math.Round((float64(p) / 100.0) * float64(len(list)))) + if n > 0 { + // I am not sure about the case where n == 0 + n-- + } + + return list[n] +} diff --git a/core/report/base.go b/core/report/base.go index 2e385a01..cd5812b8 100644 --- a/core/report/base.go +++ b/core/report/base.go @@ -32,18 +32,18 @@ var AvailableOutputServices = make(map[string]ReportService) // ReportService is the interface that abstracts different report implementations. type ReportService interface { DoneChan() <-chan struct{} - Init() error + Init(reportPercentiles bool) error Start(input chan *types.Response) Report() } // NewReportService is the factory method of the ReportService. -func NewReportService(s string) (service ReportService, err error) { - if val, ok := AvailableOutputServices[s]; ok { +func NewReportService(output string) (service ReportService, err error) { + if val, ok := AvailableOutputServices[output]; ok { // Create a new object from the service type service = reflect.New(reflect.TypeOf(val).Elem()).Interface().(ReportService) } else { - err = fmt.Errorf("unsupported output type: %s", s) + err = fmt.Errorf("unsupported output type: %s", output) } return diff --git a/core/report/stdout.go b/core/report/stdout.go index 6708cd2b..d02f6084 100644 --- a/core/report/stdout.go +++ b/core/report/stdout.go @@ -45,10 +45,11 @@ func init() { } type stdout struct { - doneChan chan struct{} - result *Result - printTicker *time.Ticker - mu sync.Mutex + doneChan chan struct{} + result *Result + printTicker *time.Ticker + mu sync.Mutex + reportPercentiles bool } var blue = color.New(color.FgHiBlue).SprintFunc() @@ -56,11 +57,12 @@ var green = color.New(color.FgHiGreen).SprintFunc() var red = color.New(color.FgHiRed).SprintFunc() var realTimePrintInterval = time.Duration(1500) * time.Millisecond -func (s *stdout) Init() (err error) { +func (s *stdout) Init(reportPercentiles bool) (err error) { s.doneChan = make(chan struct{}) s.result = &Result{ ItemReports: make(map[int16]*ScenarioItemReport), } + s.reportPercentiles = reportPercentiles color.Cyan("%s Initializing... \n", emoji.Gear) return @@ -108,11 +110,10 @@ func (s *stdout) realTimePrintStart() { func (s *stdout) liveResultPrint() { fmt.Fprintf(out, "%s %s %s\n", - green(fmt.Sprintf("%s Successful Run: %-6d %3d%% %5s", - emoji.CheckMark, s.result.SuccessCount, s.result.successPercentage(), "")), - red(fmt.Sprintf("%s Failed Run: %-6d %3d%% %5s", - emoji.CrossMark, s.result.FailedCount, s.result.failedPercentage(), "")), - blue(fmt.Sprintf("%s Avg. Duration: %.5fs", emoji.Stopwatch, s.result.AvgDuration))) + green(fmt.Sprintf("%s Successful Run: %-6d %3d%% %5s", emoji.CheckMark, s.result.SuccessCount, s.result.successPercentage(), "")), + red(fmt.Sprintf("%s Failed Run: %-6d %3d%% %5s", emoji.CrossMark, s.result.FailedCount, s.result.failedPercentage(), "")), + blue(fmt.Sprintf("%s Avg. Duration: %.5fs", emoji.Stopwatch, s.result.AvgDuration)), + ) } func (s *stdout) realTimePrintStop() { @@ -173,6 +174,22 @@ func (s *stdout) printDetails() { fmt.Fprintf(w, " %s\t:%.4fs\n", v.name, v.duration) } + if s.reportPercentiles { + // print percentalies + percentiles := []map[string]float32{ + {"P99": v.DurationPercentile(99)}, + {"P95": v.DurationPercentile(95)}, + {"P90": v.DurationPercentile(90)}, + {"P80": v.DurationPercentile(80)}, + } + fmt.Fprintln(w, "\nPercentiles:") + for _, val := range percentiles { + for name, percentile := range val { + fmt.Fprintf(w, " %s\t:%.4fs\n", name, percentile) + } + } + } + if len(v.StatusCodeDist) > 0 { fmt.Fprintln(w, "\nStatus Code (Message) :Count") for s, c := range v.StatusCodeDist { diff --git a/core/report/stdoutJson.go b/core/report/stdoutJson.go index a4017ae6..d4881a38 100644 --- a/core/report/stdoutJson.go +++ b/core/report/stdoutJson.go @@ -22,8 +22,8 @@ package report import ( "encoding/json" - "fmt" "math" + "os" "go.ddosify.com/ddosify/core/types" ) @@ -35,15 +35,18 @@ func init() { } type stdoutJson struct { - doneChan chan struct{} - result *Result + doneChan chan struct{} + result *Result + reportPercentiles bool } -func (s *stdoutJson) Init() (err error) { +func (s *stdoutJson) Init(reportPercentiles bool) (err error) { s.doneChan = make(chan struct{}) s.result = &Result{ ItemReports: make(map[int16]*ScenarioItemReport), } + s.reportPercentiles = reportPercentiles + return } @@ -54,12 +57,57 @@ func (s *stdoutJson) Start(input chan *types.Response) { s.doneChan <- struct{}{} } +type jsonResult struct { + SuccessCount int64 `json:"success_count"` + FailedCount int64 `json:"fail_count"` + AvgDuration float32 `json:"avg_duration"` + ItemReports map[int16]*jsonScenarioItemReport `json:"steps"` +} + +type jsonScenarioItemReport struct { + Name string `json:"name"` + StatusCodeDist map[int]int `json:"status_code_dist"` + ErrorDist map[string]int `json:"error_dist"` + Durations map[string]float32 `json:"durations"` + Percentiles map[string]float32 `json:"percentiles"` + SuccessCount int64 `json:"success_count"` + FailedCount int64 `json:"fail_count"` +} + func (s *stdoutJson) Report() { - p := 1e3 + jsonResult := jsonResult{ + SuccessCount: s.result.SuccessCount, + FailedCount: s.result.FailedCount, + AvgDuration: s.result.AvgDuration, + ItemReports: map[int16]*jsonScenarioItemReport{}, + } + + for key, item := range s.result.ItemReports { + jsonResult.ItemReports[key] = &jsonScenarioItemReport{ + Name: item.Name, + StatusCodeDist: item.StatusCodeDist, + ErrorDist: item.ErrorDist, + Durations: item.Durations, + SuccessCount: item.SuccessCount, + FailedCount: item.FailedCount, + } + + if s.reportPercentiles { + jsonResult.ItemReports[key].Percentiles = map[string]float32{ + "p99": item.DurationPercentile(99), + "p95": item.DurationPercentile(95), + "p90": item.DurationPercentile(90), + "p80": item.DurationPercentile(80), + } + } + + } + p := 1e3 s.result.AvgDuration = float32(math.Round(float64(s.result.AvgDuration)*p) / p) - for _, itemReport := range s.result.ItemReports { + for _, item := range jsonResult.ItemReports { + itemReport := item durations := make(map[string]float32) for d, s := range itemReport.Durations { // Less precision for durations. @@ -69,8 +117,8 @@ func (s *stdoutJson) Report() { itemReport.Durations = durations } - j, _ := json.Marshal(s.result) - printJson(j) + encoder := json.NewEncoder(os.Stdout) + encoder.Encode(&jsonResult) } func (s *stdoutJson) DoneChan() <-chan struct{} { @@ -107,10 +155,6 @@ func (s ScenarioItemReport) MarshalJSON() ([]byte, error) { }) } -var printJson = func(j []byte) { - fmt.Println(string(j)) -} - var strKeyToJsonKey = map[string]string{ "dnsDuration": "dns", "connDuration": "connection", diff --git a/core/report/stdoutJson_test.go b/core/report/stdoutJson_test.go index e1abc820..2c5e7228 100644 --- a/core/report/stdoutJson_test.go +++ b/core/report/stdoutJson_test.go @@ -22,6 +22,7 @@ package report import ( "bytes" "encoding/json" + "fmt" "reflect" "testing" "time" @@ -31,7 +32,7 @@ import ( func TestInitStdoutJson(t *testing.T) { sj := &stdoutJson{} - sj.Init() + sj.Init(false) if sj.doneChan == nil { t.Errorf("DoneChan should be initialized") @@ -130,7 +131,7 @@ func TestStdoutJsonStart(t *testing.T) { } s := &stdoutJson{} - s.Init() + s.Init(false) responseChan := make(chan *types.Response, len(responses)) go s.Start(responseChan) @@ -254,3 +255,7 @@ func TestStdoutJsonOutput(t *testing.T) { t.Errorf("Expected: %v, Found: %v", expectedOutput, output) } } + +var printJson = func(j []byte) { + fmt.Println(string(j)) +} diff --git a/core/report/stdout_test.go b/core/report/stdout_test.go index 08d206b1..cdaca9b1 100644 --- a/core/report/stdout_test.go +++ b/core/report/stdout_test.go @@ -91,7 +91,7 @@ func TestResult(t *testing.T) { func TestInit(t *testing.T) { s := &stdout{} - s.Init() + s.Init(false) if s.doneChan == nil { t.Errorf("DoneChan should be initialized") @@ -190,7 +190,7 @@ func TestStart(t *testing.T) { } s := &stdout{} - s.Init() + s.Init(false) responseChan := make(chan *types.Response, len(responses)) go s.Start(responseChan) diff --git a/core/types/hammer.go b/core/types/hammer.go index 25443bdd..e194b257 100644 --- a/core/types/hammer.go +++ b/core/types/hammer.go @@ -77,6 +77,9 @@ type Hammer struct { // Destination of the results data. ReportDestination string + // Report percentiles + ReportPercentiles bool + // Dynamic field for extra parameters. Others map[string]interface{} } diff --git a/main.go b/main.go index 194abc41..d1d2bff5 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,8 @@ var ( certPath = flag.String("cert_path", "", "A path to a certificate file (usually called 'cert.pem')") certKeyPath = flag.String("cert_key_path", "", "A path to a certificate key file (usually called 'key.pem')") + outputPercentile = flag.Bool("output-percentile", false, "Report percentile") + version = flag.Bool("version", false, "Prints version, git commit, built date (utc), go information and quit") ) @@ -190,6 +192,7 @@ var createHammerFromFlags = func() (h types.Hammer, err error) { Scenario: s, Proxy: p, ReportDestination: *output, + ReportPercentiles: *outputPercentile, } return } diff --git a/main_test.go b/main_test.go index f7d3ebce..06ccc0e3 100644 --- a/main_test.go +++ b/main_test.go @@ -63,6 +63,8 @@ func resetFlags() { *certPath = "" *certKeyPath = "" + + *outputPercentile = false } func TestDefaultFlagValues(t *testing.T) { @@ -114,6 +116,9 @@ func TestDefaultFlagValues(t *testing.T) { if *certKeyPath != "" { t.Errorf("TestDefaultFlagValues failed, expected %#v, found %#v", "", *certKeyPath) } + if *outputPercentile != false { + t.Errorf("TestDefaultFlagValues failed, expected %#v, found %#v", "", *outputPercentile) + } } func TestCreateHammer(t *testing.T) {