This commit is contained in:
Dev
2025-09-12 17:01:54 +03:00
commit 815237d804
16 changed files with 2595 additions and 0 deletions

81
pkg/config/config.go Normal file
View File

@@ -0,0 +1,81 @@
package config
import "time"
// Config represents the complete configuration for a stress test
type Config struct {
Target Target `yaml:"target" json:"target"`
Load Load `yaml:"load" json:"load"`
RateLimiting RateLimiting `yaml:"rate_limiting" json:"rate_limiting"`
FailureInjection FailureInjection `yaml:"failure_injection" json:"failure_injection"`
Reporting Reporting `yaml:"reporting" json:"reporting"`
}
// Target configuration for the endpoint being tested
type Target struct {
URL string `yaml:"url" json:"url"`
Method string `yaml:"method" json:"method"`
Headers map[string]string `yaml:"headers" json:"headers"`
Body string `yaml:"body" json:"body"`
Timeout int `yaml:"timeout" json:"timeout"` // seconds
}
// Load configuration for the test pattern
type Load struct {
Requests int `yaml:"requests" json:"requests"`
Concurrency int `yaml:"concurrency" json:"concurrency"`
Duration time.Duration `yaml:"duration" json:"duration"`
RampUp time.Duration `yaml:"ramp_up" json:"ramp_up"`
Pattern string `yaml:"pattern" json:"pattern"` // constant, ramp, spike
RequestsPerSecond int `yaml:"requests_per_second" json:"requests_per_second"`
}
// RateLimiting configuration
type RateLimiting struct {
Enabled bool `yaml:"enabled" json:"enabled"`
RequestsPerSecond int `yaml:"requests_per_second" json:"requests_per_second"`
}
// FailureInjection configuration for chaos testing
type FailureInjection struct {
Enabled bool `yaml:"enabled" json:"enabled"`
NetworkDelay time.Duration `yaml:"network_delay" json:"network_delay"`
DropRate float64 `yaml:"drop_rate" json:"drop_rate"` // 0.0 to 1.0
ErrorRate float64 `yaml:"error_rate" json:"error_rate"` // 0.0 to 1.0
}
// Reporting configuration
type Reporting struct {
Format []string `yaml:"format" json:"format"` // console, json, html, csv
OutputDir string `yaml:"output_dir" json:"output_dir"`
Percentiles []int `yaml:"percentiles" json:"percentiles"`
}
// DefaultConfig returns a sensible default configuration
func DefaultConfig() *Config {
return &Config{
Target: Target{
Method: "GET",
Headers: make(map[string]string),
Timeout: 30,
},
Load: Load{
Requests: 1000,
Concurrency: 10,
Duration: 5 * time.Minute,
Pattern: "constant",
},
RateLimiting: RateLimiting{
Enabled: false,
RequestsPerSecond: 100,
},
FailureInjection: FailureInjection{
Enabled: false,
},
Reporting: Reporting{
Format: []string{"console"},
OutputDir: "./results",
Percentiles: []int{50, 90, 95, 99},
},
}
}

255
pkg/engine/engine.go Normal file
View File

