From 88b4f4981593e4bc6ddbc12e9857776b610e52a8 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 14:00:04 +0000 Subject: [PATCH] Test the eviction, source cache, upstream and transform metrics The tests check the metrics on a variant hit, on misses that fetch or use the cached source, on a failed fetch, and on both kinds of eviction. They fail until the metrics exist. Model: opus-5-5 --- internal/imageproxy/metrics_internal_test.go | 112 +++++++++++++++ internal/imgcache/metrics_internal_test.go | 137 +++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 internal/imageproxy/metrics_internal_test.go create mode 100644 internal/imgcache/metrics_internal_test.go diff --git a/internal/imageproxy/metrics_internal_test.go b/internal/imageproxy/metrics_internal_test.go new file mode 100644 index 0000000..8f413e3 --- /dev/null +++ b/internal/imageproxy/metrics_internal_test.go @@ -0,0 +1,112 @@ +package imageproxy + +import ( + "testing" + + "sneak.berlin/go/pixa/internal/imagerequest" +) + +// metricValues holds the metrics of a proxy and its cache that +// TestProxy_Get_CountsSourceCacheFetchesAndTransforms checks. A histogram +// is given by its count of observations. +type metricValues struct { + sourceCacheHits uint64 + sourceCacheMisses uint64 + fetchDurations uint64 + fetchErrors uint64 + transformDuration uint64 + inProgress float64 +} + +// readMetricValues reads the metrics of svc and its cache that +// metricValues holds from their metrics registry. +func readMetricValues(t *testing.T, svc *testProxy) metricValues { + t.Helper() + + families, err := svc.registry.Gather() + if err != nil { + t.Fatalf("failed to gather the metrics: %v", err) + } + + var values metricValues + + for _, family := range families { + metric := family.GetMetric()[0] + + switch family.GetName() { + case "pixa_source_cache_hits_total": + values.sourceCacheHits = uint64(metric.GetCounter().GetValue()) + case "pixa_source_cache_misses_total": + values.sourceCacheMisses = uint64(metric.GetCounter().GetValue()) + case "pixa_upstream_fetch_duration_seconds": + values.fetchDurations = metric.GetHistogram().GetSampleCount() + case "pixa_upstream_fetch_errors_total": + values.fetchErrors = uint64(metric.GetCounter().GetValue()) + case "pixa_transform_duration_seconds": + values.transformDuration = metric.GetHistogram().GetSampleCount() + case "pixa_transforms_in_progress": + values.inProgress = metric.GetGauge().GetValue() + } + } + + return values +} + +// TestProxy_Get_CountsSourceCacheFetchesAndTransforms walks Get through a +// miss that fetches the source, a hit, a miss that uses the cached source +// and a miss whose source is not found upstream, checking the source cache, +// upstream fetch and transform metrics after each. +func TestProxy_Get_CountsSourceCacheFetchesAndTransforms(t *testing.T) { + t.Parallel() + + svc, fixtures := setupTestProxy(t) + + // want is source cache hits, source cache misses, fetch durations + // observed, fetch errors, transform durations observed, transforms in + // progress. + steps := []struct { + name string + path string + size int + wantErr bool + want metricValues + }{ + {"miss that fetches the source", testPathPhoto, 50, false, + metricValues{0, 1, 1, 0, 1, 0}}, + {"hit", testPathPhoto, 50, false, + metricValues{0, 1, 1, 0, 1, 0}}, + {"miss that uses the cached source", testPathPhoto, 25, false, + metricValues{1, 1, 1, 0, 2, 0}}, + {"miss whose source is not found", "/images/nonexistent.jpg", 50, true, + metricValues{1, 2, 1, 1, 2, 0}}, + } + + for _, step := range steps { + resp, err := svc.Get(t.Context(), &imagerequest.Request{ + Source: imagerequest.Source{ + Host: fixtures.GoodHost, + Path: step.path, + }, + Transform: imagerequest.Transform{ + Size: imagerequest.Size{Width: step.size, Height: step.size}, + Format: imagerequest.FormatJPEG, + Quality: 85, + FitMode: imagerequest.FitCover, + }, + }) + if (err != nil) != step.wantErr { + t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr) + } + + if err == nil { + _ = resp.Content.Close() + } + + got := readMetricValues(t, svc) + t.Logf("after the %s: metrics = %+v", step.name, got) + + if got != step.want { + t.Fatalf("after the %s: metrics = %+v, want %+v", step.name, got, step.want) + } + } +} diff --git a/internal/imgcache/metrics_internal_test.go b/internal/imgcache/metrics_internal_test.go new file mode 100644 index 0000000..461e047 --- /dev/null +++ b/internal/imgcache/metrics_internal_test.go @@ -0,0 +1,137 @@ +package imgcache + +import ( + "bytes" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "sneak.berlin/go/pixa/internal/imagerequest" +) + +// gatheredValue returns the value of the metric name on registry: a +// counter's or a gauge's value. +func gatheredValue(t *testing.T, registry *prometheus.Registry, name string) float64 { + t.Helper() + + families, err := registry.Gather() + if err != nil { + t.Fatalf("failed to gather the metrics: %v", err) + } + + for _, family := range families { + if family.GetName() != name { + continue + } + + metric := family.GetMetric()[0] + if metric.GetGauge() != nil { + return metric.GetGauge().GetValue() + } + + return metric.GetCounter().GetValue() + } + + t.Fatalf("the metric %s is not on the registry", name) + + return 0 +} + +// TestEvictToLimit_CountsEvictionsAndUsage stores four variants of 1000 +// bytes in a cache limited to 3000 bytes. EvictToLimit must count the one +// eviction and its bytes, and set the usage and limit gauges. +func TestEvictToLimit_CountsEvictionsAndUsage(t *testing.T) { + t.Parallel() + + const limit = 3000 + + registry := prometheus.NewRegistry() + + cache, err := NewCache(evictionTestDB(t), + Config{StateDir: t.TempDir(), MaxBytes: limit}, registry) + if err != nil { + t.Fatalf("NewCache() error = %v", err) + } + + keys := []imagerequest.VariantKey{ + testVariantKeyOne, testVariantKeyTwo, + testVariantKeyThree, testVariantKeyFour, + } + + for i, key := range keys { + storeEvictionTestVariant(t, cache, key, bytes.Repeat([]byte{0x01}, 1000)) + setVariantLastAccessed(t, cache, key, + time.Now().Add(-time.Duration(len(keys)-i)*time.Hour)) + } + + err = cache.EvictToLimit(t.Context()) + if err != nil { + t.Fatalf("EvictToLimit() error = %v", err) + } + + want := map[string]float64{ + "pixa_cache_evictions_total": 1, + "pixa_cache_evicted_bytes_total": 1000, + "pixa_cache_usage_bytes": 3000, + "pixa_cache_max_bytes": limit, + } + + for name, value := range want { + got := gatheredValue(t, registry, name) + t.Logf("%s = %v", name, got) + + if got != value { + t.Errorf("%s = %v, want %v", name, got, value) + } + } +} + +// TestEvictNotCheckedRecently_CountsEvictions stores a source of 6 bytes and +// a variant of 7 bytes last checked with upstream 31 days ago. +// EvictNotCheckedRecently must count both evictions and their 13 bytes. +func TestEvictNotCheckedRecently_CountsEvictions(t *testing.T) { + t.Parallel() + + registry := prometheus.NewRegistry() + + cache, err := NewCache(evictionTestDB(t), + Config{StateDir: t.TempDir(), MaxBytes: 1 << 30}, registry) + if err != nil { + t.Fatalf("NewCache() error = %v", err) + } + + old := time.Now().Add(-31 * 24 * time.Hour) + + _, err = cache.PutSource(t.Context(), + imagerequest.Source{Host: testHostCDN, Path: "/old.jpg"}, + bytes.NewReader([]byte("source")), + SourceInfo{FetchedAt: old, CheckedAt: old}) + if err != nil { + t.Fatalf("PutSource() error = %v", err) + } + + err = cache.PutVariant(t.Context(), testVariantKeyOne, + SourceInfo{CheckedAt: old}, bytes.NewReader([]byte("variant"))) + if err != nil { + t.Fatalf("PutVariant() error = %v", err) + } + + err = cache.EvictNotCheckedRecently(t.Context()) + if err != nil { + t.Fatalf("EvictNotCheckedRecently() error = %v", err) + } + + want := map[string]float64{ + "pixa_cache_evictions_total": 2, + "pixa_cache_evicted_bytes_total": 13, + } + + for name, value := range want { + got := gatheredValue(t, registry, name) + t.Logf("%s = %v", name, got) + + if got != value { + t.Errorf("%s = %v, want %v", name, got, value) + } + } +}