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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-devbar
3
- Version: 0.1.0
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 response time.
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
- ![devbar example](docs/devbar-example.svg)
36
+ ![devbar example](https://raw.githubusercontent.com/amureki/django-devbar/main/docs/devbar-example.svg)
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 X-DevBar-* response headers (default: False)
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
- | `X-DevBar-Query-Count` | Number of database queries executed |
80
- | `X-DevBar-Query-Duration` | Total time spent in database queries (ms) |
81
- | `X-DevBar-Response-Time` | Total request-response cycle time (ms) |
82
- | `X-DevBar-Has-Duplicates` | Present (value `1`) if duplicate queries detected |
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 response time.
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
- ![devbar example](docs/devbar-example.svg)
5
+ ![devbar example](https://raw.githubusercontent.com/amureki/django-devbar/main/docs/devbar-example.svg)
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 X-DevBar-* response headers (default: False)
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
- | `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) |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-devbar"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Lightweight performance devbar for Django"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,4 +1,4 @@
1
1
  from .middleware import DevBarMiddleware
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
4
4
  __all__ = ["DevBarMiddleware"]
@@ -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
- "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=[])
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
- _has_duplicates.set(False)
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": _has_duplicates.get(),
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
- _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
+ )
43
50
  else:
44
51
  seen[sql].add(params_hash)
45
52
  else:
46
- seen[sql] = {_hash_params(params)}
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))