django-devbar 0.1.0__tar.gz → 0.1.2__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.1.0 → django_devbar-0.1.2}/PKG-INFO +21 -10
- {django_devbar-0.1.0 → django_devbar-0.1.2}/README.md +20 -9
- {django_devbar-0.1.0 → django_devbar-0.1.2}/pyproject.toml +1 -1
- {django_devbar-0.1.0 → django_devbar-0.1.2}/src/django_devbar/__init__.py +1 -1
- {django_devbar-0.1.0 → django_devbar-0.1.2}/src/django_devbar/conf.py +18 -0
- django_devbar-0.1.2/src/django_devbar/middleware.py +153 -0
- {django_devbar-0.1.0 → django_devbar-0.1.2}/src/django_devbar/tracker.py +16 -9
- django_devbar-0.1.0/src/django_devbar/middleware.py +0 -89
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-devbar
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Lightweight performance devbar for Django
|
|
5
5
|
Keywords: django,debug,performance,monitoring,development
|
|
6
6
|
Author: amureki
|
|
@@ -31,9 +31,9 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
|
|
32
32
|
# django-devbar
|
|
33
33
|
|
|
34
|
-
Lightweight performance devbar for Django. Shows DB query count, query duration, and
|
|
34
|
+
Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
|
|
35
35
|
|
|
36
|
-

|
|
36
|
+

