Repository: getsentry/sentry-python

Issue:
Add `cache.hit` data to Django spans
### Problem Statement

We want to add information on if a configured cache was hit or missed in Django. 

See also https://github.com/getsentry/team-webplatform-meta/issues/60

### Solution Brainstorm

There are multiple ways to cache data in Django, we probably have to monkey patch Django in various places:
- per-site cache (middlewares) https://docs.djangoproject.com/en/4.2/topics/cache/#the-per-site-cache
- per-view cache (decorator) https://docs.djangoproject.com/en/4.2/topics/cache/#the-per-view-cache
- Template fragment caching (template tag) https://docs.djangoproject.com/en/4.2/topics/cache/#template-fragment-caching
- low level cache api https://docs.djangoproject.com/en/4.2/topics/cache/#the-low-level-cache-api
- async flavor of low level cache api https://docs.djangoproject.com/en/4.2/topics/cache/#asynchronous-support

Maybe just patching the low level cache api will handle all the cases.




Discussion:
There's some prior art in https://github.com/tim-schilling/django-debug-toolbar/blob/main/debug_toolbar/panels/cache.py we can look at!

Resolving patch:
diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py
--- a/sentry_sdk/consts.py
+++ b/sentry_sdk/consts.py
@@ -53,7 +53,15 @@ class INSTRUMENTER:
 
 # See: https://develop.sentry.dev/sdk/performance/span-data-conventions/
 class SPANDATA:
+    # An identifier for the database management system (DBMS) product being used.
+    # See: https://github.com/open-telemetry/opentelemetry-python/blob/e00306206ea25cf8549eca289e39e0b6ba2fa560/opentelemetry-semantic-conventions/src/opentelemetry/semconv/trace/__init__.py#L58
     DB_SYSTEM = "db.system"
