mirror of
https://github.com/nprasad2077/NBA_Go.git
synced 2026-09-22 14:05:13 +00:00
782 lines
22 KiB
Go
782 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// CacheStatus tracks edge/CDN cache outcome
|
|
type CacheStatus string
|
|
|
|
const (
|
|
CacheHit CacheStatus = "HIT"
|
|
CacheMiss CacheStatus = "MISS"
|
|
CacheUnknown CacheStatus = "UNKNOWN"
|
|
)
|
|
|
|
// Response tracks the result of an individual HTTP request
|
|
type Response struct {
|
|
StatusCode int
|
|
BodyLength int
|
|
Duration time.Duration
|
|
Error error
|
|
Page int
|
|
Endpoint string
|
|
CacheStatus CacheStatus
|
|
URL string
|
|
Attempt int
|
|
}
|
|
|
|
type pageBucket struct {
|
|
page int
|
|
weight float64
|
|
}
|
|
|
|
type endpointBucket struct {
|
|
endpoint string
|
|
weight float64
|
|
}
|
|
|
|
// PercentileStats holds distribution percentiles
|
|
type PercentileStats struct {
|
|
Count int
|
|
Min time.Duration
|
|
P50 time.Duration
|
|
P75 time.Duration
|
|
P90 time.Duration
|
|
P95 time.Duration
|
|
P99 time.Duration
|
|
Max time.Duration
|
|
Average time.Duration
|
|
}
|
|
|
|
// Static dataset pools for query permutations
|
|
var (
|
|
teamAbbrs = []string{
|
|
"ATL", "BOS", "BKN", "CHA", "CHI", "CLE", "DAL", "DEN", "DET", "GSW",
|
|
"HOU", "IND", "LAC", "LAL", "MEM", "MIA", "MIL", "MIN", "NOP", "NYK",
|
|
"OKC", "ORL", "PHI", "PHX", "POR", "SAC", "SAS", "TOR", "UTA", "WAS",
|
|
}
|
|
|
|
seasons = []int{2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025}
|
|
|
|
advancedSortFields = []string{
|
|
"winShares", "vorp", "per", "tsPercent", "threePAR", "ftr",
|
|
"offensiveRBPercent", "defensiveRBPercent", "totalRBPercent",
|
|
"assistPercent", "stealPercent", "blockPercent", "turnoverPercent",
|
|
"usagePercent", "offensiveWS", "defensiveWS", "winSharesPer",
|
|
"offensiveBox", "defensiveBox", "box", "games", "minutesPlayed", "age",
|
|
}
|
|
|
|
totalsSortFields = []string{
|
|
"points", "assists", "rebounds", "steals", "blocks", "games",
|
|
"minutesPlayed", "turnovers", "fieldGoals", "threePoints", "freeThrows",
|
|
}
|
|
|
|
gameAssociations = []string{
|
|
"lineScores", "playerGameBasicStats", "playerGameAdvStats",
|
|
"teamGameBasicStats", "teamGameAdvStats",
|
|
}
|
|
|
|
pageSizes = []int{10, 20, 25, 30, 40, 50}
|
|
|
|
playerIDs = []string{
|
|
"curryst01", "jamesle01", "antetgi01", "jokicni01", "doncilu01",
|
|
"tatumja01", "embiijo01", "duranke01", "butleji01", "moranja01",
|
|
}
|
|
)
|
|
|
|
func parsePageMix(spec string) ([]pageBucket, error) {
|
|
if strings.TrimSpace(spec) == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
seenPages := make(map[int]bool)
|
|
var buckets []pageBucket
|
|
for _, rawEntry := range strings.Split(spec, ",") {
|
|
entry := strings.TrimSpace(rawEntry)
|
|
parts := strings.Split(entry, ":")
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("invalid page mix entry %q: expected pages:weight", entry)
|
|
}
|
|
|
|
pageParts := strings.Split(strings.TrimSpace(parts[0]), "-")
|
|
if len(pageParts) > 2 {
|
|
return nil, fmt.Errorf("invalid page range %q", parts[0])
|
|
}
|
|
|
|
firstPage, err := strconv.Atoi(strings.TrimSpace(pageParts[0]))
|
|
if err != nil || firstPage < 1 {
|
|
return nil, fmt.Errorf("invalid first page %q", pageParts[0])
|
|
}
|
|
|
|
lastPage := firstPage
|
|
if len(pageParts) == 2 {
|
|
lastPage, err = strconv.Atoi(strings.TrimSpace(pageParts[1]))
|
|
if err != nil || lastPage < firstPage {
|
|
return nil, fmt.Errorf("invalid page range %q", parts[0])
|
|
}
|
|
}
|
|
|
|
weight, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
|
if err != nil || weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
|
|
return nil, fmt.Errorf("invalid weight %q", parts[1])
|
|
}
|
|
|
|
pageCount := lastPage - firstPage + 1
|
|
weightPerPage := weight / float64(pageCount)
|
|
for page := firstPage; page <= lastPage; page++ {
|
|
if seenPages[page] {
|
|
return nil, fmt.Errorf("page %d appears in overlapping ranges", page)
|
|
}
|
|
seenPages[page] = true
|
|
buckets = append(buckets, pageBucket{page: page, weight: weightPerPage})
|
|
}
|
|
}
|
|
|
|
return buckets, nil
|
|
}
|
|
|
|
func parseEndpointMix(spec string) ([]endpointBucket, error) {
|
|
if strings.TrimSpace(spec) == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
var buckets []endpointBucket
|
|
for _, rawEntry := range strings.Split(spec, ",") {
|
|
entry := strings.TrimSpace(rawEntry)
|
|
parts := strings.Split(entry, ":")
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("invalid endpoint mix entry %q: expected endpoint:weight", entry)
|
|
}
|
|
endpoint := strings.TrimSpace(parts[0])
|
|
if !strings.HasPrefix(endpoint, "/") {
|
|
endpoint = "/api/" + endpoint
|
|
}
|
|
|
|
weight, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
|
if err != nil || weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
|
|
return nil, fmt.Errorf("invalid weight %q for endpoint %q", parts[1], endpoint)
|
|
}
|
|
buckets = append(buckets, endpointBucket{endpoint: endpoint, weight: weight})
|
|
}
|
|
return buckets, nil
|
|
}
|
|
|
|
func selectPages(buckets []pageBucket, count int, seed int64) []int {
|
|
pages := make([]int, count)
|
|
if len(buckets) == 0 || count == 0 {
|
|
return pages
|
|
}
|
|
|
|
totalWeight := 0.0
|
|
for _, bucket := range buckets {
|
|
totalWeight += bucket.weight
|
|
}
|
|
|
|
rng := rand.New(rand.NewSource(seed))
|
|
for i := range pages {
|
|
target := rng.Float64() * totalWeight
|
|
cumulativeWeight := 0.0
|
|
for _, bucket := range buckets {
|
|
cumulativeWeight += bucket.weight
|
|
if target < cumulativeWeight {
|
|
pages[i] = bucket.page
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return pages
|
|
}
|
|
|
|
func selectEndpoint(buckets []endpointBucket, rng *rand.Rand) string {
|
|
if len(buckets) == 0 {
|
|
return ""
|
|
}
|
|
totalWeight := 0.0
|
|
for _, bucket := range buckets {
|
|
totalWeight += bucket.weight
|
|
}
|
|
|
|
target := rng.Float64() * totalWeight
|
|
cumulativeWeight := 0.0
|
|
for _, bucket := range buckets {
|
|
cumulativeWeight += bucket.weight
|
|
if target < cumulativeWeight {
|
|
return bucket.endpoint
|
|
}
|
|
}
|
|
return buckets[0].endpoint
|
|
}
|
|
|
|
func urlForPage(base url.URL, page int) string {
|
|
query := base.Query()
|
|
query.Set("page", strconv.Itoa(page))
|
|
base.RawQuery = query.Encode()
|
|
return base.String()
|
|
}
|
|
|
|
// RequestOptions configures dynamic URL and request generation
|
|
type RequestOptions struct {
|
|
BaseURL url.URL
|
|
Page int
|
|
VaryParams bool
|
|
Complexity string
|
|
CacheBust bool
|
|
EndpointMix []endpointBucket
|
|
RequestID int
|
|
WorkerID int
|
|
RNG *rand.Rand
|
|
}
|
|
|
|
// GenerateRequestURL builds a customized URL based on options and randomness
|
|
func GenerateRequestURL(opts RequestOptions) string {
|
|
targetURL := opts.BaseURL
|
|
|
|
// If endpoint mix is configured, choose target endpoint
|
|
if len(opts.EndpointMix) > 0 {
|
|
ep := selectEndpoint(opts.EndpointMix, opts.RNG)
|
|
targetURL.Path = ep
|
|
}
|
|
|
|
query := targetURL.Query()
|
|
|
|
if opts.Page > 0 {
|
|
query.Set("page", strconv.Itoa(opts.Page))
|
|
} else if opts.VaryParams {
|
|
// Random page 1-20
|
|
query.Set("page", strconv.Itoa(opts.RNG.Intn(20)+1))
|
|
}
|
|
|
|
path := strings.ToLower(targetURL.Path)
|
|
|
|
if opts.VaryParams {
|
|
// Randomize pageSize
|
|
query.Set("pageSize", strconv.Itoa(pageSizes[opts.RNG.Intn(len(pageSizes))]))
|
|
|
|
// Randomize ascending
|
|
query.Set("ascending", strconv.FormatBool(opts.RNG.Intn(2) == 1))
|
|
|
|
// Randomize season (70% chance to filter by season)
|
|
if opts.RNG.Float64() < 0.70 {
|
|
query.Set("season", strconv.Itoa(seasons[opts.RNG.Intn(len(seasons))]))
|
|
}
|
|
|
|
// Randomize team filter (50% chance)
|
|
if opts.RNG.Float64() < 0.50 {
|
|
query.Set("team", teamAbbrs[opts.RNG.Intn(len(teamAbbrs))])
|
|
}
|
|
|
|
// Randomize isPlayoff (30% chance)
|
|
if opts.RNG.Float64() < 0.30 {
|
|
query.Set("isPlayoff", strconv.FormatBool(opts.RNG.Intn(2) == 1))
|
|
}
|
|
|
|
// Endpoint specific parameters
|
|
if strings.Contains(path, "playeradvanced") {
|
|
query.Set("sortBy", advancedSortFields[opts.RNG.Intn(len(advancedSortFields))])
|
|
if opts.Complexity == "high" && opts.RNG.Float64() < 0.30 {
|
|
query.Set("playerId", playerIDs[opts.RNG.Intn(len(playerIDs))])
|
|
}
|
|
} else if strings.Contains(path, "playertotal") {
|
|
query.Set("sortBy", totalsSortFields[opts.RNG.Intn(len(totalsSortFields))])
|
|
if opts.Complexity == "high" && opts.RNG.Float64() < 0.30 {
|
|
query.Set("playerId", playerIDs[opts.RNG.Intn(len(playerIDs))])
|
|
}
|
|
} else if strings.Contains(path, "game") {
|
|
query.Set("sortBy", "date")
|
|
if opts.Complexity == "high" {
|
|
// Random subset of 1 to 4 preloaded associations to stress joins
|
|
numAssoc := opts.RNG.Intn(3) + 1
|
|
perm := opts.RNG.Perm(len(gameAssociations))
|
|
var chosen []string
|
|
for i := 0; i < numAssoc; i++ {
|
|
chosen = append(chosen, gameAssociations[perm[i]])
|
|
}
|
|
query.Set("include", strings.Join(chosen, ","))
|
|
}
|
|
} else if strings.Contains(path, "shotchart") {
|
|
if opts.RNG.Float64() < 0.60 {
|
|
query.Set("playerId", playerIDs[opts.RNG.Intn(len(playerIDs))])
|
|
}
|
|
}
|
|
}
|
|
|
|
if opts.CacheBust {
|
|
// Unique high-resolution nonce guaranteed to miss CDN / reverse-proxy cache
|
|
nonce := fmt.Sprintf("%d_%d_%d", time.Now().UnixNano(), opts.WorkerID, opts.RequestID)
|
|
query.Set("_cb", nonce)
|
|
}
|
|
|
|
targetURL.RawQuery = query.Encode()
|
|
return targetURL.String()
|
|
}
|
|
|
|
// ExtractCacheStatus parses response headers to identify cache state
|
|
func ExtractCacheStatus(headers http.Header) CacheStatus {
|
|
// 1. Cloudflare CF-Cache-Status
|
|
if cf := strings.ToUpper(headers.Get("CF-Cache-Status")); cf != "" {
|
|
switch cf {
|
|
case "HIT":
|
|
return CacheHit
|
|
case "MISS", "EXPIRED", "DYNAMIC", "BYPASS", "REVALIDATED":
|
|
return CacheMiss
|
|
}
|
|
}
|
|
|
|
// 2. Fastly / Varnish / Nginx X-Cache or X-Cache-Status
|
|
if xc := strings.ToUpper(headers.Get("X-Cache-Status")); xc != "" {
|
|
if strings.Contains(xc, "HIT") {
|
|
return CacheHit
|
|
}
|
|
if strings.Contains(xc, "MISS") {
|
|
return CacheMiss
|
|
}
|
|
}
|
|
if xc := strings.ToUpper(headers.Get("X-Cache")); xc != "" {
|
|
if strings.Contains(xc, "HIT") {
|
|
return CacheHit
|
|
}
|
|
if strings.Contains(xc, "MISS") {
|
|
return CacheMiss
|
|
}
|
|
}
|
|
|
|
// 3. Age header (standard HTTP caching: Age > 0 implies served from cache)
|
|
if ageStr := headers.Get("Age"); ageStr != "" {
|
|
if ageVal, err := strconv.Atoi(ageStr); err == nil && ageVal > 0 {
|
|
return CacheHit
|
|
}
|
|
}
|
|
|
|
return CacheUnknown
|
|
}
|
|
|
|
// CalculatePercentiles computes key percentile distributions from durations
|
|
func CalculatePercentiles(durations []time.Duration) PercentileStats {
|
|
n := len(durations)
|
|
if n == 0 {
|
|
return PercentileStats{}
|
|
}
|
|
|
|
sorted := make([]time.Duration, n)
|
|
copy(sorted, durations)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
|
|
|
var sum time.Duration
|
|
for _, d := range sorted {
|
|
sum += d
|
|
}
|
|
|
|
getPercentile := func(p float64) time.Duration {
|
|
if n == 1 {
|
|
return sorted[0]
|
|
}
|
|
rank := p * float64(n-1)
|
|
idx := int(math.Floor(rank))
|
|
if idx >= n-1 {
|
|
return sorted[n-1]
|
|
}
|
|
frac := rank - float64(idx)
|
|
return sorted[idx] + time.Duration(float64(sorted[idx+1]-sorted[idx])*frac)
|
|
}
|
|
|
|
return PercentileStats{
|
|
Count: n,
|
|
Min: sorted[0],
|
|
P50: getPercentile(0.50),
|
|
P75: getPercentile(0.75),
|
|
P90: getPercentile(0.90),
|
|
P95: getPercentile(0.95),
|
|
P99: getPercentile(0.99),
|
|
Max: sorted[n-1],
|
|
Average: sum / time.Duration(n),
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
// Command line flags
|
|
numRequests := flag.Int("n", 100, "Number of requests to send")
|
|
concurrency := flag.Int("c", 10, "Number of concurrent requests")
|
|
endpoint := flag.String("url", "http://localhost:5000/api/playeradvancedstats", "API endpoint to test")
|
|
logFile := flag.String("log", "loadtest.log", "Log file path")
|
|
apiKey := flag.String("key", "", "API key for x-api-key header")
|
|
benchmarkKey := flag.String("benchmarkKey", "", "Benchmark key for X-Benchmark-Key header (bypasses rate limiting)")
|
|
pageMix := flag.String("pageMix", "", "Weighted page mix, for example 1-3:60,4-10:30,11-20:10")
|
|
varyParams := flag.Bool("varyParams", false, "Randomize query parameters (season, team, sortBy, pageSize, etc.)")
|
|
complexity := flag.String("complexity", "standard", "Query complexity level: standard, high")
|
|
cacheBust := flag.Bool("cacheBust", false, "Append unique query nonce to guarantee 100% cold-cache misses")
|
|
rotateIPs := flag.Bool("rotateIPs", false, "Rotate synthetic client IPs (X-Real-IP / X-Forwarded-For) to simulate distributed traffic")
|
|
retryOnRateLimit := flag.Bool("retryOnRateLimit", false, "Retry requests that receive 429 Too Many Requests with backoff")
|
|
endpointMix := flag.String("endpointMix", "", "Weighted multi-endpoint mix, for example playeradvancedstats:40,playertotals:30,games:30")
|
|
timeout := flag.Duration("timeout", 30*time.Second, "HTTP request timeout per request")
|
|
seed := flag.Int64("seed", -1, "Random seed (-1 uses current timestamp)")
|
|
flag.Parse()
|
|
|
|
if *numRequests <= 0 {
|
|
log.Fatal("Number of requests must be greater than zero")
|
|
}
|
|
if *concurrency <= 0 {
|
|
log.Fatal("Concurrency must be greater than zero")
|
|
}
|
|
|
|
// Read BENCHMARK_KEY from env fallback if flag not provided
|
|
effectiveBenchmarkKey := *benchmarkKey
|
|
if effectiveBenchmarkKey == "" {
|
|
effectiveBenchmarkKey = os.Getenv("BENCHMARK_KEY")
|
|
}
|
|
|
|
parsedBaseURL, parseErr := url.Parse(*endpoint)
|
|
if parseErr != nil {
|
|
log.Fatalf("Invalid endpoint URL %q: %v", *endpoint, parseErr)
|
|
}
|
|
|
|
pageBuckets, err := parsePageMix(*pageMix)
|
|
if err != nil {
|
|
log.Fatalf("Invalid page mix: %v", err)
|
|
}
|
|
|
|
endpointBuckets, err := parseEndpointMix(*endpointMix)
|
|
if err != nil {
|
|
log.Fatalf("Invalid endpoint mix: %v", err)
|
|
}
|
|
|
|
selectionSeed := *seed
|
|
if selectionSeed == -1 {
|
|
selectionSeed = time.Now().UnixNano()
|
|
}
|
|
selectedPages := selectPages(pageBuckets, *numRequests, selectionSeed)
|
|
|
|
// Setup logging
|
|
f, err := os.OpenFile(*logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
|
if err != nil {
|
|
log.Fatalf("Error opening log file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
log.SetOutput(f)
|
|
|
|
fmt.Printf("=========================================================\n")
|
|
fmt.Printf("🏀 NBA_Go Enhanced Load Tester\n")
|
|
fmt.Printf("=========================================================\n")
|
|
fmt.Printf("Requests: %d | Concurrency: %d | Timeout: %v\n", *numRequests, *concurrency, *timeout)
|
|
fmt.Printf("Base Target: %s\n", *endpoint)
|
|
if *varyParams {
|
|
fmt.Printf("Query Variance: ENABLED (Complexity: %s)\n", *complexity)
|
|
} else {
|
|
fmt.Printf("Query Variance: DISABLED (Legacy Mode)\n")
|
|
}
|
|
if *cacheBust {
|
|
fmt.Printf("Cache Busting: ENABLED (100%% cold-cache origin benchmark)\n")
|
|
}
|
|
if effectiveBenchmarkKey != "" {
|
|
fmt.Printf("Benchmark Auth: ENABLED (X-Benchmark-Key header attached)\n")
|
|
}
|
|
if *rotateIPs {
|
|
fmt.Printf("Distributed IP Rotation: ENABLED (X-Real-IP / X-Forwarded-For)\n")
|
|
}
|
|
if len(endpointBuckets) > 0 {
|
|
fmt.Printf("Endpoint Mix: %s\n", *endpointMix)
|
|
}
|
|
fmt.Printf("Logging to: %s\n\n", *logFile)
|
|
|
|
// Channel to collect results
|
|
results := make(chan Response, *numRequests)
|
|
|
|
// Custom HTTP client with connection pooling and timeouts
|
|
transport := &http.Transport{
|
|
MaxIdleConns: *concurrency * 2,
|
|
MaxIdleConnsPerHost: *concurrency * 2,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
DisableKeepAlives: false,
|
|
}
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
Timeout: *timeout,
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, *concurrency)
|
|
var activeReqSeq int64
|
|
|
|
startTime := time.Now()
|
|
|
|
for i := 0; i < *numRequests; i++ {
|
|
wg.Add(1)
|
|
go func(reqID int) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
workerSeq := atomic.AddInt64(&activeReqSeq, 1)
|
|
threadRNG := rand.New(rand.NewSource(selectionSeed + int64(reqID)*31 + workerSeq))
|
|
|
|
targetPage := 0
|
|
if len(selectedPages) > reqID {
|
|
targetPage = selectedPages[reqID]
|
|
}
|
|
|
|
reqURL := GenerateRequestURL(RequestOptions{
|
|
BaseURL: *parsedBaseURL,
|
|
Page: targetPage,
|
|
VaryParams: *varyParams,
|
|
Complexity: *complexity,
|
|
CacheBust: *cacheBust,
|
|
EndpointMix: endpointBuckets,
|
|
RequestID: reqID,
|
|
WorkerID: int(workerSeq),
|
|
RNG: threadRNG,
|
|
})
|
|
|
|
// If no param variation and pageMix was specified, preserve legacy URL logic exactly
|
|
if !*varyParams && len(selectedPages) > 0 && !*cacheBust && len(endpointBuckets) == 0 {
|
|
reqURL = urlForPage(*parsedBaseURL, targetPage)
|
|
}
|
|
|
|
maxAttempts := 1
|
|
if *retryOnRateLimit {
|
|
maxAttempts = 3
|
|
}
|
|
|
|
var finalResp Response
|
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
start := time.Now()
|
|
req, err := http.NewRequestWithContext(context.Background(), "GET", reqURL, nil)
|
|
if err != nil {
|
|
finalResp = Response{
|
|
Duration: time.Since(start),
|
|
Error: err,
|
|
Page: targetPage,
|
|
URL: reqURL,
|
|
Attempt: attempt,
|
|
}
|
|
log.Printf("Req %d: failed creating request: %v", reqID, err)
|
|
break
|
|
}
|
|
|
|
if *apiKey != "" {
|
|
req.Header.Set("x-api-key", *apiKey)
|
|
}
|
|
if effectiveBenchmarkKey != "" {
|
|
req.Header.Set("X-Benchmark-Key", effectiveBenchmarkKey)
|
|
}
|
|
if *rotateIPs {
|
|
simIP := fmt.Sprintf("198.51.100.%d", (reqID%250)+1)
|
|
req.Header.Set("X-Real-IP", simIP)
|
|
req.Header.Set("X-Forwarded-For", simIP)
|
|
}
|
|
|
|
httpResp, err := client.Do(req)
|
|
duration := time.Since(start)
|
|
|
|
if err != nil {
|
|
finalResp = Response{
|
|
Duration: duration,
|
|
Error: err,
|
|
Page: targetPage,
|
|
URL: reqURL,
|
|
Attempt: attempt,
|
|
}
|
|
log.Printf("Req %d (page %d) failed: %v", reqID, targetPage, err)
|
|
break
|
|
}
|
|
|
|
bodyBytes, readErr := io.ReadAll(httpResp.Body)
|
|
httpResp.Body.Close()
|
|
|
|
cacheState := ExtractCacheStatus(httpResp.Header)
|
|
|
|
finalResp = Response{
|
|
StatusCode: httpResp.StatusCode,
|
|
BodyLength: len(bodyBytes),
|
|
Duration: duration,
|
|
Error: readErr,
|
|
Page: targetPage,
|
|
CacheStatus: cacheState,
|
|
URL: reqURL,
|
|
Attempt: attempt,
|
|
}
|
|
|
|
if httpResp.StatusCode == http.StatusTooManyRequests && *retryOnRateLimit && attempt < maxAttempts {
|
|
backoff := time.Duration(300*attempt)*time.Millisecond + time.Duration(threadRNG.Intn(200))*time.Millisecond
|
|
time.Sleep(backoff)
|
|
continue
|
|
}
|
|
|
|
log.Printf("Req %d: Status=%d Cache=%s Duration=%v URL=%s", reqID, httpResp.StatusCode, cacheState, duration, reqURL)
|
|
break
|
|
}
|
|
|
|
results <- finalResp
|
|
}(i)
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
// Aggregation variables
|
|
var (
|
|
totalResponses int
|
|
successCount int
|
|
errorCount int
|
|
statusCounts = make(map[int]int)
|
|
|
|
allDurations []time.Duration
|
|
hitDurations []time.Duration
|
|
missDurations []time.Duration
|
|
otherDurations []time.Duration
|
|
|
|
pageResults = make(map[int]*pageResult)
|
|
)
|
|
|
|
for res := range results {
|
|
totalResponses++
|
|
statusCounts[res.StatusCode]++
|
|
|
|
if res.Error != nil || res.StatusCode < 200 || res.StatusCode >= 400 {
|
|
errorCount++
|
|
} else {
|
|
successCount++
|
|
}
|
|
|
|
allDurations = append(allDurations, res.Duration)
|
|
|
|
switch res.CacheStatus {
|
|
case CacheHit:
|
|
hitDurations = append(hitDurations, res.Duration)
|
|
case CacheMiss:
|
|
missDurations = append(missDurations, res.Duration)
|
|
default:
|
|
otherDurations = append(otherDurations, res.Duration)
|
|
}
|
|
|
|
if res.Page > 0 {
|
|
stats, ok := pageResults[res.Page]
|
|
if !ok {
|
|
stats = &pageResult{}
|
|
pageResults[res.Page] = stats
|
|
}
|
|
stats.requests++
|
|
if res.Error != nil || res.StatusCode != http.StatusOK {
|
|
stats.failures++
|
|
} else {
|
|
stats.successes++
|
|
stats.totalDuration += res.Duration
|
|
}
|
|
}
|
|
}
|
|
|
|
totalTestDuration := time.Since(startTime)
|
|
reqsPerSec := float64(totalResponses) / totalTestDuration.Seconds()
|
|
|
|
overallStats := CalculatePercentiles(allDurations)
|
|
hitStats := CalculatePercentiles(hitDurations)
|
|
missStats := CalculatePercentiles(missDurations)
|
|
|
|
// Summary output
|
|
fmt.Printf("\n=========================================================\n")
|
|
fmt.Printf("📊 LOAD TEST EXECUTION SUMMARY\n")
|
|
fmt.Printf("=========================================================\n")
|
|
fmt.Printf("Total Requests: %d\n", totalResponses)
|
|
fmt.Printf("Successful (2xx/3xx): %d (%.1f%%)\n", successCount, float64(successCount)*100.0/float64(totalResponses))
|
|
fmt.Printf("Failed (4xx/5xx/Err): %d (%.1f%%)\n", errorCount, float64(errorCount)*100.0/float64(totalResponses))
|
|
fmt.Printf("Total Duration: %v\n", totalTestDuration)
|
|
fmt.Printf("Throughput: %.2f req/s\n\n", reqsPerSec)
|
|
|
|
// Status code distribution
|
|
fmt.Printf("--- HTTP Status Code Distribution ---\n")
|
|
var sortedStatuses []int
|
|
for code := range statusCounts {
|
|
sortedStatuses = append(sortedStatuses, code)
|
|
}
|
|
sort.Ints(sortedStatuses)
|
|
for _, code := range sortedStatuses {
|
|
desc := http.StatusText(code)
|
|
if desc == "" {
|
|
desc = "Network / Connection Error"
|
|
}
|
|
fmt.Printf(" [%d %s]: %d\n", code, desc, statusCounts[code])
|
|
}
|
|
fmt.Println()
|
|
|
|
// Latency percentiles breakdown
|
|
fmt.Printf("--- Latency Percentile Distribution (All Responses) ---\n")
|
|
fmt.Printf(" Min: %v\n", overallStats.Min)
|
|
fmt.Printf(" p50: %v (Median)\n", overallStats.P50)
|
|
fmt.Printf(" p75: %v\n", overallStats.P75)
|
|
fmt.Printf(" p90: %v\n", overallStats.P90)
|
|
fmt.Printf(" p95: %v\n", overallStats.P95)
|
|
fmt.Printf(" p99: %v\n", overallStats.P99)
|
|
fmt.Printf(" Max: %v\n", overallStats.Max)
|
|
fmt.Printf(" Avg: %v\n\n", overallStats.Average)
|
|
|
|
// Cache telemetry comparison
|
|
fmt.Printf("--- Edge Cache vs Origin Latency Split ---\n")
|
|
totalCacheTracked := len(hitDurations) + len(missDurations) + len(otherDurations)
|
|
if totalCacheTracked > 0 {
|
|
hitPct := float64(len(hitDurations)) * 100.0 / float64(totalCacheTracked)
|
|
missPct := float64(len(missDurations)) * 100.0 / float64(totalCacheTracked)
|
|
otherPct := float64(len(otherDurations)) * 100.0 / float64(totalCacheTracked)
|
|
|
|
fmt.Printf(" ⚡ Cache HITS (Edge/CDN): %d (%.1f%%)\n", len(hitDurations), hitPct)
|
|
if len(hitDurations) > 0 {
|
|
fmt.Printf(" Avg: %v | p50: %v | p95: %v | p99: %v\n",
|
|
hitStats.Average, hitStats.P50, hitStats.P95, hitStats.P99)
|
|
}
|
|
|
|
fmt.Printf(" 🔥 Cache MISSES (Origin / DB): %d (%.1f%%)\n", len(missDurations), missPct)
|
|
if len(missDurations) > 0 {
|
|
fmt.Printf(" Avg: %v | p50: %v | p95: %v | p99: %v\n",
|
|
missStats.Average, missStats.P50, missStats.P95, missStats.P99)
|
|
}
|
|
|
|
if len(otherDurations) > 0 {
|
|
fmt.Printf(" ❓ Unclassified / Direct: %d (%.1f%%)\n", len(otherDurations), otherPct)
|
|
}
|
|
}
|
|
fmt.Println()
|
|
|
|
// Per-page results (if pageMix used)
|
|
if len(pageResults) > 0 {
|
|
pages := make([]int, 0, len(pageResults))
|
|
for page := range pageResults {
|
|
pages = append(pages, page)
|
|
}
|
|
sort.Ints(pages)
|
|
|
|
fmt.Printf("--- Per-page Breakdown ---\n")
|
|
for _, page := range pages {
|
|
stats := pageResults[page]
|
|
pageAverage := time.Duration(0)
|
|
if stats.successes > 0 {
|
|
pageAverage = stats.totalDuration / time.Duration(stats.successes)
|
|
}
|
|
fmt.Printf(" Page %2d: Requests=%4d, Success=%4d, Failed=%d, Avg Latency=%v\n",
|
|
page, stats.requests, stats.successes, stats.failures, pageAverage)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
}
|
|
|
|
type pageResult struct {
|
|
requests int
|
|
successes int
|
|
failures int
|
|
totalDuration time.Duration
|
|
}
|