Repository: jazzband/django-debug-toolbar

Issue:
After upgrading to Django 4.2 LTS, I receive AttributeError on each page load. Using DDT 4.0.0
```
E0506 14:25:32.246 django.request:241 Internal Server Error: /dashboard/
Traceback (most recent call last):
  File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\django\core\handlers\exception.py", line 55, in inner
    response = get_response(request)
  File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\sentry_sdk\integrations\django\middleware.py", line 176, in __call__
    return f(*args, **kwargs)
  File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\debug_toolbar\middleware.py", line 64, in __call__
    panel.disable_instrumentation()
  File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\debug_toolbar\panels\cache.py", line 217, in disable_instrumentation
    self._unmonkey_patch_cache(cache)
  File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\debug_toolbar\panels\cache.py", line 171, in _unmonkey_patch_cache
    if original_method.__func__ == getattr(cache.__class__, name):
AttributeError: 'function' object has no attribute '__func__'
```

Commenting `debug_toolbar.panels.cache.CachePanel` allows DDT to continue to work.
```python
# settings.py
MIDDLEWARE = [
    "django.middleware.gzip.GZipMiddleware",
]

if DEBUG_TOOLBAR:
    INSTALLED_APPS += ["debug_toolbar"]
    MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
    DEBUG_TOOLBAR_PANELS = [
        "debug_toolbar.panels.history.HistoryPanel",
        "debug_toolbar.panels.versions.VersionsPanel",
        "debug_toolbar.panels.timer.TimerPanel",
        "debug_toolbar.panels.settings.SettingsPanel",
        "debug_toolbar.panels.headers.HeadersPanel",
        "debug_toolbar.panels.request.RequestPanel",
        "debug_toolbar.panels.sql.SQLPanel",
        # 'debug_toolbar.panels.staticfiles.StaticFilesPanel',  # breaks if minio is enabled
        "debug_toolbar.panels.templates.TemplatesPanel",
        # "debug_toolbar.panels.cache.CachePanel",  # commenting this line allows DDT to continue to work
        "debug_toolbar.panels.signals.SignalsPanel",
        "debug_toolbar.panels.logging.LoggingPanel",
        "debug_toolbar.panels.redirects.RedirectsPanel",
        "debug_toolbar.panels.profiling.ProfilingPanel",
    ]
```
Please let me know if you need any more information in order to solve this issue


Discussion:
Thank you for the report. Can you tell us what function is trying to be
patched? And what are you using for your cache settings in your project?

On Sat, May 6, 2023, 7:36 AM Thomas Kunambi ***@***.***>
wrote:

> E0506 14:25:32.246 django.request:241 Internal Server Error: /dashboard/
> Traceback (most recent call last):
>   File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\django\core\handlers\exception.py", line 55, in inner
>     response = get_response(request)
>   File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\sentry_sdk\integrations\django\middleware.py", line 176, in __call__
>     return f(*args, **kwargs)
>   File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\debug_toolbar\middleware.py", line 64, in __call__
>     panel.disable_instrumentation()
>   File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\debug_toolbar\panels\cache.py", line 217, in disable_instrumentation
>     self._unmonkey_patch_cache(cache)
>   File "C:\Users\Owner\.virtualenvs\premind-6P0nJig8\lib\site-packages\debug_toolbar\panels\cache.py", line 171, in _unmonkey_patch_cache
>     if original_method.__func__ == getattr(cache.__class__, name):
> AttributeError: 'function' object has no attribute '__func__'
>
> Commenting debug_toolbar.panels.cache.CachePanel allows DDT to continue
> to work.
>
> # settings.pyMIDDLEWARE = [
>     "django.middleware.gzip.GZipMiddleware",
> ]
> if DEBUG_TOOLBAR:
>     INSTALLED_APPS += ["debug_toolbar"]
>     MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
>     DEBUG_TOOLBAR_PANELS = [
>         "debug_toolbar.panels.history.HistoryPanel",
>         "debug_toolbar.panels.versions.VersionsPanel",
>         "debug_toolbar.panels.timer.TimerPanel",
>         "debug_toolbar.panels.settings.SettingsPanel",
>         "debug_toolbar.panels.headers.HeadersPanel",
>         "debug_toolbar.panels.request.RequestPanel",
>         "debug_toolbar.panels.sql.SQLPanel",
>         # 'debug_toolbar.panels.staticfiles.StaticFilesPanel',  # breaks if minio is enabled
>         "debug_toolbar.panels.templates.TemplatesPanel",
>         # "debug_toolbar.panels.cache.CachePanel",  # commenting this line allows DDT to continue to work
>         "debug_toolbar.panels.signals.SignalsPanel",
>         "debug_toolbar.panels.logging.LoggingPanel",
>         "debug_toolbar.panels.redirects.RedirectsPanel",
>         "debug_toolbar.panels.profiling.ProfilingPanel",
>     ]
>
> Please let me know if you need any more information in order to solve this
> issue
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/jazzband/django-debug-toolbar/issues/1769>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAJYZP4UMTGEHJQUW7PBYA3XEZAU5ANCNFSM6AAAAAAXYC2YPM>
> .
> You are receiving this because you are subscribed to this thread.Message
> ID: ***@***.***>
>

