Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
48ac250
Add .prof File Download Support to Profiling Panel
andoriyaprashant Jun 18, 2025
49f29eb
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 18, 2025
7ec7f25
Merge branch 'main' of https://github.com/django-commons/django-debug…
JohananOppongAmoateng Dec 17, 2025
fe4e305
Refactor profiling panel and add download functionality for .prof files
JohananOppongAmoateng Dec 17, 2025
5db558d
Add profiling data download functionality and related settings
JohananOppongAmoateng Dec 17, 2025
74ddf5e
Add tests for profiling stats generation and download functionality
JohananOppongAmoateng Dec 17, 2025
5bbb211
fix
JohananOppongAmoateng Dec 17, 2025
a285d82
fix
JohananOppongAmoateng Dec 17, 2025
2bd5cfe
fix
JohananOppongAmoateng Dec 17, 2025
e698cde
Merge branch 'main' into download-prof-files
JohananOppongAmoateng Dec 17, 2025
66e2d50
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 17, 2025
3896fe3
Merge branch 'main' of https://github.com/django-commons/django-debug…
JohananOppongAmoateng Feb 7, 2026
09f2ba0
address problems
JohananOppongAmoateng Feb 7, 2026
9829c58
docs
JohananOppongAmoateng Feb 7, 2026
cfc244b
address review concerns
JohananOppongAmoateng Feb 8, 2026
df258a2
Merge branch 'main' into download-prof-files
tim-schilling May 7, 2026
efbd2b9
Clean up PR.
tim-schilling May 7, 2026
9cbdf04
Generate the prof file on the fly.
tim-schilling May 7, 2026
05a505c
Merge branch 'main' into download-prof-files
tim-schilling May 9, 2026
34dde67
Name the profile file based on the request being made.
tim-schilling May 9, 2026
bdb6bf5
Remove the mocks from the profiling panel's integration tests.
tim-schilling May 9, 2026
776e4ba
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 9, 2026
25fa939
Remove unexpected addition of uv.lock.
tim-schilling May 9, 2026
f1c86c2
Switch to a proper FileResponse response.
tim-schilling May 10, 2026
d03fec3
Remove extra change.
tim-schilling May 10, 2026
7235ab8
Convert to a NamedTuple class.
tim-schilling May 10, 2026
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
33 changes: 30 additions & 3 deletions debug_toolbar/panels/profiling.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import cProfile
import logging
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 _

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 +188,25 @@ def generate_stats(self, request, response):
self.stats = Stats(self.profiler)
self.stats.calc_callees()

root_func = cProfile.label(super().process_request.__code__)

if (
profile_root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"]
) and os.path.exists(profile_root):
filename = f"{uuid.uuid4().hex}.prof"
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated
prof_file_path = os.path.join(profile_root, filename)
try:
self.profiler.dump_stats(prof_file_path)
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated
self.prof_file_path = signing.dumps(filename)
except OSError:
logger.error(
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated
"Failed to dump profiling stats to %s",
prof_file_path,
exc_info=True,
)
# If writing to the file fails, we don't want to break the
# whole page.

code = super().process_request.__code__
root_func = (code.co_filename, code.co_firstlineno, code.co_name)
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated
if root_func in self.stats.stats:
root = FunctionCall(self.stats, root_func, depth=0)
func_list = []
Expand All @@ -197,4 +219,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),
}
)
15 changes: 14 additions & 1 deletion debug_toolbar/panels/sql/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated
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
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated
finally:
self.db._djdt_logger = self.logger

Expand Down
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">
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated
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.
Outdated
),
]
# 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

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.

Unrealted change.

urlpatterns = DebugToolbar.get_urls()
34 changes: 33 additions & 1 deletion debug_toolbar/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -28,3 +33,30 @@ 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.
Outdated
@require_GET
@login_not_required
def download_prof_file(request):
if not (root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"]):
raise Http404()
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated

if not (file_path := request.GET.get("path")):
raise Http404()

try:
filename = signing.loads(file_path)
except signing.BadSignature:
raise Http404() from None
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated

root_path = pathlib.Path(root).resolve()
resolved_path = (root_path / filename).resolve()
if not resolved_path.is_relative_to(root_path) or not resolved_path.exists():
raise Http404()

return FileResponse(
open(resolved_path, "rb"),
as_attachment=True,
filename=resolved_path.name,
content_type="application/octet-stream",
)
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
11 changes: 11 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated

* ``PROFILER_THRESHOLD_RATIO``

Default: ``8``
Expand Down
115 changes: 115 additions & 0 deletions tests/panels/test_profiling.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import os
import shutil
import sys
import tempfile
import unittest
from unittest import mock

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

Expand Down Expand Up @@ -77,6 +84,23 @@ 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 = 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.
Expand All @@ -88,6 +112,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("cProfile.Profile.dump_stats")
def test_generate_stats_oserror(self, mock_dump_stats):
mock_dump_stats.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 +141,80 @@ 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)
Comment thread
JohananOppongAmoateng marked this conversation as resolved.
Outdated

def test_download_path_traversal(self):
with override_settings(
DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
):
url = reverse("djdt:debug_toolbar_download_prof_file")
# Sign a filename that traverses safely out of the root
path = signing.dumps("../passwd")
response = self.client.get(url, {"path": path})
self.assertEqual(response.status_code, 404)

def test_download_absolute_path(self):
with override_settings(
DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
):
url = reverse("djdt:debug_toolbar_download_prof_file")
# Create a file outside the root and try to access it via absolute path
with tempfile.NamedTemporaryFile() as tmp:
path = signing.dumps(tmp.name)
response = self.client.get(url, {"path": path})
self.assertEqual(response.status_code, 404)

def test_download_recursive_traversal(self):
with override_settings(
DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
):
url = reverse("djdt:debug_toolbar_download_prof_file")
# Try a convoluted path that resolves outside
# e.g. root/subdir/../../outside_root
path = signing.dumps(os.path.join("subdir", "..", "..", "passwd"))
response = self.client.get(url, {"path": path})
self.assertEqual(response.status_code, 404)
Loading