diff --git a/admin_dashboard.go b/admin_dashboard.go index 134b8cb..33bf05b 100644 --- a/admin_dashboard.go +++ b/admin_dashboard.go @@ -2,9 +2,11 @@ package main import ( "embed" + "fmt" "time" "github.com/gofiber/fiber/v2" + libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache" libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" ) @@ -67,23 +69,92 @@ func (ad *AdminDashboard) serveDashboard(c *fiber.Ctx) error { // getStats returns overall proxy statistics func (ad *AdminDashboard) getStats(c *fiber.Ctx) error { + uptimeSeconds := time.Since(startTime).Seconds() stats := map[string]interface{}{ - "timestamp": time.Now().Format(time.RFC3339), - "uptime": time.Since(startTime).Seconds(), - "version": "0.27.0", // TODO: Get from build info + "timestamp": time.Now().Format(time.RFC3339), + "uptime_seconds": uptimeSeconds, + "uptime_human": formatDuration(time.Since(startTime)), + "version": "0.27.0", // TODO: Get from build info } if cfg != nil && cfg.Monitoring != nil { - stats["metrics"] = map[string]interface{}{ - "succeeded": getAdminMetricValue("graphql_proxy_succeeded_total"), - "failed": getAdminMetricValue("graphql_proxy_failed_total"), - "skipped": getAdminMetricValue("graphql_proxy_skipped_total"), + succeeded := getAdminMetricValue("graphql_proxy_succeeded_total") + failed := getAdminMetricValue("graphql_proxy_failed_total") + skipped := getAdminMetricValue("graphql_proxy_skipped_total") + total := succeeded + failed + skipped + + // Request statistics + requestStats := map[string]interface{}{ + "total": total, + "succeeded": succeeded, + "failed": failed, + "skipped": skipped, + } + + // Calculate rates and percentages + if total > 0 { + requestStats["success_rate_pct"] = float64(succeeded) / float64(total) * 100 + requestStats["failure_rate_pct"] = float64(failed) / float64(total) * 100 + requestStats["skip_rate_pct"] = float64(skipped) / float64(total) * 100 + } else { + requestStats["success_rate_pct"] = 0.0 + requestStats["failure_rate_pct"] = 0.0 + requestStats["skip_rate_pct"] = 0.0 + } + + // Calculate average requests per second (lifetime) + if uptimeSeconds > 0 { + requestStats["avg_requests_per_second"] = float64(total) / uptimeSeconds + } else { + requestStats["avg_requests_per_second"] = 0.0 + } + + // Get current requests per second (last 1 second) + if rpsTracker := GetRPSTracker(); rpsTracker != nil { + requestStats["current_requests_per_second"] = rpsTracker.GetCurrentRPS() + } else { + requestStats["current_requests_per_second"] = 0.0 + } + + stats["requests"] = requestStats + + // Get cache statistics summary + cacheStats := libpack_cache.GetCacheStats() + if cacheStats != nil { + totalCacheRequests := cacheStats.CacheHits + cacheStats.CacheMisses + hitRate := 0.0 + if totalCacheRequests > 0 { + hitRate = float64(cacheStats.CacheHits) / float64(totalCacheRequests) * 100 + } + stats["cache_summary"] = map[string]interface{}{ + "hits": cacheStats.CacheHits, + "misses": cacheStats.CacheMisses, + "hit_rate_pct": hitRate, + "total_cached": cacheStats.CachedQueries, + } } } return c.JSON(stats) } +// formatDuration formats a duration into human-readable format +func formatDuration(d time.Duration) string { + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds) + } else if hours > 0 { + return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds) + } else if minutes > 0 { + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} + // getHealth returns health status func (ad *AdminDashboard) getHealth(c *fiber.Ctx) error { healthMgr := GetBackendHealthManager() @@ -154,6 +225,35 @@ func (ad *AdminDashboard) getCacheStats(c *fiber.Ctx) error { stats["ttl_seconds"] = cfg.Cache.CacheTTL stats["max_memory_mb"] = cfg.Cache.CacheMaxMemorySize stats["max_entries"] = cfg.Cache.CacheMaxEntries + + // Get runtime cache statistics + cacheStats := libpack_cache.GetCacheStats() + if cacheStats != nil { + stats["cached_queries"] = cacheStats.CachedQueries + stats["cache_hits"] = cacheStats.CacheHits + stats["cache_misses"] = cacheStats.CacheMisses + + // Calculate hit rate + totalRequests := cacheStats.CacheHits + cacheStats.CacheMisses + hitRate := 0.0 + if totalRequests > 0 { + hitRate = float64(cacheStats.CacheHits) / float64(totalRequests) * 100 + } + stats["hit_rate_pct"] = hitRate + + // Get memory usage + memoryUsage := libpack_cache.GetCacheMemoryUsage() + maxMemory := libpack_cache.GetCacheMaxMemorySize() + stats["memory_usage_bytes"] = memoryUsage + stats["memory_usage_mb"] = float64(memoryUsage) / (1024 * 1024) + + // Calculate memory usage percentage + memoryUsagePct := 0.0 + if maxMemory > 0 { + memoryUsagePct = float64(memoryUsage) / float64(maxMemory) * 100 + } + stats["memory_usage_pct"] = memoryUsagePct + } } return c.JSON(stats) diff --git a/admin_dashboard_test.go b/admin_dashboard_test.go index eb0d52d..90bd480 100644 --- a/admin_dashboard_test.go +++ b/admin_dashboard_test.go @@ -110,8 +110,19 @@ func TestAdminDashboard_GetStats(t *testing.T) { // Verify stats structure assert.NotEmpty(t, stats["timestamp"]) - assert.NotNil(t, stats["uptime"]) + assert.NotNil(t, stats["uptime_seconds"]) + assert.NotNil(t, stats["uptime_human"]) assert.NotEmpty(t, stats["version"]) + assert.NotNil(t, stats["requests"]) + + // Verify request stats structure + requests := stats["requests"].(map[string]interface{}) + assert.NotNil(t, requests["total"]) + assert.NotNil(t, requests["succeeded"]) + assert.NotNil(t, requests["failed"]) + assert.NotNil(t, requests["success_rate_pct"]) + assert.NotNil(t, requests["avg_requests_per_second"]) + assert.NotNil(t, requests["current_requests_per_second"]) } func TestAdminDashboard_GetHealth(t *testing.T) { diff --git a/main.go b/main.go index 2c9987f..f912ac0 100644 --- a/main.go +++ b/main.go @@ -387,6 +387,12 @@ func parseConfig() { healthMgr.StartHealthChecking() } + // Initialize RPS tracker for real-time requests per second monitoring + InitializeRPSTracker() + cfg.Logger.Info(&libpack_logging.LogMessage{ + Message: "RPS tracker initialized", + }) + // Load rate limit configuration with improved error handling if err := loadRatelimitConfig(); err != nil { // Log the error with clear guidance diff --git a/proxy.go b/proxy.go index a161188..306dc07 100644 --- a/proxy.go +++ b/proxy.go @@ -236,6 +236,11 @@ func createFasthttpClient(clientConfig *config) *fasthttp.Client { // proxyTheRequest handles the request proxying logic. func proxyTheRequest(c *fiber.Ctx, currentEndpoint string) error { + // Record request for RPS tracking + if rpsTracker := GetRPSTracker(); rpsTracker != nil { + rpsTracker.RecordRequest() + } + // Setup tracing if enabled var span trace.Span var ctx context.Context diff --git a/rps_tracker.go b/rps_tracker.go new file mode 100644 index 0000000..3f14332 --- /dev/null +++ b/rps_tracker.go @@ -0,0 +1,89 @@ +package main + +import ( + "sync" + "sync/atomic" + "time" +) + +// RPSTracker tracks requests per second using periodic sampling +type RPSTracker struct { + lastCount atomic.Int64 + lastSampleTime atomic.Int64 // Unix nano + currentRPS uint64 // stored as uint64, accessed with atomic operations + mu sync.RWMutex // for currentRPS updates +} + +// NewRPSTracker creates a new RPS tracker +func NewRPSTracker() *RPSTracker { + tracker := &RPSTracker{} + tracker.lastSampleTime.Store(time.Now().UnixNano()) + go tracker.updateLoop() + return tracker +} + +// RecordRequest increments the request counter +func (r *RPSTracker) RecordRequest() { + // Just increment the counter, sampling happens in background + r.lastCount.Add(1) +} + +// updateLoop periodically calculates current RPS +func (r *RPSTracker) updateLoop() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for range ticker.C { + r.sample() + } +} + +// sample calculates RPS since last sample +func (r *RPSTracker) sample() { + now := time.Now() + nowNano := now.UnixNano() + + currentCount := r.lastCount.Load() + lastSampleNano := r.lastSampleTime.Load() + + if lastSampleNano == 0 { + r.lastSampleTime.Store(nowNano) + return + } + + elapsed := float64(nowNano-lastSampleNano) / float64(time.Second) + if elapsed > 0 { + rps := float64(currentCount) / elapsed + // Store RPS as centirps for precision (multiply by 100) + r.mu.Lock() + atomic.StoreUint64(&r.currentRPS, uint64(rps*100)) + r.mu.Unlock() + } + + // Reset for next sample + r.lastCount.Store(0) + r.lastSampleTime.Store(nowNano) +} + +// GetCurrentRPS returns the current requests per second +func (r *RPSTracker) GetCurrentRPS() float64 { + r.mu.RLock() + centirps := atomic.LoadUint64(&r.currentRPS) + r.mu.RUnlock() + return float64(centirps) / 100.0 +} + +var globalRPSTracker *RPSTracker + +// InitializeRPSTracker initializes the global RPS tracker +func InitializeRPSTracker() *RPSTracker { + if globalRPSTracker == nil { + globalRPSTracker = NewRPSTracker() + } + return globalRPSTracker +} + +// GetRPSTracker returns the global RPS tracker +func GetRPSTracker() *RPSTracker { + return globalRPSTracker +}