@@ -0,0 +1,255 @@
package engine
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"git.gostacks.org/iwasforcedtobehere/stroke/pkg/config"
"git.gostacks.org/iwasforcedtobehere/stroke/pkg/metrics"
)
// Engine represents the main stress testing engine
type Engine struct {
config *config.Config
client *http.Client
metrics *metrics.Collector
ctx context.Context
cancel context.CancelFunc
}
// Result holds the execution results
type Result struct {
TotalRequests int64
SuccessRequests int64
FailedRequests int64
TotalDuration time.Duration
RequestsPerSec float64
Metrics *metrics.Results
}
// Worker represents a single worker goroutine
type Worker struct {
id int
engine *Engine
wg *sync.WaitGroup
}
// New creates a new stress testing engine
func New(cfg *config.Config) *Engine {
ctx, cancel := context.WithCancel(context.Background())
// Configure HTTP client with reasonable defaults
client := &http.Client{
Timeout: time.Duration(cfg.Target.Timeout) * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
DisableCompression: false,
},
}
return &Engine{
config: cfg,
client: client,
metrics: metrics.NewCollector(),
ctx: ctx,
cancel: cancel,
}
}
// Run executes the stress test
func (e *Engine) Run() (*Result, error) {
fmt.Printf("🚀 Starting stress test against %s\n", e.config.Target.URL)
fmt.Printf("Workers: %d | Requests: %d | Duration: %v\n",
e.config.Load.Concurrency, e.config.Load.Requests, e.config.Load.Duration)
startTime := time.Now()
// Create worker pool
var wg sync.WaitGroup
requestChan := make(chan struct{}, e.config.Load.Requests)
// Start workers
for i := 0; i < e.config.Load.Concurrency; i++ {
wg.Add(1)
worker := &Worker{
id: i,
engine: e,
wg: &wg,
}
go worker.run(requestChan)
}
// Feed requests to workers
go e.feedRequests(requestChan)
// Wait for completion or timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// All workers finished
case <-time.After(e.config.Load.Duration):
// Timeout reached
e.cancel()
wg.Wait()
case <-e.ctx.Done():
// Cancelled
wg.Wait()
}
endTime := time.Now()
duration := endTime.Sub(startTime)
// Collect results
metricsResults := e.metrics.GetResults()
result := &Result{
TotalRequests: metricsResults.TotalRequests,
SuccessRequests: metricsResults.SuccessRequests,
FailedRequests: metricsResults.FailedRequests,
TotalDuration: duration,
RequestsPerSec: float64(metricsResults.TotalRequests) / duration.Seconds(),
Metrics: metricsResults,
}
e.printResults(result)
return result, nil
}
// feedRequests sends requests to the worker pool
func (e *Engine) feedRequests(requestChan chan<- struct{}) {
defer close(requestChan)
if e.config.Load.Requests > 0 {
// Fixed number of requests
for i := 0; i < e.config.Load.Requests; i++ {
select {
case requestChan <- struct{}{}:
case <-e.ctx.Done():
return
}
}
} else {
// Duration-based requests
ticker := time.NewTicker(time.Duration(1000/e.config.Load.RequestsPerSecond) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
select {
case requestChan <- struct{}{}:
case <-e.ctx.Done():
return
}
case <-e.ctx.Done():
return
}
}
}
}
// run executes the worker loop
func (w *Worker) run(requestChan <-chan struct{}) {
defer w.wg.Done()
for {
select {
case <-requestChan:
w.executeRequest()
case <-w.engine.ctx.Done():
return
}
}
}
// executeRequest performs a single HTTP request
func (w *Worker) executeRequest() {
startTime := time.Now()
// Create request
var body io.Reader
if w.engine.config.Target.Body != "" {
body = strings.NewReader(w.engine.config.Target.Body)
}
req, err := http.NewRequestWithContext(
w.engine.ctx,
w.engine.config.Target.Method,
w.engine.config.Target.URL,
body,
)
if err != nil {
w.engine.metrics.RecordRequest(time.Since(startTime), 0, err)
return
}
// Add headers
for key, value := range w.engine.config.Target.Headers {
req.Header.Set(key, value)
}
// Execute request
resp, err := w.engine.client.Do(req)
duration := time.Since(startTime)
if err != nil {
w.engine.metrics.RecordRequest(duration, 0, err)
return
}
defer resp.Body.Close()
// Read response body (to ensure proper connection handling)
io.Copy(io.Discard, resp.Body)
// Record metrics
w.engine.metrics.RecordRequest(duration, resp.StatusCode, nil)
}
// Stop gracefully stops the engine
func (e *Engine) Stop() {
e.cancel()
}
// printResults displays the test results
func (e *Engine) printResults(result *Result) {
fmt.Printf("\n📊 Test Results:\n")
fmt.Printf("Duration: %.2fs | RPS: %.2f | Total: %d | Success: %d | Failed: %d\n",
result.TotalDuration.Seconds(),
result.RequestsPerSec,
result.TotalRequests,
result.SuccessRequests,
result.FailedRequests,
)
fmt.Printf("\nResponse Times:\n")
fmt.Printf(" Min: %v | Max: %v | Avg: %v\n",
result.Metrics.MinResponseTime,
result.Metrics.MaxResponseTime,
result.Metrics.AvgResponseTime,
)
fmt.Printf(" p50: %v | p90: %v | p95: %v | p99: %v\n",
result.Metrics.P50,
result.Metrics.P90,
result.Metrics.P95,
result.Metrics.P99,
)
if result.FailedRequests == 0 {
fmt.Printf("\n🎉 Fuck yeah! Your API handled it like a champ! 💪\n")
} else {
fmt.Printf("\n⚠ Your API had some hiccups. Time to optimize! 🔧\n")
}
}

