diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 55c9dad89e..16a058c638 100644 --- 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 index 71bf9e0b83..3560d24409 100644 --- 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 index 0000000000..cfa952eda3 --- /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 diff --git a/tests/integrations/django/myapp/urls.py b/tests/integrations/django/myapp/urls.py index ee357c843b..2ea195f084 100644 --- 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 index dbf266e1ab..2777f5b8f3 100644 --- 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 index 201854d552..41fbed0976 100644 --- 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()" else: assert name == "partial()" + + +@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 index c0ab14ae63..b3e531183f 100644 --- 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 index 0000000000..8f68c8fa14 --- /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