Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions debug_toolbar/panels/profiling.py
Original file line number Diff line number Diff line change
@@ -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__(
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use django.core.files.storage.default_storage instead of instantiating a new one?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we going in the wrong direction here trying to write actual files to the server? Couldn't we just store the file's content with the panel, then render that out like a file in download_prof_file?

Sorry to shift our direction again, but something isn't sitting right with me. What do you think? I'd love to know if you feel the same.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a question i assume by storing the files content in the panel this would be ephemeral right? If we dont need persistence we could do that

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, they would be. Unless of course they are using the DatabaseStore.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database store also prunes old records. We have no guarantees regarding the contents of debug logs for now, for example across version upgrades. At least that's my understanding

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 = []
Expand All @@ -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),
}
)
1 change: 1 addition & 0 deletions debug_toolbar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"),
Expand Down
4 changes: 4 additions & 0 deletions debug_toolbar/static/debug_toolbar/css/toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -1223,3 +1223,7 @@ To regenerate:
#djDebug .djdt-community-panel a:hover {
text-decoration: underline;
}

#djDebug .djdt-profiling-control {
margin-bottom: 10px;
}
39 changes: 24 additions & 15 deletions debug_toolbar/templates/debug_toolbar/panels/profiling.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
{% load i18n %}

{% if prof_file_path %}
<div class="djdt-profiling-control">
<a href="{% url 'djdt:debug_toolbar_download_prof_file' %}?path={{ prof_file_path|urlencode }}" class="djDebugButton" target="_blank">
Download .prof file
</a>
</div>
{% endif %}

<table>
<thead>
<tr>
Expand All @@ -13,22 +22,22 @@
<tbody>
{% for call in func_list %}
<tr class="djdt-profile-row {% if call.is_project_func %}djdt-highlighted {% endif %} {% for parent_id in call.parent_ids %} djToggleDetails_{{ parent_id }}{% endfor %}" id="profilingMain_{{ call.id }}">
<td>
<div data-djdt-styles="paddingLeft:{{ call.indent }}px">
{% if call.has_subfuncs %}
<td>
<div data-djdt-styles="paddingLeft:{{ call.indent }}px">
{% if call.has_subfuncs %}
<button type="button" class="djProfileToggleDetails djToggleSwitch" data-toggle-name="profilingMain" data-toggle-id="{{ call.id }}">-</button>
{% else %}
<span class="djNoToggleSwitch"></span>
{% endif %}
<span class="djdt-stack">{{ call.func_std_string|safe }}</span>
</div>
</td>
<td>{{ call.cumtime|floatformat:3 }}</td>
<td>{{ call.cumtime_per_call|floatformat:3 }}</td>
<td>{{ call.tottime|floatformat:3 }}</td>
<td>{{ call.tottime_per_call|floatformat:3 }}</td>
<td>{{ call.count }}</td>
</tr>
{% else %}
<span class="djNoToggleSwitch"></span>
{% endif %}
<span class="djdt-stack">{{ call.func_std_string|safe }}</span>
</div>
</td>
<td>{{ call.cumtime|floatformat:3 }}</td>
<td>{{ call.cumtime_per_call|floatformat:3 }}</td>
<td>{{ call.tottime|floatformat:3 }}</td>
<td>{{ call.tottime_per_call|floatformat:3 }}</td>
<td>{{ call.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
5 changes: 5 additions & 0 deletions debug_toolbar/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
),
]
# Per-panel URLs
for panel_class in cls.get_panel_classes():
Expand Down
1 change: 1 addition & 0 deletions debug_toolbar/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
from debug_toolbar.toolbar import DebugToolbar

app_name = APP_NAME

urlpatterns = DebugToolbar.get_urls()
26 changes: 25 additions & 1 deletion debug_toolbar/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -28,3 +31,24 @@ def render_panel(request: HttpRequest) -> JsonResponse:
content = panel.content
scripts = panel.scripts
return JsonResponse({"content": content, "scripts": scripts})


Comment thread
JohananOppongAmoateng marked this conversation as resolved.
@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
4 changes: 4 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
106 changes: 106 additions & 0 deletions tests/panels/test_profiling.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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"]
Expand All @@ -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)
Loading