django-devbar 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {django_devbar-0.3.0 → django_devbar-0.5.0}/PKG-INFO +40 -17
- django_devbar-0.5.0/README.md +90 -0
- {django_devbar-0.3.0 → django_devbar-0.5.0}/pyproject.toml +1 -1
- {django_devbar-0.3.0 → django_devbar-0.5.0}/src/django_devbar/conf.py +5 -3
- {django_devbar-0.3.0 → django_devbar-0.5.0}/src/django_devbar/middleware.py +45 -25
- {django_devbar-0.3.0 → django_devbar-0.5.0}/src/django_devbar/templates/django_devbar/devbar.html +4 -3
- django_devbar-0.5.0/src/django_devbar/tracker.py +287 -0
- django_devbar-0.3.0/README.md +0 -67
- django_devbar-0.3.0/src/django_devbar/tracker.py +0 -61
- {django_devbar-0.3.0 → django_devbar-0.5.0}/src/django_devbar/__init__.py +0 -0
- {django_devbar-0.3.0 → django_devbar-0.5.0}/src/django_devbar/templates/django_devbar/duplicates.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-devbar
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Lightweight performance devbar for Django
|
|
5
5
|
Keywords: django,debug,performance,monitoring,development
|
|
6
6
|
Author: amureki
|
|
@@ -31,13 +31,34 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
|
|
32
32
|
# django-devbar
|
|
33
33
|
|
|
34
|
+
> [!WARNING]
|
|
35
|
+
> This package is experimental and may introduce breaking changes in minor versions.
|
|
36
|
+
|
|
34
37
|
Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
|
|
35
38
|
|
|
39
|
+
## Showcase
|
|
40
|
+
|
|
41
|
+
Normal operation – showing query count, duration, and application time:
|
|
42
|
+
|
|
36
43
|

|
|
37
44
|
|
|
45
|
+
Duplicate queries detected – severity indicator and details shown:
|
|
46
|
+
|
|
38
47
|