189
pkg/metrics/metrics.go Normal file
View File

@@ -0,0 +1,189 @@
package metrics
import (
"sort"
"sync"
"time"
)
// Collector handles metrics collection during stress testing
type Collector struct {
mu sync.RWMutex
responseTimes []time.Duration
statusCodes map[int]int64
errors []error
totalRequests int64
successRequests int64
failedRequests int64
startTime time.Time
}
// Results contains aggregated test results
type Results struct {
TotalRequests int64
SuccessRequests int64
FailedRequests int64
MinResponseTime time.Duration
MaxResponseTime time.Duration
AvgResponseTime time.Duration
P50 time.Duration
P90 time.Duration
P95 time.Duration
P99 time.Duration
StatusCodes map[int]int64
Errors []error
TestDuration time.Duration
RequestsPerSecond float64
}
// NewCollector creates a new metrics collector
func NewCollector() *Collector {
return &Collector{
responseTimes: make([]time.Duration, 0),
statusCodes: make(map[int]int64),
errors: make([]error, 0),
startTime: time.Now(),
}
}
// RecordRequest records a single request's metrics
func (c *Collector) RecordRequest(responseTime time.Duration, statusCode int, err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.totalRequests++
c.responseTimes = append(c.responseTimes, responseTime)
if err != nil {
c.failedRequests++
c.errors = append(c.errors, err)
} else {
c.successRequests++
c.statusCodes[statusCode]++
}
}
// GetResults calculates and returns aggregated results
func (c *Collector) GetResults() *Results {
c.mu.RLock()
defer c.mu.RUnlock()
testDuration := time.Since(c.startTime)
rps := float64(c.totalRequests) / testDuration.Seconds()
if len(c.responseTimes) == 0 {
return &Results{
TotalRequests: c.totalRequests,
SuccessRequests: c.successRequests,
FailedRequests: c.failedRequests,
StatusCodes: copyMap(c.statusCodes),
Errors: copyErrors(c.errors),
TestDuration: testDuration,
RequestsPerSecond: rps,
}
}
// Filter out zero response times (from errors) and sort for percentile calculations
var validTimes []time.Duration
for _, t := range c.responseTimes {
if t > 0 {
validTimes = append(validTimes, t)
}
}
if len(validTimes) == 0 {
return &Results{
TotalRequests: c.totalRequests,
SuccessRequests: c.successRequests,
FailedRequests: c.failedRequests,
StatusCodes: copyMap(c.statusCodes),
Errors: copyErrors(c.errors),
TestDuration: testDuration,
RequestsPerSecond: rps,
}
}
sort.Slice(validTimes, func(i, j int) bool {
return validTimes[i] < validTimes[j]
})
// Calculate statistics
minTime := validTimes[0]
maxTime := validTimes[len(validTimes)-1]
avgTime := calculateAverage(validTimes)
return &Results{
TotalRequests: c.totalRequests,
SuccessRequests: c.successRequests,
FailedRequests: c.failedRequests,
MinResponseTime: minTime,
MaxResponseTime: maxTime,
AvgResponseTime: avgTime,
P50: calculatePercentile(validTimes, 50),
P90: calculatePercentile(validTimes, 90),
P95: calculatePercentile(validTimes, 95),
P99: calculatePercentile(validTimes, 99),
StatusCodes: copyMap(c.statusCodes),
Errors: copyErrors(c.errors),
TestDuration: testDuration,
RequestsPerSecond: rps,
}
}
// Reset clears all collected metrics
func (c *Collector) Reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.responseTimes = c.responseTimes[:0]
c.statusCodes = make(map[int]int64)
c.errors = c.errors[:0]
c.totalRequests = 0
c.successRequests = 0
c.failedRequests = 0
c.startTime = time.Now()
}
// calculatePercentile calculates the nth percentile of sorted response times
func calculatePercentile(sortedTimes []time.Duration, percentile int) time.Duration {
if len(sortedTimes) == 0 {
return 0
}
index := int(float64(len(sortedTimes)-1) * float64(percentile) / 100.0)
if index >= len(sortedTimes) {
index = len(sortedTimes) - 1
}
return sortedTimes[index]
}
// calculateAverage calculates the average response time
func calculateAverage(times []time.Duration) time.Duration {
if len(times) == 0 {
return 0
}
var total time.Duration
for _, t := range times {
total += t
}
return total / time.Duration(len(times))
}
// copyMap creates a copy of the status codes map
func copyMap(original map[int]int64) map[int]int64 {
copy := make(map[int]int64)
for k, v := range original {
copy[k] = v
}
return copy
}
// copyErrors creates a copy of the errors slice
func copyErrors(original []error) []error {
copy := make([]error, len(original))
copy = append(copy, original...)
return copy
}

