feat: implement observability and metrics (Task 22)
- Add metrics package tracking cache hit-rate, API quota, circuit breaker trips, search time - Integrate metrics with cache layer, yandex client, and API handlers - Add /metrics HTTP endpoint for Prometheus-compatible metrics exposure - Write tests for metrics functionality across cache, yandex, and API handlers - Update test files to support new metrics infrastructure
This commit is contained in:
155
internal/metrics/metrics.go
Normal file
155
internal/metrics/metrics.go
Normal file
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user