|
|
37
37
|
|
|
38
38
|
## Installation
|
|
39
39
|
|
|
@@ -60,8 +60,19 @@ DEVBAR_POSITION = "top-left"
|
|
|
60
60
|
# Show HTML overlay (default: DEBUG)
|
|
61
61
|
DEVBAR_SHOW_BAR = True
|
|
62
62
|
|
|
63
|
-
# Add
|
|
63
|
+
# Add DevBar-* response headers (default: False)
|
|
64
64
|
DEVBAR_SHOW_HEADERS = True
|
|
65
|
+
|
|
66
|
+
# Enable console logging for duplicate queries (default: True)
|
|
67
|
+
DEVBAR_ENABLE_CONSOLE = True
|
|
68
|
+
|
|
69
|
+
# Performance thresholds for warning/critical levels (defaults shown)
|
|
70
|
+
DEVBAR_THRESHOLDS = {
|
|
71
|
+
"time_warning": 500, # ms
|
|
72
|
+
"time_critical": 1500, # ms
|
|
73
|
+
"count_warning": 20, # queries
|
|
74
|
+
"count_critical": 50, # queries
|
|
75
|
+
}
|
|
65
76
|
```
|
|
66
77
|
|
|
67
78
|
## Response Headers
|
|
@@ -74,9 +85,9 @@ When `DEVBAR_SHOW_HEADERS = True`, performance metrics are added as HTTP respons
|
|
|
74
85
|
|
|
75
86
|
Headers included:
|
|
76
87
|
|
|
77
|
-
| Header | Description |
|
|
78
|
-
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
88
|
+
| Header | Example | Description |
|
|
89
|
+
|--------|---------|-------------|
|
|
90
|
+
| `DevBar-Query-Count` | `12` | Number of database queries executed |
|
|
91
|
+
| `DevBar-DB-Time` | `87ms` | Total time spent in database queries |
|
|
92
|
+
| `DevBar-App-Time` | `41ms` | Application time (total time minus DB time) |
|
|
93
|
+
| `DevBar-Duplicates` | `3` | Number of duplicate queries detected (only present if duplicates found) |
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# django-devbar
|
|
2
2
|
|
|
3
|
-
Lightweight performance devbar for Django. Shows DB query count, query duration, and
|
|
3
|
+
Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+

|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -29,8 +29,19 @@ DEVBAR_POSITION = "top-left"
|
|
|
29
29
|
# Show HTML overlay (default: DEBUG)
|
|
30
30
|
DEVBAR_SHOW_BAR = True
|
|
31
31
|
|
|
32
|
-
# Add
|
|
32
|
+
# Add DevBar-* response headers (default: False)
|
|
33
33
|
DEVBAR_SHOW_HEADERS = True
|
|
34
|
+
|
|
35
|
+
# Enable console logging for duplicate queries (default: True)
|
|
36
|
+
DEVBAR_ENABLE_CONSOLE = True
|
|
37
|
+
|
|
38
|
+
# Performance thresholds for warning/critical levels (defaults shown)
|
|
39
|
+
DEVBAR_THRESHOLDS = {
|
|
40
|
+
"time_warning": 500, # ms
|
|
41
|
+
"time_critical": 1500, # ms
|
|
42
|
+
"count_warning": 20, # queries
|
|
43
|
+
"count_critical": 50, # queries
|
|
44
|
+
}
|
|
34
45
|
```
|
|
35
46
|
|
|
36
47
|
## Response Headers
|
|
@@ -43,9 +54,9 @@ When `DEVBAR_SHOW_HEADERS = True`, performance metrics are added as HTTP respons
|
|
|
43
54
|
|
|
44
55
|
Headers included:
|
|
45
56
|
|
|
46
|
-
| Header | Description |
|
|
47
|
-
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
57
|
+
| Header | Example | Description |
|
|
58
|
+
|--------|---------|-------------|
|
|
59
|
+
| `DevBar-Query-Count` | `12` | Number of database queries executed |
|
|
60
|
+
| `DevBar-DB-Time` | `87ms` | Total time spent in database queries |
|
|
61
|
+
| `DevBar-App-Time` | `41ms` | Application time (total time minus DB time) |
|
|
62
|
+
| `DevBar-Duplicates` | `3` | Number of duplicate queries detected (only present if duplicates found) |
|
|
@@ -7,6 +7,13 @@ POSITIONS = {
|
|
|
7
7
|
"top-left": "top:0;left:0",
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
DEFAULT_THRESHOLDS = {
|
|
11
|
+
"time_warning": 500,
|
|
12
|
+
"time_critical": 1500,
|
|
13
|
+
"count_warning": 20,
|
|
14
|
+
"count_critical": 50,
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
|
|
11
18
|
def get_position():
|
|
12
19
|
key = getattr(settings, "DEVBAR_POSITION", "bottom-right")
|
|
@@ -19,3 +26,14 @@ def get_show_bar():
|
|
|
19
26
|
|
|
20
27
|
def get_show_headers():
|
|
21
28
|
return getattr(settings, "DEVBAR_SHOW_HEADERS", False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_enable_console():
|
|
32
|
+
return getattr(settings, "DEVBAR_ENABLE_CONSOLE", True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_thresholds():
|
|
36
|
+
user_thresholds = getattr(settings, "DEVBAR_THRESHOLDS", {})
|
|
37
|
+
final = DEFAULT_THRESHOLDS.copy()
|
|
38
|
+
final.update(user_thresholds)
|
|
39
|
+
return final
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from contextlib import ExitStack
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
|
|
6
|
+
from django.db import connections
|
|
7
|
+
|
|
8
|
+
from . import tracker
|
|
9
|
+
from .conf import (
|
|
10
|
+
get_position,
|
|
11
|
+
get_show_bar,
|
|
12
|
+
get_show_headers,
|
|
13
|
+
get_thresholds,
|
|
14
|
+
get_enable_console,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
STYLE_BLOCK = """<style>
|
|
18
|
+
#django-devbar {
|
|
19
|
+
position: fixed; %s; z-index: 99999;
|
|
20
|
+
font-family: -apple-system, system-ui, sans-serif;
|
|
21
|
+
font-size: 11px; font-weight: 500;
|
|
22
|
+
padding: 4px 8px; border-radius: 4px;
|
|
23
|
+
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
|
24
|
+
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
|
25
|
+
transition: all 0.2s ease;
|
|
26
|
+
cursor: default;
|
|
27
|
+
line-height: 1.3;
|
|
28
|
+
background: rgba(20, 20, 20, 0.85);
|
|
29
|
+
color: #e5e5e5;
|
|
30
|
+
}
|
|
31
|
+
#django-devbar.level-warn { border-left: 3px solid #f59e0b; }
|
|
32
|
+
#django-devbar.level-crit { border-left: 3px solid #dc2626; }
|
|
33
|
+
#django-devbar span { opacity: 0.7; margin-right: 2px; }
|
|
34
|
+
#django-devbar strong { opacity: 1; font-weight: 600; margin-right: 6px; }
|
|
35
|
+
</style>"""
|
|
36
|
+
|
|
37
|
+
BAR_TEMPLATE = """<div id="django-devbar" class="level-%s">
|
|
38
|
+
<span>db</span> <strong>%.0fms</strong>
|
|
39
|
+
<span>py</span> <strong>%.0fms</strong>
|
|
40
|
+
<span>count</span> <strong>%d</strong>%s
|
|
41
|
+
</div>"""
|
|
42
|
+
|
|
43
|
+
SCRIPT_TEMPLATE = """<script>
|
|
44
|
+
(function() {
|
|
45
|
+
const stats = %s;
|
|
46
|
+
if (stats.duplicates && stats.duplicates.length > 0) {
|
|
47
|
+
console.groupCollapsed(`⚠️ Django DevBar: Duplicate Queries Detected (${stats.duplicates.length})`);
|
|
48
|
+
console.table(stats.duplicates.map(d => ({SQL: d.sql, Parameters: d.params, Duration_ms: d.duration})));
|
|
49
|
+
console.groupEnd();
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
</script>"""
|
|
53
|
+
|
|
54
|
+
BODY_CLOSE_RE = re.compile(rb"</body\s*>", re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DevBarMiddleware:
|
|
58
|
+
def __init__(self, get_response):
|
|
59
|
+
self.get_response = get_response
|
|
60
|
+
|
|
61
|
+
def __call__(self, request):
|
|
62
|
+
tracker.reset()
|
|
63
|
+
request_start = perf_counter()
|
|
64
|
+
|
|
65
|
+
with ExitStack() as stack:
|
|
66
|
+
for alias in connections:
|
|
67
|
+
stack.enter_context(
|
|
68
|
+
connections[alias].execute_wrapper(tracker.tracking_wrapper)
|
|
69
|
+
)
|
|
70
|
+
response = self.get_response(request)
|
|
71
|
+
|
|
72
|
+
total_time = (perf_counter() - request_start) * 1000
|
|
73
|
+
stats = tracker.get_stats()
|
|
74
|
+
|
|
75
|
+
db_time = stats["duration"]
|
|
76
|
+
python_time = max(0, total_time - db_time)
|
|
77
|
+
|
|
78
|
+
stats["python_time"] = python_time
|
|
79
|
+
stats["total_time"] = total_time
|
|
80
|
+
|
|
81
|
+
thresholds = get_thresholds()
|
|
82
|
+
level = self._determine_level(stats, total_time, thresholds)
|
|
83
|
+
|
|
84
|
+
if get_show_headers():
|
|
85
|
+
self._add_headers(response, stats)
|
|
86
|
+
|
|
87
|
+
if get_show_bar() and self._can_inject(response):
|
|
88
|
+
self._inject_devbar(response, stats, level)
|
|
89
|
+
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
def _determine_level(self, stats, total_time, thresholds):
|
|
93
|
+
if (
|
|
94
|
+
total_time > thresholds["time_critical"]
|
|
95
|
+
or stats["count"] > thresholds["count_critical"]
|
|
96
|
+
):
|
|
97
|
+
return "crit"
|
|
98
|
+
if (
|
|
99
|
+
stats["has_duplicates"]
|
|
100
|
+
or total_time > thresholds["time_warning"]
|
|
101
|
+
or stats["count"] > thresholds["count_warning"]
|
|
102
|
+
):
|
|
103
|
+
return "warn"
|
|
104
|
+
return "ok"
|
|
105
|
+
|
|
106
|
+
def _add_headers(self, response, stats):
|
|
107
|
+
response["DevBar-Query-Count"] = str(stats["count"])
|
|
108
|
+
response["DevBar-DB-Time"] = f"{stats['duration']:.0f}ms"
|
|
109
|
+
response["DevBar-App-Time"] = f"{stats['python_time']:.0f}ms"
|
|
110
|
+
if stats["has_duplicates"]:
|
|
111
|
+
response["DevBar-Duplicates"] = str(len(stats["duplicate_queries"]))
|
|
112
|
+
|
|
113
|
+
def _can_inject(self, response):
|
|
114
|
+
if getattr(response, "streaming", False):
|
|
115
|
+
return False
|
|
116
|
+
content_type = response.get("Content-Type", "").lower()
|
|
117
|
+
if "html" not in content_type:
|
|
118
|
+
return False
|
|
119
|
+
if response.get("Content-Encoding"):
|
|
120
|
+
return False
|
|
121
|
+
return hasattr(response, "content")
|
|
122
|
+
|
|
123
|
+
def _inject_devbar(self, response, stats, level):
|
|
124
|
+
content = response.content
|
|
125
|
+
matches = list(BODY_CLOSE_RE.finditer(content))
|
|
126
|
+
if not matches:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
dup_marker = (
|
|
130
|
+
' <strong style="color:#f59e0b">(D)</strong>'
|
|
131
|
+
if stats["has_duplicates"]
|
|
132
|
+
else ""
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
css = STYLE_BLOCK % get_position()
|
|
136
|
+
html = BAR_TEMPLATE % (
|
|
137
|
+
level,
|
|
138
|
+
stats["duration"],
|
|
139
|
+
stats["python_time"],
|
|
140
|
+
stats["count"],
|
|
141
|
+
dup_marker,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
script = ""
|
|
145
|
+
if get_enable_console() and stats.get("duplicate_queries"):
|
|
146
|
+
console_data = {"duplicates": stats["duplicate_queries"]}
|
|
147
|
+
script = SCRIPT_TEMPLATE % json.dumps(console_data)
|
|
148
|
+
|
|
149
|
+
payload = (css + html + script).encode(response.charset or "utf-8")
|
|
150
|
+
|
|
151
|
+
idx = matches[-1].start()
|
|
152
|
+
response.content = content[:idx] + payload + content[idx:]
|
|
153
|
+
response["Content-Length"] = str(len(response.content))
|
|
@@ -3,24 +3,23 @@ from time import perf_counter
|
|
|
3
3
|
|
|
4
4
|
_query_count: ContextVar[int] = ContextVar("query_count", default=0)
|
|
5
5
|
_query_duration: ContextVar[float] = ContextVar("query_duration", default=0.0)
|
|
6
|
-
_seen_queries: ContextVar[dict] = ContextVar(
|
|
7
|
-
|
|
8
|
-
) # sql -> set of params hashes
|
|
9
|
-
_has_duplicates: ContextVar[bool] = ContextVar("has_duplicates", default=False)
|
|
6
|
+
_seen_queries: ContextVar[dict] = ContextVar("seen_queries", default={})
|
|
7
|
+
_duplicate_log: ContextVar[list] = ContextVar("duplicate_log", default=[])
|
|
10
8
|
|
|
11
9
|
|
|
12
10
|
def reset():
|
|
13
11
|
_query_count.set(0)
|
|
14
12
|
_query_duration.set(0.0)
|
|
15
13
|
_seen_queries.set({})
|
|
16
|
-
|
|
14
|
+
_duplicate_log.set([])
|
|
17
15
|
|
|
18
16
|
|
|
19
17
|
def get_stats():
|
|
20
18
|
return {
|
|
21
19
|
"count": _query_count.get(),
|
|
22
20
|
"duration": _query_duration.get(),
|
|
23
|
-
"has_duplicates":
|
|
21
|
+
"has_duplicates": bool(_duplicate_log.get()),
|
|
22
|
+
"duplicate_queries": _duplicate_log.get(),
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
|
|
@@ -36,14 +35,22 @@ def _record(sql, params, duration):
|
|
|
36
35
|
_query_duration.set(_query_duration.get() + duration)
|
|
37
36
|
|
|
38
37
|
seen = _seen_queries.get()
|
|
38
|
+
params_hash = _hash_params(params)
|
|
39
|
+
|
|
39
40
|
if sql in seen:
|
|
40
|
-
params_hash = _hash_params(params)
|
|
41
41
|
if params_hash in seen[sql]:
|
|
42
|
-
|
|
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
|
+
)
|
|
43
50
|
else:
|
|
44
51
|
seen[sql].add(params_hash)
|
|
45
52
|
else:
|
|
46
|
-
seen[sql] = {
|
|
53
|
+
seen[sql] = {params_hash}
|
|
47
54
|
|
|
48
55
|
|
|
49
56
|
def tracking_wrapper(execute, sql, params, many, context):
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
from contextlib import ExitStack
|
|
3
|
-
from time import perf_counter
|
|
4
|
-
|
|
5
|
-
from django.db import connections
|
|
6
|
-
|
|
7
|
-
from . import tracker
|
|
8
|
-
from .conf import get_position, get_show_bar, get_show_headers
|
|
9
|
-
|
|
10
|
-
DEVBAR_HTML = (
|
|
11
|
-
'<div id="django-devbar" style="position:fixed;{position};'
|
|
12
|
-
"background:rgba(0,0,0,0.7);color:rgba(255,255,255,0.85);"
|
|
13
|
-
"padding:4px 8px;margin:8px;"
|
|
14
|
-
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"
|
|
15
|
-
"font-size:10px;line-height:1.3;font-weight:500;letter-spacing:0.02em;"
|
|
16
|
-
"z-index:99999;border-radius:3px;"
|
|
17
|
-
'backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)">'
|
|
18
|
-
'<span style="opacity:0.5">queries</span> {count}{duplicates} <span style="opacity:0.5">·</span> '
|
|
19
|
-
'<span style="opacity:0.5">db</span> {duration:.0f}ms <span style="opacity:0.5">·</span> '
|
|
20
|
-
'<span style="opacity:0.5">total</span> {response_time:.0f}ms</div>'
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
DUPLICATE_MARKER = (
|
|
24
|
-
' <span style="color:#f59e0b" title="Duplicate queries detected">(d)</span>'
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
BODY_CLOSE_RE = re.compile(rb"</body\s*>", re.IGNORECASE)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class DevBarMiddleware:
|
|
31
|
-
def __init__(self, get_response):
|
|
32
|
-
self.get_response = get_response
|
|
33
|
-
|
|
34
|
-
def __call__(self, request):
|
|
35
|
-
request_start = perf_counter()
|
|
36
|
-
tracker.reset()
|
|
37
|
-
|
|
38
|
-
with ExitStack() as stack:
|
|
39
|
-
for alias in connections:
|
|
40
|
-
stack.enter_context(
|
|
41
|
-
connections[alias].execute_wrapper(tracker.tracking_wrapper)
|
|
42
|
-
)
|
|
43
|
-
response = self.get_response(request)
|
|
44
|
-
|
|
45
|
-
response_time = (perf_counter() - request_start) * 1000
|
|
46
|
-
stats = tracker.get_stats()
|
|
47
|
-
|
|
48
|
-
if get_show_headers():
|
|
49
|
-
self._add_headers(response, stats, response_time)
|
|
50
|
-
|
|
51
|
-
if get_show_bar() and self._can_inject(response):
|
|
52
|
-
self._inject_devbar(response, stats, response_time)
|
|
53
|
-
|
|
54
|
-
return response
|
|
55
|
-
|
|
56
|
-
def _add_headers(self, response, stats, response_time):
|
|
57
|
-
response["X-DevBar-Query-Count"] = str(stats["count"])
|
|
58
|
-
response["X-DevBar-Query-Duration"] = f"{stats['duration']:.1f}"
|
|
59
|
-
response["X-DevBar-Response-Time"] = f"{response_time:.1f}"
|
|
60
|
-
if stats["has_duplicates"]:
|
|
61
|
-
response["X-DevBar-Has-Duplicates"] = "1"
|
|
62
|
-
|
|
63
|
-
def _can_inject(self, response):
|
|
64
|
-
if getattr(response, "streaming", False):
|
|
65
|
-
return False
|
|
66
|
-
content_type = response.get("Content-Type", "").lower()
|
|
67
|
-
if "text/html" not in content_type:
|
|
68
|
-
return False
|
|
69
|
-
if response.get("Content-Encoding"):
|
|
70
|
-
return False
|
|
71
|
-
return hasattr(response, "content")
|
|
72
|
-
|
|
73
|
-
def _inject_devbar(self, response, stats, response_time):
|
|
74
|
-
content = response.content
|
|
75
|
-
matches = list(BODY_CLOSE_RE.finditer(content))
|
|
76
|
-
if not matches:
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
devbar_html = DEVBAR_HTML.format(
|
|
80
|
-
position=get_position(),
|
|
81
|
-
count=stats["count"],
|
|
82
|
-
duplicates=DUPLICATE_MARKER if stats["has_duplicates"] else "",
|
|
83
|
-
duration=stats["duration"],
|
|
84
|
-
response_time=response_time,
|
|
85
|
-
).encode(response.charset or "utf-8")
|
|
86
|
-
|
|
87
|
-
idx = matches[-1].start()
|
|
88
|
-
response.content = content[:idx] + devbar_html + content[idx:]
|
|
89
|
-
response["Content-Length"] = str(len(response.content))
|