package main import ( "context" "crypto/tls" "encoding/csv" "encoding/json" "flag" "fmt" "io" "log" "math" "net/http" "os" "sort" "strings" "sync" "sync/atomic" "time" ) type TimeSeriesMetric struct { Timestamp time.Time `json:"timestamp"` Requests int64 `json:"requests"` Success int64 `json:"success"` Errors int64 `json:"errors"` AvgLatency time.Duration `json:"avg_latency_ms"` MinLatency time.Duration `json:"min_latency_ms"` MaxLatency time.Duration `json:"max_latency_ms"` RPS float64 `json:"rps"` ErrorRate float64 `json:"error_rate_percent"` } type AggregatedMetrics struct { StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` TotalRequests int64 `json:"total_requests"` SuccessRequests int64 `json:"success_requests"` ErrorRequests int64 `json:"error_requests"` ErrorRate float64 `json:"error_rate_percent"` AvgRPS float64 `json:"avg_rps"` LatencyAvg time.Duration `json:"latency_avg_ms"` LatencyMedian time.Duration `json:"latency_median_ms"` LatencyP95 time.Duration `json:"latency_p95_ms"` LatencyP99 time.Duration `json:"latency_p99_ms"` LatencyMin time.Duration `json:"latency_min_ms"` LatencyMax time.Duration `json:"latency_max_ms"` ErrorsByType map[string]int64 `json:"errors_by_type"` TimeSeries []TimeSeriesMetric `json:"time_series"` } type Metrics struct { totalReqs int64 successReqs int64 errorReqs int64 latencies []time.Duration errors map[string]int64 timeSeries []TimeSeriesMetric currentWindow *WindowMetrics mu sync.RWMutex // Changé de Mutex à RWMutex startTime time.Time endTime time.Time windowSize time.Duration } type WindowMetrics struct { startTime time.Time requests int64 success int64 errors int64 latencies []time.Duration minLatency time.Duration maxLatency time.Duration mu sync.Mutex // Mutex spécifique pour la fenêtre } type Result struct { latency time.Duration err error status int } func NewMetrics(windowSize time.Duration) *Metrics { return &Metrics{ errors: make(map[string]int64), timeSeries: make([]TimeSeriesMetric, 0), windowSize: windowSize, currentWindow: &WindowMetrics{ startTime: time.Now(), latencies: make([]time.Duration, 0), minLatency: time.Hour, maxLatency: 0, }, } } func (m *Metrics) AddResult(latency time.Duration, err error, status int) { atomic.AddInt64(&m.totalReqs, 1) // Gestion des erreurs if err != nil || status >= 400 { atomic.AddInt64(&m.errorReqs, 1) m.mu.Lock() if err != nil { m.errors[err.Error()]++ } else { m.errors[fmt.Sprintf("HTTP_%d", status)]++ } m.mu.Unlock() } else { atomic.AddInt64(&m.successReqs, 1) m.mu.Lock() m.latencies = append(m.latencies, latency) m.mu.Unlock() } // Mise à jour de la fenêtre courante avec son propre mutex m.currentWindow.mu.Lock() m.currentWindow.requests++ if err == nil && status < 400 { m.currentWindow.success++ m.currentWindow.latencies = append(m.currentWindow.latencies, latency) if latency < m.currentWindow.minLatency { m.currentWindow.minLatency = latency } if latency > m.currentWindow.maxLatency { m.currentWindow.maxLatency = latency } } else { m.currentWindow.errors++ } m.currentWindow.mu.Unlock() // Vérifier si on doit fermer la fenêtre now := time.Now() if now.Sub(m.currentWindow.startTime) >= m.windowSize { m.closeCurrentWindow(now) } } func (m *Metrics) closeCurrentWindow(now time.Time) { m.currentWindow.mu.Lock() defer m.currentWindow.mu.Unlock() if m.currentWindow.requests == 0 { // Réinitialiser la fenêtre même si vide m.currentWindow.startTime = now return } // Calculer la latence moyenne de la fenêtre var avgLatency time.Duration if len(m.currentWindow.latencies) > 0 { var sum int64 = 0 for _, lat := range m.currentWindow.latencies { sum += int64(lat) } avgLatency = time.Duration(sum / int64(len(m.currentWindow.latencies))) } // Calculer la durée réelle (minimum 1ms pour éviter division par zéro) duration := now.Sub(m.currentWindow.startTime) if duration <= 0 { duration = 1 * time.Millisecond } // Calculer le RPS avec vérification des bornes rps := float64(m.currentWindow.requests) / duration.Seconds() // Vérifier les valeurs aberrantes (NaN, Inf, négatives) if math.IsNaN(rps) || math.IsInf(rps, 0) || rps < 0 { log.Printf("Warning: Invalid RPS value detected: %f, using 0", rps) rps = 0 } // Calculer le taux d'erreur errorRate := 0.0 if m.currentWindow.requests > 0 { errorRate = float64(m.currentWindow.errors) / float64(m.currentWindow.requests) * 100 } // Gérer le cas où minLatency n'a pas été mis à jour minLatency := m.currentWindow.minLatency if minLatency == time.Hour { minLatency = 0 } metric := TimeSeriesMetric{ Timestamp: m.currentWindow.startTime, Requests: m.currentWindow.requests, Success: m.currentWindow.success, Errors: m.currentWindow.errors, AvgLatency: avgLatency, MinLatency: minLatency, MaxLatency: m.currentWindow.maxLatency, RPS: rps, ErrorRate: errorRate, } m.mu.Lock() m.timeSeries = append(m.timeSeries, metric) m.mu.Unlock() // Réinitialiser la fenêtre m.currentWindow = &WindowMetrics{ startTime: now, latencies: make([]time.Duration, 0), minLatency: time.Hour, maxLatency: 0, } } func (m *Metrics) SetEndTime() { m.endTime = time.Now() m.closeCurrentWindow(m.endTime) } func (m *Metrics) GetAggregatedMetrics() *AggregatedMetrics { m.mu.RLock() defer m.mu.RUnlock() total := atomic.LoadInt64(&m.totalReqs) success := atomic.LoadInt64(&m.successReqs) errors := atomic.LoadInt64(&m.errorReqs) duration := m.endTime.Sub(m.startTime) if duration <= 0 { duration = 1 * time.Millisecond } errorRate := 0.0 if total > 0 { errorRate = float64(errors) / float64(total) * 100 } avgRPS := float64(total) / duration.Seconds() if math.IsNaN(avgRPS) || math.IsInf(avgRPS, 0) { avgRPS = 0 } // Calculer les percentiles var avgLatency, median, p95, p99, minLat, maxLat time.Duration if len(m.latencies) > 0 { sorted := make([]time.Duration, len(m.latencies)) copy(sorted, m.latencies) sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) // Éviter les overflow dans la somme var sum int64 = 0 for _, lat := range sorted { sum += int64(lat) } avgLatency = time.Duration(sum / int64(len(sorted))) median = sorted[len(sorted)/2] p95 = percentile(sorted, 0.95) p99 = percentile(sorted, 0.99) minLat = sorted[0] maxLat = sorted[len(sorted)-1] } return &AggregatedMetrics{ StartTime: m.startTime, EndTime: m.endTime, TotalRequests: total, SuccessRequests: success, ErrorRequests: errors, ErrorRate: errorRate, AvgRPS: avgRPS, LatencyAvg: avgLatency, LatencyMedian: median, LatencyP95: p95, LatencyP99: p99, LatencyMin: minLat, LatencyMax: maxLat, ErrorsByType: m.errors, TimeSeries: m.timeSeries, } } func percentile(data []time.Duration, p float64) time.Duration { if len(data) == 0 { return 0 } idx := int(float64(len(data)) * p) if idx >= len(data) { idx = len(data) - 1 } if idx < 0 { idx = 0 } return data[idx] } // Export CSV avec validation func (m *AggregatedMetrics) ExportCSV(filename string) error { file, err := os.Create(filename) if err != nil { return err } defer file.Close() writer := csv.NewWriter(file) defer writer.Flush() // En-têtes CSV headers := []string{ "Timestamp", "Requests", "Success", "Errors", "ErrorRate(%)", "AvgLatency(ms)", "MinLatency(ms)", "MaxLatency(ms)", "RPS", } if err := writer.Write(headers); err != nil { return err } // Données avec validation for _, metric := range m.TimeSeries { // S'assurer que RPS n'est pas négatif ou invalide rps := metric.RPS if math.IsNaN(rps) || math.IsInf(rps, 0) || rps < 0 { rps = 0 } row := []string{ metric.Timestamp.Format("2006-01-02 15:04:05"), fmt.Sprintf("%d", metric.Requests), fmt.Sprintf("%d", metric.Success), fmt.Sprintf("%d", metric.Errors), fmt.Sprintf("%.2f", math.Max(0, metric.ErrorRate)), fmt.Sprintf("%.2f", math.Max(0, float64(metric.AvgLatency.Milliseconds()))), fmt.Sprintf("%.2f", math.Max(0, float64(metric.MinLatency.Milliseconds()))), fmt.Sprintf("%.2f", math.Max(0, float64(metric.MaxLatency.Milliseconds()))), fmt.Sprintf("%.2f", rps), } if err := writer.Write(row); err != nil { return err } } return nil } // Export JSON func (m *AggregatedMetrics) ExportJSON(filename string) error { file, err := os.Create(filename) if err != nil { return err } defer file.Close() encoder := json.NewEncoder(file) encoder.SetIndent("", " ") return encoder.Encode(m) } // Génération HTML avec graphique et validation func (m *AggregatedMetrics) GenerateHTMLReport(filename string) error { // Nettoyer les données pour HTML cleanedData := m.cleanTimeSeriesData() html := ` Stress Test Report

Stress Test Report

Generated: ` + time.Now().Format("2006-01-02 15:04:05") + `

Summary Metrics

Total Requests
` + fmt.Sprintf("%d", m.TotalRequests) + `
Success Rate
` + fmt.Sprintf("%.2f", math.Max(0, 100-m.ErrorRate)) + `%
Avg RPS
` + fmt.Sprintf("%.2f", math.Max(0, m.AvgRPS)) + `
Avg Latency
` + fmt.Sprintf("%.2f", math.Max(0, float64(m.LatencyAvg.Milliseconds()))) + ` ms
P95 Latency
` + fmt.Sprintf("%.2f", math.Max(0, float64(m.LatencyP95.Milliseconds()))) + ` ms
P99 Latency
` + fmt.Sprintf("%.2f", math.Max(0, float64(m.LatencyP99.Milliseconds()))) + ` ms

RPS Over Time

Latency Over Time

Error Rate Over Time

Errors by Type

` + m.generateErrorList() + `
` return os.WriteFile(filename, []byte(html), 0644) } // Nettoie les données aberrantes func (m *AggregatedMetrics) cleanTimeSeriesData() []TimeSeriesMetric { cleaned := make([]TimeSeriesMetric, 0, len(m.TimeSeries)) for _, metric := range m.TimeSeries { // Filtrer les valeurs aberrantes if metric.RPS < 0 || math.IsNaN(metric.RPS) || math.IsInf(metric.RPS, 0) { metric.RPS = 0 } if metric.ErrorRate < 0 || math.IsNaN(metric.ErrorRate) { metric.ErrorRate = 0 } if metric.ErrorRate > 100 { metric.ErrorRate = 100 } cleaned = append(cleaned, metric) } return cleaned } func (m *AggregatedMetrics) generateTimeLabels(cleanedData []TimeSeriesMetric) string { labels := make([]string, len(cleanedData)) for i, metric := range cleanedData { labels[i] = `"` + metric.Timestamp.Format("15:04:05") + `"` } return "[" + strings.Join(labels, ",") + "]" } // Fonction generateFloatArray func (m *AggregatedMetrics) generateFloatArray(cleanedData []TimeSeriesMetric, extractor func(TimeSeriesMetric) float64) string { values := make([]string, len(cleanedData)) for i, metric := range cleanedData { val := extractor(metric) if math.IsNaN(val) || math.IsInf(val, 0) { val = 0 } values[i] = fmt.Sprintf("%.2f", val) } return strings.Join(values, ",") } // Fonction generateErrorList func (m *AggregatedMetrics) generateErrorList() string { if len(m.ErrorsByType) == 0 { return "
Aucune erreur détectée
" } result := "" for errType, count := range m.ErrorsByType { result += fmt.Sprintf("
%s: %d occurrences
", errType, count) } return result } type Worker struct { id int url string client *http.Client metrics *Metrics stopChan <-chan struct{} } func NewWorker(id int, url string, metrics *Metrics, stopChan <-chan struct{}) *Worker { transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } return &Worker{ id: id, url: url, client: &http.Client{Transport: transport, Timeout: 30 * time.Second}, metrics: metrics, stopChan: stopChan, } } func (w *Worker) Run(ctx context.Context) { for { select { case <-ctx.Done(): return case <-w.stopChan: return default: start := time.Now() resp, err := w.client.Get(w.url) latency := time.Since(start) if err != nil { w.metrics.AddResult(latency, err, 0) continue } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) w.metrics.AddResult(latency, nil, resp.StatusCode) } } } type StressTest struct { url string totalThreads int duration time.Duration totalRequests int64 rampUp time.Duration metrics *Metrics workers []*Worker stopChan chan struct{} wg sync.WaitGroup } func NewStressTest(url string, threads int, duration time.Duration, requests int64, rampUp time.Duration, windowSize time.Duration) *StressTest { return &StressTest{ url: url, totalThreads: threads, duration: duration, totalRequests: requests, rampUp: rampUp, metrics: NewMetrics(windowSize), stopChan: make(chan struct{}), } } func (st *StressTest) Run() { fmt.Printf("Starting stress test on %s\n", st.url) fmt.Printf("Threads: %d, Duration: %.0fs, Ramp-up: %.0fs\n", st.totalThreads, st.duration.Seconds(), st.rampUp.Seconds()) fmt.Println(stringRepeat("-", 60)) st.metrics.startTime = time.Now() ctx, cancel := context.WithCancel(context.Background()) defer cancel() rampStep := st.rampUp / time.Duration(st.totalThreads) for i := 0; i < st.totalThreads; i++ { worker := NewWorker(i, st.url, st.metrics, st.stopChan) st.workers = append(st.workers, worker) st.wg.Add(1) go func(w *Worker, delay time.Duration) { defer st.wg.Done() if delay > 0 { time.Sleep(delay) } w.Run(ctx) }(worker, time.Duration(i)*rampStep) if st.rampUp > 0 { time.Sleep(rampStep / 10) } } if st.totalRequests > 0 { go func() { for atomic.LoadInt64(&st.metrics.totalReqs) < st.totalRequests { time.Sleep(100 * time.Millisecond) } close(st.stopChan) cancel() }() } if st.duration > 0 { time.Sleep(st.duration) close(st.stopChan) cancel() } st.wg.Wait() st.metrics.SetEndTime() } func stringRepeat(s string, count int) string { result := "" for i := 0; i < count; i++ { result += s } return result } func printMetrics(metrics *AggregatedMetrics) { fmt.Println("\n" + stringRepeat("=", 60)) fmt.Println("STRESS TEST RESULTS") fmt.Println(stringRepeat("=", 60)) fmt.Printf("Total Duration: %.2f seconds\n", metrics.EndTime.Sub(metrics.StartTime).Seconds()) fmt.Printf("Total Requests: %d\n", metrics.TotalRequests) fmt.Printf("Successful Requests: %d\n", metrics.SuccessRequests) fmt.Printf("Failed Requests: %d\n", metrics.ErrorRequests) fmt.Printf("Error Rate: %.2f%%\n", metrics.ErrorRate) fmt.Printf("Requests per second: %.2f\n", metrics.AvgRPS) if metrics.LatencyAvg > 0 { fmt.Println("\nLatency Metrics:") fmt.Printf(" Average: %.2f ms\n", float64(metrics.LatencyAvg.Milliseconds())) fmt.Printf(" Median: %.2f ms\n", float64(metrics.LatencyMedian.Milliseconds())) fmt.Printf(" P95: %.2f ms\n", float64(metrics.LatencyP95.Milliseconds())) fmt.Printf(" P99: %.2f ms\n", float64(metrics.LatencyP99.Milliseconds())) fmt.Printf(" Min: %.2f ms\n", float64(metrics.LatencyMin.Milliseconds())) fmt.Printf(" Max: %.2f ms\n", float64(metrics.LatencyMax.Milliseconds())) } if len(metrics.ErrorsByType) > 0 { fmt.Println("\nError Breakdown:") for errType, count := range metrics.ErrorsByType { fmt.Printf(" %s: %d\n", errType, count) } } } func main() { var ( url string threads int duration int requests int64 rampUp int windowSize int outputCSV string outputJSON string outputHTML string ) flag.StringVar(&url, "url", "", "Target URL to test") flag.IntVar(&threads, "t", 10, "Number of threads (goroutines)") flag.IntVar(&duration, "d", 0, "Test duration in seconds") flag.Int64Var(&requests, "r", 0, "Total number of requests to make") flag.IntVar(&rampUp, "ru", 0, "Ramp-up time in seconds") flag.IntVar(&windowSize, "ws", 5, "Window size for time series in seconds") flag.StringVar(&outputCSV, "csv", "stress_test_results.csv", "Output CSV file") flag.StringVar(&outputJSON, "json", "stress_test_results.json", "Output JSON file") flag.StringVar(&outputHTML, "html", "stress_test_report.html", "Output HTML report") flag.Parse() if url == "" { fmt.Println("Error: URL is required") flag.Usage() return } if duration == 0 && requests == 0 { fmt.Println("Error: You must specify either -d or -r") return } test := NewStressTest( url, threads, time.Duration(duration)*time.Second, requests, time.Duration(rampUp)*time.Second, time.Duration(windowSize)*time.Second, ) test.Run() metrics := test.metrics.GetAggregatedMetrics() printMetrics(metrics) // Export des résultats fmt.Println("\n" + stringRepeat("-", 60)) fmt.Println("Exporting results...") if err := metrics.ExportCSV(outputCSV); err != nil { log.Printf("Error exporting CSV: %v\n", err) } else { fmt.Printf("✅ CSV exported to: %s\n", outputCSV) } if err := metrics.ExportJSON(outputJSON); err != nil { log.Printf("Error exporting JSON: %v\n", err) } else { fmt.Printf("✅ JSON exported to: %s\n", outputJSON) } if err := metrics.GenerateHTMLReport(outputHTML); err != nil { log.Printf("Error generating HTML report: %v\n", err) } else { fmt.Printf("✅ HTML report generated: %s\n", outputHTML) } }