I haven't done any additional patching, just trying to use DDT as usual. 

My cache settings:
```python
if env("CACHE_URL", default=None) is not None:
    CACHES = {
        "default": env.cache(),
    }
    if env("SESSION_CACHE_URL", default=None) is not None:
        CACHES["session"] = env.cache("SESSION_CACHE_URL")
        SESSION_ENGINE = "django.contrib.sessions.backends.cache"
        SESSION_CACHE_ALIAS = "session"
```
My environment file:
```ini
CACHE_URL=rediscache://localhost:6379/2
SESSION_CACHE_URL=rediscache://localhost:6379/3
```
I have the same problem. It is weird that I somehow can't reproduce it locally.
Here is the stack trace:

```
AttributeError: 'function' object has no attribute '__func__'
  File "django/core/handlers/exception.py", line 56, in inner
    response = get_response(request)
  File "debug_toolbar/middleware.py", line 64, in __call__
    panel.disable_instrumentation()
  File "debug_toolbar/panels/cache.py", line 217, in disable_instrumentation
    self._unmonkey_patch_cache(cache)
  File "debug_toolbar/panels/cache.py", line 171, in _unmonkey_patch_cache
    if original_method.__func__ == getattr(cache.__class__, name):
```
and sentry says this about locals on the last line:

> cache: <django.core.cache.backends.redis.RedisCache object at 0x7f6395470e10>
> name: 'get'
> original_method: <function RedisCache.get at 0x7f63954cb2e0>

and cache settings as follows:
```
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
....
   }
```
Are you sure your local instance matches the other environment? Are you
using redis locally?

On Sat, May 6, 2023, 9:48 AM Dmitry Ulupov ***@***.***> wrote:

> I have the same problem. It is weird that I somehow can't reproduce it
> locally.
> Here is the stack trace:
>
> AttributeError: 'function' object has no attribute '__func__'
>   File "django/core/handlers/exception.py", line 56, in inner
>     response = get_response(request)
>   File "debug_toolbar/middleware.py", line 64, in __call__
>     panel.disable_instrumentation()
>   File "debug_toolbar/panels/cache.py", line 217, in disable_instrumentation
>     self._unmonkey_patch_cache(cache)
>   File "debug_toolbar/panels/cache.py", line 171, in _unmonkey_patch_cache
>     if original_method.__func__ == getattr(cache.__class__, name):
>
> and sentry says this about locals on the last line:
>
> cache: <django.core.cache.backends.redis.RedisCache object at
> 0x7f6395470e10>
> name: 'get'
> original_method: <function RedisCache.get at 0x7f63954cb2e0>
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/jazzband/django-debug-toolbar/issues/1769#issuecomment-1537157527>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAJYZP2WORDT23CDNNUTCZDXEZQDDANCNFSM6AAAAAAXYC2YPM>
> .
> You are receiving this because you commented.Message ID:
> ***@***.***>
>

Can you give us more information about how this is running? Is it local via runserver? Is it using gunicorn, etc?
> Are you sure your local instance matches the other environment? Are you using redis locally?

I'm running redis through a docker container
```yml
services:
  redis:
    image: redis:6
    ports:
      - "6379:6379"
```
The Django app is run through `runserver`
The cache call recording has changed a lot in django-debug-toolbar 3.5; could you check if this version also fails? That may be useful. 

(I cannot reproduce the failure either.)
Addendum: I cannot reproduce it *locally*. I haven't tried running the toolbar in a production environment (my opinion and stance is that it is a bad idea)
It is a weird thing. After many attempts - only hints on how to reproduce it.  
So far I've managed only to somehow change stack trace! So maybe it is a different issue now.   
Image that is running on two different container-based hosting platforms fails on both of them, but downloaded locally this same image works fine.  
No need for redis. It is happening with LocMem cache as well.  

