Plain is headed towards 1.0! Subscribe for development updates →

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.

Watch on YouTube

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!

Django query stats

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.staff.querystats",
]

MIDDLEWARE = [
    "plain.middleware.security.SecurityMiddleware",
    "plain.sessions.middleware.SessionMiddleware",
    "plain.middleware.common.CommonMiddleware",
    "plain.csrf.middleware.CsrfViewMiddleware",
    "plain.auth.middleware.AuthenticationMiddleware",
    "plain.middleware.clickjacking.XFrameOptionsMiddleware",

    "plain.staff.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_staff 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 staff toolbar is enabled for every user who is_staff.

Plain staff toolbar

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.

Watch on YouTube

Requests can be re-submitted by clicking the "replay" button.

Django request log

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",
    "/staff/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 json
 2import logging
 3import threading
 4
 5from plain.http import ResponseRedirect
 6from plain.json import PlainJSONEncoder
 7from plain.models import connection
 8from plain.runtime import settings
 9from plain.urls import reverse
10
11from .core import QueryStats
12
13try:
14    try:
15        import psycopg
16    except ImportError:
17        import psycopg2 as psycopg
18except ImportError:
19    psycopg = None
20
21logger = logging.getLogger(__name__)
22_local = threading.local()
23
24
25class QueryStatsJSONEncoder(PlainJSONEncoder):
26    def default(self, obj):
27        try:
28            return super().default(obj)
29        except TypeError:
30            if psycopg and isinstance(obj, psycopg._json.Json):
31                return obj.adapted
32            else:
33                raise
34
35
36class QueryStatsMiddleware:
37    def __init__(self, get_response):
38        self.get_response = get_response
39
40    def __call__(self, request):
41        if request.GET.get("querystats") == "disable":
42            return self.get_response(request)
43
44        querystats = QueryStats(
45            # Only want these if we're getting ready to show it
46            include_tracebacks=request.GET.get("querystats") == "store"
47        )
48
49        with connection.execute_wrapper(querystats):
50            # Have to wrap this first call so it is included in the querystats,
51            # but we don't have to wrap everything else unless we are staff or debug
52            is_staff = self.is_staff_request(request)
53
54        if settings.DEBUG or is_staff:
55            # Persist it on the thread
56            _local.querystats = querystats
57
58            with connection.execute_wrapper(_local.querystats):
59                response = self.get_response(request)
60
61            if settings.DEBUG:
62                # TODO logging settings
63                logger.debug("Querystats: %s", _local.querystats)
64
65            # Make current querystats available on the current page
66            # by using the server timing API which can be parsed client-side
67            response["Server-Timing"] = _local.querystats.as_server_timing()
68
69            if request.GET.get("querystats") == "store":
70                request.session["querystats"] = json.dumps(
71                    _local.querystats.as_context_dict(), cls=QueryStatsJSONEncoder
72                )
73                return ResponseRedirect(reverse("querystats:querystats"))
74
75            del _local.querystats
76
77            return response
78
79        else:
80            return self.get_response(request)
81
82    @staticmethod
83    def is_staff_request(request):
84        if getattr(request, "impersonator", None):
85            # Support for impersonation (still want the real staff user to see the querystats)
86            return request.impersonator and request.impersonator.is_staff
87
88        return hasattr(request, "user") and request.user and request.user.is_staff