de43a71929
When the disk (or memory) tier is at cap or the volume returns ENOSPC, ops currently look like random misses with no clear "we are dropping data." Count those events as capacity_pressure_events on /metrics and log tier plus reason so operators can tell capacity pressure from a cold cache, without changing the existing evictions counter. Link: #36
386 lines
13 KiB
Go
386 lines
13 KiB
Go
// steamcache/metrics/metrics.go
|
|
package metrics
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"s1d3sw1ped/steamcache2/steamcache/logger"
|
|
)
|
|
|
|
// Metrics tracks various performance and operational metrics
|
|
type Metrics struct {
|
|
// Request metrics
|
|
TotalRequests int64
|
|
CacheHits int64
|
|
CacheMisses int64
|
|
CacheCoalesced int64
|
|
NegativeCacheHits int64 // 404/410 served from a still-valid negative cache entry
|
|
RangeCache int64 // Range requests served as 206 from an already-cached object (HIT)
|
|
RangeUpstream int64 // Range requests that required an upstream fetch (full object), served as 206
|
|
Errors int64
|
|
RateLimited int64
|
|
|
|
// Performance metrics
|
|
TotalResponseTime int64 // in nanoseconds
|
|
TotalBytesServed int64
|
|
TotalBytesSaved int64 // bytes served from cache instead of being re-downloaded from upstream
|
|
|
|
// Cache metrics
|
|
MemoryCacheSize int64
|
|
DiskCacheSize int64
|
|
MemoryCacheHits int64
|
|
DiskCacheHits int64
|
|
Promotions int64
|
|
Evictions int64
|
|
CapacityPressureEvents int64 // soft eviction under cap and/or disk ENOSPC
|
|
DiskTierReady int64 // 0=pending (or unset), 1=ready or no-disk (N/A)
|
|
|
|
// Expanded observability (upstream breakdowns, cache write failures, per-service errors)
|
|
UpstreamErrors int64
|
|
CacheWriteFailures int64
|
|
ServiceErrors map[string]int64
|
|
serviceErrorsMutex sync.RWMutex
|
|
|
|
// Service metrics
|
|
ServiceRequests map[string]int64
|
|
serviceMutex sync.RWMutex
|
|
|
|
// Time tracking
|
|
StartTime time.Time
|
|
LastResetTime time.Time
|
|
}
|
|
|
|
// NewMetrics creates a new metrics instance
|
|
func NewMetrics() *Metrics {
|
|
now := time.Now()
|
|
return &Metrics{
|
|
ServiceRequests: make(map[string]int64),
|
|
ServiceErrors: make(map[string]int64),
|
|
StartTime: now,
|
|
LastResetTime: now,
|
|
}
|
|
}
|
|
|
|
// IncrementTotalRequests increments the total request counter
|
|
func (m *Metrics) IncrementTotalRequests() {
|
|
atomic.AddInt64(&m.TotalRequests, 1)
|
|
}
|
|
|
|
// IncrementCacheHits increments the cache hit counter
|
|
func (m *Metrics) IncrementCacheHits() {
|
|
atomic.AddInt64(&m.CacheHits, 1)
|
|
}
|
|
|
|
// IncrementCacheMisses increments the cache miss counter
|
|
func (m *Metrics) IncrementCacheMisses() {
|
|
atomic.AddInt64(&m.CacheMisses, 1)
|
|
}
|
|
|
|
// IncrementNegativeCacheHits increments hits served from a 404/410 negative entry.
|
|
func (m *Metrics) IncrementNegativeCacheHits() {
|
|
atomic.AddInt64(&m.NegativeCacheHits, 1)
|
|
}
|
|
|
|
// IncrementCacheCoalesced increments the coalesced request counter
|
|
func (m *Metrics) IncrementCacheCoalesced() {
|
|
atomic.AddInt64(&m.CacheCoalesced, 1)
|
|
}
|
|
|
|
// IncrementRangeCache increments the Range-from-cache counter (HIT served as 206)
|
|
func (m *Metrics) IncrementRangeCache() {
|
|
atomic.AddInt64(&m.RangeCache, 1)
|
|
}
|
|
|
|
// IncrementRangeUpstream increments the Range-from-upstream counter (full object
|
|
// fetched upstream, requested slice served as 206)
|
|
func (m *Metrics) IncrementRangeUpstream() {
|
|
atomic.AddInt64(&m.RangeUpstream, 1)
|
|
}
|
|
|
|
// IncrementErrors increments the error counter
|
|
func (m *Metrics) IncrementErrors() {
|
|
atomic.AddInt64(&m.Errors, 1)
|
|
}
|
|
|
|
// IncrementRateLimited increments the rate limited counter
|
|
func (m *Metrics) IncrementRateLimited() {
|
|
atomic.AddInt64(&m.RateLimited, 1)
|
|
}
|
|
|
|
// AddResponseTime adds response time to the total
|
|
func (m *Metrics) AddResponseTime(duration time.Duration) {
|
|
atomic.AddInt64(&m.TotalResponseTime, int64(duration))
|
|
}
|
|
|
|
// AddBytesServed adds bytes served to the total
|
|
func (m *Metrics) AddBytesServed(bytes int64) {
|
|
atomic.AddInt64(&m.TotalBytesServed, bytes)
|
|
}
|
|
|
|
// AddBytesSaved records bytes that were served from the cache instead of being
|
|
// fetched again from the upstream (the main value metric for a cache).
|
|
func (m *Metrics) AddBytesSaved(bytes int64) {
|
|
atomic.AddInt64(&m.TotalBytesSaved, bytes)
|
|
}
|
|
|
|
// SetMemoryCacheSize sets the current memory cache size
|
|
func (m *Metrics) SetMemoryCacheSize(size int64) {
|
|
atomic.StoreInt64(&m.MemoryCacheSize, size)
|
|
}
|
|
|
|
// SetDiskCacheSize sets the current disk cache size
|
|
func (m *Metrics) SetDiskCacheSize(size int64) {
|
|
atomic.StoreInt64(&m.DiskCacheSize, size)
|
|
}
|
|
|
|
// SetDiskTierReady sets whether the disk slow tier is attached (1) or still pending (0).
|
|
// Memory-only (no disk) also uses 1 — meaning "not waiting on disk attach". Reset does not clear this.
|
|
func (m *Metrics) SetDiskTierReady(ready int64) {
|
|
atomic.StoreInt64(&m.DiskTierReady, ready)
|
|
}
|
|
|
|
// GetDiskTierReady returns 1 if disk tier is ready (or no disk configured), else 0 while attach pending.
|
|
func (m *Metrics) GetDiskTierReady() int64 {
|
|
return atomic.LoadInt64(&m.DiskTierReady)
|
|
}
|
|
|
|
// IncrementMemoryCacheHits increments memory cache hits
|
|
func (m *Metrics) IncrementMemoryCacheHits() {
|
|
atomic.AddInt64(&m.MemoryCacheHits, 1)
|
|
}
|
|
|
|
// IncrementDiskCacheHits increments disk cache hits
|
|
func (m *Metrics) IncrementDiskCacheHits() {
|
|
atomic.AddInt64(&m.DiskCacheHits, 1)
|
|
}
|
|
|
|
// IncrementServiceRequests increments requests for a specific service
|
|
func (m *Metrics) IncrementServiceRequests(service string) {
|
|
m.serviceMutex.Lock()
|
|
defer m.serviceMutex.Unlock()
|
|
m.ServiceRequests[service]++
|
|
}
|
|
|
|
// GetServiceRequests returns the number of requests for a service
|
|
func (m *Metrics) GetServiceRequests(service string) int64 {
|
|
m.serviceMutex.RLock()
|
|
defer m.serviceMutex.RUnlock()
|
|
return m.ServiceRequests[service]
|
|
}
|
|
|
|
func (m *Metrics) IncrementPromotions() { atomic.AddInt64(&m.Promotions, 1) }
|
|
func (m *Metrics) IncrementEvictions() { atomic.AddInt64(&m.Evictions, 1) }
|
|
func (m *Metrics) IncrementCapacityPressureEvents() { atomic.AddInt64(&m.CapacityPressureEvents, 1) }
|
|
|
|
// NoteSoftEviction records one cap-pressure eviction batch that freed bytes.
|
|
// Keeps the existing evictions counter and also increments capacity_pressure_events.
|
|
// Nil m is safe: the log still fires so ops can grep without metrics wired.
|
|
func NoteSoftEviction(m *Metrics, tier string, evicted uint) {
|
|
if evicted == 0 {
|
|
return
|
|
}
|
|
if m != nil {
|
|
m.IncrementEvictions()
|
|
m.IncrementCapacityPressureEvents()
|
|
}
|
|
logger.Logger.Info().
|
|
Str("tier", tier).
|
|
Str("reason", "eviction").
|
|
Uint("bytes_evicted", evicted).
|
|
Msg("cache capacity pressure")
|
|
}
|
|
|
|
// NoteNoSpace records a disk Create/Write/Mkdir ENOSPC (or equivalent) event.
|
|
func NoteNoSpace(m *Metrics, err error) {
|
|
if m != nil {
|
|
m.IncrementCapacityPressureEvents()
|
|
}
|
|
logger.Logger.Warn().
|
|
Str("tier", "disk").
|
|
Str("reason", "enospc").
|
|
Err(err).
|
|
Msg("cache capacity pressure")
|
|
}
|
|
|
|
// Additional observability counters
|
|
func (m *Metrics) IncrementUpstreamErrors() { atomic.AddInt64(&m.UpstreamErrors, 1) }
|
|
func (m *Metrics) IncrementCacheWriteFailures() { atomic.AddInt64(&m.CacheWriteFailures, 1) }
|
|
func (m *Metrics) IncrementServiceError(service string) {
|
|
m.serviceErrorsMutex.Lock()
|
|
defer m.serviceErrorsMutex.Unlock()
|
|
if m.ServiceErrors == nil {
|
|
m.ServiceErrors = make(map[string]int64)
|
|
}
|
|
m.ServiceErrors[service]++
|
|
}
|
|
|
|
// GetStats returns a snapshot of current metrics
|
|
func (m *Metrics) GetStats() *Stats {
|
|
totalRequests := atomic.LoadInt64(&m.TotalRequests)
|
|
cacheHits := atomic.LoadInt64(&m.CacheHits)
|
|
cacheMisses := atomic.LoadInt64(&m.CacheMisses)
|
|
|
|
var hitRate float64
|
|
if totalRequests > 0 {
|
|
hitRate = float64(cacheHits) / float64(totalRequests)
|
|
}
|
|
|
|
var avgResponseTime time.Duration
|
|
if totalRequests > 0 {
|
|
avgResponseTime = time.Duration(atomic.LoadInt64(&m.TotalResponseTime) / totalRequests)
|
|
}
|
|
|
|
m.serviceMutex.RLock()
|
|
serviceRequests := make(map[string]int64)
|
|
for k, v := range m.ServiceRequests {
|
|
serviceRequests[k] = v
|
|
}
|
|
m.serviceMutex.RUnlock()
|
|
|
|
serviceErrors := make(map[string]int64)
|
|
m.serviceErrorsMutex.RLock()
|
|
defer m.serviceErrorsMutex.RUnlock()
|
|
for k, v := range m.ServiceErrors {
|
|
serviceErrors[k] = v
|
|
}
|
|
|
|
return &Stats{
|
|
TotalRequests: totalRequests,
|
|
CacheHits: cacheHits,
|
|
CacheMisses: cacheMisses,
|
|
CacheCoalesced: atomic.LoadInt64(&m.CacheCoalesced),
|
|
NegativeCacheHits: atomic.LoadInt64(&m.NegativeCacheHits),
|
|
RangeCache: atomic.LoadInt64(&m.RangeCache),
|
|
RangeUpstream: atomic.LoadInt64(&m.RangeUpstream),
|
|
Errors: atomic.LoadInt64(&m.Errors),
|
|
RateLimited: atomic.LoadInt64(&m.RateLimited),
|
|
HitRate: hitRate,
|
|
AvgResponseTime: avgResponseTime,
|
|
TotalBytesServed: atomic.LoadInt64(&m.TotalBytesServed),
|
|
TotalBytesSaved: atomic.LoadInt64(&m.TotalBytesSaved),
|
|
MemoryCacheSize: atomic.LoadInt64(&m.MemoryCacheSize),
|
|
DiskCacheSize: atomic.LoadInt64(&m.DiskCacheSize),
|
|
DiskTierReady: atomic.LoadInt64(&m.DiskTierReady),
|
|
MemoryCacheHits: atomic.LoadInt64(&m.MemoryCacheHits),
|
|
DiskCacheHits: atomic.LoadInt64(&m.DiskCacheHits),
|
|
Promotions: atomic.LoadInt64(&m.Promotions),
|
|
Evictions: atomic.LoadInt64(&m.Evictions),
|
|
CapacityPressureEvents: atomic.LoadInt64(&m.CapacityPressureEvents),
|
|
ServiceRequests: serviceRequests,
|
|
UpstreamErrors: atomic.LoadInt64(&m.UpstreamErrors),
|
|
CacheWriteFailures: atomic.LoadInt64(&m.CacheWriteFailures),
|
|
ServiceErrors: serviceErrors,
|
|
Uptime: time.Since(m.StartTime),
|
|
LastResetTime: m.LastResetTime,
|
|
}
|
|
}
|
|
|
|
// Reset resets all metrics to zero
|
|
func (m *Metrics) Reset() {
|
|
atomic.StoreInt64(&m.TotalRequests, 0)
|
|
atomic.StoreInt64(&m.CacheHits, 0)
|
|
atomic.StoreInt64(&m.CacheMisses, 0)
|
|
atomic.StoreInt64(&m.CacheCoalesced, 0)
|
|
atomic.StoreInt64(&m.NegativeCacheHits, 0)
|
|
atomic.StoreInt64(&m.RangeCache, 0)
|
|
atomic.StoreInt64(&m.RangeUpstream, 0)
|
|
atomic.StoreInt64(&m.Errors, 0)
|
|
atomic.StoreInt64(&m.RateLimited, 0)
|
|
atomic.StoreInt64(&m.TotalResponseTime, 0)
|
|
atomic.StoreInt64(&m.TotalBytesServed, 0)
|
|
atomic.StoreInt64(&m.TotalBytesSaved, 0)
|
|
atomic.StoreInt64(&m.MemoryCacheHits, 0)
|
|
atomic.StoreInt64(&m.DiskCacheHits, 0)
|
|
atomic.StoreInt64(&m.Promotions, 0)
|
|
atomic.StoreInt64(&m.Evictions, 0)
|
|
atomic.StoreInt64(&m.CapacityPressureEvents, 0)
|
|
atomic.StoreInt64(&m.UpstreamErrors, 0)
|
|
atomic.StoreInt64(&m.CacheWriteFailures, 0)
|
|
|
|
m.serviceMutex.Lock()
|
|
m.ServiceRequests = make(map[string]int64)
|
|
m.serviceMutex.Unlock()
|
|
|
|
m.serviceErrorsMutex.Lock()
|
|
defer m.serviceErrorsMutex.Unlock()
|
|
m.ServiceErrors = make(map[string]int64)
|
|
|
|
m.LastResetTime = time.Now()
|
|
}
|
|
|
|
// Stats represents a snapshot of metrics
|
|
type Stats struct {
|
|
TotalRequests int64
|
|
CacheHits int64
|
|
CacheMisses int64
|
|
CacheCoalesced int64
|
|
NegativeCacheHits int64
|
|
RangeCache int64
|
|
RangeUpstream int64
|
|
Errors int64
|
|
RateLimited int64
|
|
HitRate float64
|
|
AvgResponseTime time.Duration
|
|
TotalBytesServed int64
|
|
TotalBytesSaved int64
|
|
MemoryCacheSize int64
|
|
|
|
DiskCacheSize int64
|
|
DiskTierReady int64
|
|
MemoryCacheHits int64
|
|
DiskCacheHits int64
|
|
Promotions int64
|
|
Evictions int64
|
|
CapacityPressureEvents int64
|
|
UpstreamErrors int64
|
|
CacheWriteFailures int64
|
|
ServiceErrors map[string]int64
|
|
ServiceRequests map[string]int64
|
|
Uptime time.Duration
|
|
LastResetTime time.Time
|
|
}
|
|
|
|
// WriteText emits the Prometheus-style text metrics to the ResponseWriter.
|
|
// Promoted from internal handler per Phase 3 for better package ownership.
|
|
// All fmt.Fprintf errors are intentionally discarded via _ = : this is a best-effort
|
|
// read-only debug endpoint; client disconnects or write errors during metrics dump
|
|
// are not actionable (do not affect cache correctness or require retries).
|
|
func WriteText(w http.ResponseWriter, stats *Stats) {
|
|
_, _ = fmt.Fprintf(w, "# SteamCache2 Metrics\n")
|
|
_, _ = fmt.Fprintf(w, "total_requests %d\n", stats.TotalRequests)
|
|
_, _ = fmt.Fprintf(w, "cache_hits %d\n", stats.CacheHits)
|
|
_, _ = fmt.Fprintf(w, "cache_misses %d\n", stats.CacheMisses)
|
|
_, _ = fmt.Fprintf(w, "negative_cache_hits %d\n", stats.NegativeCacheHits)
|
|
_, _ = fmt.Fprintf(w, "cache_coalesced %d\n", stats.CacheCoalesced)
|
|
_, _ = fmt.Fprintf(w, "range_cache %d\n", stats.RangeCache)
|
|
_, _ = fmt.Fprintf(w, "range_upstream %d\n", stats.RangeUpstream)
|
|
_, _ = fmt.Fprintf(w, "errors %d\n", stats.Errors)
|
|
_, _ = fmt.Fprintf(w, "rate_limited %d\n", stats.RateLimited)
|
|
_, _ = fmt.Fprintf(w, "upstream_errors %d\n", stats.UpstreamErrors)
|
|
_, _ = fmt.Fprintf(w, "cache_write_failures %d\n", stats.CacheWriteFailures)
|
|
_, _ = fmt.Fprintf(w, "memory_cache_hits %d\n", stats.MemoryCacheHits)
|
|
_, _ = fmt.Fprintf(w, "disk_cache_hits %d\n", stats.DiskCacheHits)
|
|
_, _ = fmt.Fprintf(w, "promotions %d\n", stats.Promotions)
|
|
_, _ = fmt.Fprintf(w, "evictions %d\n", stats.Evictions)
|
|
_, _ = fmt.Fprintf(w, "capacity_pressure_events %d\n", stats.CapacityPressureEvents)
|
|
for svc, cnt := range stats.ServiceErrors {
|
|
_, _ = fmt.Fprintf(w, "service_errors{service=%q} %d\n", svc, cnt)
|
|
}
|
|
for svc, cnt := range stats.ServiceRequests {
|
|
_, _ = fmt.Fprintf(w, "service_requests{service=%q} %d\n", svc, cnt)
|
|
}
|
|
_, _ = fmt.Fprintf(w, "hit_rate %.4f\n", stats.HitRate)
|
|
_, _ = fmt.Fprintf(w, "avg_response_time_ms %.2f\n", float64(stats.AvgResponseTime.Nanoseconds())/1e6)
|
|
_, _ = fmt.Fprintf(w, "total_bytes_served %d\n", stats.TotalBytesServed)
|
|
_, _ = fmt.Fprintf(w, "total_bytes_saved %d\n", stats.TotalBytesSaved)
|
|
|
|
_, _ = fmt.Fprintf(w, "memory_cache_size %d\n", stats.MemoryCacheSize)
|
|
_, _ = fmt.Fprintf(w, "disk_cache_size %d\n", stats.DiskCacheSize)
|
|
_, _ = fmt.Fprintf(w, "disk_tier_ready %d\n", stats.DiskTierReady)
|
|
_, _ = fmt.Fprintf(w, "uptime_seconds %.2f\n", stats.Uptime.Seconds())
|
|
}
|