As a workaround I've disabled cache panel. Now it works okay.  
But if Cache panel is to be manually enabled - starting from second page reload (🤔) it starts failing.  

* It does not happen on version 3.4.0 and happens on 3.8.1 and 4.0.0.
* It happens when it runs through uwsgi (2.0.21) inside image built based on python:3.11.3
* Django 4.1.9
* RedisCache, DatabaseCache and LocMem all have same behaviour.

Stack trace with LocMem:
```
AttributeError: 'function' object has no attribute '_djdt_wrapped'
  File "django/core/handlers/exception.py", line 56, in inner
    response = get_response(request)
  File "debug_toolbar/middleware.py", line 64, in __call__
    panel.disable_instrumentation()
  File "debug_toolbar/panels/cache.py", line 217, in disable_instrumentation
    self._unmonkey_patch_cache(cache)
  File "debug_toolbar/panels/cache.py", line 170, in _unmonkey_patch_cache
    original_method = getattr(cache, name)._djdt_wrapped


Sentry's context:
cache: <django.core.cache.backends.locmem.LocMemCache object at 0x7f786ec059d0>
name: 'add'
```

Sorry about messy pip freeze, but maybe it will be helpful. 

<details><summary>pip freeze</summary>

```
amqp==5.1.1
ansi2html==1.8.0
arabic-reshaper==3.0.0
asgiref==3.6.0
asn1crypto==1.5.1
astroid==2.15.4
asttokens==2.2.1
async-timeout==4.0.2
attrs==23.1.0
aws-encryption-sdk==3.1.1
backcall==0.2.0
bcrypt==4.0.1
beautifulsoup4==4.12.2
billiard==3.6.4.0
black==23.3.0
boto3==1.26.129
botocore==1.29.129
celery==5.2.7
certifi==2023.5.7
cffi==1.15.1
charset-normalizer==3.1.0
click==8.1.3
click-didyoumean==0.3.0
click-plugins==1.1.1
click-repl==0.2.0
cryptography==39.0.2
cssselect2==0.7.0
decorator==5.1.1
defusedxml==0.7.1
dill==0.3.6
Django==4.1.9
django-ace==1.19.0
django-allauth==0.54.0
django-amazon-ses==4.0.1
django-debug-toolbar==4.0.0
django-dirtyfields==1.9.2
django-pgactivity==1.1.1
django-pglock==1.1.0
django-pgtrigger==4.6.0
django-redis==5.2.0
dj-database-url==2.0.0
djhtml==3.0.6
dnspython==2.3.0
executing==1.2.0
fabric==2.7.1
factory-boy==3.2.1
Faker==18.6.2
filelock==3.12.0
freezegun==1.2.2
html5lib==1.1
idna==3.4
invoke==1.7.3
ipython==8.13.2
isort==5.12.0
jedi==0.18.2
jmespath==1.0.1
josepy==1.13.0
jsonpickle==3.0.1
kombu==5.2.4
lazy-object-proxy==1.9.0
lxml==4.9.2
matplotlib-inline==0.1.6
mccabe==0.7.0
mypy-extensions==1.0.0
netaddr==0.8.0
oauthlib==3.2.2
oscrypto==1.3.0
packaging==23.1
paramiko==2.12.0
parso==0.8.3
pathlib2==2.3.7.post1
pathspec==0.11.1
pexpect==4.8.0
pickleshare==0.7.5
Pillow==9.5.0
platformdirs==3.5.0
profilehooks==1.12.0
prometheus-client==0.16.0
prompt-toolkit==3.0.38
psutil==5.9.5
psycopg2==2.9.6
ptyprocess==0.7.0
pure-eval==0.2.2
pycparser==2.21
pycurl==7.44.1
Pygments==2.15.1
pyHanko==0.18.1
pyhanko-certvalidator==0.22.0
PyJWT==2.6.0
pylint==2.17.4
pylint-django==2.5.3
pylint-plugin-utils==0.7
PyNaCl==1.5.0
pyOpenSSL==23.1.1
pypdf==3.8.1
pypng==0.20220715.0
pyRFC3339==1.1
python3-openid==3.2.0
python-bidi==0.4.2
python-dateutil==2.8.2
python-digitalocean==1.17.0
pytz==2023.3
pytz-deprecation-shim==0.1.0.post0
PyYAML==6.0
qrcode==7.4.2
redis==4.5.4
reportlab==3.6.13
requests==2.30.0
requests-file==1.5.1
requests-oauthlib==1.3.1
s3transfer==0.6.1
sentry-sdk==1.22.1
simplejson==3.19.1
six==1.16.0
soupsieve==2.4.1
sqlparse==0.4.4
stack-data==0.6.2
stripe==5.4.0
structlog==22.3.0
svglib==1.5.1
tinycss2==1.2.1
tldextract==3.4.1
tomlkit==0.11.8
traitlets==5.9.0
typing_extensions==4.5.0
tzdata==2023.3
tzlocal==4.3
uritools==4.0.1
urllib3==1.26.15
uWSGI==2.0.21
vine==5.0.0
wcwidth==0.2.6
webencodings==0.5.1
wrapt==1.15.0
xhtml2pdf==0.2.11
```

