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