improvements nov2025 (#10)

* Add benchmark and httplog modules, update UI for modals artefacts
This commit is contained in:
2025-11-25 19:00:44 +00:00
committed by GitHub
parent 035b1cdd01
commit 3f5c1d3a5f
19 changed files with 2976 additions and 186 deletions
+130
View File
@@ -0,0 +1,130 @@
package benchmark
import (
"sort"
"time"
)
// Results holds the aggregated results of a benchmark run
type Results struct {
ForwardID string `json:"forward_id"`
URL string `json:"url"`
Method string `json:"method"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
TotalRequests int `json:"total_requests"`
Successful int `json:"successful"`
Failed int `json:"failed"`
Latencies []time.Duration `json:"-"` // Raw latencies for percentile calculation
StatusCodes map[int]int `json:"status_codes"`
Errors map[string]int `json:"errors,omitempty"`
BytesRead int64 `json:"bytes_read"`
BytesWritten int64 `json:"bytes_written"`
}
// Stats holds calculated statistics
type Stats struct {
MinLatency time.Duration `json:"min_latency_ms"`
MaxLatency time.Duration `json:"max_latency_ms"`
AvgLatency time.Duration `json:"avg_latency_ms"`
P50Latency time.Duration `json:"p50_latency_ms"`
P95Latency time.Duration `json:"p95_latency_ms"`
P99Latency time.Duration `json:"p99_latency_ms"`
Throughput float64 `json:"throughput_rps"`
Duration time.Duration `json:"duration"`
}
// NewResults creates a new Results instance
func NewResults(forwardID, url, method string) *Results {
return &Results{
ForwardID: forwardID,
URL: url,
Method: method,
StartTime: time.Now(),
Latencies: make([]time.Duration, 0),
StatusCodes: make(map[int]int),
Errors: make(map[string]int),
}
}
// RecordSuccess records a successful HTTP request (transport succeeded)
// Note: only 2xx status codes are counted as successful for statistics
func (r *Results) RecordSuccess(statusCode int, latency time.Duration, bytesRead, bytesWritten int64) {
r.TotalRequests++
// Only count 2xx as successful
if statusCode >= 200 && statusCode < 300 {
r.Successful++
} else {
r.Failed++
}
r.Latencies = append(r.Latencies, latency)
r.StatusCodes[statusCode]++
r.BytesRead += bytesRead
r.BytesWritten += bytesWritten
}
// RecordFailure records a failed request
func (r *Results) RecordFailure(err error, latency time.Duration) {
r.TotalRequests++
r.Failed++
r.Latencies = append(r.Latencies, latency)
r.Errors[err.Error()]++
}
// Finalize marks the benchmark as complete
func (r *Results) Finalize() {
r.EndTime = time.Now()
}
// CalculateStats calculates statistics from the results
func (r *Results) CalculateStats() Stats {
stats := Stats{
Duration: r.EndTime.Sub(r.StartTime),
}
if len(r.Latencies) == 0 {
return stats
}
// Sort latencies for percentile calculation
sorted := make([]time.Duration, len(r.Latencies))
copy(sorted, r.Latencies)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i] < sorted[j]
})
// Calculate min, max, avg
var total time.Duration
stats.MinLatency = sorted[0]
stats.MaxLatency = sorted[len(sorted)-1]
for _, lat := range sorted {
total += lat
}
stats.AvgLatency = total / time.Duration(len(sorted))
// Calculate percentiles
stats.P50Latency = percentile(sorted, 50)
stats.P95Latency = percentile(sorted, 95)
stats.P99Latency = percentile(sorted, 99)
// Calculate throughput
if stats.Duration > 0 {
stats.Throughput = float64(r.TotalRequests) / stats.Duration.Seconds()
}
return stats
}
// percentile calculates the p-th percentile of sorted durations
func percentile(sorted []time.Duration, p int) time.Duration {
if len(sorted) == 0 {
return 0
}
idx := (p * len(sorted)) / 100
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
+230
View File
@@ -0,0 +1,230 @@
package benchmark
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"time"
)
// ProgressCallback is called periodically with benchmark progress
type ProgressCallback func(completed, total int)
// Config holds the benchmark configuration
type Config struct {
URL string // Target URL
Method string // HTTP method
Headers map[string]string // Custom headers
Body []byte // Request body
Concurrency int // Number of concurrent workers
Requests int // Total number of requests (0 = use duration)
Duration time.Duration // Duration to run (0 = use requests)
Timeout time.Duration // Request timeout
ProgressCallback ProgressCallback // Optional callback for progress updates
}
// DefaultConfig returns a default benchmark configuration
func DefaultConfig() Config {
return Config{
Method: "GET",
Concurrency: 10,
Requests: 100,
Timeout: 30 * time.Second,
}
}
// Runner executes HTTP benchmarks
type Runner struct {
client *http.Client
}
// NewRunner creates a new benchmark runner
func NewRunner() *Runner {
return &Runner{
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// Run executes the benchmark and returns results
func (r *Runner) Run(ctx context.Context, forwardID string, cfg Config) (*Results, error) {
if cfg.URL == "" {
return nil, fmt.Errorf("URL is required")
}
if cfg.Concurrency < 1 {
cfg.Concurrency = 1
}
// Ensure concurrency doesn't exceed number of requests (for request-based mode)
if cfg.Duration == 0 && cfg.Requests > 0 && cfg.Concurrency > cfg.Requests {
cfg.Concurrency = cfg.Requests
}
if cfg.Timeout > 0 {
r.client.Timeout = cfg.Timeout
}
results := NewResults(forwardID, cfg.URL, cfg.Method)
// Create work channel
workCh := make(chan struct{}, cfg.Concurrency*2)
// Create context for cancellation
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Start workers
var wg sync.WaitGroup
var completed int64
var resultsMu sync.Mutex // Shared mutex for results access
for i := 0; i < cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r.worker(runCtx, cfg, results, &resultsMu, workCh, &completed)
}()
}
// Start progress reporter if callback is provided
if cfg.ProgressCallback != nil {
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-runCtx.Done():
return
case <-ticker.C:
cfg.ProgressCallback(int(atomic.LoadInt64(&completed)), cfg.Requests)
}
}
}()
}
// Determine how to dispatch work
if cfg.Duration > 0 {
// Duration-based: keep sending work until duration expires
timer := time.NewTimer(cfg.Duration)
defer timer.Stop()
dispatchLoop:
for {
select {
case <-timer.C:
cancel()
break dispatchLoop
case <-ctx.Done():
cancel()
break dispatchLoop
case workCh <- struct{}{}:
// Work dispatched
}
}
} else {
// Request-based: send exactly N requests
requestLoop:
for i := 0; i < cfg.Requests; i++ {
select {
case <-ctx.Done():
cancel()
break requestLoop
case workCh <- struct{}{}:
// Work dispatched
}
}
}
// Close work channel and wait for workers
close(workCh)
wg.Wait()
results.Finalize()
return results, nil
}
// worker processes requests from the work channel
func (r *Runner) worker(ctx context.Context, cfg Config, results *Results, resultsMu *sync.Mutex, workCh <-chan struct{}, completed *int64) {
for range workCh {
select {
case <-ctx.Done():
return
default:
}
start := time.Now()
statusCode, bytesRead, bytesWritten, err := r.makeRequestSafe(ctx, cfg)
latency := time.Since(start)
resultsMu.Lock()
if err != nil {
results.RecordFailure(err, latency)
} else {
results.RecordSuccess(statusCode, latency, bytesRead, bytesWritten)
}
resultsMu.Unlock()
atomic.AddInt64(completed, 1)
}
}
// makeRequestSafe wraps makeRequest with panic recovery
func (r *Runner) makeRequestSafe(ctx context.Context, cfg Config) (statusCode int, bytesRead, bytesWritten int64, err error) {
defer func() {
if rec := recover(); rec != nil {
err = fmt.Errorf("request panic: %v", rec)
}
}()
return r.makeRequest(ctx, cfg)
}
// makeRequest makes a single HTTP request
func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, bytesRead, bytesWritten int64, err error) {
var body io.Reader
if len(cfg.Body) > 0 {
body = bytes.NewReader(cfg.Body)
bytesWritten = int64(len(cfg.Body))
}
req, err := http.NewRequestWithContext(ctx, cfg.Method, cfg.URL, body)
if err != nil {
return 0, 0, 0, err
}
// Set headers
for k, v := range cfg.Headers {
req.Header.Set(k, v)
}
resp, err := r.client.Do(req)
if err != nil {
return 0, 0, bytesWritten, err
}
defer resp.Body.Close()
// Read response body to measure bytes
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, 0, bytesWritten, err
}
return resp.StatusCode, int64(len(respBody)), bytesWritten, nil
}
// Progress represents the current progress of a benchmark run
type Progress struct {
Completed int
Total int
Elapsed time.Duration
}
+282
View File
@@ -0,0 +1,282 @@
package benchmark
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResults(t *testing.T) {
r := NewResults("test-forward", "http://localhost/test", "GET")
// Record some 2xx successes
r.RecordSuccess(200, 10*time.Millisecond, 100, 0)
r.RecordSuccess(200, 20*time.Millisecond, 150, 0)
r.RecordSuccess(201, 15*time.Millisecond, 120, 0)
// Record a transport failure
r.RecordFailure(assert.AnError, 5*time.Millisecond)
r.Finalize()
assert.Equal(t, 4, r.TotalRequests)
assert.Equal(t, 3, r.Successful)
assert.Equal(t, 1, r.Failed)
assert.Equal(t, int64(370), r.BytesRead)
assert.Equal(t, 2, r.StatusCodes[200])
assert.Equal(t, 1, r.StatusCodes[201])
}
func TestResultsNon2xxCountsAsFailure(t *testing.T) {
r := NewResults("test-forward", "http://localhost/test", "GET")
// Record a 200 success
r.RecordSuccess(200, 10*time.Millisecond, 100, 0)
// Record 4xx and 5xx - these should count as failures
r.RecordSuccess(404, 10*time.Millisecond, 50, 0)
r.RecordSuccess(500, 10*time.Millisecond, 30, 0)
r.Finalize()
assert.Equal(t, 3, r.TotalRequests)
assert.Equal(t, 1, r.Successful, "Only 2xx should count as successful")
assert.Equal(t, 2, r.Failed, "4xx and 5xx should count as failed")
assert.Equal(t, 1, r.StatusCodes[200])
assert.Equal(t, 1, r.StatusCodes[404])
assert.Equal(t, 1, r.StatusCodes[500])
}
func TestResultsStats(t *testing.T) {
r := NewResults("test", "http://localhost", "GET")
// Add latencies
latencies := []time.Duration{
10 * time.Millisecond,
20 * time.Millisecond,
30 * time.Millisecond,
40 * time.Millisecond,
50 * time.Millisecond,
}
for _, lat := range latencies {
r.RecordSuccess(200, lat, 0, 0)
}
r.EndTime = r.StartTime.Add(1 * time.Second)
stats := r.CalculateStats()
assert.Equal(t, 10*time.Millisecond, stats.MinLatency)
assert.Equal(t, 50*time.Millisecond, stats.MaxLatency)
assert.Equal(t, 30*time.Millisecond, stats.AvgLatency)
assert.Equal(t, float64(5), stats.Throughput)
}
func TestPercentile(t *testing.T) {
sorted := []time.Duration{
1 * time.Millisecond,
2 * time.Millisecond,
3 * time.Millisecond,
4 * time.Millisecond,
5 * time.Millisecond,
6 * time.Millisecond,
7 * time.Millisecond,
8 * time.Millisecond,
9 * time.Millisecond,
10 * time.Millisecond,
}
// P50 = index 5 (50*10/100 = 5) = 6ms
assert.Equal(t, 6*time.Millisecond, percentile(sorted, 50))
// P95 = index 9 (95*10/100 = 9) = 10ms
assert.Equal(t, 10*time.Millisecond, percentile(sorted, 95))
// P99 = index 9 (99*10/100 = 9) = 10ms
assert.Equal(t, 10*time.Millisecond, percentile(sorted, 99))
}
func TestRunner(t *testing.T) {
// Create a test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Millisecond) // Simulate some latency
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}))
defer server.Close()
runner := NewRunner()
cfg := Config{
URL: server.URL,
Method: "GET",
Concurrency: 2,
Requests: 10,
Timeout: 5 * time.Second,
}
results, err := runner.Run(context.Background(), "test-forward", cfg)
require.NoError(t, err)
assert.Equal(t, 10, results.TotalRequests)
assert.Equal(t, 10, results.Successful)
assert.Equal(t, 0, results.Failed)
assert.Equal(t, 10, results.StatusCodes[200])
}
func TestRunnerWithDuration(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`ok`))
}))
defer server.Close()
runner := NewRunner()
cfg := Config{
URL: server.URL,
Method: "GET",
Concurrency: 2,
Duration: 100 * time.Millisecond,
Timeout: 1 * time.Second,
}
results, err := runner.Run(context.Background(), "test-forward", cfg)
require.NoError(t, err)
// Should have made some requests in 100ms
assert.Greater(t, results.TotalRequests, 0)
assert.Equal(t, results.Successful, results.StatusCodes[200])
}
func TestRunnerWithHeaders(t *testing.T) {
var receivedHeader string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeader = r.Header.Get("X-Custom-Header")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
runner := NewRunner()
cfg := Config{
URL: server.URL,
Method: "GET",
Headers: map[string]string{
"X-Custom-Header": "test-value",
},
Concurrency: 1,
Requests: 1,
}
_, err := runner.Run(context.Background(), "test", cfg)
require.NoError(t, err)
assert.Equal(t, "test-value", receivedHeader)
}
func TestRunnerWithBody(t *testing.T) {
var receivedBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := http.MaxBytesReader(w, r.Body, 1024).Read(make([]byte, 1024))
_ = body
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
runner := NewRunner()
cfg := Config{
URL: server.URL,
Method: "POST",
Body: []byte(`{"test":"data"}`),
Concurrency: 1,
Requests: 1,
}
results, err := runner.Run(context.Background(), "test", cfg)
require.NoError(t, err)
_ = receivedBody // Used for debugging
assert.Equal(t, int64(15), results.BytesWritten)
}
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
assert.Equal(t, "GET", cfg.Method)
assert.Equal(t, 10, cfg.Concurrency)
assert.Equal(t, 100, cfg.Requests)
assert.Equal(t, 30*time.Second, cfg.Timeout)
}
func TestRunnerWithProgressCallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Millisecond) // Add small delay so progress ticker can fire
w.WriteHeader(http.StatusOK)
w.Write([]byte(`ok`))
}))
defer server.Close()
runner := NewRunner()
var progressUpdates []int
var mu sync.Mutex
cfg := Config{
URL: server.URL,
Method: "GET",
Concurrency: 5,
Requests: 50, // More requests to ensure progress callbacks fire
Timeout: 5 * time.Second,
ProgressCallback: func(completed, total int) {
mu.Lock()
progressUpdates = append(progressUpdates, completed)
mu.Unlock()
},
}
results, err := runner.Run(context.Background(), "test-forward", cfg)
require.NoError(t, err)
assert.Equal(t, 50, results.TotalRequests)
// Should have received some progress updates (ticker fires every 100ms)
mu.Lock()
updates := len(progressUpdates)
mu.Unlock()
assert.Greater(t, updates, 0, "Should have received progress updates")
}
func TestRunnerConcurrencyCappedAtRequests(t *testing.T) {
requestCount := 0
var mu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
requestCount++
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
runner := NewRunner()
cfg := Config{
URL: server.URL,
Method: "GET",
Concurrency: 100, // Higher than requests
Requests: 5,
Timeout: 5 * time.Second,
}
results, err := runner.Run(context.Background(), "test", cfg)
require.NoError(t, err)
assert.Equal(t, 5, results.TotalRequests)
}