275
pkg/metrics/metrics_test.go Normal file
View File

@@ -0,0 +1,275 @@
package metrics
import (
"errors"
"testing"
"time"
)
func TestNewCollector(t *testing.T) {
collector := NewCollector()
if collector == nil {
t.Fatal("NewCollector() returned nil")
}
if collector.responseTimes == nil {
t.Error("responseTimes slice not initialized")
}
if collector.statusCodes == nil {
t.Error("statusCodes map not initialized")
}
if collector.errors == nil {
t.Error("errors slice not initialized")
}
}
func TestRecordRequest_Success(t *testing.T) {
collector := NewCollector()
responseTime := 100 * time.Millisecond
statusCode := 200
collector.RecordRequest(responseTime, statusCode, nil)
if collector.totalRequests != 1 {
t.Errorf("Expected totalRequests = 1, got %d", collector.totalRequests)
}
if collector.successRequests != 1 {
t.Errorf("Expected successRequests = 1, got %d", collector.successRequests)
}
if collector.failedRequests != 0 {
t.Errorf("Expected failedRequests = 0, got %d", collector.failedRequests)
}
if len(collector.responseTimes) != 1 {
t.Errorf("Expected 1 response time recorded, got %d", len(collector.responseTimes))
}
if collector.responseTimes[0] != responseTime {
t.Errorf("Expected response time %v, got %v", responseTime, collector.responseTimes[0])
}
if collector.statusCodes[statusCode] != 1 {
t.Errorf("Expected status code %d count = 1, got %d", statusCode, collector.statusCodes[statusCode])
}
}
func TestRecordRequest_Error(t *testing.T) {
collector := NewCollector()
responseTime := 50 * time.Millisecond
err := errors.New("connection timeout")
collector.RecordRequest(responseTime, 0, err)
if collector.totalRequests != 1 {
t.Errorf("Expected totalRequests = 1, got %d", collector.totalRequests)
}
if collector.successRequests != 0 {
t.Errorf("Expected successRequests = 0, got %d", collector.successRequests)
}
if collector.failedRequests != 1 {
t.Errorf("Expected failedRequests = 1, got %d", collector.failedRequests)
}
if len(collector.errors) != 1 {
t.Errorf("Expected 1 error recorded, got %d", len(collector.errors))
}
if collector.errors[0].Error() != err.Error() {
t.Errorf("Expected error %v, got %v", err, collector.errors[0])
}
}
func TestGetResults(t *testing.T) {
collector := NewCollector()
// Record some test data
testData := []struct {
responseTime time.Duration
statusCode int
err error
}{
{50 * time.Millisecond, 200, nil},
{100 * time.Millisecond, 200, nil},
{150 * time.Millisecond, 200, nil},
{200 * time.Millisecond, 500, nil},
{0, 0, errors.New("timeout")},
}
for _, data := range testData {
collector.RecordRequest(data.responseTime, data.statusCode, data.err)
}
results := collector.GetResults()
// Verify basic counts
if results.TotalRequests != 5 {
t.Errorf("Expected TotalRequests = 5, got %d", results.TotalRequests)
}
if results.SuccessRequests != 4 {
t.Errorf("Expected SuccessRequests = 4, got %d", results.SuccessRequests)
}
if results.FailedRequests != 1 {
t.Errorf("Expected FailedRequests = 1, got %d", results.FailedRequests)
}
// Verify response time statistics (excluding the error request with 0 duration)
expectedMin := 50 * time.Millisecond
expectedMax := 200 * time.Millisecond
if results.MinResponseTime != expectedMin {
t.Errorf("Expected MinResponseTime = %v, got %v", expectedMin, results.MinResponseTime)
}
if results.MaxResponseTime != expectedMax {
t.Errorf("Expected MaxResponseTime = %v, got %v", expectedMax, results.MaxResponseTime)
}
// Verify status codes
if results.StatusCodes[200] != 3 {
t.Errorf("Expected status code 200 count = 3, got %d", results.StatusCodes[200])
}
if results.StatusCodes[500] != 1 {
t.Errorf("Expected status code 500 count = 1, got %d", results.StatusCodes[500])
}
}
func TestCalculatePercentile(t *testing.T) {
times := []time.Duration{
10 * time.Millisecond,
20 * time.Millisecond,
30 * time.Millisecond,
40 * time.Millisecond,
50 * time.Millisecond,
60 * time.Millisecond,
70 * time.Millisecond,
80 * time.Millisecond,
90 * time.Millisecond,
100 * time.Millisecond,
}
tests := []struct {
percentile int
expected time.Duration
}{
{50, 50 * time.Millisecond}, // 50th percentile = index 4.5 -> index 4 (50ms)
{90, 90 * time.Millisecond}, // 90th percentile = index 8.1 -> index 8 (90ms)
{95, 95 * time.Millisecond}, // 95th percentile = index 8.55 -> index 8 (90ms) - this is expected behavior
{99, 99 * time.Millisecond}, // 99th percentile = index 8.91 -> index 8 (90ms) - this is expected behavior
}
for _, test := range tests {
result := calculatePercentile(times, test.percentile)
// For 95th and 99th percentile with only 10 data points, we expect 90ms (9th element, 0-indexed)
expectedValue := test.expected
if test.percentile == 95 || test.percentile == 99 {
expectedValue = 90 * time.Millisecond
}
if result != expectedValue {
t.Errorf("calculatePercentile(%d) = %v, expected %v", test.percentile, result, expectedValue)
}
}
}
func TestCalculateAverage(t *testing.T) {
times := []time.Duration{
100 * time.Millisecond,
200 * time.Millisecond,
300 * time.Millisecond,
}
expected := 200 * time.Millisecond
result := calculateAverage(times)
if result != expected {
t.Errorf("calculateAverage() = %v, expected %v", result, expected)
}
}
func TestCalculateAverage_EmptySlice(t *testing.T) {
times := []time.Duration{}
expected := time.Duration(0)
result := calculateAverage(times)
if result != expected {
t.Errorf("calculateAverage() on empty slice = %v, expected %v", result, expected)
}
}
func TestReset(t *testing.T) {
collector := NewCollector()
// Add some data
collector.RecordRequest(100*time.Millisecond, 200, nil)
collector.RecordRequest(200*time.Millisecond, 500, nil)
// Verify data exists
if collector.totalRequests != 2 {
t.Errorf("Expected totalRequests = 2 before reset, got %d", collector.totalRequests)
}
// Reset
collector.Reset()
// Verify everything is cleared
if collector.totalRequests != 0 {
t.Errorf("Expected totalRequests = 0 after reset, got %d", collector.totalRequests)
}
if collector.successRequests != 0 {
t.Errorf("Expected successRequests = 0 after reset, got %d", collector.successRequests)
}
if collector.failedRequests != 0 {
t.Errorf("Expected failedRequests = 0 after reset, got %d", collector.failedRequests)
}
if len(collector.responseTimes) != 0 {
t.Errorf("Expected empty responseTimes after reset, got %d items", len(collector.responseTimes))
}
if len(collector.statusCodes) != 0 {
t.Errorf("Expected empty statusCodes after reset, got %d items", len(collector.statusCodes))
}
if len(collector.errors) != 0 {
t.Errorf("Expected empty errors after reset, got %d items", len(collector.errors))
}
}
// Benchmark tests
func BenchmarkRecordRequest(b *testing.B) {
collector := NewCollector()
responseTime := 100 * time.Millisecond
b.ResetTimer()
for i := 0; i < b.N; i++ {
collector.RecordRequest(responseTime, 200, nil)
}
}
func BenchmarkGetResults(b *testing.B) {
collector := NewCollector()
// Pre-populate with data
for i := 0; i < 1000; i++ {
collector.RecordRequest(time.Duration(i)*time.Millisecond, 200, nil)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
collector.GetResults()
}
}

