Skip to content

Commit 6b54dca

Browse files
feat: add DatabaseStore for persistent debug data storage
- Introduced `DatabaseStore` to store debug toolbar data in the database. - Added `DebugToolbarEntry` model and migrations for persistent storage. - Updated documentation to include configuration for `DatabaseStore`. - Added tests for `DatabaseStore` functionality, including CRUD operations and cache size enforcement. Fixes #2073
1 parent 5a21920 commit 6b54dca

File tree

8 files changed

+317
-1
lines changed

8 files changed

+317
-1
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
initial = True
6+
7+
operations = [
8+
migrations.CreateModel(
9+
name="DebugToolbarEntry",
10+
fields=[
11+
(
12+
"request_id",
13+
models.UUIDField(primary_key=True, serialize=False),
14+
),
15+
("data", models.JSONField(default=dict)),
16+
("created_at", models.DateTimeField(auto_now_add=True)),
17+
],
18+
options={
19+
"verbose_name": "Debug Toolbar Entry",
20+
"verbose_name_plural": "Debug Toolbar Entries",
21+
"ordering": ["-created_at"],
22+
},
23+
),
24+
]

debug_toolbar/migrations/__init__.py

Whitespace-only changes.

debug_toolbar/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from django.db import models
2+
3+
4+
class DebugToolbarEntry(models.Model):
5+
request_id = models.UUIDField(primary_key=True)
6+
data = models.JSONField(default=dict)
7+
created_at = models.DateTimeField(auto_now_add=True)
8+
9+
class Meta:
10+
verbose_name = "Debug Toolbar Entry"
11+
verbose_name_plural = "Debug Toolbar Entries"
12+
ordering = ["-created_at"]
13+
14+
def __str__(self):
15+
return str(self.request_id)

debug_toolbar/store.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from typing import Any
77

88
from django.core.serializers.json import DjangoJSONEncoder
9+
from django.utils import timezone
910
from django.utils.encoding import force_str
1011
from django.utils.module_loading import import_string
1112

1213
from debug_toolbar import settings as dt_settings
14+
from debug_toolbar.models import DebugToolbarEntry
1315

1416
logger = logging.getLogger(__name__)
1517

@@ -140,5 +142,100 @@ def panels(cls, request_id: str) -> Any:
140142
yield panel, deserialize(data)
141143

142144

145+
class DatabaseStore(BaseStore):
146+
@classmethod
147+
def _cleanup_old_entries(cls):
148+
"""
149+
Enforce the cache size limit - keeping only the most recently used entries
150+
up to RESULTS_CACHE_SIZE.
151+
"""
152+
# Get the cache size limit from settings
153+
cache_size = dt_settings.get_config()["RESULTS_CACHE_SIZE"]
154+
155+
# Determine which entries to keep (the most recent ones up to cache_size)
156+
keep_ids = list(
157+
DebugToolbarEntry.objects.order_by("-created_at")[:cache_size].values_list(
158+
"request_id", flat=True
159+
)
160+
)
161+
162+
# Delete all entries not in the keep list
163+
if keep_ids:
164+
DebugToolbarEntry.objects.exclude(request_id__in=keep_ids).delete()
165+
166+
@classmethod
167+
def request_ids(cls):
168+
"""Return all stored request ids within the cache size limit"""
169+
cache_size = dt_settings.get_config()["RESULTS_CACHE_SIZE"]
170+
return list(
171+
DebugToolbarEntry.objects.order_by("-created_at")[:cache_size].values_list(
172+
"request_id", flat=True
173+
)
174+
)
175+
176+
@classmethod
177+
def exists(cls, request_id: str) -> bool:
178+
"""Check if the given request_id exists in the store"""
179+
return DebugToolbarEntry.objects.filter(request_id=request_id).exists()
180+
181+
@classmethod
182+
def set(cls, request_id: str):
183+
"""Set a request_id in the store and clean up old entries"""
184+
# Create or update the entry
185+
obj, created = DebugToolbarEntry.objects.get_or_create(request_id=request_id)
186+
if not created:
187+
# Update timestamp to mark as recently used
188+
obj.created_at = timezone.now()
189+
obj.save(update_fields=["created_at"])
190+
191+
# Enforce the cache size limit to clean up old entries
192+
cls._cleanup_old_entries()
193+
194+
@classmethod
195+
def clear(cls):
196+
"""Remove all requests from the store"""
197+
DebugToolbarEntry.objects.all().delete()
198+
199+
@classmethod
200+
def delete(cls, request_id: str):
201+
"""Delete the stored request for the given request_id"""
202+
DebugToolbarEntry.objects.filter(request_id=request_id).delete()
203+
204+
@classmethod
205+
def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
206+
"""Save the panel data for the given request_id"""
207+
# First ensure older entries are cleared if we exceed cache size
208+
cls.set(request_id)
209+
210+
# Ensure the request exists
211+
obj, _ = DebugToolbarEntry.objects.get_or_create(request_id=request_id)
212+
store_data = obj.data
213+
store_data[panel_id] = serialize(data)
214+
obj.data = store_data
215+
obj.save()
216+
217+
@classmethod
218+
def panel(cls, request_id: str, panel_id: str) -> Any:
219+
"""Fetch the panel data for the given request_id"""
220+
try:
221+
data = DebugToolbarEntry.objects.get(request_id=request_id).data
222+
panel_data = data.get(panel_id)
223+
if panel_data is None:
224+
return {}
225+
return deserialize(panel_data)
226+
except DebugToolbarEntry.DoesNotExist:
227+
return {}
228+
229+
@classmethod
230+
def panels(cls, request_id: str) -> Any:
231+
"""Fetch all panel data for the given request_id"""
232+
try:
233+
data = DebugToolbarEntry.objects.get(request_id=request_id).data
234+
for panel_id, panel_data in data.items():
235+
yield panel_id, deserialize(panel_data)
236+
except DebugToolbarEntry.DoesNotExist:
237+
return {}
238+
239+
143240
def get_store() -> BaseStore:
144241
return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"])

docs/changes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Serializable (don't include in main)
2424
* Update all panels to utilize data from ``Panel.get_stats()`` to load content
2525
to render. Specifically for ``Panel.title`` and ``Panel.nav_title``.
2626
* Extend example app to contain an async version.
27+
* Added ``debug_toolbar.store.DatabaseStore`` for persistent debug data
28+
storage.
2729

2830
Pending
2931
-------

docs/configuration.rst

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ Toolbar options
109109

110110
Default: ``25``
111111

112-
The toolbar keeps up to this many results in memory.
112+
The toolbar keeps up to this many results in memory or persistent storage.
113+
113114

114115
.. _ROOT_TAG_EXTRA_ATTRS:
115116

@@ -186,6 +187,24 @@ Toolbar options
186187

187188
The path to the class to be used for storing the toolbar's data per request.
188189

190+
Available store classes:
191+
192+
* ``debug_toolbar.store.MemoryStore`` - Stores data in memory
193+
* ``debug_toolbar.store.DatabaseStore`` - Stores data in the database
194+
195+
The DatabaseStore provides persistence and automatically cleans up old
196+
entries based on the ``RESULTS_CACHE_SIZE`` setting.
197+
198+
Note: For full functionality, DatabaseStore requires migrations for
199+
the debug_toolbar app:
200+
201+
.. code-block:: bash
202+
203+
python manage.py migrate debug_toolbar
204+
205+
For the DatabaseStore to work properly, you need to run migrations for the
206+
debug_toolbar app. The migrations create the necessary database table to store
207+
toolbar data.
189208

190209
.. _TOOLBAR_LANGUAGE:
191210

@@ -394,6 +413,14 @@ Here's what a slightly customized toolbar configuration might look like::
394413
'SQL_WARNING_THRESHOLD': 100, # milliseconds
395414
}
396415

416+
Here's an example of using a persistent store to keep debug data between server
417+
restarts::
418+
419+
DEBUG_TOOLBAR_CONFIG = {
420+
'TOOLBAR_STORE_CLASS': 'debug_toolbar.store.DatabaseStore',
421+
'RESULTS_CACHE_SIZE': 100, # Store up to 100 requests
422+
}
423+
397424
Theming support
398425
---------------
399426
The debug toolbar uses CSS variables to define fonts and colors. This allows

tests/test_models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import uuid
2+
3+
from django.test import TestCase
4+
5+
from debug_toolbar.models import DebugToolbarEntry
6+
7+
8+
class DebugToolbarEntryTestCase(TestCase):
9+
def test_str_method(self):
10+
test_uuid = uuid.uuid4()
11+
entry = DebugToolbarEntry(request_id=test_uuid)
12+
self.assertEqual(str(entry), str(test_uuid))
13+
14+
def test_data_field_default(self):
15+
"""Test that the data field defaults to an empty dict"""
16+
entry = DebugToolbarEntry(request_id=uuid.uuid4())
17+
self.assertEqual(entry.data, {})
18+
19+
def test_model_persistence(self):
20+
"""Test saving and retrieving a model instance"""
21+
test_uuid = uuid.uuid4()
22+
entry = DebugToolbarEntry(request_id=test_uuid, data={"test": True})
23+
entry.save()
24+
25+
# Retrieve from database and verify
26+
saved_entry = DebugToolbarEntry.objects.get(request_id=test_uuid)
27+
self.assertEqual(saved_entry.data, {"test": True})
28+
self.assertEqual(str(saved_entry), str(test_uuid))

tests/test_store.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import uuid
2+
13
from django.test import TestCase
24
from django.test.utils import override_settings
35

@@ -109,3 +111,124 @@ def test_get_store(self):
109111
)
110112
def test_get_store_with_setting(self):
111113
self.assertIs(store.get_store(), StubStore)
114+
115+
116+
class DatabaseStoreTestCase(TestCase):
117+
@classmethod
118+
def setUpTestData(cls) -> None:
119+
cls.store = store.DatabaseStore
120+
121+
def tearDown(self) -> None:
122+
self.store.clear()
123+
124+
def test_ids(self):
125+
id1 = str(uuid.uuid4())
126+
id2 = str(uuid.uuid4())
127+
self.store.set(id1)
128+
self.store.set(id2)
129+
# Convert the UUIDs to strings for comparison
130+
request_ids = {str(id) for id in self.store.request_ids()}
131+
self.assertEqual(request_ids, {id1, id2})
132+
133+
def test_exists(self):
134+
missing_id = str(uuid.uuid4())
135+
self.assertFalse(self.store.exists(missing_id))
136+
id1 = str(uuid.uuid4())
137+
self.store.set(id1)
138+
self.assertTrue(self.store.exists(id1))
139+
140+
def test_set(self):
141+
id1 = str(uuid.uuid4())
142+
self.store.set(id1)
143+
self.assertTrue(self.store.exists(id1))
144+
145+
def test_set_max_size(self):
146+
with self.settings(DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 1}):
147+
# Clear any existing entries first
148+
self.store.clear()
149+
150+
# Add first entry
151+
id1 = str(uuid.uuid4())
152+
self.store.save_panel(id1, "foo.panel", "foo.value")
153+
154+
# Verify it exists
155+
self.assertTrue(self.store.exists(id1))
156+
self.assertEqual(self.store.panel(id1, "foo.panel"), "foo.value")
157+
158+
# Add second entry, which should push out the first one due to size limit=1
159+
id2 = str(uuid.uuid4())
160+
self.store.save_panel(id2, "bar.panel", {"a": 1})
161+
162+
# Verify only the bar entry exists now
163+
# Convert the UUIDs to strings for comparison
164+
request_ids = {str(id) for id in self.store.request_ids()}
165+
self.assertEqual(request_ids, {id2})
166+
self.assertFalse(self.store.exists(id1))
167+
self.assertEqual(self.store.panel(id1, "foo.panel"), {})
168+
self.assertEqual(self.store.panel(id2, "bar.panel"), {"a": 1})
169+
170+
def test_clear(self):
171+
id1 = str(uuid.uuid4())
172+
self.store.save_panel(id1, "bar.panel", {"a": 1})
173+
self.store.clear()
174+
self.assertEqual(list(self.store.request_ids()), [])
175+
self.assertEqual(self.store.panel(id1, "bar.panel"), {})
176+
177+
def test_delete(self):
178+
id1 = str(uuid.uuid4())
179+
self.store.save_panel(id1, "bar.panel", {"a": 1})
180+
self.store.delete(id1)
181+
self.assertEqual(list(self.store.request_ids()), [])
182+
self.assertEqual(self.store.panel(id1, "bar.panel"), {})
183+
# Make sure it doesn't error
184+
self.store.delete(id1)
185+
186+
def test_save_panel(self):
187+
id1 = str(uuid.uuid4())
188+
self.store.save_panel(id1, "bar.panel", {"a": 1})
189+
self.assertTrue(self.store.exists(id1))
190+
self.assertEqual(self.store.panel(id1, "bar.panel"), {"a": 1})
191+
192+
def test_update_panel(self):
193+
id1 = str(uuid.uuid4())
194+
self.store.save_panel(id1, "test.panel", {"original": True})
195+
self.assertEqual(self.store.panel(id1, "test.panel"), {"original": True})
196+
197+
# Update the panel
198+
self.store.save_panel(id1, "test.panel", {"updated": True})
199+
self.assertEqual(self.store.panel(id1, "test.panel"), {"updated": True})
200+
201+
def test_panels_nonexistent_request(self):
202+
missing_id = str(uuid.uuid4())
203+
panels = dict(self.store.panels(missing_id))
204+
self.assertEqual(panels, {})
205+
206+
def test_panel(self):
207+
id1 = str(uuid.uuid4())
208+
missing_id = str(uuid.uuid4())
209+
self.assertEqual(self.store.panel(missing_id, "missing"), {})
210+
self.store.save_panel(id1, "bar.panel", {"a": 1})
211+
self.assertEqual(self.store.panel(id1, "bar.panel"), {"a": 1})
212+
213+
def test_panels(self):
214+
id1 = str(uuid.uuid4())
215+
self.store.save_panel(id1, "panel1", {"a": 1})
216+
self.store.save_panel(id1, "panel2", {"b": 2})
217+
panels = dict(self.store.panels(id1))
218+
self.assertEqual(len(panels), 2)
219+
self.assertEqual(panels["panel1"], {"a": 1})
220+
self.assertEqual(panels["panel2"], {"b": 2})
221+
222+
def test_cleanup_old_entries(self):
223+
# Create multiple entries
224+
ids = [str(uuid.uuid4()) for _ in range(5)]
225+
for id in ids:
226+
self.store.save_panel(id, "test.panel", {"test": True})
227+
228+
# Set a small cache size
229+
with self.settings(DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 2}):
230+
# Trigger cleanup
231+
self.store._cleanup_old_entries()
232+
233+
# Check that only the most recent 2 entries remain
234+
self.assertEqual(len(list(self.store.request_ids())), 2)

0 commit comments

Comments
 (0)