Skip to content

Support serializable toolbar #2138

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bb88c05
Add the Store API and initial documentation.
tim-schilling May 17, 2023
19b5695
Remove config from docs as sphinx says it's misspelled.
tim-schilling May 17, 2023
97fcda7
Switch to Store.request_ids and remove serialization force_str.
tim-schilling Jun 17, 2023
487dfb3
Log serialization warning when a panel errors. (#1810)
tim-schilling Aug 21, 2023
e7cf575
Ignore common venv folder.
tim-schilling Aug 20, 2023
c4201fa
Rename store_id variants to request_id
tim-schilling Aug 20, 2023
bbbbb34
Support serializable panels. This is a WIP and needs clean-up.
tim-schilling Aug 20, 2023
e2f695b
Support serializable sql panel
tim-schilling Aug 21, 2023
14a5e0c
Make Panel.panel_id a classmember.
tim-schilling Aug 21, 2023
a31115f
Force everything to a string if it can't be serialized.
tim-schilling Aug 29, 2023
71edcf5
Support serialization of FunctionCall
tim-schilling Aug 29, 2023
c03f08f
Update all panels to use data from get_stats on render
tim-schilling Sep 5, 2023
47bdabe
Extend example app to have an async version.
tim-schilling Sep 5, 2023
dd53424
Merge branch 'main' into serialize-panels
tim-schilling Jul 10, 2024
16e02f5
Rework the alerts panel to be compatible with serialization.
tim-schilling Jul 10, 2024
3e4c484
Make template panel serializable.
tim-schilling Jul 10, 2024
f4ff5f4
Avoid caching the config settings.
tim-schilling Jul 11, 2024
d3730a6
Fix tests for serializable changes with selenium.
tim-schilling Jul 11, 2024
c660269
Comment out the async button because it breaks the wsgi app.
tim-schilling Jul 11, 2024
8402c4d
Hack: Sleep before checking to see if the history panel auto updated.
tim-schilling Jul 11, 2024
e1c0755
Improve clarity of record_stats for serialization. (#1965)
tim-schilling Jul 19, 2024
5a21920
Merge branch 'main' into serializable
tim-schilling Feb 9, 2025
89c4786
Added check for pytest as test runner for IS_RUNNING_TESTS.
tim-schilling May 14, 2025
73eea66
Fixes #2073 -- Added DatabaseStore for persistent debug data storage.…
dr-rompecabezas May 14, 2025
153c22b
Merge branch 'main' into serializable
tim-schilling May 15, 2025
f8bfb0d
Move serializable changes into the main change log.
tim-schilling May 15, 2025
bf77c70
Updated replaceToolbarState to use request id.
tim-schilling May 15, 2025
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ htmlcov
.tox
geckodriver.log
coverage.xml
venv
.direnv/
.envrc
venv
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ example: ## Run the example application
--noinput --username="$(USER)" --email="$(USER)@mailinator.com"
python example/manage.py runserver

example_async:
python example/manage.py migrate --noinput
-DJANGO_SUPERUSER_PASSWORD=p python example/manage.py createsuperuser \
--noinput --username="$(USER)" --email="$(USER)@mailinator.com"
daphne example.asgi:application

example_test: ## Run the test suite for the example application
python example/manage.py test example

Expand Down
24 changes: 24 additions & 0 deletions debug_toolbar/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

operations = [
migrations.CreateModel(
name="HistoryEntry",
fields=[
(
"request_id",
models.UUIDField(primary_key=True, serialize=False),
),
("data", models.JSONField(default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"verbose_name": "history entry",
"verbose_name_plural": "history entries",
"ordering": ["-created_at"],
},
),
]
Empty file.
16 changes: 16 additions & 0 deletions debug_toolbar/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.db import models
from django.utils.translation import gettext_lazy as _


class HistoryEntry(models.Model):
request_id = models.UUIDField(primary_key=True)
data = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
verbose_name = _("history entry")
verbose_name_plural = _("history entries")
ordering = ["-created_at"]

def __str__(self):
return str(self.request_id)
35 changes: 30 additions & 5 deletions debug_toolbar/panels/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.core.handlers.asgi import ASGIRequest
from django.template.loader import render_to_string
from django.utils.functional import classproperty

from debug_toolbar import settings as dt_settings
from debug_toolbar.utils import get_name_from_obj
Expand All @@ -15,19 +16,27 @@ class Panel:
def __init__(self, toolbar, get_response):
self.toolbar = toolbar
self.get_response = get_response
self.from_store = False

# Private panel properties

@property
def panel_id(self):
return self.__class__.__name__
@classproperty
def panel_id(cls):
return cls.__name__

@property
def enabled(self) -> bool:
# check if the panel is async compatible
# Check if the panel is async compatible
if not self.is_async and isinstance(self.toolbar.request, ASGIRequest):
return False

if self.from_store:
# If the toolbar was loaded from the store the existence of
# recorded data indicates whether it was enabled or not.
# We can't use the remainder of the logic since we don't have
# a request to work off of.
return bool(self.get_stats())

# The user's cookies should override the default value
cookie_value = self.toolbar.request.COOKIES.get("djdt" + self.panel_id)
if cookie_value is not None:
Expand Down Expand Up @@ -175,9 +184,16 @@ def record_stats(self, stats):
"""
Store data gathered by the panel. ``stats`` is a :class:`dict`.

Each call to ``record_stats`` updates the statistics dictionary.
Each call to ``record_stats`` updates the store's data for
the panel.

To support backwards compatibility, it will also update the
panel's statistics dictionary.
"""
self.toolbar.stats.setdefault(self.panel_id, {}).update(stats)
self.toolbar.store.save_panel(
self.toolbar.request_id, self.panel_id, self.toolbar.stats[self.panel_id]
)

def get_stats(self):
"""
Expand Down Expand Up @@ -261,6 +277,15 @@ def generate_server_timing(self, request, response):
Does not return a value.
"""

def load_stats_from_store(self, data):
"""
Instantiate the panel from serialized data.

Return the panel instance.
"""
self.toolbar.stats.setdefault(self.panel_id, {}).update(data)
self.from_store = True

@classmethod
def run_checks(cls):
"""
Expand Down
8 changes: 3 additions & 5 deletions debug_toolbar/panels/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,9 @@ def check_invalid_file_form_configuration(self, html_content):
return self.alerts

def generate_stats(self, request, response):
if not is_processable_html_response(response):
return

html_content = response.content.decode(response.charset)
self.check_invalid_file_form_configuration(html_content)
if is_processable_html_response(response):
html_content = response.content.decode(response.charset)
self.check_invalid_file_form_configuration(html_content)

# Further alert checks can go here

Expand Down
8 changes: 5 additions & 3 deletions debug_toolbar/panels/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,16 +171,17 @@ def _record_call(self, cache, name, original_method, args, kwargs):

@property
def nav_subtitle(self):
cache_calls = len(self.calls)
stats = self.get_stats()
cache_calls = len(stats.get("calls"))
return ngettext(
"%(cache_calls)d call in %(time).2fms",
"%(cache_calls)d calls in %(time).2fms",
cache_calls,
) % {"cache_calls": cache_calls, "time": self.total_time}
) % {"cache_calls": cache_calls, "time": stats.get("total_time")}

@property
def title(self):
count = len(getattr(settings, "CACHES", ["default"]))
count = self.get_stats().get("total_caches")
return ngettext(
"Cache calls from %(count)d backend",
"Cache calls from %(count)d backends",
Expand Down Expand Up @@ -216,6 +217,7 @@ def generate_stats(self, request, response):
"hits": self.hits,
"misses": self.misses,
"counts": self.counts,
"total_caches": len(getattr(settings, "CACHES", ["default"])),
}
)

Expand Down
2 changes: 1 addition & 1 deletion debug_toolbar/panels/history/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from debug_toolbar.panels.history.panel import HistoryPanel

__all__ = ["HistoryPanel"]
__all__ = [HistoryPanel.panel_id]
4 changes: 2 additions & 2 deletions debug_toolbar/panels/history/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ class HistoryStoreForm(forms.Form):
"""
Validate params

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

store_id = forms.CharField(widget=forms.HiddenInput())
request_id = forms.CharField(widget=forms.HiddenInput())
exclude_history = forms.BooleanField(widget=forms.HiddenInput(), required=False)
24 changes: 13 additions & 11 deletions debug_toolbar/panels/history/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ class HistoryPanel(Panel):
def get_headers(self, request):
headers = super().get_headers(request)
observe_request = self.toolbar.get_observe_request()
store_id = self.toolbar.store_id
if store_id and observe_request(request):
headers["djdt-store-id"] = store_id
request_id = self.toolbar.request_id
if request_id and observe_request(request):
headers["djdt-request-id"] = request_id
return headers

@property
Expand Down Expand Up @@ -87,23 +87,25 @@ def content(self):

Fetch every store for the toolbar and include it in the template.
"""
stores = {}
for id, toolbar in reversed(self.toolbar._store.items()):
stores[id] = {
"toolbar": toolbar,
toolbar_history = {}
for request_id in reversed(self.toolbar.store.request_ids()):
toolbar_history[request_id] = {
"history_stats": self.toolbar.store.panel(
request_id, HistoryPanel.panel_id
),
"form": HistoryStoreForm(
initial={"store_id": id, "exclude_history": True}
initial={"request_id": request_id, "exclude_history": True}
),
}

return render_to_string(
self.template,
{
"current_store_id": self.toolbar.store_id,
"stores": stores,
"current_request_id": self.toolbar.request_id,
"toolbar_history": toolbar_history,
"refresh_form": HistoryStoreForm(
initial={
"store_id": self.toolbar.store_id,
"request_id": self.toolbar.request_id,
"exclude_history": True,
}
),
Expand Down
24 changes: 14 additions & 10 deletions debug_toolbar/panels/history/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from debug_toolbar._compat import login_not_required
from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar
from debug_toolbar.panels.history.forms import HistoryStoreForm
from debug_toolbar.store import get_store
from debug_toolbar.toolbar import DebugToolbar


Expand All @@ -15,12 +16,12 @@ def history_sidebar(request):
form = HistoryStoreForm(request.GET)

if form.is_valid():
store_id = form.cleaned_data["store_id"]
toolbar = DebugToolbar.fetch(store_id)
request_id = form.cleaned_data["request_id"]
toolbar = DebugToolbar.fetch(request_id)
exclude_history = form.cleaned_data["exclude_history"]
context = {}
if toolbar is None:
# When the store_id has been popped already due to
# When the request_id has been popped already due to
# RESULTS_CACHE_SIZE
return JsonResponse(context)
for panel in toolbar.panels:
Expand All @@ -36,7 +37,7 @@ def history_sidebar(request):
),
}
return JsonResponse(context)
return HttpResponseBadRequest("Form errors")
return HttpResponseBadRequest(f"Form errors: {form.errors}")


@login_not_required
Expand All @@ -49,19 +50,22 @@ def history_refresh(request):
if form.is_valid():
requests = []
# Convert to list to handle mutations happening in parallel
for id, toolbar in list(DebugToolbar._store.items()):
for request_id in get_store().request_ids():
toolbar = DebugToolbar.fetch(request_id)
requests.append(
{
"id": id,
"id": request_id,
"content": render_to_string(
"debug_toolbar/panels/history_tr.html",
{
"id": id,
"store_context": {
"toolbar": toolbar,
"request_id": request_id,
"history_context": {
"history_stats": toolbar.store.panel(
request_id, "HistoryPanel"
),
"form": HistoryStoreForm(
initial={
"store_id": id,
"request_id": request_id,
"exclude_history": True,
}
),
Expand Down
19 changes: 17 additions & 2 deletions debug_toolbar/panels/profiling.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def __init__(
self.id = id
self.parent_ids = parent_ids or []
self.hsv = hsv
self.has_subfuncs = False

def parent_classes(self):
return self.parent_classes
Expand Down Expand Up @@ -130,6 +131,21 @@ def cumtime_per_call(self):
def indent(self):
return 16 * self.depth

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_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(),
}


class ProfilingPanel(Panel):
"""
Expand All @@ -148,7 +164,6 @@ def process_request(self, request):

def add_node(self, func_list, func, max_depth, cum_time):
func_list.append(func)
func.has_subfuncs = False
if func.depth < max_depth:
for subfunc in func.subfuncs():
# Always include the user's code
Expand Down Expand Up @@ -182,4 +197,4 @@ def generate_stats(self, request, response):
dt_settings.get_config()["PROFILER_MAX_DEPTH"],
cum_time_threshold,
)
self.record_stats({"func_list": func_list})
self.record_stats({"func_list": [func.serialize() for func in func_list]})
15 changes: 12 additions & 3 deletions debug_toolbar/panels/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.conf import settings
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _
from django.views.debug import get_default_exception_reporter_filter

Expand All @@ -19,7 +19,16 @@ class SettingsPanel(Panel):
nav_title = _("Settings")

def title(self):
return _("Settings from %s") % settings.SETTINGS_MODULE
return _("Settings from %s") % self.get_stats()["settings"].get(
"SETTINGS_MODULE"
)

def generate_stats(self, request, response):
self.record_stats({"settings": dict(sorted(get_safe_settings().items()))})
self.record_stats(
{
"settings": {
key: force_str(value)
for key, value in sorted(get_safe_settings().items())
}
}
)
2 changes: 1 addition & 1 deletion debug_toolbar/panels/sql/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from debug_toolbar.panels.sql.panel import SQLPanel

__all__ = ["SQLPanel"]
__all__ = [SQLPanel.panel_id]
Loading
Loading