Skip to content
Open
Show file tree
Hide file tree
Changes from 23 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
123 changes: 121 additions & 2 deletions debug_toolbar/panels/profiling.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import cProfile
import logging
import os
from colorsys import hsv_to_rgb
from pstats import Stats

from django import forms
from django.conf import settings
from django.contrib.auth.decorators import login_not_required
from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.urls import path
from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET

from debug_toolbar import settings as dt_settings
from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar
from debug_toolbar.panels import Panel
from debug_toolbar.toolbar import DebugToolbar
from debug_toolbar.utils import get_view_path_metadata

logger = logging.getLogger(__name__)


class FunctionCall:
Expand Down Expand Up @@ -131,22 +143,69 @@ def cumtime_per_call(self):
def indent(self):
return 16 * self.depth

def primitive_count(self):
return self.stats[0]

def _func_key(self):
# Mirrors pstats.func_std_string to produce the plain-text identifier
# used in downloaded .prof files.
# https://docs.python.org/3/library/profile.html#pstats.Stats
if self.func[:2] == ("~", 0):
name = self.func[2]
if name.startswith("<") and name.endswith(">"):
return f"{{{name[1:-1]}}}"
return name
return "%s:%d(%s)" % self.func

def serialize(self):
return {
"has_subfuncs": self.has_subfuncs,
"id": self.id,
"parent_ids": self.parent_ids,
"is_project_func": self.is_project_func(),
"indent": self.indent(),
"func_key": self._func_key(),
"func_std_string": self.func_std_string(),
"cumtime": self.cumtime(),
"cumtime_per_call": self.cumtime_per_call(),
"tottime": self.tottime(),
"tottime_per_call": self.tottime_per_call(),
"count": self.count(),
"primitive_count": self.primitive_count(),
}


def _print_stats_from_function_calls(func_list: list[FunctionCall]) -> str:

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.

I'm not sure about this. I could use a second opinion on whether we want to go this approach or not.

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.

pstats.Stats can be printed to any stream-like object. So you can get a io.StringIO and simply call print_stats. I don't believe the is a need to recreate the funcitonality, right?

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 challenge is the panel doesn't show the same content.

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 profiling panel as a whole needs a bit of a rework. The threshold concept just doesn't seem to work properly. It seems like we should lean more into the actual APIs of the standard library.

Getting this all resolved is on my todo list, but I'd really like to avoid it for this feature.

"""
Create a string that is matches what pstats.Stats.print_stats would
print out.
"""
# Column layout matches pstats output: ncalls right-justified in 9
# chars, each time value formatted as f8 ("%8.3f"). The 3-space indent
# on the header and ordering lines is part of the pstats format.
# https://docs.python.org/3/library/profile.html#pstats.Stats.print_stats
Comment on lines +183 to +186

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.

I don't want to insinuate anything, but the comments read like AI code. Maybe we don't fan the fire and limit comments to only what can't be easily understood from the code itself.

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.

I called out that I used Claude in the PR.

I think in this case the comments help. There's syntax used in this function that isn't typical in my opinion, so having it explained helps.

@codingjoe codingjoe May 11, 2026

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, sorry. I only read that comment later.

Hm… depends. I think tests for "how this should look" actually help me personally more than the comments. But better too much documentation that too little 👍

At least my spidysense for AI still works. 🤠

lines = [" Ordered by: cumulative time", ""]
lines.append(
" ncalls tottime percall cumtime percall filename:lineno(function)"
)
for func in func_list:
nc = func["count"]
cc = func["primitive_count"]
# pstats shows nc/cc when they differ (recursive calls inflate nc)
ncalls_str = f"{nc}/{cc}" if nc != cc else str(nc)
# pstats prints 8 spaces instead of a percall value when the
# divisor is zero, preserving column alignment
tt_per = f"{func['tottime_per_call']:8.3f}" if nc else " " * 8
ct_per = f"{func['cumtime_per_call']:8.3f}" if cc else " " * 8
lines.append(
f"{ncalls_str:>9} {func['tottime']:8.3f} {tt_per}"
f" {func['cumtime']:8.3f} {ct_per} {func['func_key']}"
)
# pstats ends output with two blank lines
lines.extend(["", ""])
return "\n".join(lines)


class ProfilingPanel(Panel):
"""
Panel that displays profiling information.
Expand Down Expand Up @@ -184,7 +243,6 @@ def generate_stats(self, request, response):
self.stats.calc_callees()

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 +255,65 @@ 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]})
try:
url_name = get_view_path_metadata(request).url_name
except Http404:
url_name = _("<unavailable>")
self.record_stats(
{
"func_list": [func.serialize() for func in func_list],
"profile_name": f"{url_name}-{timezone.now().isoformat()}",
}
)

def get_stats(self):
"""
Access data stored by the panel. Returns a :class:`dict`.
"""
stats = super().get_stats()
# This isn't a stat, but is used in the context in the
# ProfilingPanel.content property when rendering the template.
stats["profiling_download_form"] = ProfilingDownloadForm(
initial={"request_id": self.toolbar.request_id}
)
return stats

@classmethod
def get_urls(cls):
return [
path("profiling_download/", profiling_download, name="profiling_download")
]


class ProfilingDownloadForm(forms.Form):
"""
Validate params

