Repository: Kinto/kinto

Issue:
Implement metric for cache hit ratio
```
  100 * sum(rate(cache_hits_total[$__rate_interval]))
/
  (sum(rate(cache_hits_total[$__rate_interval])) + sum(rate(cache_misses_total[$__rate_interval])))
```


Discussion:
I'd like to take this up @leplatrem. Could you assign it to me?
Great! 



Patch:
diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst
index 36a0d6117..47dea9c3b 100644
--- a/CONTRIBUTORS.rst
+++ b/CONTRIBUTORS.rst
@@ -112,3 +112,4 @@ Contributors
 * Dan Milgram <danm@intervivo.com>
 * Dex Devlon <@bxff>
 * Varun Koranne <@varun-dhruv>
+* Robin Sharma <robinrythm123@gmail.com>
\ No newline at end of file
diff --git a/docs/core/cache.rst b/docs/core/cache.rst
index 2fb967721..d98601c34 100644
--- a/docs/core/cache.rst
+++ b/docs/core/cache.rst
@@ -36,3 +36,9 @@ interface:
 
 .. autoclass:: kinto.core.cache.CacheBase
     :members:
+
+Cache Metrics Adapter
+=====================
+
+.. autoclass:: kinto.core.cache.CacheMetricsBackend
+    :members:
\ No newline at end of file
diff --git a/kinto/core/cache/__init__.py b/kinto/core/cache/__init__.py
index 8f391e381..c3cd235e2 100644
--- a/kinto/core/cache/__init__.py
+++ b/kinto/core/cache/__init__.py
@@ -10,10 +10,15 @@
 _HEARTBEAT_TTL_SECONDS = 3600
 
 
+_CACHE_HIT_METRIC_KEY = "cache_hits"
+_CACHE_MISS_METRIC_KEY = "cache_misses"
+
+
 class CacheBase:
     def __init__(self, *args, **kwargs):
         self.prefix = kwargs["cache_prefix"]
         self.max_size_bytes = kwargs.get("cache_max_size_bytes")
+        self.set_metrics_backend(kwargs.get("metrics_backend"))
 
     def initialize_schema(self, dry_run=False):
         """Create every necessary objects (like tables or indices) in the
@@ -71,6 +76,36 @@ def delete(self, key):
         """
         raise NotImplementedError
 
+    def set_metrics_backend(self, metrics_backend):
+        """Set a metrics backend via the `CacheMetricsBackend` adapter.
+
+        :param metrics_backend: A metrics backend implementing the IMetricsService interface.
+        """
+        self.metrics_backend = CacheMetricsBackend(metrics_backend)
+
+
+class CacheMetricsBackend:
+    """
+    A simple adapter for tracking cache-related metrics.
+    """
+
+    def __init__(self, metrics_backend, *args, **kwargs):
+        """Initialize with a given metrics backend.
+
+        :param metrics_backend: A metrics backend implementing the IMetricsService interface.
+        """
+        self._backend = metrics_backend
+
+    def count_hit(self):
+        """Increment the cache hit counter."""
+        if self._backend:
+            self._backend.count(key=_CACHE_HIT_METRIC_KEY)
+
+    def count_miss(self):
+        """Increment the cache miss counter."""
+        if self._backend:
+            self._backend.count(key=_CACHE_MISS_METRIC_KEY)
+
 
 def heartbeat(backend):
     def ping(request):
diff --git a/kinto/core/cache/memcached.py b/kinto/core/cache/memcached.py
index f1fefac53..7714122e6 100644
--- a/kinto/core/cache/memcached.py
+++ b/kinto/core/cache/memcached.py
@@ -68,7 +68,9 @@ def flush(self):
     def _get(self, key):
         value = self._client.get(self.prefix + key)
         if not value:
+            self.metrics_backend.count_miss()
             return None, 0
+        self.metrics_backend.count_hit()
         data = json.loads(value)
         return data["value"], data["ttl"]
 
diff --git a/kinto/core/cache/memory.py b/kinto/core/cache/memory.py
index 8fb760e61..da89efff1 100644
--- a/kinto/core/cache/memory.py
+++ b/kinto/core/cache/memory.py
@@ -73,7 +73,12 @@ def set(self, key, value, ttl):
     @synchronized
     def get(self, key):
         self._clean_expired()
-        return self._store.get(self.prefix + key)
+        value = self._store.get(self.prefix + key)
+        if value is None:
+            self.metrics_backend.count_miss()
+            return None
+        self.metrics_backend.count_hit()
+        return value
 
     @synchronized
     def delete(self, key):
