🔍 slowquery-detective
🐢 Catch slow Postgres queries live. Suggest the index. Know which request caused them. Drop-in FastAPI + SQLAlchemy middleware that fingerprints every query, attributes it to the request that ran it, runs EXPLAIN off the request path, and fixes what's actually slow.
📦 PyPI · 📖 Specs · 📝 Changelog · 🐛 Issues · 📜 License
from slowquery_detective import install
install(app, engine, service="paper-trail",
store_url=settings.database_url, authorize=verify_platform_token)
🤔 Why this exists
Your ORM generates SQL. Some of it is slow. You find out in production when p95 spikes and on-call pages you. Then you stare at pg_stat_statements, guess which query is the culprit, manually run EXPLAIN, and hope you remember the right index syntax.
slowquery-detective automates that entire loop. It hooks into SQLAlchemy events, fingerprints every query so WHERE id=1 and WHERE id=42 collapse into one pattern, tracks latency in a ring buffer, and when a statement crosses threshold_ms it runs EXPLAIN (FORMAT JSON) on the statement's real parameters — off the request path, never with ANALYZE, so the statement is never executed twice — with per-fingerprint rate limiting so a hot endpoint can't flood the worker. A deterministic rules engine catches the 80% of real wins (seq scans, missing FK indexes, sorts without indexes, functions in WHERE, SELECT *, N+1). When no rule matches, an OpenRouter-backed LLM explains the plan in plain English. The rules engine catches the boring wins — because real wins are boring and deterministic.
Since 0.2.0 it also answers a second question: which queries did this request cause? Send X-Request-Id: R and every statement R runs — in the endpoint, a background task, a spawned task — is counted against R and kept in the service's own Postgres, so one call to GET /_slowquery/queries?request_id=R on each service gives you a per-request performance receipt across a fleet.
Literal values are scrubbed before anything is stored or sent to an LLM. This is a library, not a service — it runs inside your process and writes only to the database you point it at.
✨ Features
- 🔬 Fingerprinting via sqlglot — literals scrubbed, patterns collapsed, PII never leaves the process
- 🧾 Per-request attribution —
X-Request-Idfollows the request into dependencies,BackgroundTasksand spawnedasynciotasks; calls, total and max time per request and fingerprint, regardless of threshold - 🗄️ Postgres store — the service's own database, schema
slowquery, created idempotently at startup, batched writes, 7-day retention - 🔐 Your authorization — the read routes ask an
authorize(request)hook you supply; no hook, no access - 📊 Ring buffer with 60s sliding window — p50, p95, p99 per fingerprint in constant memory
- ⚡ Async EXPLAIN on real parameters — off the hot path, no
ANALYZE, per-fingerprint cooldown, writes never explained - 🧠 6-rule deterministic engine — seq scan, missing FK index, sort without index, function in WHERE,
SELECT *, N+1 - 🤖 LLM fallback via OpenRouter — 3-model cascade (primary → fast → fallback, free models by default) with per-fingerprint cooldown
- 🧽 Scrubbed plans — a plan made with real parameters prints their values; they are replaced with
?before the plan is stored or sent anywhere
🏗️ Architecture
Data flow
flowchart LR
R["HTTP request\nX-Request-Id"] --> M["RequestIdMiddleware\n(context variable)"]
M --> A["SQLAlchemy\nhooks"]
A --> B["Fingerprinter\n(sqlglot)"]
B --> C["Ring Buffer\n60s window"]
B --> Rec["Recorder\nper fingerprint +\nper request"]
Rec -- "flush every 2 s" --> S[("Store\nslowquery schema")]
B --> D{"duration_ms ≥\nthreshold_ms?"}
D -- above --> F["EXPLAIN Worker\n(real params, no ANALYZE,\nread-only guard)"]
F -- "plan (in memory)" --> G["Rules Engine\n(6 rules)"]
F --> Sc["Scrub literals\nfrom the plan"]
Sc -- "scrubbed plan" --> S
G -- no match --> I["LLM Fallback\n(OpenRouter,\nscrubbed plan only)"]
Sc -.-> I
G -- suggestions --> S
I -- suggestion --> S
S --> Q["GET /_slowquery/queries\n(authorize hook)"]
Rules engine dispatch
flowchart TD
P["EXPLAIN plan\n+ canonical SQL"] --> R["run_rules()"]
R --> R1["SeqScanLargeTable"]
R --> R2["MissingFkIndex"]
R --> R3["SortWithoutIndex"]
R --> R4["FunctionInWhere"]
R --> R5["SelectStarWideTable"]
R --> R6["NPlusOneSuspicion"]
R1 --> S{"Any\nmatches?"}
R2 --> S
R3 --> S
R4 --> S
R5 --> S
R6 --> S
S -- yes --> T["Sort by confidence desc\nReturn suggestions"]
S -- no --> U["LLM Fallback"]
U --> V["Primary model"]
V -- "429/5xx" --> W["Fast model"]
W -- "429/5xx" --> X["Fallback model"]
V --> T
W --> T
X --> T
In a fleet
flowchart TB
subgraph "PyPI Package"
DET["slowquery-detective\n(this repo)"]
end
subgraph "Services (each with its own Postgres)"
PT["paper-trail"]
MG["magpie"]
IP["inkprint"]
IT["inkthread"]
DEMO["slowquery-demo-backend"]
end
B["Caller (e.g. bastion)\nsends X-Request-Id: R"]
DET -- "pip install" --> PT & MG & IP & IT & DEMO
B -- "requests tagged R" --> PT & MG
B -- "GET /_slowquery/queries?request_id=R" --> PT & MG
📁 Project structure
slowquery-detective/
├── src/slowquery_detective/
│ ├── __init__.py # Public surface: install(), stores, QueryRow, current_request_id
│ ├── middleware.py # install() — validation and wiring
│ ├── tagging.py # X-Request-Id → context variable (ASGI middleware)
│ ├── hooks.py # SQLAlchemy event listeners
│ ├── fingerprint.py # sqlglot-based SQL fingerprinting
│ ├── buffer.py # Ring buffer — 60s sliding window, p50/p95/p99
│ ├── recorder.py # In-memory aggregation between flushes (Batch, merge_batches)
│ ├── runtime.py # Flush loop, store readiness, retention sweep, lifecycle
│ ├── explain.py # Background EXPLAIN worker (real parameters, no ANALYZE)
│ ├── plan_scrub.py # Remove literal values from a plan
│ ├── rules/ # 6-rule deterministic engine
│ ├── llm_explainer.py # OpenRouter LLM fallback with 3-model cascade
│ ├── store.py # StoreWriter interface + InMemoryStoreWriter
│ ├── pg_store.py # PostgresStoreWriter (schema slowquery)
│ ├── store_url.py # Postgres URL validation / asyncpg normalisation
│ ├── schemas.py # QueryRow, QueryDetail, … (response models)
│ └── router.py # GET /queries, GET /queries/{id} behind authorize
├── docs/specs/ # 8 feature specs (Spec-TDD)
├── tests/
│ ├── unit/ # unit + ASGI-level composed-path tests
│ ├── integration/ # Testcontainers Postgres tests
│ ├── store_contract.py # the behaviour both stores must have
│ └── fixtures/
├── benchmarks/
├── pyproject.toml
└── LICENSE
🚀 Fleet usage
pip install "slowquery-detective[fastapi,postgres]"
from fastapi import FastAPI, Request
from sqlalchemy.ext.asyncio import create_async_engine
from slowquery_detective import install
app = FastAPI(lifespan=lifespan) # your own lifespan keeps working
engine = create_async_engine(settings.database_url) # creating it opens no connection
async def verify_platform_token(request: Request) -> bool:
return await my_token_verifier(request.headers.get("x-platform-token")) # your check
install(app, engine, service="paper-trail",
store_url=settings.database_url, authorize=verify_platform_token)
- Call
installat app construction, after your ownadd_middlewarecalls, before the app serves a request or runs its lifespan: it adds an ASGI middleware and wraps the lifespan. Middleware added afterinstallsits outside the tagger, so the queries it runs are not attributed to the request. A second call is a no-op with a warning. store_urlis the service's own database URL.postgres://,postgresql://and Neon's?sslmode=require&channel_binding=requireare accepted and normalised for asyncpg;postgresql+psycopg://is kept as given. The store opens its own small pool (2 + 2 overflow) and creates schemaslowqueryon startup —CREATE … IF NOT EXISTSunder an advisory lock, so running it on every boot is safe. Leavestore_urlout (tests, SQLite) to use the in-memory store.authorizereceives the StarletteRequestand must returnTrueto allow; anything else is 403. RaiseHTTPException(401)from it if you want a 401. Without the hook every route answers 403.- Request ids come from
X-Request-Id: 1–128 characters of[A-Za-z0-9._:-], starting with a letter or digit. Other values leave the request untagged and are otherwise ignored. Requests to the library's own routes are never tagged, so measuring request R never adds to R.current_request_id()exposes the value to your own code. - If the database is asleep or unreachable at startup, the app still starts: the error is logged, pending data is held (bounded) and the store is retried; the routes answer 503 until it is up.
📡 Routes
Mounted at mount_path (default /_slowquery), all behind authorize, kept out of your OpenAPI document.
| Method | Path | Returns |
|---|---|---|
GET |
/_slowquery/queries?request_id=&since=&limit= |
list[QueryRow], total_ms desc. limit 1–500 (default 50); since timezone-aware, on last_seen. With request_id: only that request's fingerprints, its call_count/total_ms/max_ms/first_seen/last_seen, percentiles null. |
GET |
/_slowquery/queries/{id} |
{fingerprint: QueryRow, canonical_sql, explain_plan: {plan_json, cost, captured_at} | null, suggestions, recent_samples: [{duration_ms, request_id, sampled_at}]} (20 newest). 404 for an unknown or malformed id. |
QueryRow = {id, fingerprint, first_seen, last_seen, call_count, total_ms, p50_ms, p95_ms, p99_ms, max_ms, suggestions: [{kind, source, sql, rationale, applied_at}]}, where fingerprint is the canonical SQL. Without request_id, counts and totals are lifetime numbers for the service and p50/p95/p99 are the rolling 60-second percentiles of the process that last flushed that fingerprint.
⚙️ Configuration
| Argument | Default | Description |
|---|---|---|
service |
(required) | Service id, [a-z0-9][a-z0-9_-]{0,63}; rows are kept per service |
threshold_ms |
100 |
Statements at or above this are sampled and sent to EXPLAIN |
sample_rate |
1.0 |
Fraction of untagged statements recorded; tagged statements are always recorded |
store_url |
None |
Postgres URL for the store; None with no store means in-memory |
store |
None |
A StoreWriter of your own (exclusive with store_url) |
authorize |
None |
async (Request) -> bool; None means every route answers 403 |
mount_path |
"/_slowquery" |
Where the routes live (leading /, no trailing /) |
retention_s |
604_800 |
Samples and request aggregates older than this are swept |
enable_llm |
False |
Turn on the OpenRouter fallback |
llm_config |
None |
Required when enable_llm=True; see LlmConfig |
Every argument is checked before anything is wired: a bad value raises ValueError or TypeError and leaves the app untouched.
Tables (schema slowquery)
| Table | Key | Holds |
|---|---|---|
query_fingerprints |
(service, id) |
canonical SQL, first/last seen, call count, total/max ms, last p50/p95/p99 |
request_stats |
(service, request_id, fingerprint_id) |
calls, total/max ms, first/last seen |
query_samples |
id |
slow statements: duration, request id, time |
explain_plans |
fingerprint_id |
latest scrubbed plan, cost, time |
suggestions |
id, unique (fingerprint_id, kind, sql) |
kind, source, DDL ('' = none), rationale, applied_at |
schema_version |
version |
1 |
LLM fallback
from pydantic import SecretStr
from slowquery_detective import LlmConfig, install
llm_config = LlmConfig(enabled=True, api_key=SecretStr("sk-or-v1-..."))
install(app, engine, service="paper-trail", enable_llm=True, llm_config=llm_config)
The models default to google/gemma-4-26b-a4b-it:free → minimax/minimax-m3.1-flash:free → google/gemma-4-31b-it:free (override model_primary, model_fast, model_fallback). The cascade moves on at HTTP 429 / 5xx / network errors and any non-401 4xx; 401 ends it. A per-fingerprint cooldown (60s default) stops a hot fingerprint from burning credits. The LLM only ever sees the scrubbed plan and the canonical SQL.
🔁 Migrating from 0.1
- Add
service="<your id>"toinstall(); pass anAsyncEngine. - Delete
app.include_router(dashboard_router, ...):install()mounts its own routes, atGET /_slowquery/queriesandGET /_slowquery/queries/{id}(no/apisegment). There is noapplyorstreamroute any more. - Replace
SLOWQUERY_PLATFORM_TOKEN/X-Platform-Tokenwith anauthorizehook; without one the routes answer 403.DEMO_MODEis not read. - Pass
store_url=<your database URL>if you want persistence (0.1.1 kept everything in process even when given astore_url); install with thepostgresextra. - A custom
StoreWritersubclass must implement the 0.2 interface (start,write_batch,upsert_plan,insert_suggestions,sweep,list_queries,list_request_queries,get_query,close); the contract both shipped stores pass istests/store_contract.py. - Code that built
ExplainJobs or calledplan_cache_get/synthesize_paramsmust move to the new job fields (statement,parameters,recent_call_count). Full list: CHANGELOG.
🧱 Stack
| Layer | Choice |
|---|---|
| Python | 3.12+ |
| SQL parser | sqlglot 25+ |
| Validation | pydantic 2.9+ |
| Database | SQLAlchemy 2.0 async; asyncpg for the store ([postgres] extra) |
| Async HTTP | httpx 0.27+ |
| Logging | stdlib logging |
| Middleware | FastAPI 0.115+ (via [fastapi] extra) |
| LLM client | OpenRouter REST API over httpx (no extra dependency) |
| Dev | pytest, pytest-asyncio, respx, testcontainers, hypothesis, ruff, mypy |
🧪 Testing
| Metric | Value |
|---|---|
Unit tests (-m "not slow and not integration") |
449 |
| Integration tests (Testcontainers Postgres 16) | 56 (53 + 3 @slow) |
| Slow benchmark-style tests | 6 (3 of them integration) |
| Branch coverage, unit tier (CI gate ≥ 90 %) | 92% |
| Branch coverage, unit + integration (CI gate ≥ 95 %, every module ≥ 85 %) | 98% (lowest module 91%) |
| Feature specs | 8 (under docs/specs/) |
| Type checker | mypy strict, zero errors |
The suite is Spec-TDD: the 8 feature specs under docs/specs/ enumerate every case, and 508 pytest items encode them. CI runs the unit tier with a coverage gate, then the unit and integration tiers together against a real Postgres (Testcontainers on the GitHub-hosted Docker daemon) with branch coverage and a per-module floor. Both stores pass one shared behavioural contract, tests/store_contract.py. The composed flow — a tagged request on a real asyncpg engine, listed by GET /_slowquery/queries?request_id= — is tests/integration/test_fleet_flow.py.
uv run pytest # unit tests only (default)
uv run pytest -m integration # testcontainers Postgres required
uv run pytest -m slow # benchmark-style tests
⚡ Benchmarks
Hot-path overhead, measured with no database (reproduce: uv run python benchmarks/bench_detective.py; full table in benchmarks/report.md):
| Operation | µs/op |
|---|---|
| Hook listeners end to end, per statement — untagged | ~2.3 |
Hook listeners end to end, per statement — under an X-Request-Id |
~2.7 |
| Fingerprint — LRU hit (a statement shape already seen) | ~0.14 |
Fingerprint — cold sqlglot parse, flat SELECT … 2-table JOIN |
~258 … 629 |
Ring buffer record |
~0.42 |
Recorder observe — untagged … tagged |
~0.42 … 0.53 |
| Rules engine (6 rules over a plan) — background worker, not per query | ~7.0 |
Ring buffer percentiles (1024-sample window) — once per fingerprint per flush |
~60 |
The per-statement cost is the attached before/after listener pair timed end to end — clock reads, fingerprint (LRU hit), buffer record, the recorder and the glue between them: ~2.3 µs, ~2.7 µs for a tagged statement, plus ~0.26–0.63 ms the first time a statement shape is seen (a real sqlglot parse, which stays under the library's ≤1 ms/statement overhead budget). The rules engine runs on the background EXPLAIN worker and percentiles once per flush, so neither is on the request path. Measured 2026-10-20 on Linux 7.0 / Python 3.12.14, the quietest of 8 consecutive runs (run-to-run spread up to 1.2x — see benchmarks/report.md); re-run on your target.
🧭 Engineering philosophy
| Principle | How it's applied |
|---|---|
| Spec-TDD | Every feature starts as a spec in docs/specs/, test cases enumerated before code is written |
| Negative-space programming | Illegal states unrepresentable — Suggestion is frozen Pydantic, SuggestionKind is a Literal union, QueryRow forbids extra fields, request ids and service ids are pattern-checked |
| Pure core, imperative shell | Rules, fingerprinting, plan scrubbing and merge_batches are pure; I/O lives in the runtime, the worker and the stores |
| Parse, don't validate | install() rejects invalid config before wiring anything; store URLs are parsed and normalised once; the routes reject naive datetimes and malformed ids |
| Typed everything | mypy strict, Pydantic models at every boundary, no Any crossing module lines |
⚠️ Scope & limitations
Counts are shared; percentiles are per process. With the Postgres store, every process adds its calls and totals to the same rows, so call_count, total_ms, max_ms and the per-request numbers cover the whole deployment. The p50/p95/p99 columns, though, are each process's own 60-second window, written by whichever process flushed last. The in-memory store (no store_url) sees only its own process.
Request attribution needs the caller to send X-Request-Id. Statements run outside a tagged request (startup, schedulers, the library's own background tasks) count towards the fingerprint totals only. Statements in threads started without copying the context are not attributed.
Writes are sampled, never explained. INSERT/UPDATE/DELETE and DDL are fingerprinted and counted but never sent to EXPLAIN; executemany batches are never explained either.
Any client can send X-Request-Id. A tagged statement always creates a per-request row (bounded at 10,000 new request/fingerprint pairs per process per two-second flush, swept after retention_s). On a public service where that matters, drop the header from untrusted callers at your edge, or shorten retention_s.
Postgres only. The store, EXPLAIN and the rules are Postgres-specific.
🚀 Status
| Milestone | Status |
|---|---|
| v0.1.0 on PyPI | ✅ Released 2026-04-11 |
| v0.1.1 on PyPI — security fixes, see CHANGELOG | ✅ Released 2026-10-20 |
| v0.2.0 on PyPI — fleet observability, see CHANGELOG | ✅ Released 2026-10-20 |
| 449 unit + 56 integration tests, 98% branch coverage, mypy strict | ✅ Green |
| Live demo (slowquery-demo-backend) | 🟡 Moving onto the 0.2 store |
| Dashboard (slowquery-dashboard-frontend) | 🟡 Phase 4c |
📄 License
MIT — see LICENSE.
Catch the pattern, not the literal. Suggest the index, not the prayer. — slowquery-detective
Metadata
Release files for slowquery-detective 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| slowquery_detective-0.2.0.tar.gz | 59.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| slowquery_detective-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 121.2 kB
Release files / slowquery_detective-0.2.0.tar.gz
| Download URL | slowquery_detective-0.2.0.tar.gz |
|---|---|
| Size | 59.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9004c80ca9d299d2f6bcdceb737220b7db5266d6e8bf2d0de844f32fd14e1440
|
|
BLAKE2b-256 checksum How to use checksums |
18bb8161b1b59ee81cd67be7201f5350202d18a7a840d0140b608c9a8ea51965
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.2 {"installer":{"name":"uv","version":"0.13.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / slowquery_detective-0.2.0-py3-none-any.whl
| Download URL | slowquery_detective-0.2.0-py3-none-any.whl |
|---|---|
| Size | 61.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
da08c9374f1e5a66c5100606291ec37c69fcab2e61e70a3d4e906fdf63e64201
|
|
BLAKE2b-256 checksum How to use checksums |
e5bee8d7c01d8a44ca76c6d88ce6e58117dd5f6a5477e5f14c16bdde79402f71
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.2 {"installer":{"name":"uv","version":"0.13.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for slowquery-detective 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.