Skip to content

Commit 047c9c7

Browse files
committed
Add devtools tunneling, improve headers (#2)
1 parent b617601 commit 047c9c7

File tree

8 files changed

+173
-65
lines changed

8 files changed

+173
-65
lines changed

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# django-devbar
22

3-
Lightweight performance devbar for Django. Shows DB query count, query duration, and response time.
3+
Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
44

55
![devbar example](https://raw.githubusercontent.com/amureki/django-devbar/main/docs/devbar-example.svg)
66

@@ -29,8 +29,19 @@ DEVBAR_POSITION = "top-left"
2929
# Show HTML overlay (default: DEBUG)
3030
DEVBAR_SHOW_BAR = True
3131

32-
# Add X-DevBar-* response headers (default: False)
32+
# Add DevBar-* response headers (default: False)
3333
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+
}
3445
```
3546

3647
## Response Headers
@@ -43,9 +54,9 @@ When `DEVBAR_SHOW_HEADERS = True`, performance metrics are added as HTTP respons
4354

4455
Headers included:
4556

46-
| Header | Description |
47-
|--------|-------------|
48-
| `X-DevBar-Query-Count` | Number of database queries executed |
49-
| `X-DevBar-Query-Duration` | Total time spent in database queries (ms) |
50-
| `X-DevBar-Response-Time` | Total request-response cycle time (ms) |
51-
| `X-DevBar-Has-Duplicates` | Present (value `1`) if duplicate queries detected |
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) |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "django-devbar"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
description = "Lightweight performance devbar for Django"
55
readme = "README.md"
66
license = "MIT"

src/django_devbar/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .middleware import DevBarMiddleware
22

3-
__version__ = "0.1.1"
3+
__version__ = "0.1.2"
44
__all__ = ["DevBarMiddleware"]

src/django_devbar/conf.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
"top-left": "top:0;left:0",
88
}
99

10+
DEFAULT_THRESHOLDS = {
11+
"time_warning": 500,
12+
"time_critical": 1500,
13+
"count_warning": 20,
14+
"count_critical": 50,
15+
}
16+
1017

1118
def get_position():
1219
key = getattr(settings, "DEVBAR_POSITION", "bottom-right")
@@ -19,3 +26,14 @@ def get_show_bar():
1926

2027
def get_show_headers():
2128
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

src/django_devbar/middleware.py

Lines changed: 99 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,55 @@
1+
import json
12
import re
23
from contextlib import ExitStack
34
from time import perf_counter
45

56
from django.db import connections
67

78
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>'
9+
from .conf import (
10+
get_position,
11+
get_show_bar,
12+
get_show_headers,
13+
get_thresholds,
14+
get_enable_console,
2115
)
2216

23-
DUPLICATE_MARKER = (
24-
' <span style="color:#f59e0b" title="Duplicate queries detected">(d)</span>'
25-
)
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>"""
2653

2754
BODY_CLOSE_RE = re.compile(rb"</body\s*>", re.IGNORECASE)
2855

@@ -32,8 +59,8 @@ def __init__(self, get_response):
3259
self.get_response = get_response
3360

3461
def __call__(self, request):
35-
request_start = perf_counter()
3662
tracker.reset()
63+
request_start = perf_counter()
3764

3865
with ExitStack() as stack:
3966
for alias in connections:
@@ -42,48 +69,85 @@ def __call__(self, request):
4269
)
4370
response = self.get_response(request)
4471

45-
response_time = (perf_counter() - request_start) * 1000
72+
total_time = (perf_counter() - request_start) * 1000
4673
stats = tracker.get_stats()
4774

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+
4884
if get_show_headers():
49-
self._add_headers(response, stats, response_time)
85+
self._add_headers(response, stats)
5086

5187
if get_show_bar() and self._can_inject(response):
52-
self._inject_devbar(response, stats, response_time)
88+
self._inject_devbar(response, stats, level)
5389

5490
return response
5591

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}"
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"
60110
if stats["has_duplicates"]:
61-
response["X-DevBar-Has-Duplicates"] = "1"
111+
response["DevBar-Duplicates"] = str(len(stats["duplicate_queries"]))
62112

63113
def _can_inject(self, response):
64114
if getattr(response, "streaming", False):
65115
return False
66116
content_type = response.get("Content-Type", "").lower()
67-
if "text/html" not in content_type:
117+
if "html" not in content_type:
68118
return False
69119
if response.get("Content-Encoding"):
70120
return False
71121
return hasattr(response, "content")
72122

73-
def _inject_devbar(self, response, stats, response_time):
123+
def _inject_devbar(self, response, stats, level):
74124
content = response.content
75125
matches = list(BODY_CLOSE_RE.finditer(content))
76126
if not matches:
77127
return
78128

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")
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")
86150

87151
idx = matches[-1].start()
88-
response.content = content[:idx] + devbar_html + content[idx:]
152+
response.content = content[:idx] + payload + content[idx:]
89153
response["Content-Length"] = str(len(response.content))

src/django_devbar/tracker.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,23 @@
33

44
_query_count: ContextVar[int] = ContextVar("query_count", default=0)
55
_query_duration: ContextVar[float] = ContextVar("query_duration", default=0.0)
6-
_seen_queries: ContextVar[dict] = ContextVar(
7-
"seen_queries"
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=[])
108

119

1210
def reset():
1311
_query_count.set(0)
1412
_query_duration.set(0.0)
1513
_seen_queries.set({})
16-
_has_duplicates.set(False)
14+
_duplicate_log.set([])
1715

1816

1917
def get_stats():
2018
return {
2119
"count": _query_count.get(),
2220
"duration": _query_duration.get(),
23-
"has_duplicates": _has_duplicates.get(),
21+
"has_duplicates": bool(_duplicate_log.get()),
22+
"duplicate_queries": _duplicate_log.get(),
2423
}
2524

2625

@@ -36,14 +35,22 @@ def _record(sql, params, duration):
3635
_query_duration.set(_query_duration.get() + duration)
3736

3837
seen = _seen_queries.get()
38+
params_hash = _hash_params(params)
39+
3940
if sql in seen:
40-
params_hash = _hash_params(params)
4141
if params_hash in seen[sql]:
42-
_has_duplicates.set(True)
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+
)
4350
else:
4451
seen[sql].add(params_hash)
4552
else:
46-
seen[sql] = {_hash_params(params)}
53+
seen[sql] = {params_hash}
4754

4855

4956
def tracking_wrapper(execute, sql, params, many, context):

tests/test_middleware.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,10 @@ def get_response(request):
121121
request = rf.get("/")
122122
response = middleware(request)
123123

124-
assert "X-DevBar-Query-Count" in response
125-
assert "X-DevBar-Query-Duration" in response
126-
assert "X-DevBar-Response-Time" in response
127-
assert "X-DevBar-Has-Duplicates" not in response
124+
assert "DevBar-Query-Count" in response
125+
assert "DevBar-DB-Time" in response
126+
assert "DevBar-App-Time" in response
127+
assert "DevBar-Duplicates" not in response
128128

129129
def test_headers_hidden_when_disabled(self, rf, settings):
130130
settings.DEVBAR_SHOW_HEADERS = False
@@ -138,16 +138,24 @@ def get_response(request):
138138
request = rf.get("/")
139139
response = middleware(request)
140140

141-
assert "X-DevBar-Query-Count" not in response
142-
assert "X-DevBar-Query-Duration" not in response
143-
assert "X-DevBar-Response-Time" not in response
144-
assert "X-DevBar-Has-Duplicates" not in response
141+
assert "DevBar-Query-Count" not in response
142+
assert "DevBar-DB-Time" not in response
143+
assert "DevBar-App-Time" not in response
144+
assert "DevBar-Duplicates" not in response
145145

146146
def test_has_duplicates_header_present_when_duplicates(self, rf, monkeypatch):
147147
monkeypatch.setattr(
148148
tracker,
149149
"get_stats",
150-
lambda: {"count": 2, "duration": 10.0, "has_duplicates": True},
150+
lambda: {
151+
"count": 3,
152+
"duration": 10.0,
153+
"has_duplicates": True,
154+
"duplicate_queries": [
155+
{"sql": "SELECT * FROM foo", "params": "(1,)", "duration": 5.0},
156+
{"sql": "SELECT * FROM bar", "params": "(2,)", "duration": 3.0},
157+
],
158+
},
151159
)
152160

153161
def get_response(request):
@@ -159,4 +167,4 @@ def get_response(request):
159167
request = rf.get("/")
160168
response = middleware(request)
161169

162-
assert response["X-DevBar-Has-Duplicates"] == "1"
170+
assert response["DevBar-Duplicates"] == "2"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)