|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
[optional] Chrome extension - showing detailed metrics in DevTools panel:
|
|
50
|
+
|
|
51
|
+
[](#chrome-extension)
|
|
52
|
+
|
|
53
|
+
## Motivation
|
|
54
|
+
|
|
55
|
+
While [Django Debug Toolbar](https://django-debug-toolbar.readthedocs.io/) is a proven tool trusted by countless projects, django-devbar explores a different approach to development debugging:
|
|
56
|
+
|
|
57
|
+
- **Minimal setup** — just add middleware, nothing else required
|
|
58
|
+
- **Browser DevTools integration** — Chrome extension panel works with any response type, including JSON
|
|
59
|
+
- **Zero extra dependencies** — only requires Django
|
|
60
|
+
- **Lower overhead** — no extra requests to debug endpoints
|
|
61
|
+
- **Non-intrusive** — HTML bar is optional; works via headers and DevTools panel alone
|
|
41
62
|
|
|
42
63
|
## Installation
|
|
43
64
|
|
|
@@ -65,34 +86,36 @@ All settings are optional. Configure via a `DEVBAR` dict in your Django settings
|
|
|
65
86
|
|
|
66
87
|
```python
|
|
67
88
|
DEVBAR = {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
"POSITION": "bottom-right", # bottom-right (default), bottom-left, top-right, top-left
|
|
90
|
+
"SHOW_BAR": None, # follows DEBUG; set True/False to override
|
|
91
|
+
"ENABLE_DEVTOOLS_DATA": None, # follows DEBUG; set True/False to override
|
|
71
92
|
}
|
|
72
93
|
```
|
|
73
94
|
|
|
74
95
|
## Response Headers
|
|
75
96
|
|
|
76
|
-
|
|
97
|
+
Django DevBar adds HTTP response headers with performance metrics:
|
|
98
|
+
|
|
99
|
+
- **Server-Timing** (always present) – Standard HTTP header with database, application, and total time metrics. Visible in Chrome DevTools Network tab under Timing.
|
|
100
|
+
|
|
101
|
+
An additional header is included by default in DEBUG mode:
|
|
102
|
+
|
|
103
|
+
- **DevBar-Data** - JSON header with comprehensive metrics including duplicate query details
|
|
104
|
+
|
|
105
|
+
This is useful for:
|
|
77
106
|
|
|
78
107
|
- **API endpoints** where the HTML overlay can't be displayed
|
|
79
108
|
- **Automated testing** to assert performance metrics (e.g., fail CI if query count exceeds a limit)
|
|
80
|
-
- **
|
|
109
|
+
- **Browser extensions** that need detailed duplicate query information
|
|
81
110
|
|
|
82
|
-
|
|
111
|
+
### Server-Timing format
|
|
83
112
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
| `DevBar-DB-Time` | `87ms` | Total time spent in database queries |
|
|
88
|
-
| `DevBar-App-Time` | `41ms` | Application time (total time minus DB time) |
|
|
89
|
-
| `DevBar-Duplicates` | `3` | Number of duplicate queries detected (only present if duplicates found) |
|
|
113
|
+
```
|
|
114
|
+
Server-Timing: db;dur=87.50, app;dur=41.30, total;dur=128.80
|
|
115
|
+
```
|
|
90
116
|
|
|
91
117
|
## Chrome Extension
|
|
92
118
|
|
|
93
|
-

|
|
94
|
-
|
|
95
119
|
View Django DevBar metrics directly in Chrome DevTools with the [official extension](https://chromewebstore.google.com/detail/django-devbar/fehcaaopchkbknbdhjadnmehiifdmeid).
|
|
96
|
-
Requires `DEVBAR = {'SHOW_HEADERS': True}`.
|
|
97
120
|
|
|
98
121
|
See [chrome-extension/README.md](chrome-extension/README.md) for more details.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# django-devbar
|
|
2
|
+
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> This package is experimental and may introduce breaking changes in minor versions.
|
|
5
|
+
|
|
6
|
+
Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
|
|
7
|
+
|
|
8
|
+
## Showcase
|
|
9
|
+
|
|
10
|
+
Normal operation – showing query count, duration, and application time:
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
Duplicate queries detected – severity indicator and details shown:
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
[optional] Chrome extension - showing detailed metrics in DevTools panel:
|
|
19
|
+
|
|
20
|
+
[](#chrome-extension)
|
|
21
|
+
|
|
22
|
+
## Motivation
|
|
23
|
+
|
|
24
|
+
While [Django Debug Toolbar](https://django-debug-toolbar.readthedocs.io/) is a proven tool trusted by countless projects, django-devbar explores a different approach to development debugging:
|
|
25
|
+
|
|
26
|
+
- **Minimal setup** — just add middleware, nothing else required
|
|
27
|
+
- **Browser DevTools integration** — Chrome extension panel works with any response type, including JSON
|
|
28
|
+
- **Zero extra dependencies** — only requires Django
|
|
29
|
+
- **Lower overhead** — no extra requests to debug endpoints
|
|
30
|
+
- **Non-intrusive** — HTML bar is optional; works via headers and DevTools panel alone
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Using uv (recommended)
|
|
36
|
+
uv add --dev django-devbar
|
|
37
|
+
|
|
38
|
+
# Or using pip
|
|
39
|
+
pip install django-devbar
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Add to your middleware as early as possible, but after any middleware that encodes the response (e.g., `GZipMiddleware`):
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
MIDDLEWARE = [
|
|
46
|
+
"django.middleware.security.SecurityMiddleware",
|
|
47
|
+
"django_devbar.DevBarMiddleware",
|
|
48
|
+
# ...
|
|
49
|
+
]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
All settings are optional. Configure via a `DEVBAR` dict in your Django settings:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
DEVBAR = {
|
|
58
|
+
"POSITION": "bottom-right", # bottom-right (default), bottom-left, top-right, top-left
|
|
59
|
+
"SHOW_BAR": None, # follows DEBUG; set True/False to override
|
|
60
|
+
"ENABLE_DEVTOOLS_DATA": None, # follows DEBUG; set True/False to override
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Response Headers
|
|
65
|
+
|
|
66
|
+
Django DevBar adds HTTP response headers with performance metrics:
|
|
67
|
+
|
|
68
|
+
- **Server-Timing** (always present) – Standard HTTP header with database, application, and total time metrics. Visible in Chrome DevTools Network tab under Timing.
|
|
69
|
+
|
|
70
|
+
An additional header is included by default in DEBUG mode:
|
|
71
|
+
|
|
72
|
+
- **DevBar-Data** - JSON header with comprehensive metrics including duplicate query details
|
|
73
|
+
|
|
74
|
+
This is useful for:
|
|
75
|
+
|
|
76
|
+
- **API endpoints** where the HTML overlay can't be displayed
|
|
77
|
+
- **Automated testing** to assert performance metrics (e.g., fail CI if query count exceeds a limit)
|
|
78
|
+
- **Browser extensions** that need detailed duplicate query information
|
|
79
|
+
|
|
80
|
+
### Server-Timing format
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Server-Timing: db;dur=87.50, app;dur=41.30, total;dur=128.80
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Chrome Extension
|
|
87
|
+
|
|
88
|
+
View Django DevBar metrics directly in Chrome DevTools with the [official extension](https://chromewebstore.google.com/detail/django-devbar/fehcaaopchkbknbdhjadnmehiifdmeid).
|
|
89
|
+
|
|
90
|
+
See [chrome-extension/README.md](chrome-extension/README.md) for more details.
|
|
@@ -12,7 +12,7 @@ def get_config():
|
|
|
12
12
|
return {
|
|
13
13
|
"POSITION": "bottom-right",
|
|
14
14
|
"SHOW_BAR": None, # None = use settings.DEBUG
|
|
15
|
-
"
|
|
15
|
+
"ENABLE_DEVTOOLS_DATA": None, # None = use settings.DEBUG
|
|
16
16
|
**getattr(settings, "DEVBAR", {}),
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -29,5 +29,7 @@ def get_show_bar():
|
|
|
29
29
|
return settings.DEBUG if show_bar is None else show_bar
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def
|
|
33
|
-
|
|
32
|
+
def get_enable_devtools_data():
|
|
33
|
+
config = get_config()
|
|
34
|
+
enable = config["ENABLE_DEVTOOLS_DATA"]
|
|
35
|
+
return settings.DEBUG if enable is None else enable
|
|
@@ -9,10 +9,11 @@ from django.template import Context, Engine
|
|
|
9
9
|
|
|
10
10
|
from . import tracker
|
|
11
11
|
from .conf import (
|
|
12
|
+
get_enable_devtools_data,
|
|
12
13
|
get_position,
|
|
13
14
|
get_show_bar,
|
|
14
|
-
get_show_headers,
|
|
15
15
|
)
|
|
16
|
+
from .tracker import format_sql, truncate_sql
|
|
16
17
|
|
|
17
18
|
BODY_CLOSE_RE = re.compile(rb"</body\s*>", re.IGNORECASE)
|
|
18
19
|
|
|
@@ -41,41 +42,58 @@ class DevBarMiddleware:
|
|
|
41
42
|
stats = tracker.get_stats()
|
|
42
43
|
|
|
43
44
|
db_time = stats["duration"]
|
|
44
|
-
python_time = max(0, total_time - db_time)
|
|
45
|
+
python_time = round(max(0, total_time - db_time), 2)
|
|
45
46
|
|
|
46
47
|
stats["python_time"] = python_time
|
|
47
|
-
stats["total_time"] = total_time
|
|
48
|
+
stats["total_time"] = round(total_time, 2)
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
if get_enable_devtools_data():
|
|
51
|
+
self._add_devtools_data_header(response, stats)
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
self._add_headers(response, stats)
|
|
53
|
+
self._add_server_timing_header(response, stats)
|
|
53
54
|
|
|
54
55
|
if get_show_bar() and self._can_inject(response):
|
|
55
|
-
self._inject_devbar(response, stats
|
|
56
|
+
self._inject_devbar(response, stats)
|
|
56
57
|
|
|
57
58
|
return response
|
|
58
59
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
response["DevBar-DB-Time"] = f"{stats['duration']:.0f}ms"
|
|
62
|
-
response["DevBar-App-Time"] = f"{stats['python_time']:.0f}ms"
|
|
63
|
-
if stats["has_duplicates"]:
|
|
64
|
-
response["DevBar-Duplicates"] = str(len(stats["duplicate_queries"]))
|
|
65
|
-
|
|
66
|
-
# Add comprehensive JSON data for browser DevTools extension
|
|
60
|
+
def _add_devtools_data_header(self, response, stats):
|
|
61
|
+
# Abbreviated keys used to minimize DevBar-Data header size
|
|
67
62
|
extension_data = {
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"has_duplicates": stats["has_duplicates"],
|
|
63
|
+
"c": stats["count"],
|
|
64
|
+
"db": round(stats["duration"], 2),
|
|
65
|
+
"app": stats["python_time"],
|
|
66
|
+
"full": stats["total_time"],
|
|
73
67
|
}
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
|
|
69
|
+
all_queries = stats.get("queries", [])
|
|
70
|
+
duplicates = stats.get("duplicate_queries", [])
|
|
71
|
+
|
|
72
|
+
if all_queries:
|
|
73
|
+
processed_queries = [
|
|
74
|
+
{
|
|
75
|
+
"s": truncate_sql(q["sql"]),
|
|
76
|
+
"dur": q["duration"],
|
|
77
|
+
"dup": 1 if q["is_duplicate"] else 0,
|
|
78
|
+
}
|
|
79
|
+
for q in all_queries
|
|
80
|
+
]
|
|
81
|
+
extension_data["q"] = processed_queries
|
|
82
|
+
|
|
83
|
+
if duplicates:
|
|
84
|
+
marked_duplicates = [{**d, "dup": 1} for d in duplicates]
|
|
85
|
+
extension_data["dup"] = marked_duplicates
|
|
76
86
|
|
|
77
87
|
response["DevBar-Data"] = json.dumps(extension_data)
|
|
78
88
|
|
|
89
|
+
def _add_server_timing_header(self, response, stats):
|
|
90
|
+
parts = [
|
|
91
|
+
f"db;dur={stats['duration']:.2f}",
|
|
92
|
+
f"app;dur={stats['python_time']:.2f}",
|
|
93
|
+
f"total;dur={stats['total_time']:.2f}",
|
|
94
|
+
]
|
|
95
|
+
response["Server-Timing"] = ", ".join(parts)
|
|
96
|
+
|
|
79
97
|
def _can_inject(self, response):
|
|
80
98
|
if getattr(response, "streaming", False):
|
|
81
99
|
return False
|
|
@@ -86,7 +104,7 @@ class DevBarMiddleware:
|
|
|
86
104
|
return False
|
|
87
105
|
return hasattr(response, "content")
|
|
88
106
|
|
|
89
|
-
def _inject_devbar(self, response, stats
|
|
107
|
+
def _inject_devbar(self, response, stats):
|
|
90
108
|
content = response.content
|
|
91
109
|
matches = list(BODY_CLOSE_RE.finditer(content))
|
|
92
110
|
if not matches:
|
|
@@ -101,7 +119,6 @@ class DevBarMiddleware:
|
|
|
101
119
|
Context(
|
|
102
120
|
{
|
|
103
121
|
"position": get_position(),
|
|
104
|
-
"level": level,
|
|
105
122
|
"db_time": stats["duration"],
|
|
106
123
|
"app_time": stats["python_time"],
|
|
107
124
|
"query_count": stats["count"],
|
|
@@ -119,5 +136,8 @@ class DevBarMiddleware:
|
|
|
119
136
|
def _build_duplicates_html(self, duplicates):
|
|
120
137
|
if not duplicates:
|
|
121
138
|
return ""
|
|
139
|
+
formatted_duplicates = [
|
|
140
|
+
{**dup, "sql": format_sql(dup["sql"])} for dup in duplicates
|
|
141
|
+
]
|
|
122
142
|
template = _template_engine.get_template("django_devbar/duplicates.html")
|
|
123
|
-
return template.render(Context({"duplicates":
|
|
143
|
+
return template.render(Context({"duplicates": formatted_duplicates}))
|
{django_devbar-0.3.0 → django_devbar-0.5.0}/src/django_devbar/templates/django_devbar/devbar.html
RENAMED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
color: #f5f5f5;
|
|
15
15
|
}
|
|
16
16
|
#django-devbar.level-warn { border-left: 3px solid #f59e0b; }
|
|
17
|
+
#django-devbar.has-duplicates { border-left: 3px solid #f59e0b; }
|
|
17
18
|
#django-devbar span { opacity: 0.7; }
|
|
18
19
|
#django-devbar strong { opacity: 1; font-weight: 600; }
|
|
19
20
|
#django-devbar details { display: inline; position: relative; }
|
|
@@ -85,8 +86,8 @@
|
|
|
85
86
|
}
|
|
86
87
|
@media (max-width: 640px) { #django-devbar { display: none; } }
|
|
87
88
|
</style>
|
|
88
|
-
<div id="django-devbar" class="
|
|
89
|
+
<div id="django-devbar"{% if duplicates_html %} class="has-duplicates"{% endif %}>
|
|
90
|
+
<span>queries</span> <strong>{{ query_count }}</strong> <span>·</span>
|
|
89
91
|
<span>db</span> <strong>{{ db_time|floatformat:0 }}ms</strong> <span>·</span>
|
|
90
|
-
<span>app</span> <strong>{{ app_time|floatformat:0 }}ms</strong>
|
|
91
|
-
<span>queries</span> <strong>{{ query_count }}</strong>{{ duplicates_html|safe }}
|
|
92
|
+
<span>app</span> <strong>{{ app_time|floatformat:0 }}ms</strong>{{ duplicates_html|safe }}
|
|
92
93
|
</div>
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from time import perf_counter
|
|
4
|
+
|
|
5
|
+
SQL_KEYWORDS_RE = re.compile(
|
|
6
|
+
r"\b(SELECT|FROM|WHERE|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|ON|AND|OR|ORDER BY|GROUP BY|HAVING|LIMIT|OFFSET|INSERT INTO|VALUES|UPDATE|SET|DELETE|CREATE TABLE|ALTER TABLE|DROP TABLE|DISTINCT|COUNT|SUM|AVG|MAX|MIN|UNION|EXISTS|IN|IS NULL|IS NOT NULL|LIKE|BETWEEN|CASE|WHEN|THEN|ELSE|END|ASC|DESC|AS|WITH|RECURSIVE)\b",
|
|
7
|
+
re.IGNORECASE,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
SQL_CLAUSES_RE = re.compile(
|
|
11
|
+
r"\b(FROM|WHERE|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|GROUP BY|ORDER BY|HAVING|LIMIT|OFFSET|ON|AND|OR|UNION|WITH|CASE|WHEN|THEN|ELSE)\b",
|
|
12
|
+
re.IGNORECASE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_query_count: ContextVar[int] = ContextVar("query_count", default=0)
|
|
16
|
+
_query_duration: ContextVar[float] = ContextVar("query_duration", default=0.0)
|
|
17
|
+
_seen_queries: ContextVar[dict] = ContextVar("seen_queries", default={})
|
|
18
|
+
_duplicate_log: ContextVar[list] = ContextVar("duplicate_log", default=[])
|
|
19
|
+
_query_log: ContextVar[list] = ContextVar("query_log", default=[])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def reset():
|
|
23
|
+
_query_count.set(0)
|
|
24
|
+
_query_duration.set(0.0)
|
|
25
|
+
_seen_queries.set({})
|
|
26
|
+
_duplicate_log.set([])
|
|
27
|
+
_query_log.set([])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_stats():
|
|
31
|
+
duplicate_queries = _duplicate_log.get()
|
|
32
|
+
duplicate_sqls = {d["sql"] for d in duplicate_queries}
|
|
33
|
+
|
|
34
|
+
queries = _query_log.get()
|
|
35
|
+
for q in queries:
|
|
36
|
+
q["is_duplicate"] = q["sql"] in duplicate_sqls
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"count": _query_count.get(),
|
|
40
|
+
"duration": _query_duration.get(),
|
|
41
|
+
"duplicate_queries": duplicate_queries,
|
|
42
|
+
"queries": queries,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _hash_params(params):
|
|
47
|
+
try:
|
|
48
|
+
return hash(tuple(params)) if params else 0
|
|
49
|
+
except TypeError:
|
|
50
|
+
return hash(str(params))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _record(sql, params, duration):
|
|
54
|
+
_query_count.set(_query_count.get() + 1)
|
|
55
|
+
_query_duration.set(_query_duration.get() + duration)
|
|
56
|
+
|
|
57
|
+
seen = _seen_queries.get()
|
|
58
|
+
params_hash = _hash_params(params)
|
|
59
|
+
|
|
60
|
+
query_log = _query_log.get()
|
|
61
|
+
query_log.append(
|
|
62
|
+
{
|
|
63
|
+
"sql": sql,
|
|
64
|
+
"duration": round(duration, 2),
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
_query_log.set(query_log)
|
|
68
|
+
|
|
69
|
+
if sql in seen:
|
|
70
|
+
if params_hash in seen[sql]:
|
|
71
|
+
duplicates = _duplicate_log.get()
|
|
72
|
+
duplicates.append({"sql": sql, "duration": round(duration, 2)})
|
|
73
|
+
else:
|
|
74
|
+
seen[sql].add(params_hash)
|
|
75
|
+
else:
|
|
76
|
+
seen[sql] = {params_hash}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_sql(sql):
|
|
80
|
+
"""Highlight SQL keywords with HTML spans for visual formatting.
|
|
81
|
+
|
|
82
|
+
Escapes HTML first for security, then wraps SQL keywords in span tags.
|
|
83
|
+
Used for rendering duplicates in the injected devbar HTML.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
sql: SQL query string
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Safe HTML string with keywords wrapped in spans
|
|
90
|
+
"""
|
|
91
|
+
from django.utils.html import escape
|
|
92
|
+
from django.utils.safestring import mark_safe
|
|
93
|
+
|
|
94
|
+
escaped_sql = escape(sql)
|
|
95
|
+
|
|
96
|
+
def replace_keyword(match):
|
|
97
|
+
return f'<span class="sql-keyword">{match.group(0)}</span>'
|
|
98
|
+
|
|
99
|
+
highlighted = SQL_KEYWORDS_RE.sub(replace_keyword, escaped_sql)
|
|
100
|
+
return mark_safe(highlighted)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SQLTruncator:
|
|
104
|
+
"""Handles intelligent SQL query truncation while preserving the structure."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, max_length=150):
|
|
107
|
+
self.max_length = max_length
|
|
108
|
+
|
|
109
|
+
def truncate(self, sql):
|
|
110
|
+
"""Intelligently truncate long SQL queries while preserving the structure.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
sql: SQL query string
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Truncated SQL string with "..." at the end if shortened
|
|
117
|
+
"""
|
|
118
|
+
if len(sql) <= self.max_length:
|
|
119
|
+
return sql
|
|
120
|
+
|
|
121
|
+
sql_upper = sql.upper()
|
|
122
|
+
|
|
123
|
+
if sql_upper.startswith("SELECT"):
|
|
124
|
+
result = self._truncate_select(sql, sql_upper)
|
|
125
|
+
if result:
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
return self._fallback_truncate(sql)
|
|
129
|
+
|
|
130
|
+
def _truncate_select(self, sql, sql_upper):
|
|
131
|
+
"""Truncate SELECT queries while preserving columns and joins."""
|
|
132
|
+
distinct_match = re.search(r"\bDISTINCT\b", sql_upper[:20])
|
|
133
|
+
has_distinct = distinct_match is not None
|
|
134
|
+
from_match = re.search(r"\bFROM\b", sql_upper)
|
|
135
|
+
|
|
136
|
+
if not from_match:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
from_pos = from_match.start()
|
|
140
|
+
|
|
141
|
+
if has_distinct:
|
|
142
|
+
distinct_end = distinct_match.end()
|
|
143
|
+
select_part = sql[distinct_end:from_pos].strip()
|
|
144
|
+
else:
|
|
145
|
+
select_part = sql[6:from_pos].strip()
|
|
146
|
+
|
|
147
|
+
first_column = self._extract_first_column(select_part)
|
|
148
|
+
select_clause = "SELECT DISTINCT" if has_distinct else "SELECT"
|
|
149
|
+
|
|
150
|
+
from_part = sql[from_pos:]
|
|
151
|
+
table_match = re.search(r'FROM\s+(["\[]?)(\w+)', from_part, re.IGNORECASE)
|
|
152
|
+
|
|
153
|
+
if not table_match:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
table_name = table_match.group(2)
|
|
157
|
+
result = f"{select_clause} {first_column} FROM {table_name}"
|
|
158
|
+
|
|
159
|
+
# Add joins if they fit
|
|
160
|
+
join_result = self._add_joins_if_fit(result, from_part)
|
|
161
|
+
if join_result:
|
|
162
|
+
return join_result
|
|
163
|
+
|
|
164
|
+
return result if len(result) <= self.max_length else None
|
|
165
|
+
|
|
166
|
+
def _extract_first_column(self, select_part):
|
|
167
|
+
"""Extract the first column from SELECT clause, handling quotes and parentheses."""
|
|
168
|
+
if not select_part.strip():
|
|
169
|
+
return "..."
|
|
170
|
+
columns = self._parse_columns(select_part)
|
|
171
|
+
return columns[0] + ", ..." if columns else "..."
|
|
172
|
+
|
|
173
|
+
def _parse_columns(self, select_part):
|
|
174
|
+
"""Parse columns from SELECT clause, handling complex expressions."""
|
|
175
|
+
columns = []
|
|
176
|
+
in_quotes = False
|
|
177
|
+
quote_char = None
|
|
178
|
+
in_parens = False
|
|
179
|
+
paren_depth = 0
|
|
180
|
+
current_col = ""
|
|
181
|
+
|
|
182
|
+
for i, char in enumerate(select_part):
|
|
183
|
+
if char in ('"', "'") and (i == 0 or select_part[i - 1] != "\\"):
|
|
184
|
+
if not in_quotes:
|
|
185
|
+
in_quotes = True
|
|
186
|
+
quote_char = char
|
|
187
|
+
elif char == quote_char:
|
|
188
|
+
in_quotes = False
|
|
189
|
+
quote_char = None
|
|
190
|
+
current_col += char
|
|
191
|
+
elif in_quotes:
|
|
192
|
+
current_col += char
|
|
193
|
+
elif char == "(":
|
|
194
|
+
in_parens = True
|
|
195
|
+
paren_depth += 1
|
|
196
|
+
current_col += char
|
|
197
|
+
elif char == ")":
|
|
198
|
+
paren_depth -= 1
|
|
199
|
+
if paren_depth == 0:
|
|
200
|
+
in_parens = False
|
|
201
|
+
current_col += char
|
|
202
|
+
elif char == "," and not in_parens and not in_quotes:
|
|
203
|
+
if current_col.strip():
|
|
204
|
+
columns.append(current_col.strip())
|
|
205
|
+
break
|
|
206
|
+
current_col = ""
|
|
207
|
+
elif char in " \t\n" and not in_parens and not in_quotes:
|
|
208
|
+
if current_col.strip():
|
|
209
|
+
columns.append(current_col.strip())
|
|
210
|
+
current_col = ""
|
|
211
|
+
else:
|
|
212
|
+
current_col += char
|
|
213
|
+
|
|
214
|
+
if not columns and current_col.strip():
|
|
215
|
+
columns.append(current_col.strip())
|
|
216
|
+
|
|
217
|
+
return columns
|
|
218
|
+
|
|
219
|
+
def _add_joins_if_fit(self, base_result, from_part):
|
|
220
|
+
"""Add JOIN clauses to result if they fit within max_length."""
|
|
221
|
+
join_matches = re.finditer(
|
|
222
|
+
r'\b(LEFT\s+|RIGHT\s+|INNER\s+|OUTER\s+)?JOIN\s+(["\[]?)(\w+)',
|
|
223
|
+
from_part,
|
|
224
|
+
re.IGNORECASE,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
result = base_result
|
|
228
|
+
|
|
229
|
+
for join_match in join_matches:
|
|
230
|
+
join_type = (join_match.group(1) or "").strip()
|
|
231
|
+
join_table = join_match.group(3)
|
|
232
|
+
new_result = (
|
|
233
|
+
f"{result} {join_type} JOIN {join_table}"
|
|
234
|
+
if join_type
|
|
235
|
+
else f"{result} JOIN {join_table}"
|
|
236
|
+
)
|
|
237
|
+
if len(new_result) <= self.max_length:
|
|
238
|
+
result = new_result
|
|
239
|
+
else:
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
return result if len(result) <= self.max_length else None
|
|
243
|
+
|
|
244
|
+
def _fallback_truncate(self, sql):
|
|
245
|
+
"""Fallback truncation at clause boundaries or simple length limit."""
|
|
246
|
+
# First try simple length truncation
|
|
247
|
+
if len(sql) <= self.max_length:
|
|
248
|
+
return sql
|
|
249
|
+
|
|
250
|
+
# Try clause boundary truncation
|
|
251
|
+
clause_positions = [
|
|
252
|
+
(match.start(), match.group())
|
|
253
|
+
for match in SQL_CLAUSES_RE.finditer(sql[: self.max_length + 50])
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
if clause_positions:
|
|
257
|
+
best_pos = self._find_best_truncate_position(clause_positions)
|
|
258
|
+
if best_pos > 0:
|
|
259
|
+
return sql[:best_pos].rstrip() + "..."
|
|
260
|
+
|
|
261
|
+
# Final fallback: simple truncation
|
|
262
|
+
return sql[: self.max_length].rstrip() + "..."
|
|
263
|
+
|
|
264
|
+
def _find_best_truncate_position(self, clause_positions):
|
|
265
|
+
"""Find the best position to truncate based on clause boundaries."""
|
|
266
|
+
best_pos = 0
|
|
267
|
+
for pos, _ in clause_positions:
|
|
268
|
+
if self.max_length > pos > best_pos:
|
|
269
|
+
best_pos = pos
|
|
270
|
+
|
|
271
|
+
if best_pos > self.max_length - 30:
|
|
272
|
+
best_pos = 0
|
|
273
|
+
|
|
274
|
+
return best_pos
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def truncate_sql(sql, max_length=150):
|
|
278
|
+
truncator = SQLTruncator(max_length)
|
|
279
|
+
return truncator.truncate(sql)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def tracking_wrapper(execute, sql, params, many, context):
|
|
283
|
+
start = perf_counter()
|
|
284
|
+
try:
|
|
285
|
+
return execute(sql, params, many, context)
|
|
286
|
+
finally:
|
|
287
|
+
_record(sql, params, (perf_counter() - start) * 1000)
|
django_devbar-0.3.0/README.md
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# django-devbar
|
|
2
|
-
|
|
3
|
-
Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
|
|
4
|
-
|
|
5
|
-

|
|
6
|
-
|
|
7
|
-

|
|
8
|
-
|
|
9
|
-
> **Note:** This package is experimental and may introduce breaking changes in minor versions.
|
|
10
|
-
|
|
11
|
-
## Installation
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
# Using uv (recommended)
|
|
15
|
-
uv add --dev django-devbar
|
|
16
|
-
|
|
17
|
-
# Or using pip
|
|
18
|
-
pip install django-devbar
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Add to your middleware as early as possible, but after any middleware that encodes the response (e.g., `GZipMiddleware`):
|
|
22
|
-
|
|
23
|
-
```python
|
|
24
|
-
MIDDLEWARE = [
|
|
25
|
-
"django.middleware.security.SecurityMiddleware",
|
|
26
|
-
"django_devbar.DevBarMiddleware",
|
|
27
|
-
# ...
|
|
28
|
-
]
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
## Configuration
|
|
32
|
-
|
|
33
|
-
All settings are optional. Configure via a `DEVBAR` dict in your Django settings:
|
|
34
|
-
|
|
35
|
-
```python
|
|
36
|
-
DEVBAR = {
|
|
37
|
-
'POSITION': 'bottom-right', # bottom-right, bottom-left, top-right, top-left
|
|
38
|
-
'SHOW_BAR': None, # None (default) = follows DEBUG, or True/False to override
|
|
39
|
-
'SHOW_HEADERS': False, # Add DevBar-* headers to responses
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Response Headers
|
|
44
|
-
|
|
45
|
-
When `DEVBAR = {'SHOW_HEADERS': True}`, performance metrics are added as HTTP response headers. This is useful for:
|
|
46
|
-
|
|
47
|
-
- **API endpoints** where the HTML overlay can't be displayed
|
|
48
|
-
- **Automated testing** to assert performance metrics (e.g., fail CI if query count exceeds a limit)
|
|
49
|
-
- **Monitoring tools** that can capture and aggregate header values
|
|
50
|
-
|
|
51
|
-
Headers included:
|
|
52
|
-
|
|
53
|
-
| Header | Example | Description |
|
|
54
|
-
|--------|---------|-------------|
|
|
55
|
-
| `DevBar-Query-Count` | `12` | Number of database queries executed |
|
|
56
|
-
| `DevBar-DB-Time` | `87ms` | Total time spent in database queries |
|
|
57
|
-
| `DevBar-App-Time` | `41ms` | Application time (total time minus DB time) |
|
|
58
|
-
| `DevBar-Duplicates` | `3` | Number of duplicate queries detected (only present if duplicates found) |
|
|
59
|
-
|
|
60
|
-
## Chrome Extension
|
|
61
|
-
|
|
62
|
-

|
|
63
|
-
|
|
64
|
-
View Django DevBar metrics directly in Chrome DevTools with the [official extension](https://chromewebstore.google.com/detail/django-devbar/fehcaaopchkbknbdhjadnmehiifdmeid).
|
|
65
|
-
Requires `DEVBAR = {'SHOW_HEADERS': True}`.
|
|
66
|
-
|
|
67
|
-
See [chrome-extension/README.md](chrome-extension/README.md) for more details.
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
from contextvars import ContextVar
|
|
2
|
-
from time import perf_counter
|
|
3
|
-
|
|
4
|
-
_query_count: ContextVar[int] = ContextVar("query_count", default=0)
|
|
5
|
-
_query_duration: ContextVar[float] = ContextVar("query_duration", default=0.0)
|
|
6
|
-
_seen_queries: ContextVar[dict] = ContextVar("seen_queries", default={})
|
|
7
|
-
_duplicate_log: ContextVar[list] = ContextVar("duplicate_log", default=[])
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def reset():
|
|
11
|
-
_query_count.set(0)
|
|
12
|
-
_query_duration.set(0.0)
|
|
13
|
-
_seen_queries.set({})
|
|
14
|
-
_duplicate_log.set([])
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def get_stats():
|
|
18
|
-
return {
|
|
19
|
-
"count": _query_count.get(),
|
|
20
|
-
"duration": _query_duration.get(),
|
|
21
|
-
"has_duplicates": bool(_duplicate_log.get()),
|
|
22
|
-
"duplicate_queries": _duplicate_log.get(),
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _hash_params(params):
|
|
27
|
-
try:
|
|
28
|
-
return hash(tuple(params)) if params else 0
|
|
29
|
-
except TypeError:
|
|
30
|
-
return hash(str(params))
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _record(sql, params, duration):
|
|
34
|
-
_query_count.set(_query_count.get() + 1)
|
|
35
|
-
_query_duration.set(_query_duration.get() + duration)
|
|
36
|
-
|
|
37
|
-
seen = _seen_queries.get()
|
|
38
|
-
params_hash = _hash_params(params)
|
|
39
|
-
|
|
40
|
-
if sql in seen:
|
|
41
|
-
if params_hash in seen[sql]:
|
|
42
|
-
param_str = str(params)
|
|
43
|
-
if len(param_str) > 200:
|
|
44
|
-
param_str = param_str[:200] + "..."
|
|
45
|
-
|
|
46
|
-
duplicates = _duplicate_log.get()
|
|
47
|
-
duplicates.append(
|
|
48
|
-
{"sql": sql, "params": param_str, "duration": round(duration, 2)}
|
|
49
|
-
)
|
|
50
|
-
else:
|
|
51
|
-
seen[sql].add(params_hash)
|
|
52
|
-
else:
|
|
53
|
-
seen[sql] = {params_hash}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def tracking_wrapper(execute, sql, params, many, context):
|
|
57
|
-
start = perf_counter()
|
|
58
|
-
try:
|
|
59
|
-
return execute(sql, params, many, context)
|
|
60
|
-
finally:
|
|
61
|
-
_record(sql, params, (perf_counter() - start) * 1000)
|
|
File without changes
|
|
File without changes
|