From 88b837558d44dbbb9a9b243f7a60af74fc8f6a13 Mon Sep 17 00:00:00 2001 From: Derek Frank Date: Tue, 4 Aug 2026 19:22:12 +0000 Subject: [PATCH] feat: add instrumented go-cache wrapper with Prometheus metrics Add operatorpkg/cache, a near drop-in wrapper around patrickmn/go-cache that emits Prometheus metrics under the operator_cache_* subsystem: - gets_total{name,result=hit|miss} - adds_total{name,result=added|exists} (exists = suppressed duplicate) - evictions_total{name} (TTL expiry) - deletes_total{name,type=explicit|flush} - flushes_total{name} - flush_size{name} (histogram, entries per flush) - entries{name} (gauge) The wrapper embeds *cache.Cache so every underlying method still works; only Get/Set/SetDefault/Add/Delete/Flush are overridden. Counter/gauge series are pre-initialized to 0 to avoid sparse series. The entries gauge is updated on mutation (never at scrape time) so scrapes do no cache work. A caller-supplied OnEvicted callback is chained, not replaced. --- cache/cache.go | 162 ++++++++++++++++++++++++++++++++++++++++++++ cache/metrics.go | 102 ++++++++++++++++++++++++++++ cache/suite_test.go | 126 ++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 5 files changed, 393 insertions(+) create mode 100644 cache/cache.go create mode 100644 cache/metrics.go create mode 100644 cache/suite_test.go diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..92ba48d --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,162 @@ +// Package cache wraps github.com/patrickmn/go-cache with Prometheus metrics. +// +// The wrapped Cache embeds *cache.Cache, so it is a near drop-in replacement: +// every method of the underlying cache is still available, and only the +// operations that carry an observable signal (Get, Set, SetDefault, Add, +// Delete, Flush) are overridden to record metrics. Construct one with New, +// passing a stable, low-cardinality name used as the "name" metric label. +package cache + +import ( + "time" + + "github.com/patrickmn/go-cache" +) + +// Item re-exports cache.Item so callers can migrate to this package without +// also importing go-cache directly (e.g. for the map returned by Items()). +type Item = cache.Item + +// Re-export go-cache's sentinel expiration durations so callers don't need to +// import go-cache directly to construct a cache. +const ( + NoExpiration = cache.NoExpiration + DefaultExpiration = cache.DefaultExpiration +) + +// Cache is an instrumented wrapper around *cache.Cache. The zero value is not +// usable; construct with New. +type Cache struct { + *cache.Cache + + name string + + // userOnEvicted is a caller-registered eviction callback (e.g. the ICE cache + // bumps sequence numbers here). We invoke it from our own handler so wrapping + // a cache never steals the caller's OnEvicted hook. Like go-cache's own + // OnEvicted, it is expected to be set once at construction, before the cache + // is shared across goroutines, so it needs no synchronization. + userOnEvicted func(string, interface{}) +} + +// New returns an instrumented cache. name is used as the "name" label on every +// metric and MUST be stable and low-cardinality (e.g. "aws.ssm"); never derive +// it from a cache key or other unbounded value. defaultExpiration and +// cleanupInterval are passed through to cache.New unchanged. +func New(name string, defaultExpiration, cleanupInterval time.Duration) *Cache { + c := &Cache{ + Cache: cache.New(defaultExpiration, cleanupInterval), + name: name, + } + c.Cache.OnEvicted(c.onEvicted) + c.initMetrics() + return c +} + +// initMetrics pre-touches every counter/gauge series so they report 0 from +// process start rather than blinking into existence on first use. rate() and +// hit-ratio queries then behave from t0 and never read as "no data" mid-incident. +// The flush_size histogram is intentionally left lazy: observing a fake 0 would +// corrupt the size distribution, and flushes are rare, so the series appears on +// the first real flush. +func (c *Cache) initMetrics() { + getsTotal.Add(0, c.labels(MetricLabelResult, ResultHit)) + getsTotal.Add(0, c.labels(MetricLabelResult, ResultMiss)) + addsTotal.Add(0, c.labels(MetricLabelResult, ResultAdded)) + addsTotal.Add(0, c.labels(MetricLabelResult, ResultExists)) + evictionsTotal.Add(0, c.labels()) + flushesTotal.Add(0, c.labels()) + entries.Set(0, c.labels()) +} + +// labels builds the metric label set for this cache: always the "name" label, +// plus any extra key/value pairs supplied as a flat, even-length list. +func (c *Cache) labels(kv ...string) map[string]string { + l := map[string]string{MetricLabelName: c.name} + for i := 0; i+1 < len(kv); i += 2 { + l[kv[i]] = kv[i+1] + } + return l +} + +// Get records a hit or miss and delegates to the underlying cache. +func (c *Cache) Get(k string) (interface{}, bool) { + v, ok := c.Cache.Get(k) + if ok { + getsTotal.Inc(c.labels(MetricLabelResult, ResultHit)) + } else { + getsTotal.Inc(c.labels(MetricLabelResult, ResultMiss)) + } + return v, ok +} + +// Add records whether the key was added or already existed (a suppressed +// duplicate) and delegates to the underlying cache. +func (c *Cache) Add(k string, x interface{}, d time.Duration) error { + err := c.Cache.Add(k, x, d) + if err != nil { + addsTotal.Inc(c.labels(MetricLabelResult, ResultExists)) + return err + } + addsTotal.Inc(c.labels(MetricLabelResult, ResultAdded)) + c.updateEntries() + return nil +} + +// Set delegates to the underlying cache and refreshes the entries gauge. +func (c *Cache) Set(k string, x interface{}, d time.Duration) { + c.Cache.Set(k, x, d) + c.updateEntries() +} + +// SetDefault delegates to the underlying cache and refreshes the entries gauge. +func (c *Cache) SetDefault(k string, x interface{}) { + c.Cache.SetDefault(k, x) + c.updateEntries() +} + +// Delete removes a single entry. If the entry existed, the removal is counted by +// evictions_total via the eviction callback (see onEvicted); go-cache does not +// distinguish an explicit delete from a TTL expiry, and neither do we. +func (c *Cache) Delete(k string) { + c.Cache.Delete(k) + c.updateEntries() +} + +// Flush empties the cache. go-cache's Flush does not fire OnEvicted, so we +// account for it here as a flush event plus the number of entries discarded (as +// a histogram), and reset the entries gauge. flush_size's _sum gives the total +// number of entries discarded by flushes over time. +func (c *Cache) Flush() { + n := c.Cache.ItemCount() + c.Cache.Flush() + flushesTotal.Inc(c.labels()) + flushSize.Observe(float64(n), c.labels()) + entries.Set(0, c.labels()) +} + +// OnEvicted registers a caller eviction callback, invoked from the wrapper's own +// handler so instrumentation and caller bookkeeping coexist. Like go-cache's +// OnEvicted, call this once at construction before the cache is shared across +// goroutines. +func (c *Cache) OnEvicted(f func(string, interface{})) { + c.userOnEvicted = f +} + +// onEvicted is registered with the underlying cache and fires for every real +// per-key removal — both explicit Delete and TTL expiry. It records the removal, +// refreshes the entries gauge, then invokes the caller's callback if set. +func (c *Cache) onEvicted(k string, v interface{}) { + evictionsTotal.Inc(c.labels()) + c.updateEntries() + if c.userOnEvicted != nil { + c.userOnEvicted(k, v) + } +} + +// updateEntries refreshes the size gauge. ItemCount is O(1) under the cache's +// read lock, and this runs off the scrape path, so scrapes never trigger a +// cache-wide walk or block on cache mutation. +func (c *Cache) updateEntries() { + entries.Set(float64(c.Cache.ItemCount()), c.labels()) +} diff --git a/cache/metrics.go b/cache/metrics.go new file mode 100644 index 0000000..6bc1af0 --- /dev/null +++ b/cache/metrics.go @@ -0,0 +1,102 @@ +package cache + +import ( + pmetrics "github.com/awslabs/operatorpkg/metrics" + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +const ( + MetricSubsystem = "cache" + + // MetricLabelName identifies the logical cache instance, e.g. "aws.ssm" or + // "nodeclaim.launch". This is set explicitly at construction (see New) and + // MUST be low-cardinality: never a cache key, hash, or other unbounded value. + MetricLabelName = "name" + // MetricLabelResult carries the outcome of an operation: "hit"/"miss" for + // gets and "added"/"exists" for adds. + MetricLabelResult = "result" + + ResultHit = "hit" + ResultMiss = "miss" + ResultAdded = "added" + ResultExists = "exists" +) + +// FlushSizeBuckets covers cache sizes from a handful of entries (e.g. ICE +// offerings) up to the tens-of-thousands (e.g. instance types): 1, 4, 16, 64, +// 256, 1k, 4k, 16k. Tune if a specific cache needs finer resolution. +var FlushSizeBuckets = prometheus.ExponentialBuckets(1, 4, 8) + +// These are package-level so every instrumented cache shares one metric family, +// distinguished by the "name" label. Cardinality is bounded by the number of +// distinct cache instances (~25 across karpenter today), times the small +// result/type enums, so the total series count stays well under a thousand. +var ( + getsTotal = pmetrics.NewPrometheusCounter( + metrics.Registry, + prometheus.CounterOpts{ + Namespace: pmetrics.Namespace, + Subsystem: MetricSubsystem, + Name: "gets_total", + Help: "The number of cache Get calls, partitioned by result (hit/miss).", + }, + []string{MetricLabelName, MetricLabelResult}, + ) + + addsTotal = pmetrics.NewPrometheusCounter( + metrics.Registry, + prometheus.CounterOpts{ + Namespace: pmetrics.Namespace, + Subsystem: MetricSubsystem, + Name: "adds_total", + Help: "The number of cache Add calls, partitioned by result. result=exists indicates a suppressed duplicate (dedupe hit).", + }, + []string{MetricLabelName, MetricLabelResult}, + ) + + evictionsTotal = pmetrics.NewPrometheusCounter( + metrics.Registry, + prometheus.CounterOpts{ + Namespace: pmetrics.Namespace, + Subsystem: MetricSubsystem, + Name: "evictions_total", + Help: "The number of per-key removals, whether by TTL expiry or explicit Delete. Whole-cache flushes are tracked separately by flushes_total.", + }, + []string{MetricLabelName}, + ) + + flushesTotal = pmetrics.NewPrometheusCounter( + metrics.Registry, + prometheus.CounterOpts{ + Namespace: pmetrics.Namespace, + Subsystem: MetricSubsystem, + Name: "flushes_total", + Help: "The number of times the whole cache was flushed.", + }, + []string{MetricLabelName}, + ) + + flushSize = pmetrics.NewPrometheusHistogram( + metrics.Registry, + prometheus.HistogramOpts{ + Namespace: pmetrics.Namespace, + Subsystem: MetricSubsystem, + Name: "flush_size", + Help: "The number of entries discarded per whole-cache Flush.", + Buckets: FlushSizeBuckets, + }, + []string{MetricLabelName}, + ) + + entries = pmetrics.NewPrometheusGauge( + metrics.Registry, + prometheus.GaugeOpts{ + Namespace: pmetrics.Namespace, + Subsystem: MetricSubsystem, + Name: "entries", + Help: "The current number of entries in the cache.", + }, + []string{MetricLabelName}, + ) +) diff --git a/cache/suite_test.go b/cache/suite_test.go new file mode 100644 index 0000000..7dc4a2d --- /dev/null +++ b/cache/suite_test.go @@ -0,0 +1,126 @@ +package cache_test + +import ( + "testing" + "time" + + opcache "github.com/awslabs/operatorpkg/cache" + . "github.com/awslabs/operatorpkg/test/expectations" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func Test(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Cache") +} + +// value reads a counter's current value for this cache's name plus extra labels, +// returning 0 when the series does not yet exist. +func value(metric string, name string, kv ...string) float64 { + labels := map[string]string{opcache.MetricLabelName: name} + for i := 0; i+1 < len(kv); i += 2 { + labels[kv[i]] = kv[i+1] + } + m := GetMetric("operator_cache_"+metric, labels) + if m == nil { + return 0 + } + if c := m.GetCounter(); c != nil { + return c.GetValue() + } + if g := m.GetGauge(); g != nil { + return g.GetValue() + } + return 0 +} + +var _ = Describe("Cache", func() { + var name string + var c *opcache.Cache + + BeforeEach(func() { + // Unique name per spec so metric series don't bleed across tests. + name = "test-" + CurrentSpecReport().LeafNodeText + c = opcache.New(name, time.Hour, 0) // cleanupInterval 0 => no janitor + }) + + It("initializes counter and gauge series to zero", func() { + Expect(value("gets_total", name, opcache.MetricLabelResult, opcache.ResultHit)).To(BeZero()) + Expect(value("gets_total", name, opcache.MetricLabelResult, opcache.ResultMiss)).To(BeZero()) + Expect(value("adds_total", name, opcache.MetricLabelResult, opcache.ResultAdded)).To(BeZero()) + Expect(value("adds_total", name, opcache.MetricLabelResult, opcache.ResultExists)).To(BeZero()) + Expect(value("evictions_total", name)).To(BeZero()) + Expect(value("flushes_total", name)).To(BeZero()) + Expect(value("entries", name)).To(BeZero()) + }) + + It("records hits and misses on Get", func() { + c.SetDefault("k", "v") + _, ok := c.Get("k") + Expect(ok).To(BeTrue()) + _, ok = c.Get("missing") + Expect(ok).To(BeFalse()) + + Expect(value("gets_total", name, opcache.MetricLabelResult, opcache.ResultHit)).To(BeEquivalentTo(1)) + Expect(value("gets_total", name, opcache.MetricLabelResult, opcache.ResultMiss)).To(BeEquivalentTo(1)) + }) + + It("records add vs duplicate-suppressed on Add", func() { + Expect(c.Add("k", "v", 0)).To(Succeed()) + Expect(c.Add("k", "v2", 0)).ToNot(Succeed()) // duplicate => suppressed + + Expect(value("adds_total", name, opcache.MetricLabelResult, opcache.ResultAdded)).To(BeEquivalentTo(1)) + Expect(value("adds_total", name, opcache.MetricLabelResult, opcache.ResultExists)).To(BeEquivalentTo(1)) + }) + + It("counts an explicit Delete as an eviction", func() { + c.SetDefault("k", "v") + c.Delete("k") + + Expect(value("evictions_total", name)).To(BeEquivalentTo(1)) + }) + + It("does not count a Delete of a missing key", func() { + c.Delete("missing") + Expect(value("evictions_total", name)).To(BeZero()) + }) + + It("counts a TTL expiry as an eviction", func() { + // Short TTL with a running janitor so the entry expires and is swept. + c = opcache.New(name, 10*time.Millisecond, 10*time.Millisecond) + c.SetDefault("k", "v") + Eventually(func() float64 { return value("evictions_total", name) }, time.Second).Should(BeEquivalentTo(1)) + }) + + It("accounts a Flush as an event and a size observation, separate from evictions", func() { + c.SetDefault("a", 1) + c.SetDefault("b", 2) + c.SetDefault("cc", 3) + c.Flush() + + Expect(value("flushes_total", name)).To(BeEquivalentTo(1)) + // Flush does not fire per-key OnEvicted, so evictions_total is untouched. + Expect(value("evictions_total", name)).To(BeZero()) + Expect(value("entries", name)).To(BeZero()) + }) + + It("tracks the entries gauge across mutations", func() { + c.SetDefault("a", 1) + c.SetDefault("b", 2) + Expect(value("entries", name)).To(BeEquivalentTo(2)) + c.Delete("a") + Expect(value("entries", name)).To(BeEquivalentTo(1)) + }) + + It("still invokes a caller-registered OnEvicted callback", func() { + evicted := map[string]interface{}{} + c.OnEvicted(func(k string, v interface{}) { evicted[k] = v }) + c.SetDefault("k", "v") + c.Delete("k") + + Expect(evicted).To(HaveKeyWithValue("k", "v")) + // And the metric is still recorded alongside the caller callback. + Expect(value("evictions_total", name)).To(BeEquivalentTo(1)) + }) +}) diff --git a/go.mod b/go.mod index 85c708d..ffa7c09 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/imdario/mergo v0.3.16 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/samber/lo v1.52.0 diff --git a/go.sum b/go.sum index 36d88e5..1fe1f41 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=