</details> 

Cache settings: 
```
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
        "KEY_PREFIX": "prefix",
        "VERSION": 1,
        "TIMEOUT": 24 * 60 * 60,
        "OPTIONS": {
            "MAX_ENTRIES": 5000,
            "CULL_FREQUENCY": 5,
        },
    },
}
```
I'm also seeing this after upgrading from django 4.1 to 4.2:

```
Environment:


Request Method: GET
Request URL: https://www.ideasenfoto.localhost:8000/admin/

Django Version: 4.2.1
Python Version: 3.11.2
Installed Applications:
['idf.core',
 'django.contrib.auth',
 'idf.auth',
 'idf.backoffice',
 'idf.admin',
 'idf.shipping',
 'idf.reports',
 'nested_admin',
 'django.contrib.admin',
 'django.contrib.admindocs',
 'django.contrib.contenttypes',
 'polymorphic',
 'django.contrib.sessions',
 'django.contrib.messages',
 'django.contrib.staticfiles',
 'django.contrib.humanize',
 'django.contrib.gis',
 'ckeditor',
 'idf.coupons',
 'storages',
 'debug_toolbar',
 'django_afip',
 'django.contrib.postgres',
 'django_inlinecss',
 'rest_framework',
 'idf.sync',
 'idf.imaging',
 'idf.api',
 'idf.tenants',
 'manifest',
 'mptt',
 'payments',
 'anymail',
 'idf.emails']
Installed Middleware:
['django.middleware.security.SecurityMiddleware',
 'debug_toolbar.middleware.DebugToolbarMiddleware',
 'django.contrib.sessions.middleware.SessionMiddleware',
 'django.middleware.locale.LocaleMiddleware',
 'django.middleware.common.CommonMiddleware',
 'django.middleware.csrf.CsrfViewMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware',
 'django.contrib.messages.middleware.MessageMiddleware',
 'django.middleware.clickjacking.XFrameOptionsMiddleware']



Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
               ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/middleware.py", line 176, in __call__
    return f(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/debug_toolbar/middleware.py", line 64, in __call__
    panel.disable_instrumentation()
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/debug_toolbar/panels/cache.py", line 217, in disable_instrumentation
    self._unmonkey_patch_cache(cache)
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/debug_toolbar/panels/cache.py", line 171, in _unmonkey_patch_cache
    if original_method.__func__ == getattr(cache.__class__, name):
       ^^^^^^^^^^^^^^^^^^^^^^^^

Exception Type: AttributeError at /admin/
Exception Value: 'function' object has no attribute '__func__'
```

I see sentry in the stacktrace there. AFAIK, sentry does some money-patching too, so there might be a conflict there.

My `settings.py` includes:

```py
init_sentry(
    attach_stacktrace=True,
    before_send=before_send,
    integrations=[
        AwsLambdaIntegration(timeout_warning=True),
        CeleryIntegration(),
        DjangoIntegration(),
        LoggingIntegration(event_level=logging.WARNING),
    ],
    release=version,
    in_app_exclude=["logging"],
    ignore_errors=["idf.core.views.TestException"],
    traces_sample_rate=0,
    environment=TENANT_SLUG,
)
```

If I comment out this block, my site works again. It seems that the conflict arises when debug toolbar AND sentry are both present.
Ah yes, of course. A class instance with a `__call__` method is callable but doesn't have a `__func__` attribute. I bet that's what is happening. Excellent debugging!

(I wonder if we really should do that much work in `disable_instrumentation` or if we should just not record anything if the panel is inactive.)
Since I was the author of those changes I'll try to take a look.

Resolving patch:
diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py
--- a/debug_toolbar/panels/cache.py
+++ b/debug_toolbar/panels/cache.py
@@ -30,6 +30,27 @@
 ]
 
 
