From 5dad2d527507ffb6201a8fb0ef819196c71ae17e Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Wed, 4 Dec 2024 17:26:34 -0600 Subject: [PATCH] Added comments for better readability and fixed probabistic patterns --- README.md | 6 +++- lib/metrics.go | 52 ++++++++++++++++++++++++++--- lib/requests.go | 88 ++++++++++++++++++++++++++++++++----------------- lib/types.go | 4 +++ main.go | 82 ++++++++++++++++++++++++++++----------------- 5 files changed, 166 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index d1829df..a99edbd 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,14 @@ The `-pattern` flag supports flexible request patterns: - `2p3g` : 2 POSTs followed by 3 GETs - `3g2p` : 3 GETs followed by 2 POSTs +### Probabalistic Patterns +- `20%p80%g` : 20% POST and by 80% GETs + ### Pattern Rules - Numbers specify how many requests of each type - 'p' or 'P' specifies POST requests - 'g' or 'G' specifies GET requests +- '%' indicates probabilistic requests - If no number is specified, 1 is assumed (e.g., "pg" = "1p1g") - Pattern repeats until max requests is reached @@ -46,7 +50,7 @@ The `-pattern` flag supports flexible request patterns: - `-rate`: Number of requests per second (default: 10) - `-max`: Maximum number of requests to send (default: 50) - `-url`: Target URL (default: "https://example.com") -- `-pattern`: Request pattern (e.g., "5p", "1p5g", "3g2p") +- `-pattern`: Request pattern (e.g., "5p", "1p5g", "3g2p", "10%p90%g") - `-json`: Path to JSON file for request body - `-token`: Bearer token for authorization - `-v`, `-version`: Print version information diff --git a/lib/metrics.go b/lib/metrics.go index bb7a74a..5ebd0f4 100644 --- a/lib/metrics.go +++ b/lib/metrics.go @@ -10,71 +10,105 @@ import ( "time" ) +// Global metrics instance to track performance during load testing var metrics = PerformanceMetrics{ - MinLatency: time.Duration(math.MaxInt64), + // Initialize MinLatency to the maximum possible duration + MinLatency: time.Duration(math.MaxInt64), + // Initialize response counters map ResponseCounters: make(map[int]int32), } +// UpdateMetrics synchronously updates performance metrics for each request +// - duration: time taken to complete the request +// - resp: HTTP response from the request +// - second: elapsed seconds since the start of the test func UpdateMetrics(duration time.Duration, resp *http.Response, second int) { metrics.Mu.Lock() defer metrics.Mu.Unlock() - fmt.Printf("Updating metrics - Duration: %v, Status: %d\n", duration, resp.StatusCode) // Debug log - + // Increment total requests metrics.TotalRequests++ + // Add current request's latency to total metrics.TotalLatency += duration + // Update maximum latency if current duration is higher if duration > metrics.MaxLatency { metrics.MaxLatency = duration } + // Update minimum latency if current duration is lower if duration < metrics.MinLatency { metrics.MinLatency = duration } + // Track successful responses if resp.StatusCode == http.StatusOK { metrics.TotalResponses++ metrics.ResponseCounters[second]++ } - // Debug log + // Debug log of current metrics fmt.Printf("Current metrics - Total Requests: %d, Total Responses: %d\n", metrics.TotalRequests, metrics.TotalResponses) } +// CalculateAndPrintMetrics generates a comprehensive report of load test performance +// Parameters: +// - startTime: when the load test began +// - requestsPerSecond: target request rate +// - endpoint: URL being tested +// - patterns: request patterns used in the test func CalculateAndPrintMetrics(startTime time.Time, requestsPerSecond float64, endpoint string, patterns []RequestPattern) { + // Small delay to ensure all metrics are captured time.Sleep(100 * time.Millisecond) metrics.Mu.Lock() defer metrics.Mu.Unlock() + // Calculate average latency averageLatency := time.Duration(0) if metrics.TotalRequests > 0 { averageLatency = metrics.TotalLatency / time.Duration(metrics.TotalRequests) } + // Calculate total test duration and total responses totalDuration := time.Since(startTime).Seconds() totalResponses := int32(0) for _, count := range metrics.ResponseCounters { totalResponses += count } + // Ensure MinLatency is not left at its initial max value if metrics.MinLatency == time.Duration(math.MaxInt64) { metrics.MinLatency = 0 } + // Build detailed results string results := fmt.Sprintf("Load Test Report\n") results += fmt.Sprintf("=============\n\n") + // Report endpoint and request pattern results += fmt.Sprintf("Endpoint: %s\n", endpoint) results += fmt.Sprintf("Pattern: ") + for i, p := range patterns { if i > 0 { results += " → " } - results += fmt.Sprintf("%d%s", p.Sequence, strings.ToLower(p.Verb[:1])) + + var patternDesc string + if p.Percentage > 0 && p.Percentage < 100 { + // Probabilistic pattern (e.g., "20%p80%g") + patternDesc = fmt.Sprintf("%.0f%%%s", p.Percentage, strings.ToLower(p.Verb[:1])) + } else { + // Simple or sequential pattern (e.g., "5p", "3g", "1p5g") + patternDesc = fmt.Sprintf("%d%s", p.Sequence, strings.ToLower(p.Verb[:1])) + } + + results += patternDesc } results += "\n\n" + // Detailed performance metrics results += fmt.Sprintf("Performance Metrics\n") results += fmt.Sprintf("-----------------\n") results += fmt.Sprintf("Total Requests Sent: %d\n", metrics.TotalRequests) @@ -86,15 +120,23 @@ func CalculateAndPrintMetrics(startTime time.Time, requestsPerSecond float64, en results += fmt.Sprintf("Requests/sec (Actual): %.2f\n", float64(metrics.TotalRequests)/totalDuration) results += fmt.Sprintf("Responses/sec: %.2f\n", float64(totalResponses)/totalDuration) + // Print and save the report fmt.Println(results) saveReport(results) } +// saveReport writes the load test results to a timestamped file in the .reports directory +// Parameters: +// - results: formatted results string to be saved func saveReport(results string) { + // Ensure .reports directory exists resultsDir := ".reports" os.MkdirAll(resultsDir, os.ModePerm) + // Create a unique filename based on current timestamp resultsFile := filepath.Join(resultsDir, fmt.Sprintf("%d.txt", time.Now().Unix())) + + // Write results to file if err := os.WriteFile(resultsFile, []byte(results), 0644); err != nil { fmt.Println("Error saving report:", err) return diff --git a/lib/requests.go b/lib/requests.go index 9c07eca..cbfeb16 100644 --- a/lib/requests.go +++ b/lib/requests.go @@ -6,17 +6,24 @@ import ( "math" "math/rand/v2" "net/http" - "sync" - "sync/atomic" "time" ) +// Global HTTP client for reuse across requests var client = &http.Client{} +// Error returns a formatted error string for RequestError func (e *RequestError) Error() string { return fmt.Sprintf("error making %s request to %s: %v", e.Verb, e.URL, e.Err) } +// makeRequest performs a single HTTP request and records its metrics +// Parameters: +// - verb: HTTP method (GET/POST) +// - url: target endpoint +// - token: optional bearer token for authorization +// - jsonData: optional request body +// - second: current test duration in seconds (for metrics) func makeRequest(verb, url, token string, jsonData []byte, second int) error { startTime := time.Now() @@ -44,44 +51,56 @@ func makeRequest(verb, url, token string, jsonData []byte, second int) error { duration := time.Since(startTime) + // Log request details with millisecond precision + timeStr := time.Now().Format("15:04:05.000") + fmt.Printf("[%s] %s - Status: %d - Duration: %s\n", + timeStr, + verb, + resp.StatusCode, + duration.Round(time.Millisecond)) + UpdateMetrics(duration, resp, second) return nil } +// SendRequests executes the load test according to specified parameters +// Parameters: +// - url: target endpoint +// - patterns: array of request patterns (sequential or probabilistic) +// - maxRequests: total number of requests to send +// - requestsPerSecond: target request rate +// - token: optional bearer token for authorization +// - jsonData: optional request body func SendRequests(url string, patterns []RequestPattern, maxRequests int, requestsPerSecond float64, token string, jsonData []byte) { + // Initialize metrics tracking metrics = PerformanceMetrics{ MinLatency: time.Duration(math.MaxInt64), ResponseCounters: make(map[int]int32), } startTime := time.Now() - rateLimit := time.Second / time.Duration(requestsPerSecond) - ticker := time.NewTicker(rateLimit) - defer ticker.Stop() - - var requestCount int32 - var wg sync.WaitGroup + // Calculate time interval between requests based on desired rate + interval := time.Duration(float64(time.Second) / requestsPerSecond) + nextRequestTime := startTime + requestCount := 0 patternIndex := 0 sequenceCount := 0 - for range ticker.C { - if int(requestCount) >= maxRequests { - break + // Determine pattern type based on first pattern's configuration + isProbabilistic := patterns[0].Percentage > 0 + + for requestCount < maxRequests { + // Maintain request rate by waiting until next scheduled time + now := time.Now() + if now.Before(nextRequestTime) { + time.Sleep(nextRequestTime.Sub(now)) } var selectedVerb string - if patterns[0].Sequence > 0 { - currentPattern := patterns[patternIndex] - selectedVerb = currentPattern.Verb - - sequenceCount++ - if sequenceCount >= currentPattern.Sequence { - sequenceCount = 0 - patternIndex = (patternIndex + 1) % len(patterns) - } - } else { + if isProbabilistic { + // Select verb based on percentage distribution rand := rand.Float64() * 100 cumulative := 0.0 for _, p := range patterns { @@ -91,22 +110,31 @@ func SendRequests(url string, patterns []RequestPattern, maxRequests int, reques break } } + } else { + // Select verb based on sequential pattern + currentPattern := patterns[patternIndex] + selectedVerb = currentPattern.Verb + + sequenceCount++ + if sequenceCount >= currentPattern.Sequence { + sequenceCount = 0 + patternIndex = (patternIndex + 1) % len(patterns) + } } - wg.Add(1) - go func(verb string) { - defer wg.Done() + // Launch request asynchronously to maintain timing + go func(verb string, requestTime time.Time) { err := makeRequest(verb, url, token, jsonData, int(time.Since(startTime).Seconds())) if err != nil { fmt.Printf("Error making request: %v\n", err) } - atomic.AddInt32(&requestCount, 1) - }(selectedVerb) + }(selectedVerb, nextRequestTime) + + requestCount++ + nextRequestTime = nextRequestTime.Add(interval) } - wg.Wait() - - time.Sleep(100 * time.Millisecond) - + // Allow time for final requests to complete + time.Sleep(interval * 2) CalculateAndPrintMetrics(startTime, requestsPerSecond, url, patterns) } diff --git a/lib/types.go b/lib/types.go index 983ab42..11819c2 100644 --- a/lib/types.go +++ b/lib/types.go @@ -5,6 +5,8 @@ import ( "time" ) +// PerformanceMetrics represents a thread-safe container for tracking +// performance statistics during load testing type PerformanceMetrics struct { Mu sync.Mutex TotalRequests int32 @@ -15,12 +17,14 @@ type PerformanceMetrics struct { ResponseCounters map[int]int32 } +// RequestError represents a detailed error that occurs during an HTTP request type RequestError struct { Verb string URL string Err error } +// RequestPattern defines the characteristics of requests to be sent during load testing type RequestPattern struct { Verb string Percentage float64 diff --git a/main.go b/main.go index ff81f7f..10010f3 100644 --- a/main.go +++ b/main.go @@ -4,16 +4,26 @@ import ( "flag" "fmt" "loadr/lib" + "math" "os" ) +// Version number of the loadr tool var version string = "1.0.2" +// parseCommandLine processes command line arguments and returns configuration parameters +// Returns: +// - requestsPerSecond: target rate of requests +// - maxRequests: total number of requests to send +// - url: target endpoint +// - patterns: array of request patterns +// - jsonFilePath: path to JSON file containing request body +// - bearerToken: authorization token func parseCommandLine() (float64, int, string, []lib.RequestPattern, string, string) { requestsPerSecond := flag.Float64("rate", 10, "Number of requests per second") maxRequests := flag.Int("max", 50, "Maximum number of requests to send (0 for unlimited)") url := flag.String("url", "https://example.com", "The URL to make requests to") - pattern := flag.String("pattern", "", `Request pattern (e.g., "5p" for 5 POSTs, "1p5g" for 1 POST + 5 GETs)`) + pattern := flag.String("pattern", "", `Request pattern (e.g., "5p" for 5 POSTs, "1p5g" for sequential, "20%p80%g" for probabilistic)`) jsonFilePath := flag.String("json", "", "Path to the JSON file with request data") bearerToken := flag.String("token", "", "Bearer token for authorization") versionFlag := flag.Bool("version", false, "Print the version and exit") @@ -26,51 +36,58 @@ func parseCommandLine() (float64, int, string, []lib.RequestPattern, string, str os.Exit(0) } - fmt.Printf("Received pattern value: '%s'\n", *pattern) - patterns, err := parsePattern(*pattern) if err != nil { fmt.Printf("Warning: %v. Using default GET pattern.\n", err) - patterns = []lib.RequestPattern{{Verb: "GET", Percentage: 100}} - } else { - fmt.Printf("Using pattern: %+v\n", patterns) + patterns = []lib.RequestPattern{{Verb: "GET", Sequence: 1}} } return *requestsPerSecond, *maxRequests, *url, patterns, *jsonFilePath, *bearerToken } +// parsePattern interprets the pattern string and converts it to RequestPattern structs +// Pattern formats: +// - Sequential: "5p" (5 POSTs), "1p5g" (1 POST then 5 GETs) +// - Probabilistic: "20%p80%g" (20% POSTs, 80% GETs) +// +// Returns error if pattern is invalid or percentages don't sum to 100 func parsePattern(pattern string) ([]lib.RequestPattern, error) { if pattern == "" { - return []lib.RequestPattern{{Verb: "GET", Percentage: 100}}, nil + return []lib.RequestPattern{{Verb: "GET", Sequence: 1}}, nil } var patterns []lib.RequestPattern var current int + isProbabilistic := false + // Parse pattern string character by character for i := 0; i < len(pattern); i++ { c := pattern[i] switch { case c >= '0' && c <= '9': current = current*10 + int(c-'0') - case c == 'p' || c == 'P': - if current == 0 { - current = 1 + case c == '%': + isProbabilistic = true + case c == 'p' || c == 'P' || c == 'g' || c == 'G': + verb := "GET" + if c == 'p' || c == 'P' { + verb = "POST" } - patterns = append(patterns, lib.RequestPattern{ - Verb: "POST", - Sequence: current, - Percentage: 0, - }) - current = 0 - case c == 'g' || c == 'G': - if current == 0 { - current = 1 + + if isProbabilistic { + patterns = append(patterns, lib.RequestPattern{ + Verb: verb, + Percentage: float64(current), + }) + } else { + if current == 0 { + current = 1 // Default to 1 if no number specified + } + patterns = append(patterns, lib.RequestPattern{ + Verb: verb, + Sequence: current, + }) } - patterns = append(patterns, lib.RequestPattern{ - Verb: "GET", - Sequence: current, - Percentage: 0, - }) current = 0 default: return nil, fmt.Errorf("invalid pattern character: %c", c) @@ -81,17 +98,22 @@ func parsePattern(pattern string) ([]lib.RequestPattern, error) { return nil, fmt.Errorf("no valid patterns found in: %s", pattern) } - total := 0 - for _, p := range patterns { - total += p.Sequence - } - for i := range patterns { - patterns[i].Percentage = float64(patterns[i].Sequence) / float64(total) * 100 + // For probabilistic patterns, ensure percentages sum to 100 + if isProbabilistic { + total := 0.0 + for _, p := range patterns { + total += p.Percentage + } + if math.Abs(total-100.0) > 0.001 { + return nil, fmt.Errorf("percentages must sum to 100, got: %.1f", total) + } } return patterns, nil } +// readJSONFile loads and returns the contents of a JSON file if specified +// Returns nil if filePath is empty func readJSONFile(filePath string) ([]byte, error) { if filePath == "" { return nil, nil