plain.querystats
On-page database query stats in development and production.
On each page, the query stats will display how many database queries were performed and how long they took.
Clicking the stats in the toolbar will show the full SQL query log with tracebacks and timings. This is even designed to work in production, making it much easier to discover and debug performance issues on production data!
It will also point out duplicate queries,
which can typically be removed by using select_related
,
prefetch_related
, or otherwise refactoring your code.
Installation
# settings.py
INSTALLED_PACKAGES = [
# ...
"plain.admin.querystats",
]
MIDDLEWARE = [
"plain.sessions.middleware.SessionMiddleware",
"plain.auth.middleware.AuthenticationMiddleware",
"plain.admin.querystats.QueryStatsMiddleware",
# Put additional middleware below querystats
# ...
]
We strongly recommend using the plain-toolbar along with this, but if you aren't, you can add the querystats to your frontend templates with this include:
{% include "querystats/button.html" %}
Note that you will likely want to surround this with an if DEBUG
or is_admin
check.
To view querystats you need to send a POST request to ?querystats=store
(i.e. via a <form>
),
and the template include is the easiest way to do that.
Tailwind CSS
This package is styled with Tailwind CSS,
and pairs well with plain-tailwind
.
If you are using your own Tailwind implementation, you can modify the "content" in your Tailwind config to include any Plain packages:
// tailwind.config.js
module.exports = {
content: [
// ...
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
],
// ...
}
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
plain.toolbar
The admin toolbar is enabled for every user who is_admin
.
Installation
Add plaintoolbar
to your INSTALLED_PACKAGES
,
and the {% toolbar %}
to your base template:
# settings.py
INSTALLED_PACKAGES += [
"plaintoolbar",
]
<!-- base.template.html -->
{% load toolbar %}
<!doctype html>
<html lang="en">
<head>
...
</head>
<body>
{% toolbar %}
...
</body>
More specific settings can be found below.
Tailwind CSS
This package is styled with Tailwind CSS,
and pairs well with plain-tailwind
.
If you are using your own Tailwind implementation, you can modify the "content" in your Tailwind config to include any Plain packages:
// tailwind.config.js
module.exports = {
content: [
// ...
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
],
// ...
}
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
plain.requestlog
The request log stores a local history of HTTP requests and responses during plain work
(Django runserver).
The request history will make it easy to see redirects, 400 and 500 level errors, form submissions, API calls, webhooks, and more.
Requests can be re-submitted by clicking the "replay" button.
Installation
# settings.py
INSTALLED_PACKAGES += [
"plainrequestlog",
]
MIDDLEWARE = MIDDLEWARE + [
# ...
"plainrequestlog.RequestLogMiddleware",
]
The default settings can be customized if needed:
# settings.py
DEV_REQUESTS_IGNORE_PATHS = [
"/sw.js",
"/favicon.ico",
"/admin/jsi18n/",
]
DEV_REQUESTS_MAX = 50
Tailwind CSS
This package is styled with Tailwind CSS,
and pairs well with plain-tailwind
.
If you are using your own Tailwind implementation, you can modify the "content" in your Tailwind config to include any Plain packages:
// tailwind.config.js
module.exports = {
content: [
// ...
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
],
// ...
}
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
1import time
2import traceback
3from collections import Counter
4
5import sqlparse
6
7from plain.utils.functional import cached_property
8
9IGNORE_STACK_FILES = [
10 "threading",
11 "socketserver",
12 "wsgiref",
13 "gunicorn",
14 "whitenoise",
15 "sentry_sdk",
16 "querystats/core",
17 "plain/template/base",
18 "plain/utils/decorators",
19 "plain/db",
20 "plain/utils/functional",
21 "plain/core/servers",
22 "plain/core/handlers",
23]
24
25
26def pretty_print_sql(sql):
27 return sqlparse.format(sql, reindent=True, keyword_case="upper")
28
29
30def get_stack():
31 return "".join(tidy_stack(traceback.format_stack()))
32
33
34def tidy_stack(stack):
35 lines = []
36
37 skip_next = False
38
39 for line in stack:
40 if skip_next:
41 skip_next = False
42 continue
43
44 if line.startswith(' File "') and any(
45 ignore in line for ignore in IGNORE_STACK_FILES
46 ):
47 skip_next = True
48 continue
49
50 lines.append(line)
51
52 return lines
53
54
55class QueryStats:
56 def __init__(self, include_tracebacks):
57 self.queries = []
58 self.include_tracebacks = include_tracebacks
59
60 def __str__(self):
61 s = f"{self.num_queries} queries in {self.total_time_display}"
62 if self.duplicate_queries:
63 s += f" ({self.num_duplicate_queries} duplicates)"
64 return s
65
66 def __call__(self, execute, sql, params, many, context):
67 current_query = {"sql": sql, "params": params, "many": many}
68 start = time.monotonic()
69
70 result = execute(sql, params, many, context)
71
72 if self.include_tracebacks:
73 current_query["tb"] = get_stack()
74
75 # if many, then X times is len(params)
76
77 current_query["result"] = result
78
79 current_query["duration"] = time.monotonic() - start
80
81 self.queries.append(current_query)
82 return result
83
84 @cached_property
85 def total_time(self):
86 return sum(q["duration"] for q in self.queries)
87
88 @staticmethod
89 def get_time_display(seconds):
90 if seconds < 0.01:
91 return f"{seconds * 1000:.0f} ms"
92 return f"{seconds:.2f} seconds"
93
94 @cached_property
95 def total_time_display(self):
96 return self.get_time_display(self.total_time)
97
98 @cached_property
99 def num_queries(self):
100 return len(self.queries)
101
102 # @cached_property
103 # def models(self):
104 # # parse table names from self.queries sql
105 # table_names = [x for x in [q['sql'].split(' ')[2] for q in self.queries] if x]
106 # models = connection.introspection.installed_models(table_names)
107 # return models
108
109 @cached_property
110 def duplicate_queries(self):
111 sqls = [q["sql"] for q in self.queries]
112 duplicates = {k: v for k, v in Counter(sqls).items() if v > 1}
113 return duplicates
114
115 @cached_property
116 def num_duplicate_queries(self):
117 # Count the number of "excess" queries by getting how many there
118 # are minus the initial one (and potentially only one required)
119 return sum(self.duplicate_queries.values()) - len(self.duplicate_queries)
120
121 def as_summary_dict(self):
122 return {
123 "summary": str(self),
124 "total_time": self.total_time,
125 "num_queries": self.num_queries,
126 "num_duplicate_queries": self.num_duplicate_queries,
127 }
128
129 def as_context_dict(self):
130 # If we don't create a dict, the instance of this class
131 # is lost before we can use it in the template
132 for query in self.queries:
133 # Add some useful display info
134 query["duration_display"] = self.get_time_display(query["duration"])
135 query["sql_display"] = pretty_print_sql(query["sql"])
136 duplicates = self.duplicate_queries.get(query["sql"], 0)
137 if duplicates:
138 query["duplicate_count"] = duplicates
139
140 summary = self.as_summary_dict()
141
142 return {
143 **summary,
144 "total_time_display": self.total_time_display,
145 "queries": self.queries,
146 }
147
148 def as_server_timing(self):
149 duration = self.total_time * 1000 # put in ms
150 duration = round(duration, 2)
151 description = str(self)
152 return f'querystats;dur={duration};desc="{description}"'