// Command dashboard-loadtest runs a concurrent read-only HTTP load test against // a deployed GL dashboard. package main import ( "context" "errors" "flag" "fmt" "io" "net/http" "net/url" "os" "os/signal" "sort" "strings" "sync" "sync/atomic" "syscall" "time" ) type result struct { Duration time.Duration Requests int64 Errors int64 Non2xx int64 Latency []time.Duration } func main() { baseURL := flag.String("url", "http://127.0.0.1:8080", "dashboard base URL") paths := flag.String("paths", "/", "comma-separated request paths") concurrency := flag.Int("concurrency", 20, "number of concurrent workers") duration := flag.Duration("duration", 15*time.Second, "test duration") requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each request") flag.Parse() if *concurrency < 1 || *duration <= 0 || *requestTimeout <= 0 { fmt.Fprintln(os.Stderr, "concurrency and durations must be positive") os.Exit(2) } targets, err := targets(*baseURL, *paths) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = *concurrency transport.MaxIdleConnsPerHost = *concurrency client := &http.Client{Transport: transport, Timeout: *requestTimeout} defer transport.CloseIdleConnections() signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() ctx, cancel := context.WithTimeout(signalCtx, *duration) defer cancel() fmt.Printf("Dashboard load test: %d workers for %s against %s\n", *concurrency, *duration, *baseURL) started := time.Now() result := run(ctx, client, targets, *concurrency) result.Duration = time.Since(started) printResult(result) if result.Errors > 0 || result.Non2xx > 0 { os.Exit(1) } } func targets(baseURL, pathList string) ([]string, error) { base, err := url.Parse(baseURL) if err != nil || base.Scheme == "" || base.Host == "" { return nil, fmt.Errorf("invalid dashboard URL %q", baseURL) } paths := strings.Split(pathList, ",") result := make([]string, 0, len(paths)) for _, value := range paths { value = strings.TrimSpace(value) if value == "" { continue } reference, parseErr := url.Parse(value) if parseErr != nil || reference.IsAbs() || !strings.HasPrefix(reference.Path, "/") { return nil, fmt.Errorf("invalid request path %q", value) } result = append(result, base.ResolveReference(reference).String()) } if len(result) == 0 { return nil, errors.New("at least one request path is required") } return result, nil } func run(ctx context.Context, client *http.Client, targets []string, concurrency int) result { var ( requests int64 failures int64 non2xx int64 sequence uint64 latency = make([]time.Duration, 0, concurrency*100) lock sync.Mutex workers sync.WaitGroup ) workers.Add(concurrency) for range concurrency { go func() { defer workers.Done() for ctx.Err() == nil { target := targets[(atomic.AddUint64(&sequence, 1)-1)%uint64(len(targets))] request, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodGet, target, nil) if err != nil { atomic.AddInt64(&failures, 1) continue } started := time.Now() response, err := client.Do(request) elapsed := time.Since(started) if err != nil { atomic.AddInt64(&requests, 1) atomic.AddInt64(&failures, 1) continue } _, copyErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20)) closeErr := response.Body.Close() atomic.AddInt64(&requests, 1) if response.StatusCode < 200 || response.StatusCode >= 300 { atomic.AddInt64(&non2xx, 1) } if copyErr != nil || closeErr != nil { atomic.AddInt64(&failures, 1) } lock.Lock() latency = append(latency, elapsed) lock.Unlock() } }() } workers.Wait() return result{Requests: requests, Errors: failures, Non2xx: non2xx, Latency: latency} } func printResult(value result) { seconds := value.Duration.Seconds() requestsPerSecond := float64(value.Requests) if seconds > 0 { requestsPerSecond /= seconds } fmt.Printf("requests: %d\n", value.Requests) fmt.Printf("throughput: %.1f req/s\n", requestsPerSecond) fmt.Printf("latency: p50=%s p95=%s p99=%s\n", percentile(value.Latency, 50), percentile(value.Latency, 95), percentile(value.Latency, 99)) fmt.Printf("errors: %d transport, %d non-2xx\n", value.Errors, value.Non2xx) } func percentile(values []time.Duration, percent int) time.Duration { if len(values) == 0 { return 0 } ordered := append([]time.Duration(nil), values...) sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] }) index := (len(ordered)*percent + 99) / 100 if index < 1 { index = 1 } return ordered[index-1] }