package main
import (
"math"
"sort"
"net/http"
)
type Calculator interface {
GetMetricsResult() Metrics
CalculatePerTrial(requests []int, method string, trialNum int)
PercentileN(size int, percentile int) int
}
type Metrics struct {
// Metrics per trial benchmark
GetMetrics []MetricsDetail
PostMetrics []MetricsDetail
PutMetrics []MetricsDetail
PatchMetrics []MetricsDetail
DeleteMetrics []MetricsDetail
TimeRange []float64
}
type MetricsDetail struct {
Percentile99 float64
Percentile95 float64
PercentileAvg float64
PercentileMax float64
PercentileMin float64
Rps float64
}
// Constructor
func NewCalculator(trialNum int) Calculator {
return Metrics{
GetMetrics: make([]MetricsDetail, trialNum),
PostMetrics: make([]MetricsDetail, trialNum),
PutMetrics: make([]MetricsDetail, trialNum),
PatchMetrics: make([]MetricsDetail, trialNum),
DeleteMetrics: make([]MetricsDetail, trialNum),
TimeRange: make([]float64, trialNum),
}
}
func (m Metrics) CalculatePerTrial(requests []int, method string, trialNum int) {
index := trialNum - 1
samplingSize := len(requests)
sort.Ints(requests)
ignore95Index := m.PercentileN(samplingSize, 95) - 1
percentile95 := requests[ignore95Index]
ignore99Index := m.PercentileN(samplingSize, 99) - 1
percentile99 := requests[ignore99Index]
var avgLatency, maxLatency, minLatency, currentRps, beforeRps int
for i, v := range requests {
avgLatency += v
beforeRps = currentRps
currentRps = v
if i == 0 {
minLatency = currentRps
maxLatency = currentRps
}
if currentRps > beforeRps {
maxLatency = currentRps
}
if currentRps < beforeRps {
minLatency = currentRps
}
}
detail := MetricsDetail{
Percentile99: float64(percentile99),
Percentile95: float64(percentile95),
PercentileAvg: float64(avgLatency / len(requests)),
PercentileMax: float64(maxLatency),
PercentileMin: float64(minLatency),
Rps: float64(len(requests)) / float64(durationSeconds),
}
switch method {
case http.MethodGet:
m.GetMetrics[index] = detail
case http.MethodPost:
m.PostMetrics[index] = detail
case http.MethodPut:
m.PutMetrics[index] = detail
case http.MethodPatch:
m.PatchMetrics[index] = detail
case http.MethodDelete:
m.DeleteMetrics[index] = detail
}
m.TimeRange[index] = float64(trialNum * durationSeconds)
}
func (m Metrics) GetMetricsResult() Metrics {
return m
}
func (m Metrics) PercentileN(size int, percentile int) int {
n := (float64(percentile) / float64(100)) * float64(size)
return int(math.Round(n*1) / 1)
}
package main
import (
"fmt"
"io"
"strings"
"time"
"math/rand"
"net/http"
)
var durationSeconds = 5
type BenchmarkClient interface {
Attack(attackNum int) Result
GetRandomHttpRequests() []*http.Request
}
type HttpClient struct {
HttpClient *http.Client
RandomHttpRequests []*http.Request
RequestDuration time.Duration
}
type Result struct {
Get []int
Post []int
Put []int
Patch []int
Delete []int
}
// New BenchmarkClient
func NewBenchmarkClient(url string, methods []string, headers map[string]string, body io.Reader, percentages map[string]int) BenchmarkClient {
var requests []*http.Request
for _, method := range methods {
var request *http.Request
for targetMethod, percentage := range percentages {
if strings.EqualFold(method, targetMethod) {
// GenerateCharts request per percentage method
for i := 0; i < percentage; i++ {
if !strings.EqualFold(method, http.MethodGet) {
request, _ = http.NewRequest(method, url, body)
} else {
request, _ = http.NewRequest(method, url, nil)
}
// Set headers
for headerKey, headerValue := range headers {
request.Header.Set(headerKey, headerValue)
}
requests = append(requests, request)
}
}
}
}
shuffleRequest(requests)
fmt.Print("Http request info = ")
for _, r := range requests {
fmt.Printf("%s ", r.Method)
}
fmt.Println()
client := new(http.Client)
return HttpClient{
HttpClient: client,
RandomHttpRequests: requests,
RequestDuration: time.Duration(durationSeconds) * time.Second,
}
}
func (h HttpClient) Attack(attackNum int) Result {
var getLatency, postLatency, putLatency, patchLatency, deleteLatency []int
fmt.Printf("## Attack number %d: Start benchmark for duration %d seconds\n", attackNum, durationSeconds)
for begin := time.Now(); time.Since(begin) < h.RequestDuration; {
// Random Http Method request
for _, request := range h.RandomHttpRequests {
start := makeTimestamp()
res, err := h.HttpClient.Do(request)
if err == nil && (res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated) {
end := makeTimestamp()
latency := end - start
switch request.Method {
case http.MethodGet:
getLatency = append(getLatency, int(latency))
case http.MethodPost:
postLatency = append(postLatency, int(latency))
case http.MethodPut:
putLatency = append(putLatency, int(latency))
case http.MethodPatch:
patchLatency = append(patchLatency, int(latency))
case http.MethodDelete:
deleteLatency = append(deleteLatency, int(latency))
}
}
}
}
fmt.Printf("## Attack number %d End benchmark\n", attackNum)
return Result{
Get: getLatency,
Post: postLatency,
Put: putLatency,
Patch: patchLatency,
Delete: deleteLatency,
}
}
func (h HttpClient) GetRandomHttpRequests() []*http.Request {
return h.RandomHttpRequests
}
func shuffleRequest(requests []*http.Request) {
rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(requests), func(i, j int) { requests[i], requests[j] = requests[j], requests[i] })
}
func makeTimestamp() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
package main
import (
"fmt"
"os"
"reflect"
"strings"
"time"
"io/ioutil"
"net/http"
"github.com/wcharczuk/go-chart/v2"
)
type Graph interface {
Output(charts []chart.Chart, startTime time.Time, endTime time.Time)
GenerateCharts(timeRange []float64) []chart.Chart
}
type GraphImpl struct {
GetGraphDetail *GraphDetail
PostGraphDetail *GraphDetail
PutGraphDetail *GraphDetail
PatchGraphDetail *GraphDetail
DeleteGraphDetail *GraphDetail
}
type GraphDetail struct {
YPercentile99 []float64
YPercentile95 []float64
YPercentileAvg []float64
YPercentileMax []float64
YPercentileMin []float64
Rps []float64
}
func NewGraph(metrics Metrics) Graph {
getGraphDetail := convertMetricsToGraph(metrics.GetMetrics)
postGraphDetail := convertMetricsToGraph(metrics.PostMetrics)
putGraphDetail := convertMetricsToGraph(metrics.PutMetrics)
patchGraphDetail := convertMetricsToGraph(metrics.PatchMetrics)
deleteGraphDetail := convertMetricsToGraph(metrics.DeleteMetrics)
return GraphImpl{
GetGraphDetail: getGraphDetail,
PostGraphDetail: postGraphDetail,
PutGraphDetail: putGraphDetail,
PatchGraphDetail: patchGraphDetail,
DeleteGraphDetail: deleteGraphDetail,
}
}
func (g GraphImpl) Output(charts []chart.Chart, startTime time.Time, endTime time.Time) {
body := fmt.Sprintf(fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>HTTP CB results</title>
<style type="text/css">
.title {text-align: center}
.container {display: flex; margin-left:100px; margin-right:100px}
.child {flex-grow: 1;}
.graph-img {width: 600px}
</style>
</head>
<body>
<div>
<p class="title">Benchmark time: <b>%s</b> ~ <b>%s</b></p><br>`,
startTime.String(), endTime.String()))
for i := 0; i < len(charts)/2; i++ {
index := i * 2
body = body + `<div class="container">`
f, _ := os.Create(fmt.Sprintf("%s.png", charts[index].Title))
charts[index].Render(chart.PNG, f)
body = body + fmt.Sprintf(`
<div class="child">
<img src="./%s.png" class="graph-img" />
<ul>
<li><b style="color: red">Red Line:</b> Maximum Percentile</li>
<li><b>Black Line:</b> Minimum Percentile</li>
<li><b style="color: orange">Orange Line:</b> Average Percentile</li>
<li><b style="color: blue">Blue Line:</b> 99 Percentile</li>
<li><b style="color: green">Green Line:</b> 95 Percentile</li>
</ul></div>`, charts[index].Title)
f, _ = os.Create(fmt.Sprintf("%s.png", charts[index+1].Title))
charts[index+1].Render(chart.PNG, f)
body = body + fmt.Sprintf(`
<div class="child">
<img src="./%s.png" class="graph-img" />
<ul>
<li><b>Black Line:</b> Request per seconds</li>
</ul></div>`, charts[index+1].Title)
body = body + `</div>`
}
body = body + `</div></body></html>`
ioutil.WriteFile("index.html", []byte(body), 0644)
}
func (g GraphImpl) GenerateCharts(timeRange []float64) []chart.Chart {
t := reflect.TypeOf(g)
elem := reflect.ValueOf(&g).Elem()
cnt := elem.NumField()
var charts []chart.Chart
for i := 0; i < cnt; i++ {
structName := t.Field(i).Name
structData := elem.Field(i)
detail := structData.Interface().(*GraphDetail)
if detail == nil {
continue
}
charts = append(charts, latencyChart(structName, timeRange, *detail))
charts = append(charts, rpsChart(structName, timeRange, *detail))
}
return charts
}
func convertMetricsToGraph(metricsDetail []MetricsDetail) *GraphDetail {
var percentile99, percentile95, percentileAvg, percentileMax, percentileMin, rps []float64
for _, v := range metricsDetail {
if v.PercentileMax == 0 && v.PercentileMin == 0 && v.PercentileAvg == 0 && v.Percentile95 == 0 && v.Percentile99 == 0 {
continue
}
percentile99 = append(percentile99, v.Percentile99)
percentile95 = append(percentile95, v.Percentile95)
percentileAvg = append(percentileAvg, v.PercentileAvg)
percentileMax = append(percentileMax, v.PercentileMax)
percentileMin = append(percentileMin, v.PercentileMin)
rps = append(rps, v.Rps)
}
if len(percentileMax) == 0 || len(percentileMin) == 0 || len(percentileAvg) == 0 || len(percentile99) == 0 || len(percentile95) == 0 {
return nil
}
return &GraphDetail{
YPercentile95: percentile95,
YPercentile99: percentile99,
YPercentileAvg: percentileAvg,
YPercentileMax: percentileMax,
YPercentileMin: percentileMin,
Rps: rps,
}
}
func getChartPrefixTitleFrom(structName string) string {
var method string
if strings.Contains(strings.ToLower(structName), strings.ToLower(http.MethodGet)) {
method = http.MethodGet
} else if strings.Contains(strings.ToLower(structName), strings.ToLower(http.MethodPost)) {
method = http.MethodPost
} else if strings.Contains(strings.ToLower(structName), strings.ToLower(http.MethodPut)) {
method = http.MethodPut
} else if strings.Contains(strings.ToLower(structName), strings.ToLower(http.MethodPatch)) {
method = http.MethodPatch
} else if strings.Contains(strings.ToLower(structName), strings.ToLower(http.MethodDelete)) {
method = http.MethodDelete
}
return method
}
func latencyChart(structName string, x []float64, detail GraphDetail) chart.Chart {
c := chart.Chart{
Title: getChartPrefixTitleFrom(structName) + " Latency",
XAxis: chart.XAxis{
Name: "Time (sec)",
},
YAxis: chart.YAxis{
Name: "Latency (ms)",
},
Series: []chart.Series{
chart.ContinuousSeries{
Name: "99 Percentile",
Style: chart.Style{
StrokeColor: chart.ColorBlue,
},
XValues: x,
YValues: detail.YPercentile99,
},
chart.ContinuousSeries{
Name: "95 Percentile",
Style: chart.Style{
StrokeColor: chart.ColorGreen,
},
XValues: x,
YValues: detail.YPercentile95,
},
chart.ContinuousSeries{
Name: "Average Percentile",
Style: chart.Style{
StrokeColor: chart.ColorOrange,
},
XValues: x,
YValues: detail.YPercentileAvg,
},
chart.ContinuousSeries{
Name: "Maximum Percentile",
Style: chart.Style{
StrokeColor: chart.ColorRed,
},
XValues: x,
YValues: detail.YPercentileMax,
},
chart.ContinuousSeries{
Name: "Minimum Percentile",
Style: chart.Style{
StrokeColor: chart.ColorBlack,
},
XValues: x,
YValues: detail.YPercentileMin,
},
},
}
return c
}
func rpsChart(structName string, x []float64, detail GraphDetail) chart.Chart {
c := chart.Chart{
Title: getChartPrefixTitleFrom(structName) + " Request per seconds",
XAxis: chart.XAxis{
Name: "Time (sec)",
},
YAxis: chart.YAxis{
Name: "Request per seconds",
},
Series: []chart.Series{
chart.ContinuousSeries{
Name: "Request per seconds",
Style: chart.Style{
StrokeColor: chart.ColorBlack,
},
XValues: x,
YValues: detail.Rps,
},
},
}
return c
}
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
const (
// Required
// ex: https://example.com
EnvTargetUrl = "TARGET_URL"
// Required
// Comma separated
// ex: PUT,GET
EnvHttpMethods = "HTTP_METHODS"
// Required
// HashMap data structure
// ex: {"Authorization": "Bearer ", "Content-Type": "application/json"}
EnvHttpHeaders = "HTTP_HEADERS"
// Required
// Maximum is 3.
// ex: 2
EnvThreadNum = "THREAD_NUM"
// Required
// Maximum is 20. Takes up to 5 minutes
// ex: 20
EnvTrialNum = "TRIAL_NUM"
// Optional
// HashMap data structure
// If only one http method, always 100 percent set method
// ex: {"POST": 4, "GET": 6}
EnvReqHttpMethodPercentages = "REQ_HTTP_METHOD_PERCENTAGES"
// Optional
// Using GitHub pages
// ex: true || false
EnvPermanent = "PERMANENT"
// Optional
// If not empty, always use body when not GET method
// ex: {"email": "test@gmail.com", "password": "A_test12345-"}
EnvHttpRequestBody = "HTTP_REQ_BODY"
// Optional
// ex: https://slack.com
EnvSlackWebHookUrl = "SLACK_WEB_HOOK_URL"
// Optional
// If set this one, notify slack when do not achieve
// ex: 200
EnvSlackNotifyThreshHoldLatencyMillis = "SLACK_NOTIFY_THRESHOLD_LATENCY_MILLIS"
)
func main() {
errMsg := ValidateEnv()
if errMsg != nil {
for _, v := range errMsg {
fmt.Println(v)
}
return
}
runtime := NewRuntimeInfo()
client := NewBenchmarkClient(
runtime.TargetUrl,
runtime.HttpMethods,
runtime.HttpHeaders,
runtime.HttpRequestBody,
runtime.HttpRequestMethodPercentage,
)
calculator := NewCalculator(runtime.TrialNum)
startTime := time.Now().UTC()
for i := 1; i <= runtime.TrialNum; i++ {
var wg sync.WaitGroup
var result Result
for index := 0; index < runtime.ThreadNum; index++ {
wg.Add(runtime.ThreadNum)
go func(i int) {
defer wg.Done()
data := client.Attack(i)
result.Get = append(result.Get, data.Get...)
result.Post = append(result.Post, data.Post...)
result.Put = append(result.Put, data.Put...)
result.Patch = append(result.Patch, data.Patch...)
result.Delete = append(result.Delete, data.Delete...)
}(i)
}
wg.Wait()
calculator.CalculatePerTrial(result.Get, http.MethodGet, i)
calculator.CalculatePerTrial(result.Post, http.MethodPost, i)
calculator.CalculatePerTrial(result.Put, http.MethodPut, i)
calculator.CalculatePerTrial(result.Patch, http.MethodPatch, i)
calculator.CalculatePerTrial(result.Delete, http.MethodDelete, i)
time.Sleep(1 * time.Second)
}
endTime := time.Now().UTC()
metrics := calculator.GetMetricsResult()
graph := NewGraph(metrics)
charts := graph.GenerateCharts(metrics.TimeRange)
graph.Output(charts, startTime, endTime)
}
package main
import (
"io"
"os"
"strconv"
"strings"
"encoding/json"
)
type RuntimeInfo struct {
TargetUrl string
HttpMethods []string
HttpHeaders map[string]string
ThreadNum int
TrialNum int
HttpRequestMethodPercentage map[string]int
Permanent bool
HttpRequestBody io.Reader
SlackWebHookUrl string
SlackNotifyThreshHoldLatencyMillis int
}
// New RuntimeInfo from environment variable
func NewRuntimeInfo() RuntimeInfo {
targetUrl := os.Getenv(EnvTargetUrl)
methods := strings.Split(os.Getenv(EnvHttpMethods), ",")
headers := make(map[string]string)
json.Unmarshal([]byte(os.Getenv(EnvHttpHeaders)), &headers)
threadNum, _ := strconv.Atoi(os.Getenv(EnvThreadNum))
trialNum, _ := strconv.Atoi(os.Getenv(EnvTrialNum))
percentages := make(map[string]int)
json.Unmarshal([]byte(os.Getenv(EnvReqHttpMethodPercentages)), &percentages)
permanent, _ := strconv.ParseBool(os.Getenv(EnvPermanent))
body := strings.NewReader(os.Getenv(EnvHttpRequestBody))
slackWebHookUrl := os.Getenv(EnvSlackWebHookUrl)
slackNotifyThreshHoldLatencyMillis, _ := strconv.Atoi(os.Getenv(EnvSlackNotifyThreshHoldLatencyMillis))
return RuntimeInfo{
TargetUrl: targetUrl,
HttpMethods: methods,
HttpHeaders: headers,
ThreadNum: threadNum,
TrialNum: trialNum,
HttpRequestMethodPercentage: percentages,
Permanent: permanent,
HttpRequestBody: body,
SlackWebHookUrl: slackWebHookUrl,
SlackNotifyThreshHoldLatencyMillis: slackNotifyThreshHoldLatencyMillis,
}
}
package main
import (
"fmt"
"os"
"strconv"
"strings"
"encoding/json"
"net/http"
)
var allowedHttpMethod = []string{
http.MethodGet,
http.MethodPatch,
http.MethodPut,
http.MethodPost,
http.MethodDelete,
}
func ValidateEnv() map[string]string {
result := make(map[string]string)
validateTargetUrl(result)
validateHttpMethods(result)
validateHttpHeaders(result)
validateReqHttpMethodPercentage(result)
validateHttpRequestBody(result)
validateThreadNum(result)
validateTrialNum(result)
validatePermanent(result)
validateSlackNotifyThreshHoldLatencyMillis(result)
return result
}
// Validate TARGET_URL env
func validateTargetUrl(result map[string]string) map[string]string {
env := os.Getenv(EnvTargetUrl)
if validateEmpty(env) {
result[EnvTargetUrl] = fmt.Sprintf("Environment valiable %s is required.", EnvTargetUrl)
return result
}
if !strings.HasPrefix(env, "http") || !strings.HasPrefix(env, "https") {
result[EnvTargetUrl] = fmt.Sprintf("Environment valiable %s has only http or https protocol.", EnvTargetUrl)
return result
}
return nil
}
// Validate HTTP_METHODS env
func validateHttpMethods(result map[string]string) map[string]string {
env := os.Getenv(EnvHttpMethods)
if validateEmpty(env) {
result[EnvHttpMethods] = fmt.Sprintf("Environment valiable %s is required.", EnvHttpMethods)
return result
}
isContain := false
methods := strings.Split(env, ",")
for _, v := range allowedHttpMethod {
for _, m := range methods {
if strings.EqualFold(v, m) {
isContain = true
break
}
}
}
if !isContain {
result[EnvHttpMethods] = fmt.Sprintf("Environment valiable %s is only supprt %v.", EnvHttpMethods, allowedHttpMethod)
return result
}
return nil
}
// Validate HTTP_HEADERS env
func validateHttpHeaders(result map[string]string) map[string]string {
env := os.Getenv(EnvHttpHeaders)
if validateEmpty(env) {
result[EnvHttpHeaders] = fmt.Sprintf("Environment valiable %s is required.", EnvHttpHeaders)
return result
}
headers := make(map[string]interface{})
if err := json.Unmarshal([]byte(env), &headers); err != nil {
result[EnvHttpHeaders] = fmt.Sprintf("Environment valiable %s not hashmap structure.", EnvHttpHeaders)
return result
}
return nil
}
// Validate REQ_HTTP_METHOD_PERCENTAGES env
func validateReqHttpMethodPercentage(result map[string]string) map[string]string {
methods := strings.Split(os.Getenv(EnvHttpMethods), ",")
if len(methods) > 1 {
env := os.Getenv(EnvReqHttpMethodPercentages)
if validateEmpty(env) {
result[EnvReqHttpMethodPercentages] = fmt.Sprintf("Environment valiable %s is required because method is multiple.", EnvReqHttpMethodPercentages)
return result
}
percentages := make(map[string]int)
if err := json.Unmarshal([]byte(env), &percentages); err != nil {
result[EnvReqHttpMethodPercentages] = fmt.Sprintf("Environment valiable %s not hashmap structure.", EnvReqHttpMethodPercentages)
return result
}
var totalPercent int
for _, v := range percentages {
totalPercent = totalPercent + v
}
if totalPercent != 10 {
result[EnvReqHttpMethodPercentages] = fmt.Sprintf("Environment valiable %s requires percentage of 10.", EnvReqHttpMethodPercentages)
return result
}
}
return nil
}
// Validate HTTP_REQ_BODY env
func validateHttpRequestBody(result map[string]string) map[string]string {
env := os.Getenv(EnvHttpRequestBody)
if validateEmpty(env) {
result[EnvHttpRequestBody] = fmt.Sprintf("Environment valiable %s is required.", EnvHttpRequestBody)
return result
}
body := make(map[string]interface{})
if err := json.Unmarshal([]byte(env), &body); err != nil {
result[EnvHttpRequestBody] = fmt.Sprintf("Environment valiable %s not hashmap structure.", EnvHttpRequestBody)
return result
}
return nil
}
// Validate THREAD_NUM env
func validateThreadNum(result map[string]string) map[string]string {
env := os.Getenv(EnvThreadNum)
if validateEmpty(env) {
result[EnvThreadNum] = fmt.Sprintf("Environment valiable %s is required.", EnvThreadNum)
return result
}
if _, err := strconv.Atoi(env); err != nil {
result[EnvThreadNum] = fmt.Sprintf("Environment valiable %s is not number.", EnvThreadNum)
return result
}
return nil
}
// Validate TRIAL_NUM env
func validateTrialNum(result map[string]string) map[string]string {
env := os.Getenv(EnvTrialNum)
if validateEmpty(env) {
result[EnvTrialNum] = fmt.Sprintf("Environment valiable %s is required.", EnvTrialNum)
return result
}
if _, err := strconv.Atoi(env); err != nil {
result[EnvTrialNum] = fmt.Sprintf("Environment valiable %s is not number.", EnvTrialNum)
return result
}
return nil
}
// Validate PERMANENT env
func validatePermanent(result map[string]string) map[string]string {
env := os.Getenv(EnvPermanent)
if validateEmpty(env) {
return result
}
if !strings.EqualFold(env, "true") || !strings.EqualFold(env, "false") {
result[EnvPermanent] = fmt.Sprintf("Environment valiable %s is true or false.", EnvPermanent)
return result
}
return nil
}
// Validate SLACK_NOTIFY_THRESHOLD_LATENCY_MILLIS env
func validateSlackNotifyThreshHoldLatencyMillis(result map[string]string) map[string]string {
env := os.Getenv(EnvSlackNotifyThreshHoldLatencyMillis)
if validateEmpty(env) {
return result
}
if _, err := strconv.Atoi(env); err != nil {
result[EnvSlackNotifyThreshHoldLatencyMillis] = fmt.Sprintf("Environment valiable %s is not number.", EnvSlackNotifyThreshHoldLatencyMillis)
return result
}
return nil
}
// Validate empty
func validateEmpty(data string) bool {
if data == "" {
return true
}
return false
}