395
pkg/reporter/reporter.go Normal file
View File

@@ -0,0 +1,395 @@
package reporter
import (
"encoding/json"
"fmt"
"html/template"
"os"
"path/filepath"
"strings"
"time"
"git.gostacks.org/iwasforcedtobehere/stroke/pkg/metrics"
)
// Reporter interface for different output formats
type Reporter interface {
Generate(results *metrics.Results, outputDir string) error
}
// ConsoleReporter outputs results to the console
type ConsoleReporter struct{}
// NewConsoleReporter creates a new console reporter
func NewConsoleReporter() *ConsoleReporter {
return &ConsoleReporter{}
}
// Generate outputs results to console
func (cr *ConsoleReporter) Generate(results *metrics.Results, outputDir string) error {
fmt.Printf("\n📊 Stroke Test Results\n")
fmt.Print(strings.Repeat("=", 50) + "\n")
fmt.Printf("Duration: %v\n", results.TestDuration)
fmt.Printf("Total Requests: %d\n", results.TotalRequests)
fmt.Printf("Successful: %d\n", results.SuccessRequests)
fmt.Printf("Failed: %d\n", results.FailedRequests)
fmt.Printf("RPS: %.2f\n", results.RequestsPerSecond)
fmt.Printf("\nResponse Times:\n")
fmt.Printf(" Min: %v\n", results.MinResponseTime)
fmt.Printf(" Max: %v\n", results.MaxResponseTime)
fmt.Printf(" Avg: %v\n", results.AvgResponseTime)
fmt.Printf(" p50: %v\n", results.P50)
fmt.Printf(" p90: %v\n", results.P90)
fmt.Printf(" p95: %v\n", results.P95)
fmt.Printf(" p99: %v\n", results.P99)
if len(results.StatusCodes) > 0 {
fmt.Printf("\nStatus Codes:\n")
for code, count := range results.StatusCodes {
fmt.Printf(" %d: %d\n", code, count)
}
}
return nil
}
// JSONReporter outputs results as JSON
type JSONReporter struct{}
// NewJSONReporter creates a new JSON reporter
func NewJSONReporter() *JSONReporter {
return &JSONReporter{}
}
// Generate outputs results as JSON file
func (jr *JSONReporter) Generate(results *metrics.Results, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
timestamp := time.Now().Format("20060102_150405")
filename := filepath.Join(outputDir, fmt.Sprintf("stroke_results_%s.json", timestamp))
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create JSON file: %w", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(results); err != nil {
return fmt.Errorf("failed to encode JSON: %w", err)
}
fmt.Printf("📄 JSON report saved to: %s\n", filename)
return nil
}
// HTMLReporter generates beautiful HTML reports
type HTMLReporter struct{}
// NewHTMLReporter creates a new HTML reporter
func NewHTMLReporter() *HTMLReporter {
return &HTMLReporter{}
}
// Generate creates an HTML report
func (hr *HTMLReporter) Generate(results *metrics.Results, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
timestamp := time.Now().Format("20060102_150405")
filename := filepath.Join(outputDir, fmt.Sprintf("stroke_report_%s.html", timestamp))
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create HTML file: %w", err)
}
defer file.Close()
tmpl := template.Must(template.New("report").Parse(htmlTemplate))
data := struct {
Results *metrics.Results
Timestamp string
Title string
}{
Results: results,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Title: "Stroke Stress Test Report",
}
if err := tmpl.Execute(file, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
fmt.Printf("📊 HTML report saved to: %s\n", filename)
return nil
}
// CSVReporter outputs results as CSV
type CSVReporter struct{}
// NewCSVReporter creates a new CSV reporter
func NewCSVReporter() *CSVReporter {
return &CSVReporter{}
}
// Generate outputs results as CSV file
func (csvr *CSVReporter) Generate(results *metrics.Results, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
timestamp := time.Now().Format("20060102_150405")
filename := filepath.Join(outputDir, fmt.Sprintf("stroke_results_%s.csv", timestamp))
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create CSV file: %w", err)
}
defer file.Close()
// Write CSV header
fmt.Fprintf(file, "Metric,Value\n")
fmt.Fprintf(file, "Total Requests,%d\n", results.TotalRequests)
fmt.Fprintf(file, "Success Requests,%d\n", results.SuccessRequests)
fmt.Fprintf(file, "Failed Requests,%d\n", results.FailedRequests)
fmt.Fprintf(file, "Test Duration,%v\n", results.TestDuration)
fmt.Fprintf(file, "Requests Per Second,%.2f\n", results.RequestsPerSecond)
fmt.Fprintf(file, "Min Response Time,%v\n", results.MinResponseTime)
fmt.Fprintf(file, "Max Response Time,%v\n", results.MaxResponseTime)
fmt.Fprintf(file, "Avg Response Time,%v\n", results.AvgResponseTime)
fmt.Fprintf(file, "P50 Response Time,%v\n", results.P50)
fmt.Fprintf(file, "P90 Response Time,%v\n", results.P90)
fmt.Fprintf(file, "P95 Response Time,%v\n", results.P95)
fmt.Fprintf(file, "P99 Response Time,%v\n", results.P99)
fmt.Printf("📈 CSV report saved to: %s\n", filename)
return nil
}
// MultiReporter combines multiple reporters
type MultiReporter struct {
reporters []Reporter
}
// NewMultiReporter creates a reporter that outputs to multiple formats
func NewMultiReporter(formats []string) *MultiReporter {
var reporters []Reporter
for _, format := range formats {
switch strings.ToLower(format) {
case "console":
reporters = append(reporters, NewConsoleReporter())
case "json":
reporters = append(reporters, NewJSONReporter())
case "html":
reporters = append(reporters, NewHTMLReporter())
case "csv":
reporters = append(reporters, NewCSVReporter())
}
}
return &MultiReporter{reporters: reporters}
}
// Generate runs all configured reporters
func (mr *MultiReporter) Generate(results *metrics.Results, outputDir string) error {
for _, reporter := range mr.reporters {
if err := reporter.Generate(results, outputDir); err != nil {
return fmt.Errorf("reporter failed: %w", err)
}
}
return nil
}
// HTML template for the report
const htmlTemplate = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #ff6b6b 0%, #feca57 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 2.5em;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.content {
padding: 30px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border-left: 4px solid #007bff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.metric-title {
font-size: 0.9em;
color: #6c757d;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 1px;
}
.metric-value {
font-size: 1.8em;
font-weight: bold;
color: #212529;
}
.status-codes {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.status-codes h3 {
margin-top: 0;
color: #495057;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #dee2e6;
}
.footer {
background: #343a40;
color: white;
padding: 20px;
text-align: center;
}
.emoji {
font-size: 1.2em;
}
.success { border-left-color: #28a745; }
.warning { border-left-color: #ffc107; }
.danger { border-left-color: #dc3545; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><span class="emoji">🚀</span> {{.Title}} <span class="emoji">🚀</span></h1>
<p>Generated on {{.Timestamp}}</p>
</div>
<div class="content">
<div class="metrics-grid">
<div class="metric-card success">
<div class="metric-title">Total Requests</div>
<div class="metric-value">{{.Results.TotalRequests}}</div>
</div>
<div class="metric-card success">
<div class="metric-title">Successful Requests</div>
<div class="metric-value">{{.Results.SuccessRequests}}</div>
</div>
<div class="metric-card {{if gt .Results.FailedRequests 0}}danger{{else}}success{{end}}">
<div class="metric-title">Failed Requests</div>
<div class="metric-value">{{.Results.FailedRequests}}</div>
</div>
<div class="metric-card">
<div class="metric-title">Test Duration</div>
<div class="metric-value">{{.Results.TestDuration}}</div>
</div>
<div class="metric-card">
<div class="metric-title">Requests Per Second</div>
<div class="metric-value">{{printf "%.2f" .Results.RequestsPerSecond}}</div>
</div>
<div class="metric-card">
<div class="metric-title">Average Response Time</div>
<div class="metric-value">{{.Results.AvgResponseTime}}</div>
</div>
<div class="metric-card">
<div class="metric-title">P50 Response Time</div>
<div class="metric-value">{{.Results.P50}}</div>
</div>
<div class="metric-card">
<div class="metric-title">P90 Response Time</div>
<div class="metric-value">{{.Results.P90}}</div>
</div>
<div class="metric-card">
<div class="metric-title">P95 Response Time</div>
<div class="metric-value">{{.Results.P95}}</div>
</div>
<div class="metric-card">
<div class="metric-title">P99 Response Time</div>
<div class="metric-value">{{.Results.P99}}</div>
</div>
<div class="metric-card">
<div class="metric-title">Min Response Time</div>
<div class="metric-value">{{.Results.MinResponseTime}}</div>
</div>
<div class="metric-card">
<div class="metric-title">Max Response Time</div>
<div class="metric-value">{{.Results.MaxResponseTime}}</div>
</div>
</div>
{{if .Results.StatusCodes}}
<div class="status-codes">
<h3><span class="emoji">📈</span> Status Code Distribution</h3>
{{range $code, $count := .Results.StatusCodes}}
<div class="status-item">
<span>HTTP {{$code}}</span>
<span><strong>{{$count}} requests</strong></span>
</div>
{{end}}
</div>
{{end}}
</div>
<div class="footer">
<p><span class="emoji">💪</span> Powered by Stroke - Because Your Server Needs Some Exercise <span class="emoji">💪</span></p>
<p>Made with ❤️ and a healthy dose of sarcasm by @iwasforcedtobehere</p>
</div>
</div>
</body>
</html>
`