diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py index 263e7a004..138f2041d 100644 --- a/debug_toolbar/panels/profiling.py +++ b/debug_toolbar/panels/profiling.py @@ -1,9 +1,11 @@ import cProfile import os +import uuid from colorsys import hsv_to_rgb from pstats import Stats from django.conf import settings +from django.core import signing from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ @@ -183,8 +185,15 @@ def generate_stats(self, request, response): self.stats = Stats(self.profiler) self.stats.calc_callees() - root_func = cProfile.label(super().process_request.__code__) + if ( + root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"] + ) and os.path.exists(root): + filename = f"{uuid.uuid4().hex}.prof" + prof_file_path = os.path.join(root, filename) + self.profiler.dump_stats(prof_file_path) + self.prof_file_path = signing.dumps(filename) + root_func = cProfile.label(super().process_request.__code__) if root_func in self.stats.stats: root = FunctionCall(self.stats, root_func, depth=0) func_list = [] @@ -197,4 +206,9 @@ def generate_stats(self, request, response): dt_settings.get_config()["PROFILER_MAX_DEPTH"], cum_time_threshold, ) - self.record_stats({"func_list": [func.serialize() for func in func_list]}) + self.record_stats( + { + "func_list": [func.serialize() for func in func_list], + "prof_file_path": getattr(self, "prof_file_path", None), + } + ) diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py index 11d378413..6201b8f96 100644 --- a/debug_toolbar/panels/sql/tracking.py +++ b/debug_toolbar/panels/sql/tracking.py @@ -146,7 +146,20 @@ def _last_executed_query(self, sql, params): # process during the .last_executed_query() call. self.db._djdt_logger = None try: - return self.db.ops.last_executed_query(self.cursor, sql, params) + # Handle executemany: take the first set of parameters for formatting + if ( + isinstance(params, (list, tuple)) + and len(params) > 0 + and isinstance(params[0], (list, tuple)) + ): + sample_params = params[0] + else: + sample_params = params + + try: + return self.db.ops.last_executed_query(self.cursor, sql, sample_params) + except Exception: + return sql finally: self.db._djdt_logger = self.logger diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index ba64c8273..8cddcba92 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -52,6 +52,7 @@ def _is_running_tests(): "PRETTIFY_SQL": True, "PROFILER_CAPTURE_PROJECT_CODE": True, "PROFILER_MAX_DEPTH": 10, + "PROFILER_PROFILE_ROOT": None, "PROFILER_THRESHOLD_RATIO": 8, "SHOW_TEMPLATE_CONTEXT": True, "SKIP_TEMPLATE_PREFIXES": ("django/forms/widgets/", "admin/widgets/"), diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css index 044e15e5f..ce91bde09 100644 --- a/debug_toolbar/static/debug_toolbar/css/toolbar.css +++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css @@ -1223,3 +1223,7 @@ To regenerate: #djDebug .djdt-community-panel a:hover { text-decoration: underline; } + +#djDebug .djdt-profiling-control { + margin-bottom: 10px; +} diff --git a/debug_toolbar/templates/debug_toolbar/panels/profiling.html b/debug_toolbar/templates/debug_toolbar/panels/profiling.html index 422111f79..870b81891 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/profiling.html +++ b/debug_toolbar/templates/debug_toolbar/panels/profiling.html @@ -1,4 +1,13 @@ {% load i18n %} + +{% if prof_file_path %} +
+ + Download .prof file + +
+{% endif %} + @@ -13,22 +22,22 @@ {% for call in func_list %} - - - - - - - + {% else %} + + {% endif %} + {{ call.func_std_string|safe }} + + + + + + + + {% endfor %}
-
- {% if call.has_subfuncs %} +
+
+ {% if call.has_subfuncs %} - {% else %} - - {% endif %} - {{ call.func_std_string|safe }} -
-
{{ call.cumtime|floatformat:3 }}{{ call.cumtime_per_call|floatformat:3 }}{{ call.tottime|floatformat:3 }}{{ call.tottime_per_call|floatformat:3 }}{{ call.count }}
{{ call.cumtime|floatformat:3 }}{{ call.cumtime_per_call|floatformat:3 }}{{ call.tottime|floatformat:3 }}{{ call.tottime_per_call|floatformat:3 }}{{ call.count }}
diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 0e22c8f06..805d34489 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -163,6 +163,11 @@ def get_urls(cls) -> list[URLPattern | URLResolver]: # Global URLs urlpatterns = [ path("render_panel/", views.render_panel, name="render_panel"), + path( + "download_prof_file/", + views.download_prof_file, + name="debug_toolbar_download_prof_file", + ), ] # Per-panel URLs for panel_class in cls.get_panel_classes(): diff --git a/debug_toolbar/urls.py b/debug_toolbar/urls.py index 5aa0d69e9..38ed785aa 100644 --- a/debug_toolbar/urls.py +++ b/debug_toolbar/urls.py @@ -2,4 +2,5 @@ from debug_toolbar.toolbar import DebugToolbar app_name = APP_NAME + urlpatterns = DebugToolbar.get_urls() diff --git a/debug_toolbar/views.py b/debug_toolbar/views.py index 739f2f314..570386c85 100644 --- a/debug_toolbar/views.py +++ b/debug_toolbar/views.py @@ -1,7 +1,12 @@ -from django.http import HttpRequest, JsonResponse +import pathlib + +from django.core import signing +from django.http import FileResponse, Http404, HttpRequest, JsonResponse from django.utils.html import escape from django.utils.translation import gettext as _ +from django.views.decorators.http import require_GET +from debug_toolbar import settings as dt_settings from debug_toolbar._compat import login_not_required from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar from debug_toolbar.panels import Panel @@ -28,3 +33,27 @@ def render_panel(request: HttpRequest) -> JsonResponse: content = panel.content scripts = panel.scripts return JsonResponse({"content": content, "scripts": scripts}) + + +@require_GET +def download_prof_file(request): + if not (root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"]): + raise Http404() + + if not (file_path := request.GET.get("path")): + raise Http404() + + try: + filename = signing.loads(file_path) + except signing.BadSignature: + raise Http404() from None + + resolved_path = pathlib.Path(root) / filename + if not resolved_path.exists(): + raise Http404() + + response = FileResponse( + open(resolved_path, "rb"), content_type="application/octet-stream" + ) + response["Content-Disposition"] = f'attachment; filename="{resolved_path.name}"' + return response diff --git a/docs/changes.rst b/docs/changes.rst index 948b90dec..4164c389a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,6 +18,9 @@ Pending * Added test to confirm Django's ``TestCase.assertNumQueries`` works. * Fixed string representation of values in settings panel. * Declared support for Django 6.0. +* Added the ability to download the profiling data as a file. This feature is + disabled by default and requires the ``PROFILER_PROFILE_ROOT`` setting to be + configured. 6.1.0 (2025-10-30) ------------------ diff --git a/docs/configuration.rst b/docs/configuration.rst index 2ff363888..bbcce5db4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -351,6 +351,17 @@ Panel options This setting affects the depth of function calls in the profiler's analysis. +* ``PROFILER_PROFILE_ROOT`` + + Default: ``None`` + + Panel: profiling + + This setting controls the directory where profile files are saved. If set + to ``None`` (the default), the profile file is not saved and the download + link is not shown. This directory must exist and be writable by the + web server process. + * ``PROFILER_THRESHOLD_RATIO`` Default: ``8`` diff --git a/tests/panels/test_profiling.py b/tests/panels/test_profiling.py index 320c657ac..363c896dc 100644 --- a/tests/panels/test_profiling.py +++ b/tests/panels/test_profiling.py @@ -1,10 +1,16 @@ +import os +import shutil import sys +import tempfile import unittest from django.contrib.auth.models import User +from django.core import signing from django.db import IntegrityError, transaction from django.http import HttpResponse +from django.test import TestCase from django.test.utils import override_settings +from django.urls import reverse from debug_toolbar.panels.profiling import ProfilingPanel @@ -77,6 +83,24 @@ def test_generate_stats_no_profiler(self): response = HttpResponse() self.assertIsNone(self.panel.generate_stats(self.request, response)) + @override_settings( + DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tempfile.gettempdir()} + ) + def test_generate_stats_signed_path(self): + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + path = self.panel.prof_file_path + self.assertTrue(path) + # Check that it's a valid signature + filename = signing.loads(path) + self.assertTrue(filename.endswith(".prof")) + + def test_generate_stats_no_root(self): + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + # Should not have a path if root is not set + self.assertFalse(hasattr(self.panel, "prof_file_path")) + def test_generate_stats_no_root_func(self): """ Test generating stats using profiler without root function. @@ -103,3 +127,48 @@ def test_view_executed_once(self): with self.assertRaises(IntegrityError), transaction.atomic(): response = self.client.get("/new_user/") self.assertEqual(User.objects.count(), 1) + + +class ProfilingDownloadViewTestCase(TestCase): + def setUp(self): + self.root = tempfile.mkdtemp() + self.filename = "test.prof" + self.filepath = os.path.join(self.root, self.filename) + with open(self.filepath, "wb") as f: + f.write(b"data") + self.signed_path = signing.dumps(self.filename) + + def tearDown(self): + shutil.rmtree(self.root) + + def test_download_no_root_configured(self): + response = self.client.get(reverse("djdt:debug_toolbar_download_prof_file")) + self.assertEqual(response.status_code, 404) + + def test_download_valid(self): + with override_settings( + DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} + ): + url = reverse("djdt:debug_toolbar_download_prof_file") + response = self.client.get(url, {"path": self.signed_path}) + self.assertEqual(response.status_code, 200) + self.assertEqual(list(response.streaming_content), [b"data"]) + + def test_download_invalid_signature(self): + with override_settings( + DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} + ): + url = reverse("djdt:debug_toolbar_download_prof_file") + # Tamper with the signature + response = self.client.get(url, {"path": self.signed_path + "bad"}) + self.assertEqual(response.status_code, 404) + + def test_download_missing_file(self): + with override_settings( + DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} + ): + url = reverse("djdt:debug_toolbar_download_prof_file") + # Sign a filename that doesn't exist + path = signing.dumps("missing.prof") + response = self.client.get(url, {"path": path}) + self.assertEqual(response.status_code, 404)