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:
2026-08-17 15:27:44 +03:00
parent 5842667fff
commit c58fbb50b1
10 changed files with 285 additions and 55 deletions

View File

@@ -8,6 +8,8 @@ import (
"time"
"github.com/go-redis/redis/v8"
"trip-planner/internal/metrics"
)
// CacheKey defines the structure for cache keys used throughout the application.
@@ -39,22 +41,25 @@ type Cache interface {
// redisClient is a wrapper around go-redis client for dependency injection.
type redisClient struct {
client *redis.Client
metrics *metrics.Metrics
}
// NewRedisClient creates a new Redis client wrapper.
func NewRedisClient(client *redis.Client) *redisClient {
return &redisClient{client: client}
func NewRedisClient(client *redis.Client, m *metrics.Metrics) *redisClient {
return &redisClient{client: client, metrics: m}
}
// Get retrieves a value from cache by key.
func (r *redisClient) Get(ctx context.Context, key *CacheKey) ([]byte, error) {
val, err := r.client.Get(ctx, keyString(key)).Bytes()
if errors.Is(err, redis.Nil) {
r.metrics.RecordCacheMiss("cache") // record cache miss at redis client level
return nil, nil // cache miss
}
if err != nil {
return nil, fmt.Errorf("cache get: %w", err)
}
r.metrics.RecordCacheHit("cache") // record cache hit at redis client level
return val, nil
}
@@ -125,9 +130,9 @@ type cacheStore struct {
}
// NewCacheStore creates a new cache store with the given Redis client.
func NewCacheStore(client *redis.Client) Cache {
func NewCacheStore(client *redis.Client, m *metrics.Metrics) Cache {
return &cacheStore{
redisClient: NewRedisClient(client),
redisClient: NewRedisClient(client, m),
}
}
@@ -161,12 +166,13 @@ func GetSearchKey(from, to, date string) *CacheKey {
// CacheAside represents the cache-aside pattern implementation.
// It follows the pattern: Redis → miss → Postgres/API → write-back to Redis.
type CacheAside struct {
store Cache
store Cache
metrics *metrics.Metrics
}
// NewCacheAside creates a new CacheAside instance.
func NewCacheAside(store Cache) *CacheAside {
return &CacheAside{store: store}
func NewCacheAside(store Cache, m *metrics.Metrics) *CacheAside {
return &CacheAside{store: store, metrics: m}
}
// GetOrSetFuncPattern is a generic pattern for cache-aside operations.
@@ -204,6 +210,7 @@ func (c *CacheAside) GetStation(ctx context.Context, key *CacheKey, fetch func()
// GetSearch retrieves search results from cache, falling back to the provided fetch function.
// Uses appropriate TTL based on whether the date is near-term or far-term.
// Records cache hit/miss metrics.
func (c *CacheAside) GetSearch(ctx context.Context, key *CacheKey, fetch func() ([]byte, error), isFarTerm bool) ([]byte, error) {
var ttl time.Duration
if isFarTerm {
@@ -211,7 +218,33 @@ func (c *CacheAside) GetSearch(ctx context.Context, key *CacheKey, fetch func()
} else {
ttl = SearchNearTermTTL
}
return c.GetOrSetFuncPattern(ctx, key, fetch, ttl)
// Try cache first
data, err := c.store.Get(ctx, key)
if err == nil && data != nil {
c.metrics.RecordCacheHit("search") // cache hit
return data, nil
}
// Cache miss: fetch from backend
if errors.Is(err, redis.Nil) {
c.metrics.RecordCacheMiss("search") // record search cache miss
} else if err != nil {
return nil, err
}
// Fetch from backend
data, err = fetch()
if err != nil {
return nil, err
}
// Write back to cache
if err := c.store.Set(ctx, key, data, ttl); err != nil {
return nil, err
}
return data, nil
}
// InvalidateCity removes a city entry from cache.
@@ -238,11 +271,13 @@ func (c *CacheAside) Delete(ctx context.Context, key *CacheKey) error {
func (c *CacheAside) Get(ctx context.Context, key *CacheKey) ([]byte, error) {
val, err := c.store.Get(ctx, key)
if errors.Is(err, redis.Nil) {
c.metrics.RecordCacheMiss("cache_aside") // record cache aside miss
return nil, nil // cache miss
}
if err != nil {
return nil, fmt.Errorf("cache get: %w", err)
}
c.metrics.RecordCacheHit("cache_aside") // record cache aside hit
return val, nil
}
@@ -271,4 +306,4 @@ func (c *CacheAside) Increment(ctx context.Context, key *CacheKey) (int64, error
// Decrement decrements a counter key.
func (c *CacheAside) Decrement(ctx context.Context, key *CacheKey) (int64, error) {
return c.store.Decrement(ctx, key)
}
}

View File

@@ -5,6 +5,8 @@ import (
"testing"
"github.com/go-redis/redis/v8"
"trip-planner/internal/metrics"
)
// TestCacheGetSet tests basic Get and Set operations.
@@ -12,7 +14,7 @@ func TestCacheGetSet(t *testing.T) {
ctx := context.Background()
client := NewRedisClient(redis.NewClient(&redis.Options{
Addr: "localhost:6379",
}))
}), metrics.New())
// Test Set
key := &CacheKey{Kind: "city", Code: "c146"}
@@ -81,7 +83,7 @@ func TestCacheAsideGetOrSet(t *testing.T) {
ctx := context.Background()
client := NewRedisClient(redis.NewClient(&redis.Options{
Addr: "localhost:6379",
}))
}), metrics.New())
fetchCallCount := 0
fetchFunc := func() ([]byte, error) {
@@ -91,7 +93,7 @@ func TestCacheAsideGetOrSet(t *testing.T) {
// First call: cache miss, should fetch from backend
key := &CacheKey{Kind: "station", Code: "s9600213"}
data, err := NewCacheAside(client).GetOrSetFuncPattern(ctx, key, fetchFunc, CityTTL)
data, err := NewCacheAside(client, metrics.New()).GetOrSetFuncPattern(ctx, key, fetchFunc, CityTTL)
if err != nil {
t.Fatalf("expected no error on cache miss, got: %v", err)
}
@@ -104,7 +106,7 @@ func TestCacheAsideGetOrSet(t *testing.T) {
// Second call: cache hit, should not fetch from backend
fetchCallCount = 0
data, err = NewCacheAside(client).GetOrSetFuncPattern(ctx, key, fetchFunc, CityTTL)
data, err = NewCacheAside(client, metrics.New()).GetOrSetFuncPattern(ctx, key, fetchFunc, CityTTL)
if err != nil {
t.Fatalf("expected no error on cache hit, got: %v", err)
}
@@ -121,7 +123,7 @@ func TestCacheAsideGetCity(t *testing.T) {
ctx := context.Background()
client := NewRedisClient(redis.NewClient(&redis.Options{
Addr: "localhost:6379",
}))
}), metrics.New())
fetchCallCount := 0
fetchFunc := func() ([]byte, error) {
@@ -130,7 +132,7 @@ func TestCacheAsideGetCity(t *testing.T) {
}
key := &CacheKey{Kind: "city", Code: "c146"}
data, err := NewCacheAside(client).GetCity(ctx, key, fetchFunc)
data, err := NewCacheAside(client, metrics.New()).GetCity(ctx, key, fetchFunc)
if err != nil {
t.Fatalf("expected no error on cache miss for city, got: %v", err)
}
@@ -143,10 +145,13 @@ func TestCacheAsideGetCity(t *testing.T) {
// Second call: cache hit
fetchCallCount = 0
data, err = NewCacheAside(client).GetCity(ctx, key, fetchFunc)
data, err = NewCacheAside(client, metrics.New()).GetCity(ctx, key, fetchFunc)
if err != nil {
t.Fatalf("expected no error on cache hit for city, got: %v", err)
}
if string(data) != `{"code":"c146","title":"Simferopol"}` {
t.Errorf("expected %s, got %s", `{"code":"c146","title":"Simferopol"}`, string(data))
}
if fetchCallCount != 0 {
t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount)
}
@@ -157,7 +162,7 @@ func TestCacheAsideGetSearch(t *testing.T) {
ctx := context.Background()
client := NewRedisClient(redis.NewClient(&redis.Options{
Addr: "localhost:6379",
}))
}), metrics.New())
fetchNearTerm := func() ([]byte, error) {
return []byte(`{"near_term":true}`), nil
@@ -172,7 +177,7 @@ func TestCacheAsideGetSearch(t *testing.T) {
farKey := &CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-09-15"}
// Near-term: should use SearchNearTermTTL (3 hours)
data, err := NewCacheAside(client).GetSearch(ctx, nearKey, fetchNearTerm, false)
data, err := NewCacheAside(client, metrics.New()).GetSearch(ctx, nearKey, fetchNearTerm, false)
if err != nil {
t.Fatalf("expected no error on near-term search cache miss, got: %v", err)
}
@@ -181,7 +186,7 @@ func TestCacheAsideGetSearch(t *testing.T) {
}
// Far-term: should use SearchFarTermTTL (7 days)
data, err = NewCacheAside(client).GetSearch(ctx, farKey, fetchFarTerm, true)
data, err = NewCacheAside(client, metrics.New()).GetSearch(ctx, farKey, fetchFarTerm, true)
if err != nil {
t.Fatalf("expected no error on far-term search cache miss, got: %v", err)
}
@@ -195,7 +200,7 @@ func TestCacheInvalidate(t *testing.T) {
ctx := context.Background()
client := NewRedisClient(redis.NewClient(&redis.Options{
Addr: "localhost:6379",
}))
}), metrics.New())
// Set up some keys
cityKey := &CacheKey{Kind: "city", Code: "c146"}
@@ -208,20 +213,20 @@ func TestCacheInvalidate(t *testing.T) {
client.Set(ctx, searchKey, []byte(`{"search":true}`), SearchNearTermTTL)
// Invalidate city
err := NewCacheAside(client).InvalidateCity(ctx, cityKey)
err := NewCacheAside(client, metrics.New()).InvalidateCity(ctx, cityKey)
if err != nil {
t.Fatalf("expected no error invalidating city, got: %v", err)
}
// Invalidate station
err = NewCacheAside(client).InvalidateStation(ctx, stationKey)
err = NewCacheAside(client, metrics.New()).InvalidateStation(ctx, stationKey)
if err != nil {
t.Fatalf("expected no error invalidating station, got: %v", err)
}
// Invalidate search
err = NewCacheAside(client).InvalidateSearch(ctx, searchKey)
err = NewCacheAside(client, metrics.New()).InvalidateSearch(ctx, searchKey)
if err != nil {
t.Fatalf("expected no error invalidating search, got: %v", err)
}
}
}