1
0
Fork 0
This commit is contained in:
Atridad Lahiji 2024-12-04 16:40:52 -06:00
commit adba4e25dc
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
10 changed files with 489 additions and 0 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
# flyctl launch added from .gitignore
**/loadr
**/.reports
fly.toml

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
loadr
.reports
.DS_Store

40
Dockerfile Normal file
View file

@ -0,0 +1,40 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
# Build loadr
RUN go build -o loadr
# Create and build the server using a shell script
RUN <<EOF cat > server.go
package main
import (
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Loadr is ready"))
})
log.Printf("Server starting on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
EOF
RUN go build -o server server.go
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/loadr .
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["/app/server"]

64
README.md Normal file
View file

@ -0,0 +1,64 @@
# Loadr
A lightweight REST load testing tool with robust support for different request patterns, token auth, and performance reports.
## Installation
```bash
go build
```
## Quick Start
```bash
# Simple pattern: 5 POST requests
./loadr -rate=20 -max=100 -url=http://api.example.com -pattern=5p
# Mixed pattern: 1 POST followed by 5 GETs, repeating
./loadr -rate=20 -max=100 -url=http://api.example.com -pattern=1p5g
# With authentication and request body
./loadr -rate=20 -max=100 -url=http://api.example.com -pattern=2p3g -json=./data.json -token=YourBearerToken
```
## Request Patterns
The `-pattern` flag supports flexible request patterns:
### Simple Patterns
- `5p` : 5 POST requests
- `3g` : 3 GET requests
### Sequential Patterns
- `1p5g` : 1 POST followed by 5 GETs
- `2p3g` : 2 POSTs followed by 3 GETs
- `3g2p` : 3 GETs followed by 2 POSTs
### Pattern Rules
- Numbers specify how many requests of each type
- 'p' or 'P' specifies POST requests
- 'g' or 'G' specifies GET requests
- If no number is specified, 1 is assumed (e.g., "pg" = "1p1g")
- Pattern repeats until max requests is reached
## Command Line Flags
- `-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")
- `-json`: Path to JSON file for request body
- `-token`: Bearer token for authorization
- `-v`, `-version`: Print version information
## Reports
Test results are automatically:
1. Displayed in the console
2. Saved to `.reports/[timestamp].txt`
Reports include:
- Total requests sent and received
- Average, maximum, and minimum latency
- Requests per second (sent and received)
- Pattern information

14
fly.toml Normal file
View file

@ -0,0 +1,14 @@
app = "loadr"
primary_region = "ord"
[build]
[http_service]
internal_port = 8080
force_https = true
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[vm]]
size = "shared-cpu-1x"

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module loadr
go 1.23

104
lib/metrics.go Normal file
View file

@ -0,0 +1,104 @@
package lib
import (
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
var metrics = PerformanceMetrics{
MinLatency: time.Duration(math.MaxInt64),
ResponseCounters: make(map[int]int32),
}
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
metrics.TotalRequests++
metrics.TotalLatency += duration
if duration > metrics.MaxLatency {
metrics.MaxLatency = duration
}
if duration < metrics.MinLatency {
metrics.MinLatency = duration
}
if resp.StatusCode == http.StatusOK {
metrics.TotalResponses++
metrics.ResponseCounters[second]++
}
// Debug log
fmt.Printf("Current metrics - Total Requests: %d, Total Responses: %d\n",
metrics.TotalRequests, metrics.TotalResponses)
}
func CalculateAndPrintMetrics(startTime time.Time, requestsPerSecond float64, endpoint string, patterns []RequestPattern) {
time.Sleep(100 * time.Millisecond)
metrics.Mu.Lock()
defer metrics.Mu.Unlock()
averageLatency := time.Duration(0)
if metrics.TotalRequests > 0 {
averageLatency = metrics.TotalLatency / time.Duration(metrics.TotalRequests)
}
totalDuration := time.Since(startTime).Seconds()
totalResponses := int32(0)
for _, count := range metrics.ResponseCounters {
totalResponses += count
}
if metrics.MinLatency == time.Duration(math.MaxInt64) {
metrics.MinLatency = 0
}
results := fmt.Sprintf("Load Test Report\n")
results += fmt.Sprintf("=============\n\n")
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]))
}
results += "\n\n"
results += fmt.Sprintf("Performance Metrics\n")
results += fmt.Sprintf("-----------------\n")
results += fmt.Sprintf("Total Requests Sent: %d\n", metrics.TotalRequests)
results += fmt.Sprintf("Total Responses Received: %d\n", totalResponses)
results += fmt.Sprintf("Average Latency: %s\n", averageLatency)
results += fmt.Sprintf("Max Latency: %s\n", metrics.MaxLatency)
results += fmt.Sprintf("Min Latency: %s\n", metrics.MinLatency)
results += fmt.Sprintf("Requests/sec (Target): %.2f\n", requestsPerSecond)
results += fmt.Sprintf("Requests/sec (Actual): %.2f\n", float64(metrics.TotalRequests)/totalDuration)
results += fmt.Sprintf("Responses/sec: %.2f\n", float64(totalResponses)/totalDuration)
fmt.Println(results)
saveReport(results)
}
func saveReport(results string) {
resultsDir := ".reports"
os.MkdirAll(resultsDir, os.ModePerm)
resultsFile := filepath.Join(resultsDir, fmt.Sprintf("%d.txt", time.Now().Unix()))
if err := os.WriteFile(resultsFile, []byte(results), 0644); err != nil {
fmt.Println("Error saving report:", err)
return
}
fmt.Println("Report saved:", resultsFile)
}

112
lib/requests.go Normal file
View file

@ -0,0 +1,112 @@
package lib
import (
"bytes"
"fmt"
"math"
"math/rand/v2"
"net/http"
"sync"
"sync/atomic"
"time"
)
var client = &http.Client{}
func (e *RequestError) Error() string {
return fmt.Sprintf("error making %s request to %s: %v", e.Verb, e.URL, e.Err)
}
func makeRequest(verb, url, token string, jsonData []byte, second int) error {
startTime := time.Now()
var req *http.Request
var err error
if jsonData != nil {
req, err = http.NewRequest(verb, url, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
} else {
req, err = http.NewRequest(verb, url, nil)
}
if err != nil {
return &RequestError{Verb: verb, URL: url, Err: err}
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return &RequestError{Verb: verb, URL: url, Err: err}
}
defer resp.Body.Close()
duration := time.Since(startTime)
UpdateMetrics(duration, resp, second)
return nil
}
func SendRequests(url string, patterns []RequestPattern, maxRequests int, requestsPerSecond float64, token string, jsonData []byte) {
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
patternIndex := 0
sequenceCount := 0
for range ticker.C {
if int(requestCount) >= maxRequests {
break
}
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 {
rand := rand.Float64() * 100
cumulative := 0.0
for _, p := range patterns {
cumulative += p.Percentage
if rand <= cumulative {
selectedVerb = p.Verb
break
}
}
}
wg.Add(1)
go func(verb string) {
defer wg.Done()
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)
}
wg.Wait()
time.Sleep(100 * time.Millisecond)
CalculateAndPrintMetrics(startTime, requestsPerSecond, url, patterns)
}

28
lib/types.go Normal file
View file

@ -0,0 +1,28 @@
package lib
import (
"sync"
"time"
)
type PerformanceMetrics struct {
Mu sync.Mutex
TotalRequests int32
TotalResponses int32
TotalLatency time.Duration
MaxLatency time.Duration
MinLatency time.Duration
ResponseCounters map[int]int32
}
type RequestError struct {
Verb string
URL string
Err error
}
type RequestPattern struct {
Verb string
Percentage float64
Sequence int
}

117
main.go Normal file
View file

@ -0,0 +1,117 @@
package main
import (
"flag"
"fmt"
"loadr/lib"
"os"
)
var version string = "1.0.2"
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)`)
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")
versionFlagShort := flag.Bool("v", false, "Print the version and exit")
flag.Parse()
if *versionFlag || *versionFlagShort {
fmt.Println("Version:", version)
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)
}
return *requestsPerSecond, *maxRequests, *url, patterns, *jsonFilePath, *bearerToken
}
func parsePattern(pattern string) ([]lib.RequestPattern, error) {
if pattern == "" {
return []lib.RequestPattern{{Verb: "GET", Percentage: 100}}, nil
}
var patterns []lib.RequestPattern
var current int
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
}
patterns = append(patterns, lib.RequestPattern{
Verb: "POST",
Sequence: current,
Percentage: 0,
})
current = 0
case c == 'g' || c == 'G':
if current == 0 {
current = 1
}
patterns = append(patterns, lib.RequestPattern{
Verb: "GET",
Sequence: current,
Percentage: 0,
})
current = 0
default:
return nil, fmt.Errorf("invalid pattern character: %c", c)
}
}
if len(patterns) == 0 {
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
}
return patterns, nil
}
func readJSONFile(filePath string) ([]byte, error) {
if filePath == "" {
return nil, nil
}
return os.ReadFile(filePath)
}
func main() {
requestsPerSecond, maxRequests, url, patterns, jsonFilePath, bearerToken := parseCommandLine()
if maxRequests <= 0 {
fmt.Println("Error: max must be an integer greater than 0")
return
}
jsonData, err := readJSONFile(jsonFilePath)
if err != nil {
fmt.Println("Error reading JSON file:", err)
return
}
lib.SendRequests(url, patterns, maxRequests, requestsPerSecond, bearerToken, jsonData)
}