Re-work
This commit is contained in:
commit
adba4e25dc
10 changed files with 489 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# flyctl launch added from .gitignore
|
||||||
|
**/loadr
|
||||||
|
**/.reports
|
||||||
|
fly.toml
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
loadr
|
||||||
|
.reports
|
||||||
|
.DS_Store
|
40
Dockerfile
Normal file
40
Dockerfile
Normal 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
64
README.md
Normal 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
14
fly.toml
Normal 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
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module loadr
|
||||||
|
|
||||||
|
go 1.23
|
104
lib/metrics.go
Normal file
104
lib/metrics.go
Normal 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
112
lib/requests.go
Normal 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
28
lib/types.go
Normal 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
117
main.go
Normal 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)
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue