Files
trip-planner/internal/metrics/metrics.go

155 lines
4.1 KiB
Go

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 {
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
}