+def _monkey_patch_method(cache, name):
+    original_method = getattr(cache, name)
+
+    @functools.wraps(original_method)
+    def wrapper(*args, **kwargs):
+        panel = cache._djdt_panel
+        if panel is None:
+            return original_method(*args, **kwargs)
+        else:
+            return panel._record_call(cache, name, original_method, args, kwargs)
+
+    setattr(cache, name, wrapper)
+
+
+def _monkey_patch_cache(cache):
+    if not hasattr(cache, "_djdt_patched"):
+        for name in WRAPPED_CACHE_METHODS:
+            _monkey_patch_method(cache, name)
+        cache._djdt_patched = True
+
+
 class CachePanel(Panel):
     """
     Panel that displays the cache statistics.
@@ -72,7 +93,8 @@ def wrapper(self, alias):
                 cache = original_method(self, alias)
                 panel = cls.current_instance()
                 if panel is not None:
-                    panel._monkey_patch_cache(cache)
+                    _monkey_patch_cache(cache)
+                    cache._djdt_panel = panel
                 return cache
 
             CacheHandler.create_connection = wrapper
@@ -120,14 +142,17 @@ def _store_call_info(
     def _record_call(self, cache, name, original_method, args, kwargs):
         # Some cache backends implement certain cache methods in terms of other cache
         # methods (e.g. get_or_set() in terms of get() and add()).  In order to only
-        # record the calls made directly by the user code, set the _djdt_recording flag
-        # here to cause the monkey patched cache methods to skip recording additional
-        # calls made during the course of this call.
-        cache._djdt_recording = True
-        t = time.time()
-        value = original_method(*args, **kwargs)
-        t = time.time() - t
-        cache._djdt_recording = False
+        # record the calls made directly by the user code, set the cache's _djdt_panel
+        # attribute to None before invoking the original method, which will cause the
+        # monkey-patched cache methods to skip recording additional calls made during
+        # the course of this call, and then reset it back afterward.
+        cache._djdt_panel = None
+        try:
+            t = time.time()
+            value = original_method(*args, **kwargs)
+            t = time.time() - t
+        finally:
+            cache._djdt_panel = self
 
         self._store_call_info(
             name=name,
@@ -141,40 +166,6 @@ def _record_call(self, cache, name, original_method, args, kwargs):
         )
         return value
 
-    def _monkey_patch_method(self, cache, name):
-        original_method = getattr(cache, name)
-
-        @functools.wraps(original_method)
-        def wrapper(*args, **kwargs):
-            # If this call is being made as part of the implementation of another cache
-            # method, don't record it.
-            if cache._djdt_recording:
-                return original_method(*args, **kwargs)
-            else:
-                return self._record_call(cache, name, original_method, args, kwargs)
-
-        wrapper._djdt_wrapped = original_method
-        setattr(cache, name, wrapper)
-
-    def _monkey_patch_cache(self, cache):
-        if not hasattr(cache, "_djdt_patched"):
-            for name in WRAPPED_CACHE_METHODS:
-                self._monkey_patch_method(cache, name)
-            cache._djdt_patched = True
-            cache._djdt_recording = False
-
-    @staticmethod
-    def _unmonkey_patch_cache(cache):
-        if hasattr(cache, "_djdt_patched"):
-            for name in WRAPPED_CACHE_METHODS:
-                original_method = getattr(cache, name)._djdt_wrapped
-                if original_method.__func__ == getattr(cache.__class__, name):
-                    delattr(cache, name)
-                else:
-                    setattr(cache, name, original_method)
-            del cache._djdt_patched
-            del cache._djdt_recording
-
     # Implement the Panel API
 
     nav_title = _("Cache")
@@ -204,7 +195,8 @@ def enable_instrumentation(self):
         # the .ready() method will ensure that any new cache connections that get opened
         # during this request will also be monkey patched.
         for cache in caches.all(initialized_only=True):
-            self._monkey_patch_cache(cache)
+            _monkey_patch_cache(cache)
+            cache._djdt_panel = self
         # Mark this panel instance as the current one for the active thread/async task
         # context.  This will be used by the CacheHander.create_connection() monkey
         # patch.
@@ -214,7 +206,7 @@ def disable_instrumentation(self):
         if hasattr(self._context_locals, "current_instance"):
             del self._context_locals.current_instance
         for cache in caches.all(initialized_only=True):
-            self._unmonkey_patch_cache(cache)
+            cache._djdt_panel = None
 
     def generate_stats(self, request, response):
         self.record_stats(


Test patch: