package metrics import ( "sync" "time" ) // Metrics holds all observability metrics for the trip planner service. type Metrics struct { // Cache metrics per layer CacheHits map[string]int64 // per-layer hit counts (city, station, search) CacheMisses map[string]int64 // per-layer miss counts // API quota remaining (per key or global) APIQuotaRemaining int64 // Circuit breaker metrics CircuitBreakerTrips int64 // total circuit breaker trips (opened) // Search metrics SearchCount int64 // total number of searches SearchDuration *histogram // distribution of search durations // Internal counters mu sync.Mutex layerTTLs map[string]time.Duration } // histogram tracks duration values and computes simple stats. type histogram struct { mu sync.Mutex values []int64 // nanoseconds maxValues int } // New creates a new Metrics instance with initialized maps. func New() *Metrics { return &Metrics{ CacheHits: make(map[string]int64), CacheMisses: make(map[string]int64), layerTTLs: make(map[string]time.Duration), SearchDuration: &histogram{maxValues: 1000}, } } // RecordCacheHit records a cache hit for the given layer. func (m *Metrics) RecordCacheHit(layer string) { m.mu.Lock() defer m.mu.Unlock() m.CacheHits[layer]++ } // RecordCacheMiss records a cache miss for the given layer. func (m *Metrics) RecordCacheMiss(layer string) { m.mu.Lock() defer m.mu.Unlock() m.CacheMisses[layer]++ } // RecordAPIQuota records the remaining API quota. func (m *Metrics) RecordAPIQuota(remaining int64) { m.mu.Lock() defer m.mu.Unlock() m.APIQuotaRemaining = remaining } // RecordCircuitBreakerTrip records a circuit breaker trip. func (m *Metrics) RecordCircuitBreakerTrip() { m.mu.Lock() defer m.mu.Unlock() m.CircuitBreakerTrips++ } // RecordSearch records a completed search with its duration in nanoseconds. func (m *Metrics) RecordSearch(durationNS int64) { m.mu.Lock() defer m.mu.Unlock() m.SearchCount++ m.SearchDuration.values = append(m.SearchDuration.values, durationNS) // Trim if exceeding max if len(m.SearchDuration.values) > m.SearchDuration.maxValues { m.SearchDuration.values = m.SearchDuration.values[len(m.SearchDuration.values)-m.SearchDuration.maxValues:] } } // GetCacheHitRate returns the hit rate (hits / (hits + misses)) for a layer. func (m *Metrics) GetCacheHitRate(layer string) float64 { m.mu.Lock() defer m.mu.Unlock() hits := m.CacheHits[layer] misses := m.CacheMisses[layer] total := hits + misses if total == 0 { return 0 } return float64(hits) / float64(total) } // GetMetricsJSON returns all metrics as a JSON-friendly map. func (m *Metrics) GetMetricsJSON() map[string]interface{} { m.mu.Lock() defer m.mu.Unlock() avgSearchDuration := 0.0 if m.SearchCount > 0 && len(m.SearchDuration.values) > 0 { var total int64 for _, v := range m.SearchDuration.values { total += v } avgSearchDuration = float64(total) / float64(len(m.SearchDuration.values)) / 1e6 // convert to milliseconds } result := map[string]interface{}{ "cache_hits": m.CacheHits, "cache_misses": m.CacheMisses, "cache_hit_rate": m.getOverallHitRate(), "api_quota_remaining": m.APIQuotaRemaining, "circuit_breaker_trips": m.CircuitBreakerTrips, "search_count": m.SearchCount, "avg_search_duration_ms": avgSearchDuration, "layer_ttls": m.layerTTLs, } return result } // getOverallHitRate calculates overall hit rate across all layers. func (m *Metrics) getOverallHitRate() float64 { var totalHits, totalMisses int64 for _, hits := range m.CacheHits { totalHits += hits } for _, misses := range m.CacheMisses { totalMisses += misses } total := totalHits + totalMisses if total == 0 { return 0 } return float64(totalHits) / float64(total) } // SetLayerTTL sets the TTL for a cache layer (for documentation/observability). func (m *Metrics) SetLayerTTL(layer string, ttl time.Duration) { m.mu.Lock() defer m.mu.Unlock() m.layerTTLs[layer] = ttl } // GetLayerTTL returns the TTL for a cache layer. func (m *Metrics) GetLayerTTL(layer string) (time.Duration, bool) { m.mu.Lock() defer m.mu.Unlock() ttl, ok := m.layerTTLs[layer] return ttl, ok }