+
+    # A boolean indicating whether the requested data was found in the cache.
+    CACHE_HIT = "cache.hit"
+
+    # The size of the requested data in bytes.
+    CACHE_ITEM_SIZE = "cache.item_size"
     """
     An identifier for the database management system (DBMS) product being used.
     See: https://github.com/open-telemetry/opentelemetry-python/blob/e00306206ea25cf8549eca289e39e0b6ba2fa560/opentelemetry-semantic-conventions/src/opentelemetry/semconv/trace/__init__.py#L58
@@ -76,6 +84,7 @@ class SPANDATA:
 
 
 class OP:
+    CACHE = "cache"
     DB = "db"
     DB_REDIS = "db.redis"
     EVENT_DJANGO = "event.django"
diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py
--- a/sentry_sdk/integrations/django/__init__.py
+++ b/sentry_sdk/integrations/django/__init__.py
@@ -40,7 +40,6 @@
 except ImportError:
     raise DidNotEnable("Django not installed")
 
-
 from sentry_sdk.integrations.django.transactions import LEGACY_RESOLVER
 from sentry_sdk.integrations.django.templates import (
     get_template_frame_from_exception,
@@ -50,6 +49,11 @@
 from sentry_sdk.integrations.django.signals_handlers import patch_signals
 from sentry_sdk.integrations.django.views import patch_views
 
+if DJANGO_VERSION[:2] > (1, 8):
+    from sentry_sdk.integrations.django.caching import patch_caching
+else:
+    patch_caching = None  # type: ignore
+
 
 if TYPE_CHECKING:
     from typing import Any
@@ -92,11 +96,16 @@ class DjangoIntegration(Integration):
     transaction_style = ""
     middleware_spans = None
     signals_spans = None
+    cache_spans = None
 
     def __init__(
-        self, transaction_style="url", middleware_spans=True, signals_spans=True
+        self,
+        transaction_style="url",
+        middleware_spans=True,
+        signals_spans=True,
+        cache_spans=True,
     ):
-        # type: (str, bool, bool) -> None
+        # type: (str, bool, bool, bool) -> None
         if transaction_style not in TRANSACTION_STYLE_VALUES:
             raise ValueError(
                 "Invalid value for transaction_style: %s (must be in %s)"
@@ -105,6 +114,7 @@ def __init__(
         self.transaction_style = transaction_style
         self.middleware_spans = middleware_spans
         self.signals_spans = signals_spans
+        self.cache_spans = cache_spans
 
     @staticmethod
     def setup_once():
@@ -224,6 +234,9 @@ def _django_queryset_repr(value, hint):
         patch_templates()
         patch_signals()
 
+        if patch_caching is not None:
+            patch_caching()
+
 
 _DRF_PATCHED = False
 _DRF_PATCH_LOCK = threading.Lock()
diff --git a/sentry_sdk/integrations/django/caching.py b/sentry_sdk/integrations/django/caching.py
new file mode 100644
--- /dev/null
+++ b/sentry_sdk/integrations/django/caching.py
@@ -0,0 +1,105 @@
+import functools
+from typing import TYPE_CHECKING
+
+from django import VERSION as DJANGO_VERSION
+from django.core.cache import CacheHandler
+
+from sentry_sdk import Hub
+from sentry_sdk.consts import OP, SPANDATA
+from sentry_sdk._compat import text_type
+
+
+if TYPE_CHECKING:
+    from typing import Any
+    from typing import Callable
+
+
+METHODS_TO_INSTRUMENT = [
+    "get",
+    "get_many",
+]
+
+
+def _patch_cache_method(cache, method_name):
+    # type: (CacheHandler, str) -> None
+    from sentry_sdk.integrations.django import DjangoIntegration
+
+    def _instrument_call(cache, method_name, original_method, args, kwargs):
+        # type: (CacheHandler, str, Callable[..., Any], Any, Any) -> Any
+        hub = Hub.current
+        integration = hub.get_integration(DjangoIntegration)
+        if integration is None or not integration.cache_spans:
+            return original_method(*args, **kwargs)
+
+        description = "{} {}".format(method_name, " ".join(args))
+
+        with hub.start_span(op=OP.CACHE, description=description) as span:
+            value = original_method(*args, **kwargs)
+
+            if value:
+                span.set_data(SPANDATA.CACHE_HIT, True)
+
+                size = len(text_type(value).encode("utf-8"))
+                span.set_data(SPANDATA.CACHE_ITEM_SIZE, size)
+
+            else:
+                span.set_data(SPANDATA.CACHE_HIT, False)
+
+            return value
+
+    original_method = getattr(cache, method_name)
+
+    @functools.wraps(original_method)
+    def sentry_method(*args, **kwargs):
+        # type: (*Any, **Any) -> Any
+        return _instrument_call(cache, method_name, original_method, args, kwargs)
+
+    setattr(cache, method_name, sentry_method)
+
+
+def _patch_cache(cache):
+    # type: (CacheHandler) -> None
+    if not hasattr(cache, "_sentry_patched"):
+        for method_name in METHODS_TO_INSTRUMENT:
+            _patch_cache_method(cache, method_name)
+        cache._sentry_patched = True
+
+
+def patch_caching():
+    # type: () -> None
+    from sentry_sdk.integrations.django import DjangoIntegration
+
+    if not hasattr(CacheHandler, "_sentry_patched"):
+        if DJANGO_VERSION < (3, 2):
+            original_get_item = CacheHandler.__getitem__
+
+            @functools.wraps(original_get_item)
+            def sentry_get_item(self, alias):
+                # type: (CacheHandler, str) -> Any
+                cache = original_get_item(self, alias)
+
+                integration = Hub.current.get_integration(DjangoIntegration)
+                if integration and integration.cache_spans:
+                    _patch_cache(cache)
+
+                return cache
+
+            CacheHandler.__getitem__ = sentry_get_item
+            CacheHandler._sentry_patched = True
+
+        else:
+            original_create_connection = CacheHandler.create_connection
+
+            @functools.wraps(original_create_connection)
+            def sentry_create_connection(self, alias):
+                # type: (CacheHandler, str) -> Any
+                cache = original_create_connection(self, alias)
+
+                integration = Hub.current.get_integration(DjangoIntegration)
+                if integration and integration.cache_spans:
+                    _patch_cache(cache)
+
+                return cache
+
+            CacheHandler.create_connection = sentry_create_connection
+            CacheHandler._sentry_patched = True


Test patch:
diff --git a/tests/integrations/django/myapp/urls.py b/tests/integrations/django/myapp/urls.py
--- a/tests/integrations/django/myapp/urls.py
+++ b/tests/integrations/django/myapp/urls.py
@@ -28,6 +28,13 @@ def path(path, *args, **kwargs):
 
 urlpatterns = [
     path("view-exc", views.view_exc, name="view_exc"),
+    path("cached-view", views.cached_view, name="cached_view"),
+    path("not-cached-view", views.not_cached_view, name="not_cached_view"),
+    path(
+        "view-with-cached-template-fragment",
+        views.view_with_cached_template_fragment,
+        name="view_with_cached_template_fragment",
+    ),
     path(
         "read-body-and-view-exc",
         views.read_body_and_view_exc,
diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py
--- a/tests/integrations/django/myapp/views.py
+++ b/tests/integrations/django/myapp/views.py
@@ -7,11 +7,14 @@
 from django.core.exceptions import PermissionDenied
 from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerError
 from django.shortcuts import render
+from django.template import Context, Template
 from django.template.response import TemplateResponse
 from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_page
 from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import ListView
 
+
 try:
     from rest_framework.decorators import api_view
     from rest_framework.response import Response
@@ -49,6 +52,28 @@ def view_exc(request):
     1 / 0
 
 
+@cache_page(60)
+def cached_view(request):
+    return HttpResponse("ok")
+
+
+def not_cached_view(request):
+    return HttpResponse("ok")
+
+
+def view_with_cached_template_fragment(request):
+    template = Template(
+        """{% load cache %}
+        Not cached content goes here.
+        {% cache 500 some_identifier %}
+            And here some cached content.
+        {% endcache %}
+        """
+    )
+    rendered = template.render(Context({}))
+    return HttpResponse(rendered)
+
+
 # This is a "class based view" as previously found in the sentry codebase. The
 # interesting property of this one is that csrf_exempt, as a class attribute,
 # is not in __dict__, so regular use of functools.wraps will not forward the
diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py
--- a/tests/integrations/django/test_basic.py
+++ b/tests/integrations/django/test_basic.py
@@ -2,10 +2,11 @@
 
 import json
 import pytest
-import pytest_django
+import random
 from functools import partial
 
 from werkzeug.test import Client
+
 from django import VERSION as DJANGO_VERSION
 from django.contrib.auth.models import User
 from django.core.management import execute_from_command_line
@@ -22,25 +23,10 @@
 from sentry_sdk.integrations.django import DjangoIntegration
 from sentry_sdk.integrations.django.signals_handlers import _get_receiver_name
 from sentry_sdk.integrations.executing import ExecutingIntegration
-
 from tests.integrations.django.myapp.wsgi import application
+from tests.integrations.django.utils import pytest_mark_django_db_decorator
 
-# Hack to prevent from experimental feature introduced in version `4.3.0` in `pytest-django` that
-# requires explicit database allow from failing the test
-pytest_mark_django_db_decorator = partial(pytest.mark.django_db)
-try:
-    pytest_version = tuple(map(int, pytest_django.__version__.split(".")))
-    if pytest_version > (4, 2, 0):
-        pytest_mark_django_db_decorator = partial(
-            pytest.mark.django_db, databases="__all__"
-        )
-except ValueError:
-    if "dev" in pytest_django.__version__:
-        pytest_mark_django_db_decorator = partial(
-            pytest.mark.django_db, databases="__all__"
-        )
-except AttributeError:
-    pass
+DJANGO_VERSION = DJANGO_VERSION[:2]
 
 
 @pytest.fixture
@@ -48,6 +34,36 @@ def client():
     return Client(application)
 
 
+@pytest.fixture
+def use_django_caching(settings):
+    settings.CACHES = {
+        "default": {
+            "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+            "LOCATION": "unique-snowflake-%s" % random.randint(1, 1000000),
+        }
+    }
+
+
+@pytest.fixture
+def use_django_caching_with_middlewares(settings):
+    settings.CACHES = {
+        "default": {
+            "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+            "LOCATION": "unique-snowflake-%s" % random.randint(1, 1000000),
+        }
+    }
+    if hasattr(settings, "MIDDLEWARE"):
+        middleware = settings.MIDDLEWARE
+    elif hasattr(settings, "MIDDLEWARE_CLASSES"):
+        middleware = settings.MIDDLEWARE_CLASSES
+    else:
+        middleware = None
+
+    if middleware is not None:
+        middleware.insert(0, "django.middleware.cache.UpdateCacheMiddleware")
+        middleware.append("django.middleware.cache.FetchFromCacheMiddleware")
+
+
 def test_view_exceptions(sentry_init, client, capture_exceptions, capture_events):
     sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
     exceptions = capture_exceptions()
@@ -906,3 +922,215 @@ def dummy(a, b):
         assert name == "functools.partial(<function " + a_partial.func.__name__ + ">)"
     else:
         assert name == "partial(<function " + a_partial.func.__name__ + ">)"
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_disabled_middleware(
+    sentry_init,
+    client,
+    capture_events,
+    use_django_caching_with_middlewares,
+    settings,
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=False,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("not_cached_view"))
+    client.get(reverse("not_cached_view"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 0
+    assert len(second_event["spans"]) == 0
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_disabled_decorator(
+    sentry_init, client, capture_events, use_django_caching
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=False,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+    client.get(reverse("cached_view"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 0
+    assert len(second_event["spans"]) == 0
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_disabled_templatetag(
+    sentry_init, client, capture_events, use_django_caching
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=False,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("view_with_cached_template_fragment"))
+    client.get(reverse("view_with_cached_template_fragment"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 0
+    assert len(second_event["spans"]) == 0
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_middleware(
+    sentry_init,
+    client,
+    capture_events,
+    use_django_caching_with_middlewares,
+    settings,
+):
+    client.application.load_middleware()
+
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("not_cached_view"))
+    client.get(reverse("not_cached_view"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 1
+    assert first_event["spans"][0]["op"] == "cache"
+    assert first_event["spans"][0]["description"].startswith(
+        "get views.decorators.cache.cache_header."
+    )
+    assert first_event["spans"][0]["data"] == {"cache.hit": False}
+
+    assert len(second_event["spans"]) == 2
+    assert second_event["spans"][0]["op"] == "cache"
+    assert second_event["spans"][0]["description"].startswith(
+        "get views.decorators.cache.cache_header."
+    )
+    assert second_event["spans"][0]["data"] == {"cache.hit": False}
+
+    assert second_event["spans"][1]["op"] == "cache"
+    assert second_event["spans"][1]["description"].startswith(
+        "get views.decorators.cache.cache_page."
+    )
+    assert second_event["spans"][1]["data"]["cache.hit"]
+    assert "cache.item_size" in second_event["spans"][1]["data"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_decorator(sentry_init, client, capture_events, use_django_caching):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+    client.get(reverse("cached_view"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 1
+    assert first_event["spans"][0]["op"] == "cache"
+    assert first_event["spans"][0]["description"].startswith(
+        "get views.decorators.cache.cache_header."
+    )
+    assert first_event["spans"][0]["data"] == {"cache.hit": False}
+
+    assert len(second_event["spans"]) == 2
+    assert second_event["spans"][0]["op"] == "cache"
+    assert second_event["spans"][0]["description"].startswith(
+        "get views.decorators.cache.cache_header."
+    )
+    assert second_event["spans"][0]["data"] == {"cache.hit": False}
+
+    assert second_event["spans"][1]["op"] == "cache"
+    assert second_event["spans"][1]["description"].startswith(
+        "get views.decorators.cache.cache_page."
+    )
+    assert second_event["spans"][1]["data"]["cache.hit"]
+    assert "cache.item_size" in second_event["spans"][1]["data"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_templatetag(
+    sentry_init, client, capture_events, use_django_caching
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("view_with_cached_template_fragment"))
+    client.get(reverse("view_with_cached_template_fragment"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 1
+    assert first_event["spans"][0]["op"] == "cache"
+    assert first_event["spans"][0]["description"].startswith(
+        "get template.cache.some_identifier."
+    )
+    assert first_event["spans"][0]["data"] == {"cache.hit": False}
+
+    assert len(second_event["spans"]) == 1
+    assert second_event["spans"][0]["op"] == "cache"
+    assert second_event["spans"][0]["description"].startswith(
+        "get template.cache.some_identifier."
+    )
+    assert second_event["spans"][0]["data"]["cache.hit"]
+    assert "cache.item_size" in second_event["spans"][0]["data"]
diff --git a/tests/integrations/django/test_data_scrubbing.py b/tests/integrations/django/test_data_scrubbing.py
--- a/tests/integrations/django/test_data_scrubbing.py
+++ b/tests/integrations/django/test_data_scrubbing.py
@@ -1,12 +1,10 @@
-from functools import partial
 import pytest
-import pytest_django
 
 from werkzeug.test import Client
 
 from sentry_sdk.integrations.django import DjangoIntegration
-
 from tests.integrations.django.myapp.wsgi import application
+from tests.integrations.django.utils import pytest_mark_django_db_decorator
 
 try:
     from django.urls import reverse
@@ -14,24 +12,6 @@
     from django.core.urlresolvers import reverse
 
 
-# Hack to prevent from experimental feature introduced in version `4.3.0` in `pytest-django` that
-# requires explicit database allow from failing the test
-pytest_mark_django_db_decorator = partial(pytest.mark.django_db)
-try:
-    pytest_version = tuple(map(int, pytest_django.__version__.split(".")))
-    if pytest_version > (4, 2, 0):
-        pytest_mark_django_db_decorator = partial(
-            pytest.mark.django_db, databases="__all__"
-        )
-except ValueError:
-    if "dev" in pytest_django.__version__:
-        pytest_mark_django_db_decorator = partial(
-            pytest.mark.django_db, databases="__all__"
-        )
-except AttributeError:
-    pass
-
-
 @pytest.fixture
 def client():
     return Client(application)
diff --git a/tests/integrations/django/utils.py b/tests/integrations/django/utils.py
new file mode 100644
--- /dev/null
+++ b/tests/integrations/django/utils.py
@@ -0,0 +1,22 @@
+from functools import partial
+
+import pytest
+import pytest_django
+
+
+# Hack to prevent from experimental feature introduced in version `4.3.0` in `pytest-django` that
+# requires explicit database allow from failing the test
+pytest_mark_django_db_decorator = partial(pytest.mark.django_db)
+try:
+    pytest_version = tuple(map(int, pytest_django.__version__.split(".")))
+    if pytest_version > (4, 2, 0):
+        pytest_mark_django_db_decorator = partial(
+            pytest.mark.django_db, databases="__all__"
+        )
+except ValueError:
+    if "dev" in pytest_django.__version__:
+        pytest_mark_django_db_decorator = partial(
+            pytest.mark.django_db, databases="__all__"
+        )
+except AttributeError:
+    pass