request_id: The key for the store instance to be fetched.
"""

request_id = forms.CharField(widget=forms.HiddenInput())


@require_GET
@login_not_required
@require_show_toolbar
@render_with_toolbar_language
def profiling_download(request):
form = ProfilingDownloadForm(request.GET)

if form.is_valid():
request_id: str = form.cleaned_data["request_id"]
toolbar: DebugToolbar | None = DebugToolbar.fetch(request_id)
if toolbar is None:
return HttpResponseBadRequest("Request is no longer available.")
panel = toolbar.get_panel_by_id(ProfilingPanel.panel_id)
panel_stats = panel.get_stats()
if "func_list" not in panel_stats:
return HttpResponseBadRequest("No profiling data exists for this request.")
content = _print_stats_from_function_calls(panel_stats["func_list"])
response = HttpResponse(content, content_type="text/plain")

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 Content-Disposition header would enable you to propose a filename to the browser.

profile_name = panel_stats.get("profile_name") or f"request-{request_id}"
response["Content-Disposition"] = f'attachment; filename="{profile_name}.prof"'
return response
return HttpResponseBadRequest(f"Form errors: {form.errors}")
42 changes: 18 additions & 24 deletions debug_toolbar/panels/request.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django.http import Http404
from django.urls import resolve
from django.utils.translation import gettext_lazy as _

from debug_toolbar.panels import Panel
from debug_toolbar.utils import get_name_from_obj, sanitize_and_sort_request_vars
from debug_toolbar.utils import (
get_view_path_metadata,
sanitize_and_sort_request_vars,
)


class RequestPanel(Panel):
Expand Down Expand Up @@ -32,30 +34,22 @@ def generate_stats(self, request, response):
}
)

view_info = {
"view_func": _("<no view>"),
"view_args": "None",
"view_kwargs": "None",
"view_urlname": "None",
}
try:
match = resolve(request.path_info)
func, args, kwargs = match
view_info["view_func"] = get_name_from_obj(func)
view_info["view_args"] = args
view_info["view_kwargs"] = kwargs

if getattr(match, "url_name", False):
url_name = match.url_name
if match.namespaces:
url_name = ":".join([*match.namespaces, url_name])
else:
url_name = _("<unavailable>")

view_info["view_urlname"] = url_name

view_metadata = get_view_path_metadata(request)
except Http404:
pass
view_info = {
"view_func": _("<no view>"),
"view_args": "None",
"view_kwargs": "None",
"view_urlname": "None",
}
else:
view_info = {
"view_func": view_metadata.view_func,
"view_args": view_metadata.view_args,
"view_kwargs": view_metadata.view_kwargs,
"view_urlname": view_metadata.url_name,
}
self.record_stats(view_info)

if hasattr(request, "session"):
Expand Down
36 changes: 21 additions & 15 deletions debug_toolbar/templates/debug_toolbar/panels/profiling.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{% load i18n %}

<form method="get" action="{% url 'djdt:profiling_download' %}" target="_blank">
{{ profiling_download_form.as_div }}
<button>Download</button>
</form>
Comment on lines +3 to +6

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.

Why use a form and a button for a GET request? Why not use an a-tag with a download attribute.

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.

I did it for consistency with the history panel which was originally done because the form used to inherit from SignedDataForm. I do like that it enforces the data validation more strictly.

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.

Which currently is only an ID, right? I don't really care much, but without knowing the history, I just looked a little odd.

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.

Yup, yup. That's correct.


<table>
<thead>
<tr>
Expand All @@ -13,22 +19,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>
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()
36 changes: 35 additions & 1 deletion debug_toolbar/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
import sys
import warnings
from collections.abc import Sequence
from dataclasses import dataclass
from pprint import PrettyPrinter, pformat
from typing import Any

from asgiref.local import Local
from django.http import QueryDict
from django.http import HttpRequest, QueryDict
from django.template import Node
from django.urls import resolve
from django.utils.html import format_html
from django.utils.safestring import SafeString, mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.debug import get_default_exception_reporter_filter

from debug_toolbar import _compat as compat, _stubs as stubs, settings as dt_settings
Expand Down Expand Up @@ -415,3 +418,34 @@ def get_csp_nonce(request) -> str | None:
return csp_nonce
# Django's built-in CSP support uses get_nonce(request)
return compat.get_nonce(request)


@dataclass
class ViewPathMetadata:

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.

Dataclasses are great class factories but not great datastructure primitives. NamedTuples are based on C-structs and much more suitable for immutable data structures.

Suggested change
@dataclass
class ViewPathMetadata:
class ViewPathMetadata(typing.NamedTuple):

view_func: str
view_args: tuple[Any]
view_kwargs: dict[str, Any]
url_name: str


def get_view_path_metadata(request: HttpRequest) -> ViewPathMetadata:

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.

This could be a from_request factory class method on the actual ViewPathMetadata class.

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.

Good call

"""
Retrieve the routing metadata for a request being routed through
a particular path to the view.
"""
match = resolve(request.path_info)
func, args, kwargs = match

if getattr(match, "url_name", False):
url_name = match.url_name
if match.namespaces:
url_name = ":".join([*match.namespaces, url_name])
else:
url_name = _("<unavailable>")

return ViewPathMetadata(
view_func=get_name_from_obj(func),

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.

If you want to stick with a dataclass, this should probably be part of post_init.

view_args=args,
view_kwargs=kwargs,
url_name=url_name,
)
2 changes: 2 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Pending
``esupgrade``.
* Updated tox configuration to treat ``DeprecationWarning``,
``ResourceWarning``, and ``PendingDeprecationWarning`` as errors.
* Added the ability to download the profiling data as a file.


6.3.0 (2026-04-01)
------------------
Expand Down
Loading
Loading