-
Notifications
You must be signed in to change notification settings - Fork 25
feat: add instrumented go-cache wrapper with Prometheus metrics #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}, | ||
| ) | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Theres a pretty small perf hit here because theres a bunch of allocations + the metric itself - it tripled the CPU usage on every
cache.Get()I don't think its that large, but since there is limited value in the metric it might not be worth it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea I think we can drop it