diff --git a/kinto/core/cache/postgresql/__init__.py b/kinto/core/cache/postgresql/__init__.py
index 86a54e4f8..9cae8f75d 100644
--- a/kinto/core/cache/postgresql/__init__.py
+++ b/kinto/core/cache/postgresql/__init__.py
@@ -156,8 +156,11 @@ def get(self, key):
             conn.execute(sa.text(purge))
             result = conn.execute(sa.text(query), dict(key=self.prefix + key))
             if result.rowcount > 0:
+                self.metrics_backend.count_hit()
                 value = result.fetchone().value
                 return json.loads(value)
+            self.metrics_backend.count_miss()
+            return None
 
     def delete(self, key):
         query = "DELETE FROM cache WHERE key = :key RETURNING value;"
diff --git a/kinto/core/initialization.py b/kinto/core/initialization.py
index 312df435e..aaa2cc226 100644
--- a/kinto/core/initialization.py
+++ b/kinto/core/initialization.py
@@ -228,7 +228,9 @@ def eos_tween(request):
         else:
             code = "hard-eol"
             request.response = errors.http_error(
-                HTTPGone(), errno=errors.ERRORS.SERVICE_DEPRECATED, message=deprecation_msg
+                HTTPGone(),
+                errno=errors.ERRORS.SERVICE_DEPRECATED,
+                message=deprecation_msg,
             )
 
         errors.send_alert(request, eos_message, url=eos_url, code=code)
@@ -459,6 +461,11 @@ def on_app_created(event):
         else:
             metrics.watch_execution_time(metrics_service, policy, prefix="authentication")
 
+        # Set cache metrics backend
+        cache_backend = config.registry.cache
+        if isinstance(cache_backend, cache.CacheBase):
+            cache_backend.set_metrics_backend(metrics_service)
+
     config.add_subscriber(on_app_created, ApplicationCreated)
 
     def on_new_response(event):


Test patch:
diff --git a/tests/test_cache_metrics.py b/tests/test_cache_metrics.py
new file mode 100644
index 000000000..e4b4ae20c
--- /dev/null
+++ b/tests/test_cache_metrics.py
@@ -0,0 +1,70 @@
+# tests/test_cache_metrics.py
+
+import unittest
+
+from kinto.core.testing import skip_if_no_memcached, skip_if_no_postgresql, skip_if_no_prometheus
+
+from .support import BaseWebTest
+
+
+@skip_if_no_prometheus
+class BaseCacheMetricsTest(BaseWebTest):
+    @classmethod
+    def get_app_settings(cls, extras=None):
+        settings = super().get_app_settings(extras)
+        settings["includes"] += "\nkinto.plugins.prometheus"
+        return settings
+
+    def setUp(self):
+        super().setUp()
+        from kinto.plugins.prometheus import reset_registry
+
+        reset_registry()
+
+    def test_cache_miss_and_hit_counters(self):
+        # Miss: key does not exist.
+        self.cache.get("unknown-key")
+
+        # Hits: store then retrieve twice.
+        self.cache.set("foo", {"bar": 42}, ttl=30)
+        self.cache.get("foo")
+        self.cache.get("foo")
+
+        # Fetch the raw Prometheus metrics.
+        resp = self.app.get("/__metrics__")
+
+        # We should see 1 miss and 2 hits.
+        self.assertIn("kinto_cache_misses_total 1.0", resp.text)
+        self.assertIn("kinto_cache_hits_total 2.0", resp.text)
+
+
+class MemoryCacheMetricsTest(BaseCacheMetricsTest, unittest.TestCase):
+    """Run cache metrics tests with default (memory) backend."""
+
+    pass
+
+
+@skip_if_no_memcached
+class MemcachedCacheMetricsTest(BaseCacheMetricsTest, unittest.TestCase):
+    """Run cache metrics tests with Memcached cache backend."""
+
+    @classmethod
+    def get_app_settings(cls, extras=None):
+        settings = super().get_app_settings(extras)
+        # Switch to Memcached backend
+        settings["cache_backend"] = "kinto.core.cache.memcached"
+        settings["cache_hosts"] = "127.0.0.1:11211"
+        return settings
+
+
+@skip_if_no_postgresql
+class PostgreSQLCacheMetricsTest(BaseCacheMetricsTest, unittest.TestCase):
+    """Run cache metrics tests with PostgreSQL cache backend."""
+
+    @classmethod
+    def get_app_settings(cls, extras=None):
+        settings = super().get_app_settings(extras)
+        # Switch to PostgreSQL backend
+        settings["cache_backend"] = "kinto.core.cache.postgresql"
+        settings["cache_url"] = "postgresql://postgres:postgres@localhost:5432/testdb"
+        return settings