In-application mode: N+1 detection per request#

Status Proposed
Author Maciej ZieliƄski
Date 2026-10-13
Target release 0.5.0 (experimental from 0.5.0rc1)
Extends design.md, design-0.3-api.md

1. Problem#

pytest-queryguard only measures inside pytest: a collector is pushed around each test, and class-level listeners on Engine and Session feed every active collector (a process-wide stack). That is right for a test suite, where one test runs at a time, but it cannot run inside a development server:

2. Goals#

  1. Run the same detectors (relationship loads, repeated statements, column loads, optional small repeats and write loops) on every HTTP request of a running ASGI or WSGI application, and report findings through logging, warnings or a callback, with optional per-route query budgets.
  2. Statements of concurrent requests, tasks and threads never mix. The one exception is a thread pool on builds where threads inherit the context, used without copy_context().run (see the context-local collector below).
  3. Opt in per engine: watch(engine) and unwatch(engine), for Engine and AsyncEngine.
  4. No cost when not enabled: importing the module registers nothing.
  5. Never break the application: every internal error degrades to one log record and switches the in-app mode off.
  6. An optional development panel (JSON and a self-contained HTML page).
  7. The pytest plugin keeps its behaviour, its listeners and its tests.

Non-goals: production monitoring, sampling, persistence, authentication of the panel, the missing-index check (it runs EXPLAIN on the request's connection; that belongs in tests), and framework-specific integrations beyond pure ASGI and WSGI.

3. API#

A new subpackage, pytest_queryguard.app, importable without pytest:

from pytest_queryguard import app as queryguard

queryguard.watch(engine)                      # Engine or AsyncEngine
asgi_app = queryguard.QueryGuardASGI(asgi_app, budgets={"GET /orders": 3})
wsgi_app = queryguard.QueryGuardWSGI(flask_app.wsgi_app, report="warn")

with queryguard.track("nightly-job") as report:  # any unit of work
    run_job()
Name Meaning
watch(engine), unwatch(engine) Attach and detach the in-app listeners to one engine. Idempotent.
track(name, *, settings) Context manager: everything watched that runs in this context is one unit; yields a live RequestReport, finished on exit.
QueryGuardASGI(app, ...) Pure ASGI middleware: one track() per http request.
QueryGuardWSGI(app, ...) WSGI middleware: the same for WSGI.
Settings Thresholds, repeat_scope, budgets, default_budget, report, max_events.
RequestReport Method, path, route, status, statements (Query), times, findings (Finding), budget.
set_route(template) Names the route of the current request for budgets (Flask).
internal_error() The error that switched the in-app mode off, or None.

4. Decisions#

5. Risks#

Edit this page on GitHub