diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py index 263e7a004..2fdd6f8f6 100644 --- a/debug_toolbar/panels/profiling.py +++ b/debug_toolbar/panels/profiling.py @@ -1,15 +1,21 @@ import cProfile +import logging import os +import tempfile from colorsys import hsv_to_rgb from pstats import Stats from django.conf import settings +from django.core.files.base import ContentFile +from django.core.files.storage import FileSystemStorage from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from debug_toolbar import settings as dt_settings from debug_toolbar.panels import Panel +logger = logging.getLogger(__name__) + class FunctionCall: def __init__( @@ -183,8 +189,27 @@ def generate_stats(self, request, response): self.stats = Stats(self.profiler) self.stats.calc_callees() - root_func = cProfile.label(super().process_request.__code__) + profile_root = dt_settings.get_config()["PROFILER_PROFILE_ROOT"] + storage = FileSystemStorage(location=profile_root) + filename = f"djdt_profile_{self.toolbar.request_id}.prof" + try: + with tempfile.NamedTemporaryFile() as tmp: + self.profiler.dump_stats(tmp.name) + if storage.exists(filename): + storage.delete(filename) + # ContentFile reads the stream, so we point back to start + tmp.seek(0) + saved_name = storage.save(filename, ContentFile(tmp.read())) + self.prof_file_path = saved_name + except Exception: + logger.exception( + "Failed to dump profiling stats to %s", + filename, + ) + # If writing to the file fails, we don't want to break the + # whole page. + 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 +222,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/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..44cc87418 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..9c39dc357 100644 --- a/debug_toolbar/views.py +++ b/debug_toolbar/views.py @@ -1,7 +1,10 @@ -from django.http import HttpRequest, JsonResponse +from django.core.files.storage import FileSystemStorage +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 +31,24 @@ def render_panel(request: HttpRequest) -> JsonResponse: content = panel.content scripts = panel.scripts return JsonResponse({"content": content, "scripts": scripts}) + + +@require_GET +@login_not_required +def download_prof_file(request): + root = dt_settings.get_config()["PROFILER_PROFILE_ROOT"] + # If root is None, FileSystemStorage defaults to MEDIA_ROOT + storage = FileSystemStorage(location=root) + + if not (filename := request.GET.get("path")): + raise Http404() + + try: + return FileResponse( + storage.open(filename), + as_attachment=True, + filename=filename, + content_type="application/octet-stream", + ) + except FileNotFoundError: + raise Http404() from None diff --git a/docs/changes.rst b/docs/changes.rst index 7b1c39849..9167fa1ed 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -3,6 +3,10 @@ Change log Pending ------- +* 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.2.0 (2026-01-20) ------------------ diff --git a/docs/configuration.rst b/docs/configuration.rst index 2ff363888..dfd5d9fc2 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -351,6 +351,16 @@ 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. + * ``PROFILER_THRESHOLD_RATIO`` Default: ``8`` diff --git a/tests/panels/test_profiling.py b/tests/panels/test_profiling.py index 320c657ac..2c9c3cac2 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 unittest import mock from django.contrib.auth.models import User 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,41 @@ def test_generate_stats_no_profiler(self): response = HttpResponse() self.assertIsNone(self.panel.generate_stats(self.request, response)) + def test_generate_stats_signed_path(self): + with tempfile.TemporaryDirectory() as tmpdir: + with self.settings(DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tmpdir}): + 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 = path + self.assertTrue(filename.endswith(".prof")) + + def test_generate_stats_no_root(self): + # If PROFILER_PROFILE_ROOT is None, it should default to MEDIA_ROOT (or default storage location) + # We need to ensure we can write to it for this test to pass without logging an error. + # But wait, BaseTestCase might not set up MEDIA_ROOT. + # let's override settings to be safe, pointing MEDIA_ROOT to a temp dir. + with tempfile.TemporaryDirectory() as tmpdir: + with self.settings( + DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": None}, + MEDIA_ROOT=tmpdir, + STORAGES={ + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + } + }, + ): + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + # Should now have a path because we fall back to default storage + self.assertTrue(hasattr(self.panel, "prof_file_path")) + self.assertTrue(self.panel.prof_file_path.endswith(".prof")) + # Verify it was written to tmpdir + full_path = os.path.join(tmpdir, self.panel.prof_file_path) + self.assertTrue(os.path.exists(full_path)) + def test_generate_stats_no_root_func(self): """ Test generating stats using profiler without root function. @@ -88,6 +129,20 @@ def test_generate_stats_no_root_func(self): self.panel.generate_stats(self.request, response) self.assertNotIn("func_list", self.panel.get_stats()) + @mock.patch("django.core.files.storage.FileSystemStorage.save") + def test_generate_stats_oserror(self, mock_save): + mock_save.side_effect = OSError + with tempfile.TemporaryDirectory() as tmpdir: + with self.settings(DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tmpdir}): + response = self.panel.process_request(self.request) + with self.assertLogs( + "debug_toolbar.panels.profiling", level="ERROR" + ) as cm: + self.panel.generate_stats(self.request, response) + self.assertIn("Failed to dump profiling stats", cm.output[0]) + # Ensure prof_file_path is not set/updated if dump fails + self.assertFalse(hasattr(self.panel, "prof_file_path")) + @override_settings( DEBUG=True, DEBUG_TOOLBAR_PANELS=["debug_toolbar.panels.profiling.ProfilingPanel"] @@ -103,3 +158,54 @@ 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.path = self.filename + + def tearDown(self): + shutil.rmtree(self.root) + + def test_download_default_storage(self): + # Verify downloading works if PROFILER_PROFILE_ROOT is unset (None), + # falling back to default storage (which often uses MEDIA_ROOT for FileSystemStorage default). + # We simulate this by setting MEDIA_ROOT to our temp dir and PROFILER_PROFILE_ROOT to None. + with override_settings( + DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": None}, + MEDIA_ROOT=self.root, + STORAGES={ + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + } + }, + ): + url = reverse("djdt:debug_toolbar_download_prof_file") + # The file 'test.prof' exists in self.root (which is now MEDIA_ROOT) + response = self.client.get(url, {"path": self.filename}) + self.assertEqual(response.status_code, 200) + self.assertEqual(list(response.streaming_content), [b"data"]) + + 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.path}) + self.assertEqual(response.status_code, 200) + self.assertEqual(list(response.streaming_content), [b"data"]) + + 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 = "missing.prof" + response = self.client.get(url, {"path": path}) + self.assertEqual(response.status_code, 404)