diff --git a/cmd/sponge/commands/assistant/chat.go b/cmd/sponge/commands/assistant/chat.go index 89aa99c..90cf4df 100644 --- a/cmd/sponge/commands/assistant/chat.go +++ b/cmd/sponge/commands/assistant/chat.go @@ -102,7 +102,7 @@ func chat(asst *assistantParams) error { } if input == "r" || input == "R" { client.RefreshContext() - fmt.Println(color.HiBlackString("Finished refreshing context.") + "\n\n") + fmt.Println(color.HiBlackString("Start a new conversation.") + "\n\n") continue } diff --git a/cmd/sponge/commands/perftest.go b/cmd/sponge/commands/perftest.go new file mode 100644 index 0000000..da8d354 --- /dev/null +++ b/cmd/sponge/commands/perftest.go @@ -0,0 +1,29 @@ +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/http" + "github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/websocket" +) + +// PerftestCommand command entry +func PerftestCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "perftest", + Short: "Performance testing for HTTP/1.1, HTTP/2, HTTP/3, and websocket", + Long: `Perftest is a performance testing tool that supports HTTP/1.1, HTTP/2, HTTP/3, and WebSocket protocols. +It also allows real-time statistics to be pushed to a custom HTTP server or Prometheus.`, + SilenceErrors: true, + SilenceUsage: true, + } + + cmd.AddCommand( + http.PerfTestHTTPCMD(), + http.PerfTestHTTP2CMD(), + http.PerfTestHTTP3CMD(), + websocket.PerfTestWebsocketCMD(), + ) + + return cmd +} diff --git a/cmd/sponge/commands/perftest/README.md b/cmd/sponge/commands/perftest/README.md new file mode 100644 index 0000000..77a9592 --- /dev/null +++ b/cmd/sponge/commands/perftest/README.md @@ -0,0 +1,194 @@ +## English | [简体中文](readme-cn.md) + +## PerfTest + +`perftest` is a lightweight and high-performance testing tool that supports **HTTP/1.1, HTTP/2, HTTP/3, and WebSocket** protocols. +It can execute high-concurrency requests efficiently and push real-time statistics to a custom HTTP server or **Prometheus** for monitoring and analysis. + +
+ +### ✨ Features + +* ✅ Support for **HTTP/1.1, HTTP/2, HTTP/3, WebSocket** +* ✅ Two modes: **fixed total requests** or **fixed duration** +* ✅ Configurable **workers (concurrency)** +* ✅ Support for **GET, POST, custom request bodies** +* ✅ **Real-time statistics push** (HTTP server / Prometheus) +* ✅ **Detailed performance reports** (QPS, latency distribution, data transfer, status codes, etc.) +* ✅ **WebSocket message performance test** with custom message payload and send interval + +
+ +### 📦 Installation + +```bash +go install github.com/go-dev-frame/sponge/cmd/perftest@latest +``` + +After installation, run `perftest -h` to see usage. + +
+ +### 🚀 Usage Examples + +#### 1. HTTP/1.1 Performance Test + +```bash +# Default mode: worker=CPU*3, 5000 requests, GET request +sponge perftest http --url=http://localhost:8080/user/1 + +# Fixed number of requests: 50 workers, 500k requests +sponge perftest http --worker=50 --total=500000 --url=http://localhost:8080/user/1 + +# Fixed number of requests: POST with JSON body +sponge perftest http --worker=50 --total=500000 --method=POST \ + --url=http://localhost:8080/user \ + --body='{"name":"Alice","age":25}' + +# Fixed duration: 50 workers, run for 10s +sponge perftest http --worker=50 --duration=10s --url=http://localhost:8080/user/1 + +# Push statistics to custom HTTP server every 1s +sponge perftest http --worker=50 --total=500000 \ + --url=http://localhost:8080/user/1 \ + --push-url=http://localhost:9090/report + +# Push statistics to Prometheus (job=xxx) +sponge perftest http --worker=50 --duration=10s \ + --url=http://localhost:8080/user/1 \ + --push-url=http://localhost:9091/metrics \ + --prometheus-job-name=perftest-http +``` + +**Report Example:** + +``` +500000 / 500000 [==================================================] 100.00% 8.85s + +========== HTTP/1.1 Performance Test Report ========== + +[Requests] + • Total Requests: 500000 + • Successful: 500000 (100%) + • Failed: 0 + • Total Duration: 8.85s + • Throughput (QPS): 56489.26 req/sec + +[Latency] + • Average: 0.88 ms + • Minimum: 0.00 ms + • Maximum: 21.56 ms + • P25: 0.00 ms + • P50: 1.01 ms + • P95: 2.34 ms + +[Data Transfer] + • Sent: 12.5 MB + • Received: 24.5 MB + +[Status Codes] + • 200: 500000 +``` + +
+ +#### 2. HTTP/2 Performance Test + +Usage is the same as HTTP/1.1, just replace `http` with `http2`: + +```bash +sponge perftest http2 --worker=50 --total=500000 --url=http2://localhost:8080/user/1 +``` + +
+ +#### 3. HTTP/3 Performance Test + +Usage is the same as HTTP/1.1, just replace `http` with `http3`: + +```bash +sponge perftest http3 --worker=50 --total=500000 --url=http3://localhost:8080/user/1 +``` + +
+ +#### 4. WebSocket Performance Test + +```bash +# Default: 10 workers, 10s duration, random(10) string message +sponge perftest websocket --url=ws://localhost:8080/ws + +# Send fixed string messages, interval=10ms +sponge perftest websocket --worker=100 --duration=1m \ + --send-interval=10ms \ + --body-string=abcdefghijklmnopqrstuvwxyz \ + --url=ws://localhost:8080/ws + +# Send JSON messages, default no interval +sponge perftest websocket --worker=10 --duration=10s \ + --body='{"name":"Alice","age":25}' \ + --url=ws://localhost:8080/ws + +# Send JSON messages, interval=10ms +sponge perftest websocket --worker=100 --duration=1m \ + --send-interval=10ms \ + --body='{"name":"Alice","age":25}' \ + --url=ws://localhost:8080/ws +``` + +**Report Example:** + +``` +5.0s / 5.0s [==================================================] 100.00% + +========== WebSocket Performance Test Report ========== + +[Connections] + • Total: 10 + • Successful: 10 (100%) + • Failed: 0 + • Latency: min: 14.80 ms, avg: 14.80 ms, max: 14.80 ms + +[Messages Sent] + • Total Messages: 2954089 + • Total Bytes: 295408900 + • Throughput (QPS): 590817.80 msgs/sec + +[Messages Received] + • Total Messages: 2954089 + • Total Bytes: 295408900 + • Throughput (QPS): 590817.80 msgs/sec +``` + +
+ +### ⚙️ Common Parameters + +| Parameter | Description | Example | +| ----------------------- |-------------------------------------------------------------------------------|-------------------------------------------| +| `--url`, `-u` | Request URL (http/https/ws) | `--url=http://localhost:8080/user/1` | +| `--worker`, `-w` | Number of concurrent workers (default = CPU\*3) | `--worker=50` | +| `--total`, `-t` | Total number of requests (mutually exclusive with `--duration`, `--duration` higher priority) | `--total=500000` | +| `--duration`, `-d` | Duration of the test (mutually exclusive with `--total`) | `--duration=10s` | +| `--method`, `-m` | HTTP method | `--method=POST` | +| `--body`, `-b` | Request body (JSON supported) | `--body='{"name":"Alice"}'` | +| `--body-string`,`-s` | WebSocket message string body | `--body-string=hello` | +| `--send-interval`, `-i` | Interval between WebSocket messages | `--send-interval=10ms` | +| `--push-url`, `-p` | URL to push statistics | `--push-url=http://localhost:9090/report` | +| `--prometheus-job-name`, `-j` | Job name for Prometheus metrics | `--prometheus-job-name=perftest-http` | + +
+ +### 📊 Typical Use Cases + +* ✅ Performance testing **Web APIs (HTTP/1.1/2/3)** +* ✅ Comparing performance differences across HTTP versions +* ✅ Stress-testing **real-time WebSocket services** +* ✅ **CI/CD integration** with Prometheus monitoring + +
+ +### 📝 Summary + +`perftest` is a simple yet powerful performance testing tool. +It’s suitable for quick performance verification as well as integration into **CI/CD pipelines** or **real-time monitoring systems** with Prometheus and Grafana. diff --git a/cmd/sponge/commands/perftest/common/common.go b/cmd/sponge/commands/perftest/common/common.go new file mode 100644 index 0000000..c9cc88d --- /dev/null +++ b/cmd/sponge/commands/perftest/common/common.go @@ -0,0 +1,55 @@ +package common + +import ( + "encoding/json" + "fmt" + "math/rand" + "os" + "strings" + "time" +) + +// CheckBodyParam checks if the body parameter is provided in JSON or file format. +func CheckBodyParam(bodyJSON string, bodyFile string) (string, error) { + var body []byte + if bodyJSON != "" { + bodyJSON = strings.Trim(bodyJSON, "'") + body = []byte(bodyJSON) + } else { + if bodyFile != "" { + data, err := os.ReadFile(bodyFile) + if err != nil { + return "", err + } + body = data + } + } + + if len(body) == 0 { + return "", nil + } + + ok, err := isValidJSON(body) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("invalid JSON format") + } + + return string(body), nil +} + +func isValidJSON(data []byte) (bool, error) { + var js interface{} + if err := json.Unmarshal(data, &js); err != nil { + return false, err + } + return true, nil +} + +// NewID generates a new ID for each request. +func NewID() int64 { + ns := time.Now().UnixMilli() * 1000000 + return ns + rand.Int63n(1000000) +} diff --git a/cmd/sponge/commands/perftest/common/progress_bar.go b/cmd/sponge/commands/perftest/common/progress_bar.go new file mode 100644 index 0000000..6b08bc0 --- /dev/null +++ b/cmd/sponge/commands/perftest/common/progress_bar.go @@ -0,0 +1,232 @@ +package common + +import ( + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/fatih/color" +) + +// Bar represents a thread-safe progress bar. +type Bar struct { + total int64 // total items + current int64 // current progress + startTime time.Time + barWidth int // display width in terminal + graph string // symbol for completed portion + arrow string // symbol for current progress + space string // symbol for remaining portion + + lastDrawNano atomic.Int64 + updateInterval time.Duration // refresh interval to avoid frequent I/O +} + +// NewBar returns a new progress bar with the given total count. +func NewBar(total int64, t time.Time) *Bar { + b := &Bar{ + total: total, + startTime: t, + barWidth: 50, + graph: "=", + arrow: ">", + space: " ", + updateInterval: 500 * time.Millisecond, + } + + b.lastDrawNano.Store(0) + return b +} + +// Increment advances the progress by 1 and redraws the bar if needed. +func (b *Bar) Increment() { + atomic.AddInt64(&b.current, 1) + b.draw() +} + +// Finish marks the bar as complete and prints the final state. +func (b *Bar) Finish() { + atomic.StoreInt64(&b.current, b.total) + b.draw() + fmt.Println() +} + +// shouldDraw reports whether a redraw should occur using a CAS timestamp. +// Ensures only one goroutine wins the right to draw within the interval. +func (b *Bar) shouldDraw() bool { + now := time.Now().UnixNano() + intervalNano := b.updateInterval.Nanoseconds() + + //for { + lastNano := b.lastDrawNano.Load() + if now-lastNano < intervalNano { + // Too close to the last draw, skip + return false + } + + // Attempt to update the timestamp + if b.lastDrawNano.CompareAndSwap(lastNano, now) { + return true + } + return false + //} +} + +// draw renders the bar in the terminal. +// Uses shouldDraw to avoid excessive refreshes. +func (b *Bar) draw() { + current := atomic.LoadInt64(&b.current) + + // Redraw only when reaching refresh interval or on completion. + // Finish() always forces a final draw. + if current < b.total && !b.shouldDraw() { + return + } + + percent := float64(current) / float64(b.total) + if percent > 1.0 { + percent = 1.0 + } + filledLength := int(float64(b.barWidth) * percent) + + // Build the visual bar + var barBuilder strings.Builder + barBuilder.Grow(b.barWidth + 2) + barBuilder.WriteString("[") + barBuilder.WriteString(strings.Repeat(b.graph, filledLength)) + + // Show arrow only when not finished + if current < b.total { + if filledLength < b.barWidth { + barBuilder.WriteString(b.arrow) + barBuilder.WriteString(strings.Repeat(b.space, b.barWidth-filledLength-1)) + } else { + barBuilder.WriteString(strings.Repeat(b.space, b.barWidth-filledLength)) + } + } else { + barBuilder.WriteString(strings.Repeat(b.space, b.barWidth-filledLength)) + } + barBuilder.WriteString("]") + + elapsed := time.Since(b.startTime).Seconds() + + str := fmt.Sprintf("%8d / %-8d %s %6.2f%% %.2fs", current, b.total, barBuilder.String(), percent*100, elapsed) + fmt.Printf("\r%s", color.HiBlackString(str)) +} + +// ----------------------------------------------------------------- + +// TimeBar represents a thread-safe time-based progress bar. +type TimeBar struct { + totalDuration time.Duration // total duration + startTime time.Time // start time + barWidth int // display width in terminal + graph string // symbol for completed portion + arrow string // symbol for current progress + space string // symbol for remaining portion + + // Background goroutine control + wg sync.WaitGroup + done chan struct{} +} + +// NewTimeBar returns a new time-based progress bar with the given duration. +func NewTimeBar(totalDuration time.Duration) *TimeBar { + return &TimeBar{ + totalDuration: totalDuration, + barWidth: 50, + graph: "=", + arrow: ">", + space: " ", + done: make(chan struct{}), + } +} + +// Start begins automatic updates in a background goroutine. +func (b *TimeBar) Start() { + b.startTime = time.Now() + b.wg.Add(1) + go b.run() +} + +// Finish stops the progress bar and ensures the final state is drawn. +func (b *TimeBar) Finish() { + select { + case <-b.done: + return + default: + close(b.done) + } + b.wg.Wait() + fmt.Println() +} + +// run periodically refreshes the bar in the background. +func (b *TimeBar) run() { + defer b.wg.Done() + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-b.done: + b.draw(true) + return + case <-ticker.C: + if time.Since(b.startTime) >= b.totalDuration { + b.draw(true) + return + } + b.draw(false) + } + } +} + +// draw renders the bar in the terminal. +// isFinal indicates whether this is the last draw. +func (b *TimeBar) draw(isFinal bool) { + elapsed := time.Since(b.startTime) + percent := elapsed.Seconds() / b.totalDuration.Seconds() + + // Handle final state and overflow + if isFinal || percent >= 1.0 { + percent = 1.0 + elapsed = b.totalDuration + } + + filledLength := int(float64(b.barWidth) * percent) + + // Build the visual bar + var barBuilder strings.Builder + barBuilder.Grow(b.barWidth + 2) + barBuilder.WriteString("[") + barBuilder.WriteString(strings.Repeat(b.graph, filledLength)) + + // Show arrow if not complete + if percent < 1.0 { + if filledLength < b.barWidth { + barBuilder.WriteString(b.arrow) + barBuilder.WriteString(strings.Repeat(b.space, b.barWidth-filledLength-1)) + } + } + + remainingSpace := b.barWidth - barBuilder.Len() + 1 // +1 for '[' + if remainingSpace > 0 { + barBuilder.WriteString(strings.Repeat(b.space, remainingSpace)) + } + + barBuilder.WriteString("]") + + // Print with carriage return for alignment. + // Format times with one decimal place for consistency. + str := fmt.Sprintf("%.1fs / %.1fs %s %.2f%%", + elapsed.Seconds(), + b.totalDuration.Seconds(), + barBuilder.String(), + percent*100, + ) + fmt.Printf("\r%s", color.HiBlackString(str)) +} diff --git a/cmd/sponge/commands/perftest/http/http1.go b/cmd/sponge/commands/perftest/http/http1.go new file mode 100644 index 0000000..6e538f6 --- /dev/null +++ b/cmd/sponge/commands/perftest/http/http1.go @@ -0,0 +1,139 @@ +package http + +import ( + "crypto/tls" + "fmt" + "net/http" + "runtime" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common" +) + +// PerfTestHTTPCMD creates a new cobra.Command for HTTP/1.1 performance test. +func PerfTestHTTPCMD() *cobra.Command { + var ( + targetURL string + method string + body string + bodyFile string + headers []string + + worker int + total uint64 + duration time.Duration + + out string + pushURL string + prometheusJobName string + ) + + cmd := &cobra.Command{ + Use: "http", + Short: "Run performance test for HTTP/1.1 API", + Long: "Run performance test for HTTP/1.1 API.", + Example: color.HiBlackString(` # Default mode: worker=CPU*3, 5000 requests, GET request + sponge perftest http --url=http://localhost:8080/user/1 + + # Fixed number of requests: 50 workers, 500k requests, GET request + sponge perftest http --worker=50 --total=500000 --url=http://localhost:8080/user/1 + + # Fixed number of requests: 50 workers, 500k requests, POST request with JSON body + sponge perftest http --worker=50 --total=500000 --url=http://localhost:8080/user --method=POST --body={\"name\":\"Alice\",\"age\":25} + + # Fixed duration: 50 workers, duration 10s, GET request + sponge perftest http --worker=50 --duration=10s --url=http://localhost:8080/user/1 + + # Fixed duration: 50 workers, duration 10s, POST request with JSON body + sponge perftest http --worker=50 --duration=10s --url=http://localhost:8080/user --method=POST --body={\"name\":\"Alice\",\"age\":25} + + # Fixed number of requests: 50 workers, 500k requests, GET request, push statistics to custom HTTP server every 1s + sponge perftest http --worker=50 --total=500000 --url=http://localhost:8080/user/1 --push-url=http://localhost:7070/report + + # Fixed duration: 50 workers, duration 10s, get request, push statistics to Prometheus (job=xxx) + sponge perftest http --worker=50 --duration=10s --url=http://localhost:8080/user/1 --push-url=http://localhost:9091/metrics --prometheus-job-name=perftest-http`), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + bodyData, err := common.CheckBodyParam(body, bodyFile) + if err != nil { + return err + } + + params := &HTTPReqParams{ + URL: targetURL, + Method: method, + Headers: headers, + Body: bodyData, + version: "HTTP/1.1", + } + + b := PerfTestHTTP{ + ID: common.NewID(), + Client: newHTTPClient(), + Params: params, + Worker: worker, + TotalRequests: total, + Duration: duration, + PushURL: pushURL, + PrometheusJobName: prometheusJobName, + } + if err = b.checkParams(); err != nil { + return err + } + + var stats *Statistics + if duration > 0 { + stats, err = b.RunWithFixedDuration() + } else { + stats, err = b.RunWithFixedRequestsNum() + } + + if err != nil { + return err + } + if out != "" && stats != nil { + err = stats.Save(out) + if err != nil { + fmt.Println() + return fmt.Errorf("failed to save statistics to file: %s", err) + } + fmt.Printf("\nsave statistics to '%s' successfully\n", out) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&targetURL, "url", "u", "", "request URL") + _ = cmd.MarkFlagRequired("url") + cmd.Flags().StringVarP(&method, "method", "m", "GET", "request method") + cmd.Flags().StringSliceVarP(&headers, "header", "e", nil, "request headers") + cmd.Flags().StringVarP(&body, "body", "b", "", "request body (priority higher than --body-file)") + cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "request body file") + + cmd.Flags().IntVarP(&worker, "worker", "w", runtime.NumCPU()*3, "number of workers concurrently processing requests") + cmd.Flags().Uint64VarP(&total, "total", "t", 5000, "total requests") + cmd.Flags().DurationVarP(&duration, "duration", "d", 0, "duration of the test, e.g., 10s, 1m (priority higher than --total)") + + cmd.Flags().StringVarP(&out, "out", "o", "", "save statistics to JSON file") + cmd.Flags().StringVarP(&pushURL, "push-url", "p", "", "push statistics to target URL once per second ") + cmd.Flags().StringVarP(&prometheusJobName, "prometheus-job-name", "j", "", "if not empty, the --push-url parameter value indicates prometheus url") + + return cmd +} + +func newHTTPClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Skip certificate validation + MaxIdleConns: 1000, + MaxIdleConnsPerHost: 1000, + IdleConnTimeout: 90 * time.Second, + }, + Timeout: 10 * time.Second, + } +} diff --git a/cmd/sponge/commands/perftest/http/http2.go b/cmd/sponge/commands/perftest/http/http2.go new file mode 100644 index 0000000..1700b91 --- /dev/null +++ b/cmd/sponge/commands/perftest/http/http2.go @@ -0,0 +1,139 @@ +package http + +import ( + "crypto/tls" + "fmt" + "net/http" + "runtime" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common" +) + +// PerfTestHTTP2CMD creates a new cobra.Command for HTTP/2 performance test. +func PerfTestHTTP2CMD() *cobra.Command { + var ( + targetURL string + method string + body string + bodyFile string + headers []string + + worker int + total uint64 + duration time.Duration + + out string + pushURL string + prometheusJobName string + ) + + cmd := &cobra.Command{ + Use: "http2", + Short: "Run performance test for HTTP/2 API", + Long: "Run performance test for HTTP/2 API.", + Example: color.HiBlackString(` # Default mode: worker=CPU*3, 5000 requests, GET request + sponge perftest http2 --url=https://localhost:6443/user/1 + + # Fixed number of requests: 50 workers, 500k requests, GET request + sponge perftest http2 --worker=50 --total=500000 --url=https://localhost:6443/user/1 + + # Fixed number of requests: 50 workers, 500k requests, POST request with JSON body + sponge perftest http2 --worker=50 --total=500000 --url=https://localhost:6443/user --method=POST --body={\"name\":\"Alice\",\"age\":25} + + # Fixed duration: 50 workers, duration 10s, GET request + sponge perftest http2 --worker=50 --duration=10s --url=https://localhost:6443/user/1 + + # Fixed duration: 50 workers, duration 10s, POST request with JSON body + sponge perftest http2 --worker=50 --duration=10s --url=https://localhost:6443/user --method=POST --body={\"name\":\"Alice\",\"age\":25} + + # Fixed number of requests: 50 workers, 500k requests, GET request, push statistics to custom HTTP server every 1s + sponge perftest http2 --worker=50 --total=500000 --url=https://localhost:6443/user/1 --push-url=http://localhost:7070/report + + # Fixed duration: 50 workers, duration 10s, get request, push statistics to Prometheus (job=xxx) + sponge perftest http2 --worker=50 --duration=10s --url=https://localhost:6443/user/1 --push-url=http://localhost:9091/metrics --prometheus-job-name=perftest-http2`), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + bodyData, err := common.CheckBodyParam(body, bodyFile) + if err != nil { + return err + } + + params := &HTTPReqParams{ + URL: targetURL, + Method: method, + Headers: headers, + Body: bodyData, + version: "HTTP/2", + } + + b := PerfTestHTTP{ + ID: common.NewID(), + Client: newHTTP2Client(), + Params: params, + Worker: worker, + TotalRequests: total, + Duration: duration, + PushURL: pushURL, + PrometheusJobName: prometheusJobName, + } + if err = b.checkParams(); err != nil { + return err + } + + var stats *Statistics + if duration > 0 { + stats, err = b.RunWithFixedDuration() + } else { + stats, err = b.RunWithFixedRequestsNum() + } + if err != nil { + return err + } + if out != "" && stats != nil { + err = stats.Save(out) + if err != nil { + fmt.Println() + return fmt.Errorf("failed to save statistics to file: %s", err) + } + fmt.Printf("\nsave statistics to '%s' successfully\n", out) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&targetURL, "url", "u", "", "request URL") + _ = cmd.MarkFlagRequired("url") + cmd.Flags().StringVarP(&method, "method", "m", "GET", "request method") + cmd.Flags().StringSliceVarP(&headers, "header", "e", nil, "request headers") + cmd.Flags().StringVarP(&body, "body", "b", "", "request body (priority higher than --body-file)") + cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "request body file") + + cmd.Flags().IntVarP(&worker, "worker", "w", runtime.NumCPU()*3, "number of workers concurrently processing requests") + cmd.Flags().Uint64VarP(&total, "total", "t", 5000, "total requests") + cmd.Flags().DurationVarP(&duration, "duration", "d", 0, "duration of the test, e.g., 10s, 1m (priority higher than --total)") + + cmd.Flags().StringVarP(&out, "out", "o", "", "save statistics to JSON file") + cmd.Flags().StringVarP(&pushURL, "push-url", "p", "", "push statistics to target URL once per second ") + cmd.Flags().StringVarP(&prometheusJobName, "prometheus-job-name", "j", "", "if not empty, the push-url parameter value indicates prometheus url") + + return cmd +} + +func newHTTP2Client() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Skip certificate validation + MaxIdleConns: 1000, + MaxIdleConnsPerHost: 1000, + IdleConnTimeout: 90 * time.Second, + ForceAttemptHTTP2: true, + }, + Timeout: 10 * time.Second, + } +} diff --git a/cmd/sponge/commands/perftest/http/http3.go b/cmd/sponge/commands/perftest/http/http3.go new file mode 100644 index 0000000..61d5452 --- /dev/null +++ b/cmd/sponge/commands/perftest/http/http3.go @@ -0,0 +1,150 @@ +package http + +import ( + "crypto/tls" + "fmt" + "net/http" + "runtime" + "time" + + "github.com/fatih/color" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/spf13/cobra" + + "github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common" +) + +// PerfTestHTTP3CMD creates a new cobra.Command for HTTP/3 performance test. +func PerfTestHTTP3CMD() *cobra.Command { + var ( + targetURL string + method string + body string + bodyFile string + headers []string + + worker int + total uint64 + duration time.Duration + + out string + pushURL string + prometheusJobName string + ) + + cmd := &cobra.Command{ + Use: "http3", + Short: "Run performance test for HTTP/3 API", + Long: "Run performance test for HTTP/3 API.", + Example: color.HiBlackString(` # Default mode: worker=CPU*3, 5000 requests, GET request + sponge perftest http3 --url=https://localhost:8443/user/1 + + # Fixed number of requests: 50 workers, 500k requests, GET request + sponge perftest http3 --worker=50 --total=500000 --url=https://localhost:8443/user/1 + + # Fixed number of requests: 50 workers, 500k requests, POST request with JSON body + sponge perftest http3 --worker=50 --total=500000 --url=https://localhost:8443/user --method=POST --body={\"name\":\"Alice\",\"age\":25} + + # Fixed duration: 50 workers, duration 10s, GET request + sponge perftest http3 --worker=50 --duration=10s --url=https://localhost:8443/user/1 + + # Fixed duration: 50 workers, duration 10s, POST request with JSON body + sponge perftest http3 --worker=50 --duration=10s --url=https://localhost:8443/user --method=POST --body={\"name\":\"Alice\",\"age\":25} + + # Fixed number of requests: 50 workers, 500k requests, GET request, push statistics to custom HTTP server every 1s + sponge perftest http3 --worker=50 --total=500000 --url=https://localhost:8443/user/1 --push-url=http://localhost:7070/report + + # Fixed duration: 50 workers, duration 10s, get request, push statistics to Prometheus (job=xxx) + sponge perftest http3 --worker=50 --duration=10s --url=https://localhost:8443/user/1 --push-url=http://localhost:9091/metrics --prometheus-job-name=perftest-http3`), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + bodyData, err := common.CheckBodyParam(body, bodyFile) + if err != nil { + return err + } + + params := &HTTPReqParams{ + URL: targetURL, + Method: method, + Headers: headers, + Body: bodyData, + version: "HTTP/3", + } + + b := PerfTestHTTP{ + ID: common.NewID(), + Client: newHTTP3Client(), + Params: params, + Worker: worker, + TotalRequests: total, + Duration: duration, + PushURL: pushURL, + PrometheusJobName: prometheusJobName, + } + if err = b.checkParams(); err != nil { + return err + } + + var stats *Statistics + if duration > 0 { + stats, err = b.RunWithFixedDuration() + } else { + stats, err = b.RunWithFixedRequestsNum() + } + if err != nil { + return err + } + if out != "" && stats != nil { + err = stats.Save(out) + if err != nil { + fmt.Println() + return fmt.Errorf("failed to save statistics to file: %s", err) + } + fmt.Printf("\nsave statistics to '%s' successfully\n", out) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&targetURL, "url", "u", "", "request URL") + _ = cmd.MarkFlagRequired("url") + cmd.Flags().StringVarP(&method, "method", "m", "GET", "request method") + cmd.Flags().StringSliceVarP(&headers, "header", "e", nil, "request headers") + cmd.Flags().StringVarP(&body, "body", "b", "", "request body (priority higher than --body-file)") + cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "request body file") + + cmd.Flags().IntVarP(&worker, "worker", "w", runtime.NumCPU()*3, "number of workers concurrently processing requests") + cmd.Flags().Uint64VarP(&total, "total", "t", 5000, "total requests") + cmd.Flags().DurationVarP(&duration, "duration", "d", 0, "duration of the test, e.g., 10s, 1m (priority higher than --total)") + + cmd.Flags().StringVarP(&out, "out", "o", "", "save statistics to JSON file") + cmd.Flags().StringVarP(&pushURL, "push-url", "p", "", "push statistics to target URL once per second ") + cmd.Flags().StringVarP(&prometheusJobName, "prometheus-job-name", "j", "", "if not empty, the push-url parameter value indicates prometheus url") + + return cmd +} + +func newHTTP3Client() *http.Client { + return &http.Client{ + Transport: &http3.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Skip certificate validation + + // quic.Config provides fine control over the underlying QUIC connections + QUICConfig: &quic.Config{ + MaxIdleTimeout: 10 * time.Second, + KeepAlivePeriod: 5 * time.Second, + InitialStreamReceiveWindow: 6 * 1024 * 1024, + InitialConnectionReceiveWindow: 15 * 1024 * 1024, + MaxStreamReceiveWindow: 6 * 1024 * 1024, + MaxConnectionReceiveWindow: 15 * 1024 * 1024, + MaxIncomingStreams: 1000, + MaxIncomingUniStreams: 1000, + HandshakeIdleTimeout: 5 * time.Second, + }, + }, + Timeout: 10 * time.Second, + } +} diff --git a/cmd/sponge/commands/perftest/http/run.go b/cmd/sponge/commands/perftest/http/run.go new file mode 100644 index 0000000..d6b301a --- /dev/null +++ b/cmd/sponge/commands/perftest/http/run.go @@ -0,0 +1,347 @@ +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/fatih/color" + + "github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common" +) + +// PerfTestHTTP performance test parameters for HTTP +type PerfTestHTTP struct { + ID int64 // performance test ID + + Client *http.Client + Params *HTTPReqParams + + Worker int + TotalRequests uint64 + Duration time.Duration + + PushURL string + PrometheusJobName string +} + +func (p *PerfTestHTTP) checkParams() error { + if p.Worker == 0 { + return fmt.Errorf("'--worker' number must be greater than 0") + } + + if p.TotalRequests == 0 && p.Duration == 0 { + return errors.New("'--duration' and '--total' must be set one of them") + } + + if p.PrometheusJobName != "" && p.PushURL == "" { + return errors.New("'--prometheus-job-name' has already been set, '--push-url' must be set") + } + + return nil +} + +// RunWithFixedRequestsNum implements performance with a fixed number of requests. +func (p *PerfTestHTTP) RunWithFixedRequestsNum() (*Statistics, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + var wg sync.WaitGroup + + jobs := make(chan struct{}, p.Worker) + resultCh := make(chan Result, p.Worker*3) + statsDone := make(chan struct{}) + bar := &common.Bar{} + + collector := &statsCollector{ + durations: make([]float64, 0, p.TotalRequests), + } + var spc *statsPrometheusCollector + var start time.Time + + // The collector counts the results of each request, closes the statsDone + // channel, and notifies the main thread of the end + if p.PushURL == "" { + go collector.collect(resultCh, statsDone) + } else { + if p.PrometheusJobName == "" { + spc = &statsPrometheusCollector{} + } else { + spc = newStatsPrometheusCollector() + } + //go collector.collectAndPush(ctx, resultCh, statsDone, spc, b.PushURL, b.PrometheusJobName, b.Params, start) + go collector.collectAndPush(ctx, resultCh, statsDone, spc, p, start) + } + + for i := 0; i < p.Worker; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for range jobs { + requestOnce(p.Client, p.Params, resultCh) + bar.Increment() + } + }() + } + + start = time.Now() + bar = common.NewBar(int64(p.TotalRequests), start) + // Distribute tasks and listen for context cancellation events +loop: + for i := uint64(0); i < p.TotalRequests; i++ { + select { + case jobs <- struct{}{}: + case <-ctx.Done(): + break loop + } + } + + close(jobs) + + wg.Wait() + close(resultCh) + + <-statsDone + + totalTime := time.Since(start) + if ctx.Err() == nil { + bar.Finish() + } else { + fmt.Println() + } + + statistics, err := collector.printReport(totalTime, p.TotalRequests, p.Params) + + if p.PushURL != "" { + spc.copyStatsCollector(collector) + pushStatistics(spc, p, totalTime) + } + + return statistics, err +} + +// RunWithFixedDuration implements performance with a fixed duration. +func (p *PerfTestHTTP) RunWithFixedDuration() (*Statistics, error) { + // Create a context that will be canceled when the duration is over + ctx, cancel := context.WithTimeout(context.Background(), p.Duration) + defer cancel() + + // Handle manual interruption (Ctrl+C) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + var wg sync.WaitGroup + resultCh := make(chan Result, p.Worker*3) + statsDone := make(chan struct{}) + + // Since the total number of requests is unknown, we initialize with a reasonable capacity to reduce reallocation's. + collector := &statsCollector{ + durations: make([]float64, 0, 100000), + } + var spc *statsPrometheusCollector + var start time.Time + + if p.PushURL == "" { + go collector.collect(resultCh, statsDone) + } else { + if p.PrometheusJobName == "" { + spc = &statsPrometheusCollector{} + } else { + spc = newStatsPrometheusCollector() + } + go collector.collectAndPush(ctx, resultCh, statsDone, spc, p, start) + } + + // Start workers + for i := 0; i < p.Worker; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // Keep sending requests until the context is canceled + for { + select { + case <-ctx.Done(): + return // Exit goroutine when context is canceled + default: + requestOnce(p.Client, p.Params, resultCh) + } + } + }() + } + + start = time.Now() + bar := common.NewTimeBar(p.Duration) + bar.Start() + + <-ctx.Done() // Wait for the timeout or a signal + + totalTime := time.Since(start) + + // Wait for all workers to finish their current request + wg.Wait() + // Close the result channel to signal the collector that no more results will be sent + close(resultCh) + // Wait for the collector to process all the results in the channel + <-statsDone + + if errors.Is(ctx.Err(), context.Canceled) { + fmt.Println() + } else { + bar.Finish() + } + + // The total number of requests is the count of collected results + totalRequests := collector.successCount + collector.errorCount + statistics, err := collector.printReport(totalTime, totalRequests, p.Params) + + if p.PushURL != "" { + spc.copyStatsCollector(collector) + pushStatistics(spc, p, totalTime) + } + + return statistics, err +} + +func pushStatistics(spc *statsPrometheusCollector, b *PerfTestHTTP, totalTime time.Duration) { + var err error + ctx, _ := context.WithTimeout(context.Background(), time.Second*3) //nolint + if b.PrometheusJobName == "" { + err = spc.PushToServer(ctx, b.PushURL, totalTime, b.Params, b.ID) + } else { + err = spc.PushToPrometheus(ctx, b.PushURL, b.PrometheusJobName, totalTime) + } + _, _ = color.New(color.Bold).Println("[Push Statistics]") + var result = color.GreenString("ok") + if err != nil { + result = color.RedString("%v", err) + } + fmt.Printf(" • %s\n", result) +} + +// ------------------------------------------------------------------------------------------- + +// Result record the results of the request +type Result struct { + Duration time.Duration + ReqSize int64 + RespSize int64 + StatusCode int + Err error +} + +type HTTPReqParams struct { + URL string + Method string + Headers []string + Body string + + version string +} + +func buildRequest(params *HTTPReqParams) (*http.Request, error) { + var req *http.Request + var err error + + reqMethod := strings.ToUpper(params.Method) + if reqMethod == "POST" || reqMethod == "PUT" || reqMethod == "PATCH" || reqMethod == "DELETE" { + body := bytes.NewReader([]byte(params.Body)) + req, err = http.NewRequest(reqMethod, params.URL, body) + if err == nil { + req.Header.Set("Content-Type", "application/json") + } + } else { + req, err = http.NewRequest(reqMethod, params.URL, nil) + } + + for _, h := range params.Headers { + kvs := strings.SplitN(h, ":", 2) + if len(kvs) == 2 { + req.Header.Set(strings.TrimSpace(kvs[0]), strings.TrimSpace(kvs[1])) + } + } + + return req, err +} + +func requestOnce(client *http.Client, params *HTTPReqParams, ch chan<- Result) { + req, err := buildRequest(params) + if err != nil { + ch <- Result{Err: err} + return + } + + var reqSize int64 + if req.Body != nil { + reqSize = req.ContentLength + if seeker, ok := req.Body.(io.ReadSeeker); ok { + _, _ = seeker.Seek(0, io.SeekStart) + } + } + + begin := time.Now() + resp, err := client.Do(req) + if err != nil { // Check for request-level errors (e.g. timeout, DNS resolution failure) + duration := time.Since(begin) + ch <- Result{ + Duration: duration, + ReqSize: reqSize, + RespSize: 0, + Err: err, + } + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { // Check if the response status code is not 2xx + respSize, _ := io.Copy(io.Discard, resp.Body) + duration := time.Since(begin) + ch <- Result{ + Duration: duration, + ReqSize: reqSize, + RespSize: respSize, + StatusCode: resp.StatusCode, + Err: fmt.Errorf("%s, [%s] %s", http.StatusText(resp.StatusCode), req.Method, req.URL.String()), + } + return + } + + respSize, err := io.Copy(io.Discard, resp.Body) + if err != nil { + duration := time.Since(begin) + ch <- Result{ + Duration: duration, + ReqSize: reqSize, + RespSize: respSize, + StatusCode: resp.StatusCode, + Err: err, + } + return + } + + duration := time.Since(begin) + ch <- Result{ + Duration: duration, + ReqSize: reqSize, + RespSize: respSize, + StatusCode: resp.StatusCode, + } +} diff --git a/cmd/sponge/commands/perftest/http/stats.go b/cmd/sponge/commands/perftest/http/stats.go new file mode 100644 index 0000000..7d6705c --- /dev/null +++ b/cmd/sponge/commands/perftest/http/stats.go @@ -0,0 +1,525 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "os" + "path/filepath" + "sort" + "time" + + "github.com/fatih/color" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/push" +) + +// collector of statistical results +type statsCollector struct { + durations []float64 + totalReqBytes int64 + totalRespBytes int64 + successCount uint64 + errorCount uint64 + errSet map[string]struct{} + statusCodeSet map[int]int64 +} + +func (c *statsCollector) collect(results <-chan Result, done chan<- struct{}) { + errSet := make(map[string]struct{}) + statusCodes := make(map[int]int64) + + for r := range results { + if r.Err == nil { + c.successCount++ + } else { + c.errorCount++ + if _, ok := errSet[r.Err.Error()]; !ok { + errSet[r.Err.Error()] = struct{}{} + } + } + if _, ok := statusCodes[r.StatusCode]; !ok { + statusCodes[r.StatusCode] = 1 + } else { + //statusCodes[r.StatusCode] += 1 + statusCodes[r.StatusCode]++ + } + + c.durations = append(c.durations, float64(r.Duration)) + c.totalReqBytes += r.ReqSize + c.totalRespBytes += r.RespSize + } + c.errSet = errSet + c.statusCodeSet = statusCodes + + close(done) +} + +// nolint +func (c *statsCollector) collectAndPush(ctx context.Context, results <-chan Result, done chan<- struct{}, + spc *statsPrometheusCollector, p *PerfTestHTTP, start time.Time) { + errSet := make(map[string]struct{}) + statusCodes := make(map[int]int64) + + pushTicker := time.NewTicker(time.Second) + defer pushTicker.Stop() + start = time.Now() + + for r := range results { + if r.Err == nil { + c.successCount++ + } else { + c.errorCount++ + if _, ok := errSet[r.Err.Error()]; !ok { + errSet[r.Err.Error()] = struct{}{} + } + } + if _, ok := statusCodes[r.StatusCode]; !ok { + statusCodes[r.StatusCode] = 1 + } else { + //statusCodes[r.StatusCode] += 1 + statusCodes[r.StatusCode]++ + } + + c.durations = append(c.durations, float64(r.Duration)) + c.totalReqBytes += r.ReqSize + c.totalRespBytes += r.RespSize + c.errSet = errSet + c.statusCodeSet = statusCodes + select { + case <-pushTicker.C: + spc.copyStatsCollector(c) + if p.PrometheusJobName != "" { + spc.PushToPrometheusAsync(ctx, p.PushURL, p.PrometheusJobName, time.Since(start)) + } else { + spc.PushToServerAsync(ctx, p.PushURL, time.Since(start), p.Params, p.ID) + } + default: + continue + } + } + + close(done) +} + +func (c *statsCollector) toStatistics(totalTime time.Duration, totalRequests uint64, params *HTTPReqParams) *Statistics { + sort.Float64s(c.durations) + + var totalDuration float64 + for _, d := range c.durations { + totalDuration += d + } + + var avg, minLatency, maxLatency float64 + var p25, p50, p95 float64 + + if c.successCount > 0 { + avg = totalDuration / float64(c.successCount) + minLatency = c.durations[0] + maxLatency = c.durations[c.successCount-1] + percentile := func(p float64) float64 { + index := int(float64(c.successCount-1) * p) + return c.durations[index] + } + p25 = percentile(0.25) + p50 = percentile(0.50) + p95 = percentile(0.95) + } + + var errors []string + for errStr := range c.errSet { + errors = append(errors, errStr) + } + + body := params.Body + if len(body) > 300 { + body = body[:300] + "..." + } + + return &Statistics{ + URL: params.URL, + Method: params.Method, + Body: body, + + TotalRequests: totalRequests, + Errors: errors, + SuccessCount: c.successCount, + ErrorCount: c.errorCount, + TotalTime: totalTime.Seconds(), + QPS: float64(c.successCount) / totalTime.Seconds(), + + AvgLatency: convertToMilliseconds(avg), + P25Latency: convertToMilliseconds(p25), + P50Latency: convertToMilliseconds(p50), + P95Latency: convertToMilliseconds(p95), + MinLatency: convertToMilliseconds(minLatency), + MaxLatency: convertToMilliseconds(maxLatency), + + TotalSent: float64(c.totalReqBytes), + TotalReceived: float64(c.totalRespBytes), + StatusCodes: c.statusCodeSet, + } +} + +func (c *statsCollector) printReport(totalDuration time.Duration, totalRequests uint64, params *HTTPReqParams) (*Statistics, error) { + fmt.Printf("\n========== %s Performance Test Report ==========\n\n", params.version) + if c.successCount == 0 { + _, _ = color.New(color.Bold).Println("[Requests]") + fmt.Printf(" • %-19s%d\n", "Total Requests:", totalRequests) + fmt.Printf(" • %-19s%d%s\n", "Successful:", 0, color.RedString(" (0%)")) + fmt.Printf(" • %-19s%d%s\n", "Failed:", c.errorCount, color.RedString(" ✗")) + fmt.Printf(" • %-19s%.2fs\n\n", "Total Duration:", totalDuration.Seconds()) + + if len(c.statusCodeSet) > 0 { + printStatusCodeSet(c.statusCodeSet) + } + + if len(c.errSet) > 0 { + printErrorSet(c.errSet) + } + return nil, nil + } + + st := c.toStatistics(totalDuration, totalRequests, params) + + _, _ = color.New(color.Bold).Println("[Requests]") + fmt.Printf(" • %-19s%d\n", "Total Requests:", st.TotalRequests) + successStr := fmt.Sprintf(" • %-19s%d", "Successful:", st.SuccessCount) + failureStr := fmt.Sprintf(" • %-19s%d", "Failed:", st.ErrorCount) + if st.TotalRequests > 0 { + if totalRequests == st.SuccessCount { + successStr += color.GreenString(" (100%)") + } else if st.ErrorCount > 0 { + if st.SuccessCount == 0 { + successStr += color.RedString(" (0%)") + } else { + successStr += color.YellowString(" (%d%%)", int(float64(st.SuccessCount)/float64(st.TotalRequests)*100)) + } + failureStr += color.RedString(" ✗") + } + } + fmt.Println(successStr) + fmt.Println(failureStr) + fmt.Printf(" • %-19s%.2fs\n", "Total Duration:", st.TotalTime) + fmt.Printf(" • %-19s%.2f req/sec\n\n", "Throughput (QPS):", st.QPS) + + _, _ = color.New(color.Bold).Println("[Latency]") + fmt.Printf(" • %-19s%.2f ms\n", "Average:", st.AvgLatency) + fmt.Printf(" • %-19s%.2f ms\n", "Minimum:", st.MinLatency) + fmt.Printf(" • %-19s%.2f ms\n", "Maximum:", st.MaxLatency) + fmt.Printf(" • %-19s%.2f ms\n", "P25:", st.P25Latency) + fmt.Printf(" • %-19s%.2f ms\n", "P50:", st.P50Latency) + fmt.Printf(" • %-19s%.2f ms\n\n", "P95:", st.P95Latency) + + _, _ = color.New(color.Bold).Println("[Data Transfer]") + fmt.Printf(" • %-19s%.0f Bytes\n", "Sent:", st.TotalSent) + fmt.Printf(" • %-19s%.0f Bytes\n\n", "Received:", st.TotalReceived) + + if len(c.statusCodeSet) > 0 { + printStatusCodeSet(st.StatusCodes) + } + + if len(c.errSet) > 0 { + printErrorSet(c.errSet) + } + + return st, nil +} + +func printStatusCodeSet(statusCodeSet map[int]int64) { + codes := make([]int, 0, len(statusCodeSet)) + for code := range statusCodeSet { + codes = append(codes, code) + } + sort.Ints(codes) + + _, _ = color.New(color.Bold).Println("[Status Codes]") + for _, code := range codes { + fmt.Printf(" • %-19s%d\n", fmt.Sprintf("%d:", code), statusCodeSet[code]) + } + fmt.Println() +} + +func printErrorSet(errSet map[string]struct{}) { + _, _ = color.New(color.Bold).Println("[Error Details]") + for errStr := range errSet { + fmt.Printf(" • %s\n", color.RedString(errStr)) + } + fmt.Println() +} + +// -------------------------------------------------------------------------------- + +// Statistics statistical data +type Statistics struct { + PerfTestID int64 `json:"perf_test_id"` // Performance Test ID + + URL string `json:"url"` // performed request URL + Method string `json:"method"` // request method + Body string `json:"body"` // request body (JSON) + + TotalRequests uint64 `json:"total_requests"` // total requests + TotalTime float64 `json:"total_time"` // seconds + SuccessCount uint64 `json:"success_count"` // successful requests (status code 2xx) + ErrorCount uint64 `json:"error_count"` // failed requests (status code not 2xx) + Errors []string `json:"errors"` // error details + + QPS float64 `json:"qps"` // requests per second (Throughput) + AvgLatency float64 `json:"avg_latency"` // average latency (ms) + P25Latency float64 `json:"p25_latency"` // 25th percentile latency (ms) + P50Latency float64 `json:"p50_latency"` // 50th percentile latency (ms) + P95Latency float64 `json:"p95_latency"` // 95th percentile latency (ms) + MinLatency float64 `json:"min_latency"` // minimum latency (ms) + MaxLatency float64 `json:"max_latency"` // maximum latency (ms) + + TotalSent float64 `json:"total_sent"` // total sent (bytes) + TotalReceived float64 `json:"total_received"` // total received (bytes) + + StatusCodes map[int]int64 `json:"status_codes"` // status code distribution (count) +} + +// Save saves the statistics data to a JSON file. +func (s *Statistics) Save(filePath string) error { + err := ensureFileExists(filePath) + if err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + err = os.WriteFile(filePath, data, 0644) + if err != nil { + return err + } + return nil +} + +func ensureFileExists(filePath string) error { + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create directories: %w", err) + } + _, err := os.Stat(filePath) + if os.IsNotExist(err) { + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + } + return nil +} + +func convertToMilliseconds(f float64) float64 { + if f <= 0.0 { + return 0 + } + return math.Round((f/1e6)*100) / 100 +} + +// -------------------------------------------------------------------------- + +type statsPrometheusCollector struct { + statsCollector *statsCollector + + // prometheus metrics + totalRequestsGauge prometheus.Gauge + successGauge prometheus.Gauge + errorGauge prometheus.Gauge + totalTimeGauge prometheus.Gauge + qpsGauge prometheus.Gauge + avgLatencyGauge prometheus.Gauge + p25LatencyGauge prometheus.Gauge + p50LatencyGauge prometheus.Gauge + p95LatencyGauge prometheus.Gauge + minLatencyGauge prometheus.Gauge + maxLatencyGauge prometheus.Gauge + totalSentGauge prometheus.Gauge + totalRecvGauge prometheus.Gauge + statusCodeGaugeVec *prometheus.GaugeVec +} + +func newStatsPrometheusCollector() *statsPrometheusCollector { + return &statsPrometheusCollector{ + totalRequestsGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_total_requests", Help: "Total requests"}), + successGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_success_count", Help: "Successful requests"}), + errorGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_error_count", Help: "Failed requests"}), + totalTimeGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_total_time_seconds", Help: "Total time elapsed"}), + qpsGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_qps", Help: "Queries per second"}), + avgLatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_avg_latency_ms", Help: "Average latency (ms)"}), + p25LatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_p25_latency_ms", Help: "P25 latency (ms)"}), + p50LatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_p50_latency_ms", Help: "P50 latency (ms)"}), + p95LatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_p95_latency_ms", Help: "P95 latency (ms)"}), + minLatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_min_latency_ms", Help: "Minimum latency (ms)"}), + maxLatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_max_latency_ms", Help: "Maximum latency (ms)"}), + totalSentGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_total_sent_bytes", Help: "Total bytes sent"}), + totalRecvGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_total_received_bytes", Help: "Total bytes received"}), + statusCodeGaugeVec: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "performance_test_status_code_count", + Help: "Count of responses by HTTP status code", + }, + []string{"status_code"}, + ), + } +} + +func (spc *statsPrometheusCollector) copyStatsCollector(s *statsCollector) { + var durations = make([]float64, 0, 100000) + if s.durations != nil { + durations = make([]float64, len(s.durations)) + copy(durations, s.durations) + } + + var errSet = make(map[string]struct{}) + if s.errSet != nil { + errSet = make(map[string]struct{}, len(s.errSet)) + for k, v := range s.errSet { + errSet[k] = v + } + } + + var statusCodeSet = make(map[int]int64) + if s.statusCodeSet != nil { + statusCodeSet = make(map[int]int64, len(s.statusCodeSet)) + for k, v := range s.statusCodeSet { + statusCodeSet[k] = v + } + } + spc.statsCollector = &statsCollector{ + durations: durations, + errSet: errSet, + statusCodeSet: statusCodeSet, + totalReqBytes: s.totalReqBytes, + totalRespBytes: s.totalRespBytes, + successCount: s.successCount, + errorCount: s.errorCount, + } +} + +func percentile(sorted []float64, p float64) float64 { + if len(sorted) == 0 { + return 0 + } + idx := int(math.Round((float64(len(sorted)) - 1) * p)) + if idx < 0 { + idx = 0 + } else if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +// PushToPrometheus pushes the statistics to a Prometheus. +func (spc *statsPrometheusCollector) PushToPrometheus(ctx context.Context, pushGatewayURL, jobName string, elapsed time.Duration) error { + totalReq := spc.statsCollector.successCount + spc.statsCollector.errorCount + qps := 0.0 + if elapsed.Seconds() > 0 { + qps = float64(spc.statsCollector.successCount) / elapsed.Seconds() + } + + d := append([]float64(nil), spc.statsCollector.durations...) + sort.Float64s(d) + avg, minVal, maxVal := 0.0, 0.0, 0.0 + if len(d) > 0 { + sum := 0.0 + minVal = d[0] + maxVal = d[len(d)-1] + for _, v := range d { + sum += v + } + avg = sum / float64(len(d)) + } + + // set gauges + spc.totalRequestsGauge.Set(float64(totalReq)) + spc.successGauge.Set(float64(spc.statsCollector.successCount)) + spc.errorGauge.Set(float64(spc.statsCollector.errorCount)) + spc.totalTimeGauge.Set(elapsed.Seconds()) + spc.qpsGauge.Set(qps) + spc.avgLatencyGauge.Set(avg * 1000) + spc.p25LatencyGauge.Set(percentile(d, 0.25) * 1000) + spc.p50LatencyGauge.Set(percentile(d, 0.50) * 1000) + spc.p95LatencyGauge.Set(percentile(d, 0.95) * 1000) + spc.minLatencyGauge.Set(minVal * 1000) + spc.maxLatencyGauge.Set(maxVal * 1000) + spc.totalSentGauge.Set(float64(spc.statsCollector.totalReqBytes)) + spc.totalRecvGauge.Set(float64(spc.statsCollector.totalRespBytes)) + + for code, count := range spc.statsCollector.statusCodeSet { + spc.statusCodeGaugeVec.WithLabelValues(fmt.Sprintf("%d", code)).Set(float64(count)) + } + + pusher := push.New(pushGatewayURL, jobName). + Collector(spc.totalRequestsGauge). + Collector(spc.successGauge). + Collector(spc.errorGauge). + Collector(spc.totalTimeGauge). + Collector(spc.qpsGauge). + Collector(spc.avgLatencyGauge). + Collector(spc.p25LatencyGauge). + Collector(spc.p50LatencyGauge). + Collector(spc.p95LatencyGauge). + Collector(spc.minLatencyGauge). + Collector(spc.maxLatencyGauge). + Collector(spc.totalSentGauge). + Collector(spc.totalRecvGauge). + Collector(spc.statusCodeGaugeVec) + + return pusher.PushContext(ctx) +} + +// PushToPrometheusAsync pushes the statistics to a Prometheus asynchronously +func (spc *statsPrometheusCollector) PushToPrometheusAsync(ctx context.Context, pushGatewayURL, jobName string, elapsed time.Duration) { + go func() { + _ = spc.PushToPrometheus(ctx, pushGatewayURL, jobName, elapsed) + }() +} + +// PushToServer pushes the statistics data to a custom server +// body is the JSON data of Statistics struct +func (spc *statsPrometheusCollector) PushToServer(ctx context.Context, pushURL string, elapsed time.Duration, httpReqParams *HTTPReqParams, id int64) error { + statistics := spc.statsCollector.toStatistics(elapsed, spc.statsCollector.successCount+spc.statsCollector.errorCount, httpReqParams) + statistics.PerfTestID = id + + _, err := postWithContext(ctx, pushURL, statistics) + return err +} + +// PushToServerAsync pushes the statistics data to a custom server asynchronously +func (spc *statsPrometheusCollector) PushToServerAsync(ctx context.Context, pushURL string, elapsed time.Duration, httpReqParams *HTTPReqParams, id int64) { + go func() { + _ = spc.PushToServer(ctx, pushURL, elapsed, httpReqParams, id) + }() +} + +func postWithContext(ctx context.Context, url string, data *Statistics) (*http.Response, error) { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal data: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + return resp, nil + } + + return resp, fmt.Errorf(`post "%s" failed with status code %d`, url, resp.StatusCode) +} diff --git a/cmd/sponge/commands/perftest/websocket/client.go b/cmd/sponge/commands/perftest/websocket/client.go new file mode 100644 index 0000000..90fbf2e --- /dev/null +++ b/cmd/sponge/commands/perftest/websocket/client.go @@ -0,0 +1,170 @@ +package websocket + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// Client represents a single WebSocket client worker. +type Client struct { + id int + conn *websocket.Conn + stats *statsCollector + url string + + isJSON bool + //sendPayloadTemplate map[string]any // isJSON=true, for JSON data + sendPayloadBytes []byte // if isJSON = true, sendPayloadBytes is the JSON data, otherwise, it is the binary data + sendTicker *time.Ticker +} + +// NewClient creates a new WebSocket client worker. +func NewClient(id int, url string, stats *statsCollector, sendInterval time.Duration, payloadData []byte, isJSON bool) *Client { + var ticker *time.Ticker + if sendInterval > 0 { + ticker = time.NewTicker(sendInterval) + } + + return &Client{ + id: id, + stats: stats, + url: url, + isJSON: isJSON, + sendPayloadBytes: payloadData, + sendTicker: ticker, + } +} + +// Dial establishes a WebSocket connection to the server. +func (c *Client) Dial(ctx context.Context) error { + customDialer := websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + Proxy: http.ProxyFromEnvironment, + } + + dialStartTime := time.Now() + conn, _, err := customDialer.DialContext(ctx, c.url, nil) + connectTime := time.Since(dialStartTime) + if err != nil { + c.stats.AddConnectFailure() + c.stats.errSet.Add(err.Error()) + return err + } + c.stats.RecordConnectTime(connectTime) + c.stats.AddConnectSuccess() + c.conn = conn + return nil +} + +// Run starts the client worker. +func (c *Client) Run(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + err := c.Dial(ctx) + if err != nil { + return + } + if c.conn == nil { + return + } + defer c.conn.Close() + defer c.stats.AddDisconnect() + + var loopWg sync.WaitGroup + loopWg.Add(2) + + go c.writeLoop(ctx, &loopWg) + go c.readLoop(ctx, &loopWg) + + <-ctx.Done() + + // Wait for loops to finish with a timeout + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan struct{}) + go func() { + loopWg.Wait() + close(done) + }() + select { + case <-done: + case <-shutdownCtx.Done(): + } +} + +func (c *Client) sendData(ctx context.Context) error { + if ctx.Err() != nil { + return nil + } + if err := c.conn.WriteMessage(websocket.TextMessage, c.sendPayloadBytes); err != nil { + c.stats.AddError() + return err + } + c.stats.AddMessageSent() + c.stats.AddSentBytes(uint64(len(c.sendPayloadBytes))) + + return nil +} + +// writeLoop handles sending messages with sequence numbers. +func (c *Client) writeLoop(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + if c.sendTicker != nil { + defer c.sendTicker.Stop() + } + + if c.sendTicker != nil { + for { + select { + case <-c.sendTicker.C: + if err := c.sendData(ctx); err != nil { + c.stats.AddError() + c.stats.errSet.Add(err.Error()) + return + } + case <-ctx.Done(): + return + } + } + } else { + for { + if ctx.Err() != nil { + return + } + if err := c.sendData(ctx); err != nil { + c.stats.AddError() + c.stats.errSet.Add(err.Error()) + return + } + } + } +} + +// readLoop handles receiving messages and checking for latency and order. +func (c *Client) readLoop(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + for { + //_ = c.conn.SetReadDeadline(time.Now().Add(time.Second)) + msgType, p, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + c.stats.errSet.Add(err.Error()) + c.stats.AddError() + } + if ctx.Err() != nil { + return + } + continue + } + + if msgType == websocket.TextMessage { + c.stats.AddMessageRecv() + c.stats.AddRecvBytes(uint64(len(p))) + } + } +} diff --git a/cmd/sponge/commands/perftest/websocket/stats.go b/cmd/sponge/commands/perftest/websocket/stats.go new file mode 100644 index 0000000..82c687f --- /dev/null +++ b/cmd/sponge/commands/perftest/websocket/stats.go @@ -0,0 +1,281 @@ +package websocket + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/fatih/color" +) + +type ErrSet struct { + m sync.Map +} + +func NewErrSet() *ErrSet { + return &ErrSet{ + m: sync.Map{}, + } +} + +func (s *ErrSet) Add(key string) { + s.m.Store(key, struct{}{}) +} + +func (s *ErrSet) List() []string { + var result []string + + s.m.Range(func(key, value interface{}) bool { + if str, ok := key.(string); ok { + result = append(result, str) + } + return true + }) + + return result +} + +type statsCollector struct { + // Connection + activeConns int64 + connectSuccessCount uint64 + connectFailureCount uint64 + totalConnectTime uint64 // ns + minConnectTime uint64 // ns + maxConnectTime uint64 // ns + + // Message + messageSentCount uint64 + messageRecvCount uint64 + sentBytesCount uint64 // sent total bytes + recvBytesCount uint64 // received total bytes + errorCount uint64 + + errSet *ErrSet // set of errors +} + +type Statistics struct { + URL string `json:"url"` // performed request URL + Duration float64 `json:"duration"` // seconds + + // Connections + TotalConnections uint64 `json:"total_connections"` + SuccessConnections uint64 `json:"success_connections"` + FailedConnections uint64 `json:"failed_connections"` + AvgConnectLatency float64 `json:"avg_connect_latency"` // ms + MinConnectLatency float64 `json:"min_connect_latency"` // ms + MaxConnectLatency float64 `json:"max_connect_latency"` // ms + + // Messages + TotalMessagesSent uint64 `json:"total_messages_sent"` + TotalMessagesReceived uint64 `json:"total_messages_received"` + SentMessageQPS float64 `json:"sent_message_qps"` // sent messages per second + ReceivedMessageQPS float64 `json:"received_message_qps"` // received messages per second + TotalBytesSent uint64 `json:"total_bytes_sent"` // bytes + TotalBytesReceived uint64 `json:"total_bytes_received"` // bytes + + ErrorCount uint64 `json:"error_count"` // total errors + Errors []string `json:"errors"` // list of errors +} + +func (s *Statistics) Save(filePath string) error { + err := ensureFileExists(filePath) + if err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + err = os.WriteFile(filePath, data, 0644) + if err != nil { + return err + } + return nil +} + +// --- Atomic operations for thread safety --- + +func (s *statsCollector) AddConnectSuccess() { + atomic.AddUint64(&s.connectSuccessCount, 1) + atomic.AddInt64(&s.activeConns, 1) +} + +func (s *statsCollector) AddConnectFailure() { + atomic.AddUint64(&s.connectFailureCount, 1) +} + +func (s *statsCollector) AddDisconnect() { + atomic.AddInt64(&s.activeConns, -1) +} + +func (s *statsCollector) RecordConnectTime(d time.Duration) { + ns := uint64(d.Nanoseconds()) + atomic.AddUint64(&s.totalConnectTime, ns) + + minTime := atomic.LoadUint64(&s.minConnectTime) + if minTime == 0 { + atomic.CompareAndSwapUint64(&s.minConnectTime, minTime, ns) + } else if ns < minTime { + atomic.CompareAndSwapUint64(&s.maxConnectTime, minTime, ns) + } + maxTime := atomic.LoadUint64(&s.maxConnectTime) + if ns > maxTime { + atomic.CompareAndSwapUint64(&s.maxConnectTime, maxTime, ns) + } +} + +func (s *statsCollector) AddMessageSent() { + atomic.AddUint64(&s.messageSentCount, 1) +} + +func (s *statsCollector) AddMessageRecv() { + atomic.AddUint64(&s.messageRecvCount, 1) +} + +func (s *statsCollector) AddSentBytes(bytes uint64) { + atomic.AddUint64(&s.sentBytesCount, bytes) +} + +func (s *statsCollector) AddRecvBytes(bytes uint64) { + atomic.AddUint64(&s.recvBytesCount, bytes) +} + +func (s *statsCollector) AddError() { + atomic.AddUint64(&s.errorCount, 1) +} + +// Snapshot creates a read-only copy of the current stats. +func (s *statsCollector) Snapshot() statsCollector { + return statsCollector{ + connectSuccessCount: atomic.LoadUint64(&s.connectSuccessCount), + connectFailureCount: atomic.LoadUint64(&s.connectFailureCount), + activeConns: atomic.LoadInt64(&s.activeConns), + totalConnectTime: atomic.LoadUint64(&s.totalConnectTime), + minConnectTime: atomic.LoadUint64(&s.minConnectTime), + maxConnectTime: atomic.LoadUint64(&s.maxConnectTime), + messageSentCount: atomic.LoadUint64(&s.messageSentCount), + messageRecvCount: atomic.LoadUint64(&s.messageRecvCount), + sentBytesCount: atomic.LoadUint64(&s.sentBytesCount), + recvBytesCount: atomic.LoadUint64(&s.recvBytesCount), + errorCount: atomic.LoadUint64(&s.errorCount), + } +} + +// PrintReport prints a formatted report of the current stats. +func (s *statsCollector) PrintReport(duration time.Duration, targetURL string) *Statistics { + snapshot := s.Snapshot() + + totalConnections := snapshot.connectSuccessCount + snapshot.connectFailureCount + avgConnectTimeMs := 0.0 + if snapshot.connectSuccessCount > 0 { + avgConnectTimeMs = float64(snapshot.totalConnectTime) / float64(snapshot.connectSuccessCount) / 1e6 + } + minConnTimeMs := float64(snapshot.minConnectTime) / 1e6 + maxConnTimeMs := float64(snapshot.maxConnectTime) / 1e6 + errSet := s.errSet.List() + + fmt.Printf("\n========== WebSocket Performance Test Report ==========\n\n") + if snapshot.connectSuccessCount == 0 { + _, _ = color.New(color.Bold).Println("[Connections]") + fmt.Printf(" • %-20s%d\n", "Total:", totalConnections) + fmt.Printf(" • %-20s%d%s\n", "Successful:", 0, color.RedString(" (0%)")) + fmt.Printf(" • %-20s%d%s\n", "Failed:", snapshot.connectFailureCount, color.RedString(" ✗")) + fmt.Printf(" • %-20smin: %.2f ms, avg: %.2f ms, max: %.2f ms\n\n", "Latency:", minConnTimeMs, avgConnectTimeMs, maxConnTimeMs) + if len(errSet) > 0 { + _, _ = color.New(color.Bold).Println("[Error Details]") + for _, errStr := range errSet { + fmt.Printf(" • %s\n", color.RedString(errStr)) + } + } + return nil + } + + sentThroughput := 0.0 + recvThroughput := 0.0 + if seconds := duration.Seconds(); seconds > 0 { + sentThroughput = float64(snapshot.messageSentCount) / seconds + recvThroughput = float64(snapshot.messageRecvCount) / seconds + } + + st := &Statistics{ + URL: targetURL, + Duration: duration.Seconds(), + + TotalConnections: totalConnections, + SuccessConnections: snapshot.connectSuccessCount, + FailedConnections: snapshot.connectFailureCount, + AvgConnectLatency: avgConnectTimeMs, + MinConnectLatency: minConnTimeMs, + MaxConnectLatency: maxConnTimeMs, + + SentMessageQPS: sentThroughput, + ReceivedMessageQPS: recvThroughput, + TotalMessagesSent: snapshot.messageSentCount, + TotalMessagesReceived: snapshot.messageRecvCount, + TotalBytesSent: snapshot.sentBytesCount, + TotalBytesReceived: snapshot.recvBytesCount, + + ErrorCount: snapshot.errorCount, + Errors: errSet, + } + + _, _ = color.New(color.Bold).Println("[Connections]") + fmt.Printf(" • %-20s%d\n", "Total:", st.TotalConnections) + successStr := fmt.Sprintf(" • %-20s%d", "Successful:", st.SuccessConnections) + failureStr := fmt.Sprintf(" • %-20s%d", "Failed:", st.FailedConnections) + if st.TotalConnections > 0 { + if st.TotalConnections == st.SuccessConnections { + successStr += color.GreenString(" (100%)") + } else if st.FailedConnections > 0 { + if st.SuccessConnections == 0 { + successStr += color.RedString(" (0%)") + } else { + successStr += color.YellowString(" (%d%%)", int(float64(st.SuccessConnections)/float64(st.TotalConnections)*100)) + } + failureStr += color.RedString(" ✗") + } + } + fmt.Println(successStr) + fmt.Println(failureStr) + fmt.Printf(" • %-20smin: %.2f ms, avg: %.2f ms, max: %.2f ms\n\n", "Latency:", st.MinConnectLatency, st.AvgConnectLatency, st.MaxConnectLatency) + + _, _ = color.New(color.Bold).Println("[Messages Sent]") + fmt.Printf(" • %-20s%d\n", "Total Messages:", st.TotalMessagesSent) + fmt.Printf(" • %-20s%d\n", "Total Bytes:", st.TotalBytesSent) + fmt.Printf(" • %-20s%.2f msgs/sec\n\n", "Throughput (QPS):", st.SentMessageQPS) + + _, _ = color.New(color.Bold).Println("[Messages Received]") + fmt.Printf(" • %-20s%d\n", "Total Messages:", st.TotalMessagesReceived) + fmt.Printf(" • %-20s%d\n", "Total Bytes:", st.TotalBytesReceived) + fmt.Printf(" • %-20s%.2f msgs/sec\n\n", "Throughput (QPS):", st.ReceivedMessageQPS) + + if len(errSet) > 0 { + _, _ = color.New(color.Bold).Println("[Error Details]") + for _, errStr := range errSet { + fmt.Printf(" • %s\n", color.RedString(errStr)) + } + } + + return st +} + +func ensureFileExists(filePath string) error { + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create directories: %w", err) + } + _, err := os.Stat(filePath) + if os.IsNotExist(err) { + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + } + return nil +} diff --git a/cmd/sponge/commands/perftest/websocket/websocket.go b/cmd/sponge/commands/perftest/websocket/websocket.go new file mode 100644 index 0000000..41d4562 --- /dev/null +++ b/cmd/sponge/commands/perftest/websocket/websocket.go @@ -0,0 +1,178 @@ +package websocket + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common" + "github.com/go-dev-frame/sponge/pkg/krand" +) + +// PerfTestWebsocketCMD creates a cobra command for websocket performance test +func PerfTestWebsocketCMD() *cobra.Command { + var ( + targetURL string + worker int + duration time.Duration + sendInterval time.Duration + rampUp time.Duration + + bodyString string + bodyJSON string + bodyFile string + + out string + ) + + cmd := &cobra.Command{ + Use: "websocket", + Short: "Run performance test for websocket", + Long: "Run performance test for websocket.", + Example: color.HiBlackString(` # Default: 10 workers, 10s duration, random(10) string message + sponge perftest websocket --url=ws://localhost:8080/ws + + # Send fixed string messages, 100 workers, 1m duration, each worker sends messages every 10ms + sponge perftest websocket --worker=100 --duration=1m --send-interval=10ms --body-string=abcdefghijklmnopqrstuvwxyz --url=ws://localhost:8080/ws + + # Send JSON messages, 10 workers, 10s duration + sponge perftest websocket --worker=10 --duration=10s --body={\"name\":\"Alice\",\"age\":25} --url=ws://localhost:8080/ws + + # Send JSON messages, 100 workers, 1m duration, each worker sends messages every 10ms + sponge perftest websocket --worker=100 --duration=1m --send-interval=10ms --body={\"name\":\"Alice\",\"age\":25} --url=ws://localhost:8080/ws`), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + body, err := common.CheckBodyParam(bodyJSON, bodyFile) + if err != nil { + return err + } + + var payloadData []byte + isJSON := false + if body != "" { + payloadData = []byte(body) + isJSON = true + } else { + if bodyString != "" { + payloadData = []byte(bodyString) + } else { + payloadData = krand.Bytes(krand.R_All, 10) // default payload data random(10) + } + } + + p := &perfTestParams{ + targetURL: targetURL, + worker: worker, + duration: duration, + sendInterval: sendInterval, + rampUp: rampUp, + payloadData: payloadData, + isJSON: isJSON, + out: out, + } + + return p.run() + }, + } + + cmd.Flags().StringVarP(&targetURL, "url", "u", "", "request URL") + _ = cmd.MarkFlagRequired("url") + cmd.Flags().IntVarP(&worker, "worker", "c", 10, "number of concurrent websocket clients") + cmd.Flags().DurationVarP(&duration, "duration", "d", time.Second*10, "duration of the test, e.g., 10s, 1m") + cmd.Flags().DurationVarP(&sendInterval, "send-interval", "i", 0, "interval for sending messages per client") + cmd.Flags().DurationVarP(&rampUp, "ramp-up", "r", 0, "time to ramp up all connections (e.g., 10s)") + + cmd.Flags().StringVarP(&bodyJSON, "body", "b", "", "request body (JSON String, priority higher than --body-file, --body-string)") + cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "request body file") + cmd.Flags().StringVarP(&bodyString, "body-string", "s", "", "request body (String)") + + cmd.Flags().StringVarP(&out, "out", "o", "", "save statistics to JSON file") + + return cmd +} + +type perfTestParams struct { + targetURL string + worker int + duration time.Duration + sendInterval time.Duration + rampUp time.Duration + + payloadData []byte + isJSON bool + + out string +} + +func (p *perfTestParams) run() error { + mainCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Test duration timer + go func() { + <-time.After(p.duration) + cancel() + }() + + // OS interrupt signal + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-interrupt + cancel() + }() + + // Calculate ramp-up delay + var rampUpDelay time.Duration + if p.worker > 0 && p.rampUp > 0 { + rampUpDelay = p.rampUp / time.Duration(p.worker) + } + + stats := &statsCollector{errSet: NewErrSet()} + bar := common.NewTimeBar(p.duration) + bar.Start() + + // Start all client workers with ramp-up + var wg sync.WaitGroup + for i := 0; i < p.worker; i++ { + if mainCtx.Err() != nil { + break + } + + wg.Add(1) + client := NewClient(i+1, p.targetURL, stats, p.sendInterval, p.payloadData, p.isJSON) + go client.Run(mainCtx, &wg) + + if rampUpDelay > 0 { + time.Sleep(rampUpDelay) + } + } + + // Wait for all workers to finish + wg.Wait() + if !errors.Is(mainCtx.Err(), context.Canceled) { + bar.Finish() + } + fmt.Println() + + st := stats.PrintReport(p.duration, p.targetURL) + if p.out != "" && st != nil { + err := st.Save(p.out) + if err != nil { + fmt.Println() + return fmt.Errorf("failed to save statistics to file: %v", err) + } + fmt.Printf("\nsave statistics to '%s' successfully\n", p.out) + } + + return nil +} diff --git a/cmd/sponge/commands/plugins.go b/cmd/sponge/commands/plugins.go index 3c5668f..6088f7d 100644 --- a/cmd/sponge/commands/plugins.go +++ b/cmd/sponge/commands/plugins.go @@ -24,6 +24,7 @@ var pluginNames = []string{ "protoc-gen-go-gin", "protoc-gen-go-rpc-tmpl", "protoc-gen-json-field", + "perftest", "protoc-gen-openapiv2", "protoc-gen-doc", "swag", @@ -41,6 +42,7 @@ var installPluginCommands = map[string]string{ "protoc-gen-go-gin": "github.com/go-dev-frame/sponge/cmd/protoc-gen-go-gin@latest", "protoc-gen-go-rpc-tmpl": "github.com/go-dev-frame/sponge/cmd/protoc-gen-go-rpc-tmpl@latest", "protoc-gen-json-field": "github.com/go-dev-frame/sponge/cmd/protoc-gen-json-field@latest", + "perftest": "github.com/go-dev-frame/sponge/cmd/perftest@latest", "protoc-gen-openapiv2": "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest", "protoc-gen-doc": "github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@latest", "swag": "github.com/swaggo/swag/cmd/swag@v1.8.12", @@ -178,7 +180,8 @@ func installPlugins(lackNames []string) { } func adaptInternalCommand(name string, pkgAddr string) string { - if name == "protoc-gen-go-gin" || name == "protoc-gen-go-rpc-tmpl" || name == "protoc-gen-json-field" { + if name == "protoc-gen-go-gin" || name == "protoc-gen-go-rpc-tmpl" || + name == "protoc-gen-json-field" || name == "perftest" { if version != "v0.0.0" { return strings.ReplaceAll(pkgAddr, "@latest", "@"+version) } diff --git a/cmd/sponge/commands/root.go b/cmd/sponge/commands/root.go index 624c770..473cb43 100644 --- a/cmd/sponge/commands/root.go +++ b/cmd/sponge/commands/root.go @@ -45,6 +45,7 @@ Docs: %s`, GenGraphCommand(), TemplateCommand(), AssistantCommand(), + PerftestCommand(), ) return cmd diff --git a/cmd/sponge/server/handler.go b/cmd/sponge/server/handler.go index 17172f5..a497fd0 100644 --- a/cmd/sponge/server/handler.go +++ b/cmd/sponge/server/handler.go @@ -93,7 +93,6 @@ func ListLLM(c *gin.Context) { {Label: "gpt-4.1-mini", Value: "gpt-4.1-mini"}, {Label: "gpt-4o", Value: "gpt-4o"}, {Label: "gpt-4o-mini", Value: "gpt-4o-mini"}, - {Label: "o4-mini-high", Value: "o4-mini-high"}, }, "gemini": { {Label: "gemini-2.5-flash", Value: "gemini-2.5-flash"}, @@ -332,6 +331,78 @@ func HandleAssistant(c *gin.Context) { response.Success(c, resultInfo) } +// HandlePerformanceTest handle performance test +func HandlePerformanceTest(c *gin.Context) { + form := &GenerateCodeForm{} + err := c.ShouldBindJSON(form) + if err != nil { + responseErr(c, err, errcode.InvalidParams) + return + } + + args := strings.Split(form.Arg, " ") + params := parseCommandArgs(args) + if len(args) > 2 { + params.Protocol = args[1] + } + if params.TotalRequests > 0 { + params.TestType = "requests" + } else if params.Duration != "" { + params.TestType = "duration" + } + if params.JobName != "" && params.PushURL != "" { + params.PushType = "prometheus" + params.PrometheusURL = params.PushURL + params.PushURL = "" + } else if params.PushURL != "" { + params.PushType = "custom" + } + + ctx, _ := context.WithTimeout(context.Background(), time.Minute*30) // nolint + result := gobash.Run(ctx, "sponge", args...) + resultInfo := "" + count := 0 + for v := range result.StdOut { + count++ + if count == 1 { // first line is the command + continue + } + if strings.Contains(v, "Waiting for assistant responses") { + continue + } + resultInfo += v + } + if result.Err != nil { + responseErr(c, result.Err, errcode.InternalServerError) + return + } + + recordObj().set(c.ClientIP(), form.Path, params) + + resultInfo = splitString(resultInfo, "Performance Test Report ==========") + + response.Success(c, resultInfo) +} + +func splitString(str string, sep string) string { + lines := strings.Split(str, "\n") + startIndex := 0 + isFound := false + + for i, line := range lines { + if strings.Contains(line, sep) { + isFound = true + startIndex = i + break + } + } + + if isFound { + return strings.Join(lines[startIndex:], "\n") + } + return str +} + // GetRecord generate run command record func GetRecord(c *gin.Context) { pathParam := c.Param("path") diff --git a/cmd/sponge/server/http.go b/cmd/sponge/server/http.go index 48ae9cb..175ceb9 100644 --- a/cmd/sponge/server/http.go +++ b/cmd/sponge/server/http.go @@ -58,6 +58,7 @@ func NewRouter(spongeAddr string, isLog bool) *gin.Engine { apiV1.POST("/generate", GenerateCode) apiV1.POST("/getTemplateInfo", GetTemplateInfo) apiV1.POST("/assistant", HandleAssistant) + apiV1.POST("/performanceTest", HandlePerformanceTest) apiV1.POST("/uploadFiles", UploadFiles) apiV1.POST("/listTables", ListTables) apiV1.GET("/listDrivers", ListDbDrivers) diff --git a/cmd/sponge/server/record.go b/cmd/sponge/server/record.go index 1b9276e..cfa22fa 100644 --- a/cmd/sponge/server/record.go +++ b/cmd/sponge/server/record.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "strconv" "strings" "sync" "time" @@ -44,6 +45,20 @@ type parameters struct { GoFile string `json:"goFile"` IsSpecifiedGoFile bool `json:"isSpecifiedGoFile"` + Protocol string `json:"protocol"` + URL string `json:"url"` + Method string `json:"method"` + Body string `json:"body"` + Headers []string `json:"headers"` + Worker int `json:"worker"` + TestType string `json:"testType"` + TotalRequests uint64 `json:"totalRequests"` + Duration string `json:"duration"` + PushType string `json:"pushType"` + PushURL string `json:"pushUrl"` + PrometheusURL string `json:"prometheusUrl"` + JobName string `json:"jobName"` + SuitedMonoRepo bool `json:"suitedMonoRepo"` } @@ -186,6 +201,41 @@ func parseCommandArgs(args []string) *parameters { case "--file": params.GoFile = val params.IsSpecifiedGoFile = true + + case "--url": + params.URL = val + case "--method": + params.Method = val + case "--body": + params.Body = val + case "--header": + if val != "" { + params.Headers = append(params.Headers, val) + } + case "--worker": + vl, _ := strconv.Atoi(val) + if vl > 0 { + params.Worker = vl + } + case "--total": + vl, _ := strconv.ParseUint(val, 10, 64) + if vl > 0 { + params.TotalRequests = vl + } + case "--duration": + val = strings.TrimSuffix(val, "s") + vl, _ := strconv.Atoi(val) + if vl > 0 { + params.Duration = val + } + case "--push-url": + if val != "" { + params.PushURL = val + } + case "--prometheus-job-name": + if val != "" { + params.JobName = val + } } } } diff --git a/go.mod b/go.mod index df5ff14..f7b2bc2 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/IBM/sarama v1.43.2 github.com/alicebob/miniredis/v2 v2.23.0 - github.com/bojand/ghz v0.117.0 + github.com/bojand/ghz v0.120.0 github.com/dgraph-io/ristretto v0.2.0 github.com/fatih/color v1.13.0 github.com/felixge/fgprof v0.9.3 @@ -32,7 +32,8 @@ require ( github.com/nacos-group/nacos-sdk-go/v2 v2.2.7 github.com/natefinch/lumberjack v2.0.0+incompatible github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.14.0 + github.com/prometheus/client_golang v1.19.1 + github.com/quic-go/quic-go v0.54.0 github.com/rabbitmq/amqp091-go v1.9.0 github.com/redis/go-redis/extra/redisotel/v9 v9.7.0 github.com/redis/go-redis/v9 v9.7.0 @@ -177,7 +178,6 @@ require ( github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -196,8 +196,9 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_model v0.6.0 // indirect - github.com/prometheus/common v0.37.0 // indirect - github.com/prometheus/procfs v0.8.0 // indirect + github.com/prometheus/common v0.53.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.7.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect @@ -223,14 +224,16 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect go.opentelemetry.io/otel/metric v1.26.0 // indirect go.uber.org/atomic v1.7.0 // indirect + go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.8.0 // indirect + golang.org/x/mod v0.18.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.8.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect gopkg.in/ini.v1 v1.66.4 // indirect diff --git a/go.sum b/go.sum index 24cb416..82da6ab 100644 --- a/go.sum +++ b/go.sum @@ -77,7 +77,6 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafo github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 h1:NqugFkGxx1TXSh/pBcU00Y6bljgDPaFdh5MUSeJ7e50= github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= @@ -108,8 +107,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bojand/ghz v0.117.0 h1:dTMxg+tUcLMw8BYi7vQPjXsrM2DJ20ns53hz1am1SbQ= -github.com/bojand/ghz v0.117.0/go.mod h1:MXspmKdJie7NAS0IHzqG9X5h6zO3tIRGQ6Tkt8sAwa4= +github.com/bojand/ghz v0.120.0 h1:6F4wsmZVwFg5UnD+/R+IABWk6sKE/0OKIBdUQUZnOdo= +github.com/bojand/ghz v0.120.0/go.mod h1:HfECuBZj1v02XObGnRuoZgyB1PR24/25dIYiJIMjJnE= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -126,7 +125,6 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -213,12 +211,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -463,12 +457,10 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5i github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -477,7 +469,6 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM= github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= @@ -487,7 +478,6 @@ github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuV github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -528,7 +518,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= @@ -559,7 +548,6 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nacos-group/nacos-sdk-go/v2 v2.2.7 h1:wCC1f3/VzIR1WD30YKeJGZAOchYCK/35mLC8qWt6Q6o= github.com/nacos-group/nacos-sdk-go/v2 v2.2.7/go.mod h1:VYlyDPlQchPC31PmfBustu81vsOkdpCuO5k0dRdQcFc= github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= @@ -603,11 +591,8 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:Om github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= -github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -616,19 +601,17 @@ github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZ github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE= -github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= +github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= +github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= -github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc3Aoo= github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= @@ -651,8 +634,6 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g= -github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4= github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= @@ -664,7 +645,6 @@ github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= @@ -789,6 +769,8 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= @@ -847,8 +829,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -887,11 +869,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= @@ -908,8 +887,6 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -949,7 +926,6 @@ golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -964,8 +940,6 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -974,7 +948,6 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -982,7 +955,6 @@ golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -990,7 +962,6 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1075,8 +1046,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=