Skip to content

Commit 0d871af

Browse files
committed
Add calculation caching, WebSocket rate limiting, and HTTP caching headers
- Implement 1-second TTL cache for expensive metric calculations - Add 100ms minimum delay for WebSocket broadcasts with intelligent coalescing - Add differentiated HTTP caching: static content (5min-1hr), dynamic content (no-cache) - Invalidate cache automatically when underlying data changes - Prevent WebSocket flooding and reduce CPU usage under high query loads
1 parent fa36466 commit 0d871af

File tree

1 file changed

+106
-6
lines changed

1 file changed

+106
-6
lines changed

dnscrypt-proxy/monitoring_ui.go

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ type MetricsCollector struct {
7878
maxMemoryBytes int64
7979
currentMemoryBytes int64
8080
privacyLevel int
81+
82+
// Caching for expensive calculations
83+
cacheMutex sync.RWMutex
84+
cachedMetrics map[string]interface{}
85+
cacheLastUpdate time.Time
86+
cacheTTL time.Duration
8187
}
8288

8389
// QueryLogEntry - Entry for the query log
@@ -112,6 +118,12 @@ type MonitoringUI struct {
112118
clients map[*websocket.Conn]bool
113119
clientsMutex sync.Mutex
114120
proxy *Proxy
121+
122+
// WebSocket broadcast rate limiting
123+
broadcastMutex sync.Mutex
124+
lastBroadcast time.Time
125+
broadcastMinDelay time.Duration
126+
pendingBroadcast bool
115127
}
116128

117129
// NewMonitoringUI - Creates a new monitoring UI
@@ -145,6 +157,9 @@ func NewMonitoringUI(proxy *Proxy) *MonitoringUI {
145157
maxMemoryBytes: int64(maxMemoryMB * 1024 * 1024),
146158
currentMemoryBytes: 0,
147159
privacyLevel: proxy.monitoringUI.PrivacyLevel,
160+
// Initialize caching with 1 second TTL
161+
cacheTTL: time.Second,
162+
cachedMetrics: make(map[string]interface{}),
148163
}
149164

150165
dlog.Debugf("Metrics collector initialized with privacy level: %d", metricsCollector.privacyLevel)
@@ -173,6 +188,8 @@ func NewMonitoringUI(proxy *Proxy) *MonitoringUI {
173188
},
174189
clients: make(map[*websocket.Conn]bool),
175190
proxy: proxy,
191+
// Initialize broadcast rate limiting with 100ms minimum delay
192+
broadcastMinDelay: 100 * time.Millisecond,
176193
}
177194
}
178195

@@ -265,6 +282,9 @@ func (ui *MonitoringUI) UpdateMetrics(pluginsState PluginsState, msg *dns.Msg, s
265282
}
266283
mc.countersMutex.Unlock()
267284

285+
// Invalidate cache since counters changed
286+
mc.invalidateCache()
287+
268288
// Update query types - separate lock
269289
if msg != nil && len(msg.Question) > 0 {
270290
question := msg.Question[0]
@@ -392,14 +412,31 @@ func (ui *MonitoringUI) UpdateMetrics(pluginsState PluginsState, msg *dns.Msg, s
392412
mc.queryLogMutex.Unlock()
393413
}
394414

395-
// Broadcast updates to WebSocket clients
396-
go ui.broadcastMetrics()
415+
// Broadcast updates to WebSocket clients (rate limited)
416+
ui.scheduleBroadcast()
417+
}
418+
419+
// invalidateCache - Marks the cache as stale (call when data changes)
420+
func (mc *MetricsCollector) invalidateCache() {
421+
mc.cacheMutex.Lock()
422+
mc.cacheLastUpdate = time.Time{} // Zero time to force refresh
423+
mc.cacheMutex.Unlock()
397424
}
398425

399426
// GetMetrics - Returns the current metrics
400427
func (mc *MetricsCollector) GetMetrics() map[string]interface{} {
401428
dlog.Debugf("GetMetrics called")
402429

430+
// Check cache first
431+
mc.cacheMutex.RLock()
432+
if time.Since(mc.cacheLastUpdate) < mc.cacheTTL && mc.cachedMetrics != nil {
433+
cached := mc.cachedMetrics
434+
mc.cacheMutex.RUnlock()
435+
dlog.Debugf("Returning cached metrics")
436+
return cached
437+
}
438+
mc.cacheMutex.RUnlock()
439+
403440
// Read basic counters first
404441
mc.countersMutex.RLock()
405442
totalQueries := mc.totalQueries
@@ -551,8 +588,8 @@ func (mc *MetricsCollector) GetMetrics() map[string]interface{} {
551588
copy(recentQueries, mc.recentQueries)
552589
mc.queryLogMutex.RUnlock()
553590

554-
// Return all metrics
555-
return map[string]interface{}{
591+
// Return all metrics and cache the result
592+
metrics := map[string]interface{}{
556593
"total_queries": totalQueries,
557594
"queries_per_second": queriesPerSecond,
558595
"uptime_seconds": time.Since(startTime).Seconds(),
@@ -566,22 +603,44 @@ func (mc *MetricsCollector) GetMetrics() map[string]interface{} {
566603
"query_types": queryTypesList,
567604
"recent_queries": recentQueries,
568605
}
606+
607+
// Cache the computed metrics
608+
mc.cacheMutex.Lock()
609+
mc.cachedMetrics = metrics
610+
mc.cacheLastUpdate = time.Now()
611+
mc.cacheMutex.Unlock()
612+
613+
dlog.Debugf("Computed and cached new metrics")
614+
return metrics
569615
}
570616

571617
// setCORSHeaders - Sets standard CORS headers for all responses
572618
func setCORSHeaders(w http.ResponseWriter) {
573619
w.Header().Set("Access-Control-Allow-Origin", "*")
574620
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
575621
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
622+
}
623+
624+
// setDynamicCacheHeaders - Sets cache headers for dynamic content (metrics, API)
625+
func setDynamicCacheHeaders(w http.ResponseWriter) {
576626
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
577627
w.Header().Set("Pragma", "no-cache")
578628
w.Header().Set("Expires", "0")
579629
}
580630

631+
// setStaticCacheHeaders - Sets cache headers for static content
632+
func setStaticCacheHeaders(w http.ResponseWriter, maxAge int) {
633+
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
634+
w.Header().Set("Expires", time.Now().Add(time.Duration(maxAge)*time.Second).Format(http.TimeFormat))
635+
}
636+
581637
// handleTestQuery - Handles test query requests for debugging
582638
func (ui *MonitoringUI) handleTestQuery(w http.ResponseWriter, r *http.Request) {
583639
dlog.Debugf("Adding test query")
584640

641+
// Test queries modify state - no cache
642+
setDynamicCacheHeaders(w)
643+
585644
// Create a fake DNS message
586645
msg := &dns.Msg{}
587646
msg.SetQuestion("test.example.com.", dns.TypeA)
@@ -629,6 +688,9 @@ func (ui *MonitoringUI) handleRoot(w http.ResponseWriter, r *http.Request) {
629688

630689
// If this is a simple version request, return a simple page
631690
if r.URL.Query().Get("simple") == "1" {
691+
// Simple page has dynamic content - no cache
692+
setDynamicCacheHeaders(w)
693+
632694
metrics := ui.metricsCollector.GetMetrics()
633695

634696
// Create a simple HTML page with the metrics
@@ -647,7 +709,8 @@ func (ui *MonitoringUI) handleRoot(w http.ResponseWriter, r *http.Request) {
647709
return
648710
}
649711

650-
// Serve the main dashboard page
712+
// Serve the main dashboard page - cache for 5 minutes since template is static
713+
setStaticCacheHeaders(w, 300)
651714
w.Header().Set("Content-Type", "text/html")
652715
w.Write([]byte(MainHTMLTemplate))
653716
}
@@ -656,8 +719,9 @@ func (ui *MonitoringUI) handleRoot(w http.ResponseWriter, r *http.Request) {
656719
func (ui *MonitoringUI) handleMetrics(w http.ResponseWriter, r *http.Request) {
657720
dlog.Debugf("Received metrics request from %s", r.RemoteAddr)
658721

659-
// Set CORS headers
722+
// Set CORS headers and dynamic cache headers for API
660723
setCORSHeaders(w)
724+
setDynamicCacheHeaders(w)
661725

662726
// Handle preflight OPTIONS request
663727
if r.Method == "OPTIONS" {
@@ -837,6 +901,8 @@ func (ui *MonitoringUI) handleStatic(w http.ResponseWriter, r *http.Request) {
837901
// handleStaticJS - Serves the JavaScript for the monitoring UI
838902
func (ui *MonitoringUI) handleStaticJS(w http.ResponseWriter, r *http.Request) {
839903
setCORSHeaders(w)
904+
// JavaScript is static - cache for 1 hour
905+
setStaticCacheHeaders(w, 3600)
840906
w.Header().Set("Content-Type", "application/javascript")
841907
w.Write([]byte(MonitoringJSContent))
842908
}
@@ -863,6 +929,40 @@ func (ui *MonitoringUI) basicAuthMiddleware(next http.Handler) http.Handler {
863929
})
864930
}
865931

932+
// scheduleBroadcast - Rate-limited scheduling of WebSocket broadcasts
933+
func (ui *MonitoringUI) scheduleBroadcast() {
934+
ui.broadcastMutex.Lock()
935+
defer ui.broadcastMutex.Unlock()
936+
937+
now := time.Now()
938+
timeSinceLastBroadcast := now.Sub(ui.lastBroadcast)
939+
940+
if timeSinceLastBroadcast >= ui.broadcastMinDelay {
941+
// Enough time has passed, broadcast immediately
942+
ui.lastBroadcast = now
943+
ui.pendingBroadcast = false
944+
go ui.broadcastMetrics()
945+
} else {
946+
// Too soon, schedule a delayed broadcast if not already pending
947+
if !ui.pendingBroadcast {
948+
ui.pendingBroadcast = true
949+
delay := ui.broadcastMinDelay - timeSinceLastBroadcast
950+
go func() {
951+
time.Sleep(delay)
952+
ui.broadcastMutex.Lock()
953+
if ui.pendingBroadcast {
954+
ui.lastBroadcast = time.Now()
955+
ui.pendingBroadcast = false
956+
ui.broadcastMutex.Unlock()
957+
ui.broadcastMetrics()
958+
} else {
959+
ui.broadcastMutex.Unlock()
960+
}
961+
}()
962+
}
963+
}
964+
}
965+
866966
// broadcastMetrics - Broadcasts metrics to all connected WebSocket clients
867967
func (ui *MonitoringUI) broadcastMetrics() {
868968
metrics := ui.metricsCollector.GetMetrics()

0 commit comments

Comments
 (0)