slowpoke-python 0.1.3__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.
- slowpoke_python-0.1.3/LICENSE +21 -0
- slowpoke_python-0.1.3/PKG-INFO +244 -0
- slowpoke_python-0.1.3/README.md +220 -0
- slowpoke_python-0.1.3/pyproject.toml +46 -0
- slowpoke_python-0.1.3/setup.cfg +4 -0
- slowpoke_python-0.1.3/src/slowpoke/__init__.py +174 -0
- slowpoke_python-0.1.3/src/slowpoke/_config.py +45 -0
- slowpoke_python-0.1.3/src/slowpoke/_origin.py +136 -0
- slowpoke_python-0.1.3/src/slowpoke/_sender.py +145 -0
- slowpoke_python-0.1.3/src/slowpoke/_tracer.py +235 -0
- slowpoke_python-0.1.3/src/slowpoke/asgi.py +77 -0
- slowpoke_python-0.1.3/src/slowpoke/celery.py +67 -0
- slowpoke_python-0.1.3/src/slowpoke/django/__init__.py +226 -0
- slowpoke_python-0.1.3/src/slowpoke/django/apps.py +12 -0
- slowpoke_python-0.1.3/src/slowpoke/flask.py +55 -0
- slowpoke_python-0.1.3/src/slowpoke/sqlalchemy.py +71 -0
- slowpoke_python-0.1.3/src/slowpoke_python.egg-info/PKG-INFO +244 -0
- slowpoke_python-0.1.3/src/slowpoke_python.egg-info/SOURCES.txt +29 -0
- slowpoke_python-0.1.3/src/slowpoke_python.egg-info/dependency_links.txt +1 -0
- slowpoke_python-0.1.3/src/slowpoke_python.egg-info/requires.txt +12 -0
- slowpoke_python-0.1.3/src/slowpoke_python.egg-info/top_level.txt +1 -0
- slowpoke_python-0.1.3/tests/test_celery.py +66 -0
- slowpoke_python-0.1.3/tests/test_django.py +254 -0
- slowpoke_python-0.1.3/tests/test_fastapi.py +158 -0
- slowpoke_python-0.1.3/tests/test_flask.py +144 -0
- slowpoke_python-0.1.3/tests/test_origin.py +94 -0
- slowpoke_python-0.1.3/tests/test_otlp_fixtures.py +178 -0
- slowpoke_python-0.1.3/tests/test_sender.py +151 -0
- slowpoke_python-0.1.3/tests/test_sqlalchemy.py +163 -0
- slowpoke_python-0.1.3/tests/test_tracer.py +360 -0
- slowpoke_python-0.1.3/tests/test_weight.py +45 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christian Cannata
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slowpoke-python
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Tells Slowpoke which line of your Python app ran each query: Django, SQLAlchemy, FastAPI, Flask
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Framework :: Django
|
|
8
|
+
Classifier: Framework :: Django :: 4.2
|
|
9
|
+
Classifier: Framework :: Django :: 5.2
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: django
|
|
16
|
+
Requires-Dist: Django>=4.2; extra == "django"
|
|
17
|
+
Provides-Extra: sqlalchemy
|
|
18
|
+
Requires-Dist: SQLAlchemy>=1.4; extra == "sqlalchemy"
|
|
19
|
+
Provides-Extra: flask
|
|
20
|
+
Requires-Dist: Flask>=2.0; extra == "flask"
|
|
21
|
+
Provides-Extra: fastapi
|
|
22
|
+
Requires-Dist: starlette; extra == "fastapi"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
<h1 align="center">slowpoke (Python)</h1>
|
|
26
|
+
|
|
27
|
+
<p align="center"><b>Which line of your code is slow. Not which query — which line.</b></p>
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<a href="https://github.com/christiancannata/slowpoke-python/actions/workflows/tests.yml"><img alt="tests" src="https://github.com/christiancannata/slowpoke-python/actions/workflows/tests.yml/badge.svg"></a>
|
|
31
|
+
<a href="https://pypi.org/project/slowpoke-python/"><img alt="PyPI" src="https://img.shields.io/pypi/v/slowpoke-python"></a>
|
|
32
|
+
<a href="https://pypi.org/project/slowpoke-python/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/slowpoke-python"></a>
|
|
33
|
+
<a href="https://github.com/christiancannata/slowpoke-python/actions/workflows/codeql.yml"><img alt="CodeQL" src="https://github.com/christiancannata/slowpoke-python/actions/workflows/codeql.yml/badge.svg"></a>
|
|
34
|
+
<a href="#performance"><img alt="runtime dependencies: 0" src="https://img.shields.io/badge/runtime%20dependencies-0-brightgreen"></a>
|
|
35
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/christiancannata/slowpoke-python"><img alt="OpenSSF Scorecard" src="https://api.scorecard.dev/projects/github.com/christiancannata/slowpoke-python/badge"></a>
|
|
36
|
+
<a href="LICENSE"><img alt="MIT" src="https://img.shields.io/pypi/l/slowpoke-python"></a>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
A slow query tells you *what* is slow. It never tells you **where**, and a tool that points at
|
|
42
|
+
`site-packages/django/db/models/sql/compiler.py:1398` has told you nothing at all.
|
|
43
|
+
|
|
44
|
+
This package sends [Slowpoke](https://github.com/christiancannata/slowpoke) the file and line of **your**
|
|
45
|
+
code behind every query — for every request, every Celery task and every command cron runs:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
GET /orders/<int:pk>/ 820 ms · 34 queries
|
|
49
|
+
SELECT * FROM orders WHERE id = %s 4 ms shop/views.py:42
|
|
50
|
+
SELECT * FROM customers WHERE id = %s 3 ms shop/models.py:88 ← ×31, one per order
|
|
51
|
+
SELECT SUM(total) FROM invoices WHERE order_id=%s 9 ms shop/serializers.py:17
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
That last column is the whole point. Slowpoke turns it into N+1 detection and missions that name a file,
|
|
55
|
+
each with a price in seconds of waiting per day — so the argument about what to fix first is over.
|
|
56
|
+
|
|
57
|
+
**One query, however the driver writes it.** psycopg2 sends `%(id)s`, MySQLdb sends `%s`, SQLite sends `?`:
|
|
58
|
+
the same statement is one query in Slowpoke, not three.
|
|
59
|
+
|
|
60
|
+
**Cron and workers too.** A Celery task and a management command are not endpoints, and this package does
|
|
61
|
+
not pretend they are: they go to the Jobs page with how long they took, how often they failed and the same
|
|
62
|
+
`file:line` for their queries. Nobody is waiting for them, which is exactly why nobody notices when they get
|
|
63
|
+
slower.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
pip install slowpoke-python
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The name on PyPI carries the language, like the other packages of this project; what you import is
|
|
72
|
+
`slowpoke`. (Plain `slowpoke` on PyPI is a different thing, published once in 2012 and never again.)
|
|
73
|
+
|
|
74
|
+
No SDK, no extension, no key to carry, no account anywhere. The package talks to the Slowpoke agent on the
|
|
75
|
+
same machine, which needs one line in `/etc/slowpoke/agent.yaml`:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
sources:
|
|
79
|
+
- type: otlp # the agent listens on 127.0.0.1:4318
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Django
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
INSTALLED_APPS = [..., "slowpoke.django"]
|
|
86
|
+
MIDDLEWARE = ["slowpoke.django.SlowpokeMiddleware", ...] # first, so the timing covers the others
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Every database connection is hooked with `connection.execute_wrapper`, including the ones opened later in
|
|
90
|
+
other threads. Sync and async views both work. **Management commands are traced as well** — that is cron —
|
|
91
|
+
except the ones that never end (`runserver`, `runworker`, `rqworker`, `qcluster`…); `SLOWPOKE_COMMANDS=false`
|
|
92
|
+
turns them off. File paths are relative to `BASE_DIR`.
|
|
93
|
+
|
|
94
|
+
### SQLAlchemy (with Flask, FastAPI, or a script)
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import slowpoke.sqlalchemy
|
|
98
|
+
|
|
99
|
+
slowpoke.sqlalchemy.instrument(engine) # an Engine or an AsyncEngine
|
|
100
|
+
slowpoke.sqlalchemy.instrument() # or every Engine, including the ones created later
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Flask · FastAPI / Starlette
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import slowpoke.flask
|
|
107
|
+
slowpoke.flask.init_app(app)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from slowpoke.asgi import SlowpokeMiddleware
|
|
112
|
+
app.add_middleware(SlowpokeMiddleware) # add it last: it becomes the outermost middleware
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Celery
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import slowpoke.celery
|
|
119
|
+
|
|
120
|
+
slowpoke.celery.install() # wherever the Celery app is created
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
One trace per task the worker runs, with its queue and whether it failed. A task dispatched from a request
|
|
124
|
+
is traced where it runs, not where it was sent from.
|
|
125
|
+
|
|
126
|
+
### Scripts and anything else
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import slowpoke
|
|
130
|
+
|
|
131
|
+
@slowpoke.command("import_orders") # cron, a script: nobody is waiting
|
|
132
|
+
def import_orders(): ...
|
|
133
|
+
|
|
134
|
+
with slowpoke.job("rebuild_search_index", queue="nightly"): # your own worker loop
|
|
135
|
+
...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
A short script can call `slowpoke.flush()` before exiting; otherwise the last traces get at most half a
|
|
139
|
+
second at interpreter exit.
|
|
140
|
+
|
|
141
|
+
## Performance
|
|
142
|
+
|
|
143
|
+
The rule this package is built on is the one the whole project follows: **never make the application
|
|
144
|
+
slower**. Measured, not claimed, and you can run it yourself with `./bin/bench` — everything the package
|
|
145
|
+
does *while a request is running*: recording each query, finding the line behind it, building the trace.
|
|
146
|
+
|
|
147
|
+
| | Python 3.9 | Python 3.13 |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| per query | 4.6 µs | 3.5 µs |
|
|
150
|
+
| **a request with 50 queries** | **0.23 ms** | **0.17 ms** |
|
|
151
|
+
|
|
152
|
+
For scale: a request that spends 800 ms in your code and your database pays about **two ten-thousandths** of
|
|
153
|
+
that to be measured. Everything else happens off the request:
|
|
154
|
+
|
|
155
|
+
| | |
|
|
156
|
+
|---|---|
|
|
157
|
+
| **Sent from a background thread** | the request only appends to a list and drops the finished trace into a bounded queue with `put_nowait`. Encoding and the HTTP call happen on one daemon thread |
|
|
158
|
+
| **Never waits** | a hard time budget (`SLOWPOKE_TIMEOUT`, 0.1 s) and every error swallowed: an agent that is missing, slow or broken costs one trace, never a request. A full queue drops the trace |
|
|
159
|
+
| **Never copies your data** | the stack is walked with `sys._getframe` to a bounded depth: no argument, no local variable, ever |
|
|
160
|
+
| **Bounded** | 500 queries described per request at most, the rest counted; statements over 10 000 characters cut; a bounded per-file cache |
|
|
161
|
+
| **Quiet when idle** | queries outside a request, a task or a command — a worker polling its broker, a shell you opened — cost one context variable lookup and are not recorded |
|
|
162
|
+
| **Async-safe** | the trace lives in `contextvars`: it follows `sync_to_async`, `run_in_threadpool` and asyncio tasks, and a statement run in a worker thread is attributed to the line that awaited it |
|
|
163
|
+
|
|
164
|
+
About 1000 lines of Python. **No runtime dependency at all**: only the standard library. A test in the suite fails the day that stops being true,
|
|
165
|
+
and another one fails if anything but source and documentation ends up in a published copy.
|
|
166
|
+
|
|
167
|
+
## What is sent, and what never is
|
|
168
|
+
|
|
169
|
+
Sent only to the agent on your machine or private network:
|
|
170
|
+
|
|
171
|
+
- **per request** — method, route template (`/orders/<int:pk>/`, `/orders/{order_id}`), status code, start
|
|
172
|
+
and end time. When no route matched, the path without its query string;
|
|
173
|
+
- **per task** — the task name, the queue it came from, whether it failed;
|
|
174
|
+
- **per command** — the command name (`close_orders`), how long it took, whether it raised;
|
|
175
|
+
- **per query** — the SQL **with placeholders** exactly as the driver receives it, the database engine, the
|
|
176
|
+
real duration, and the first application file and line on the stack, outside `site-packages/`,
|
|
177
|
+
`dist-packages/`, the standard library and this package.
|
|
178
|
+
|
|
179
|
+
**Never sent** — parameter values, request parameters, headers, cookies, session, the user, exception
|
|
180
|
+
messages. If you write literal values into raw SQL yourself, they are part of the statement, and the agent
|
|
181
|
+
redacts them before anything leaves the machine.
|
|
182
|
+
|
|
183
|
+
## Configuration
|
|
184
|
+
|
|
185
|
+
Everything has a default that works. Nothing has to be set.
|
|
186
|
+
|
|
187
|
+
| Variable | Default | |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `SLOWPOKE_ENABLED` | `true` | `false` turns everything off: nothing recorded, nothing sent |
|
|
190
|
+
| `SLOWPOKE_OTLP_ENDPOINT` | `http://127.0.0.1:4318/v1/traces` | plain http to a local or private host only (private IPs, `localhost`, a Docker service name, `.local`/`.internal`); anything else disables the package |
|
|
191
|
+
| `SLOWPOKE_TIMEOUT` | `0.1` | seconds the background thread gives the agent, connect and write together |
|
|
192
|
+
| `SLOWPOKE_SERVICE` | the code root folder's name | the name of this application in Slowpoke |
|
|
193
|
+
| `SLOWPOKE_COMMANDS` | `true` | trace Django management commands |
|
|
194
|
+
| `SLOWPOKE_MAX_QUERIES` | `500` | queries described per request, task or command; the rest are counted |
|
|
195
|
+
| `SLOWPOKE_MAX_SQL_LENGTH` | `10000` | longer statements are cut |
|
|
196
|
+
| `SLOWPOKE_BACKTRACE_LIMIT` | `100` | stack frames inspected to find your line |
|
|
197
|
+
| `SLOWPOKE_CODE_ROOT` | `BASE_DIR` in Django, else the working directory | file paths are sent relative to it |
|
|
198
|
+
| `SLOWPOKE_QUEUE_SIZE` | `256` | traces waiting for the agent; past that they are dropped |
|
|
199
|
+
|
|
200
|
+
The same settings can be passed in code: `slowpoke.configure(service="shop", code_root="/srv/app")`.
|
|
201
|
+
|
|
202
|
+
## Compatibility
|
|
203
|
+
|
|
204
|
+
| Python | Django | SQLAlchemy | Flask | FastAPI / Starlette | Celery |
|
|
205
|
+
|---|---|---|---|---|---|
|
|
206
|
+
| 3.9 – 3.13 | 4.2, 5.2, 6 | 1.4, 2.x | 2.x, 3.x | current | 5.x |
|
|
207
|
+
|
|
208
|
+
The oldest combination (Python 3.9 with Django 4.2, SQLAlchemy 1.4, Flask 2.3, FastAPI 0.100) and the newest
|
|
209
|
+
both run the full suite on every push and every week.
|
|
210
|
+
|
|
211
|
+
## Quality
|
|
212
|
+
|
|
213
|
+
| | |
|
|
214
|
+
|---|---|
|
|
215
|
+
| **96 tests** | unit tests and integration tests on real Django, Flask, FastAPI and Celery applications, on every combination above |
|
|
216
|
+
| **Same wire, both sides** | `spec/python_otlp_fixtures.json` in the Slowpoke repository holds payloads exactly as this package sends them, with what the agent must read from each — including the fingerprint that folds `%s`, `%(name)s` and `?` into one query. The agent's Go tests replay that file: a change here the agent cannot read fails there |
|
|
217
|
+
| **ruff** | in CI, on every push |
|
|
218
|
+
| **CodeQL** and **OpenSSF Scorecard** | on the code and on the workflows, which are pinned by commit |
|
|
219
|
+
| **Signed provenance** | every release archive carries a Sigstore attestation |
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
./bin/test 3.13 # newest Django, SQLAlchemy, Flask, FastAPI
|
|
223
|
+
./bin/test 3.9 'Django~=4.2.0' 'SQLAlchemy~=1.4.0' 'Flask~=2.3.0' 'Werkzeug<3' 'fastapi~=0.100.0' 'httpx<0.28'
|
|
224
|
+
./bin/test 3.13 -k origin tests/test_django.py # arguments starting with "-" or containing "/" go to pytest
|
|
225
|
+
./bin/test-matrix # every supported combination, one after the other
|
|
226
|
+
./bin/bench # the numbers in Performance, on your machine
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Everything runs in Docker. Nothing is installed on your machine.
|
|
230
|
+
|
|
231
|
+
## Security
|
|
232
|
+
|
|
233
|
+
It reads no request data, sends nothing outside your machine or private network, and cannot break or slow a
|
|
234
|
+
request. What it does and never does, how to report a vulnerability and how to verify a release are in
|
|
235
|
+
[SECURITY.md](SECURITY.md).
|
|
236
|
+
|
|
237
|
+
Do not run it together with OpenTelemetry auto-instrumentation (`opentelemetry-instrument`,
|
|
238
|
+
`DjangoInstrumentor`, `SQLAlchemyInstrumentor`) pointed at the same agent: every request and query would
|
|
239
|
+
arrive twice. This package replaces it, and gives the origin it cannot.
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT, see [LICENSE](LICENSE). Slowpoke itself is free and self-hosted: the measures stay on your machines,
|
|
244
|
+
and nothing about your application ever leaves them.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<h1 align="center">slowpoke (Python)</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center"><b>Which line of your code is slow. Not which query — which line.</b></p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/christiancannata/slowpoke-python/actions/workflows/tests.yml"><img alt="tests" src="https://github.com/christiancannata/slowpoke-python/actions/workflows/tests.yml/badge.svg"></a>
|
|
7
|
+
<a href="https://pypi.org/project/slowpoke-python/"><img alt="PyPI" src="https://img.shields.io/pypi/v/slowpoke-python"></a>
|
|
8
|
+
<a href="https://pypi.org/project/slowpoke-python/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/slowpoke-python"></a>
|
|
9
|
+
<a href="https://github.com/christiancannata/slowpoke-python/actions/workflows/codeql.yml"><img alt="CodeQL" src="https://github.com/christiancannata/slowpoke-python/actions/workflows/codeql.yml/badge.svg"></a>
|
|
10
|
+
<a href="#performance"><img alt="runtime dependencies: 0" src="https://img.shields.io/badge/runtime%20dependencies-0-brightgreen"></a>
|
|
11
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/christiancannata/slowpoke-python"><img alt="OpenSSF Scorecard" src="https://api.scorecard.dev/projects/github.com/christiancannata/slowpoke-python/badge"></a>
|
|
12
|
+
<a href="LICENSE"><img alt="MIT" src="https://img.shields.io/pypi/l/slowpoke-python"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
A slow query tells you *what* is slow. It never tells you **where**, and a tool that points at
|
|
18
|
+
`site-packages/django/db/models/sql/compiler.py:1398` has told you nothing at all.
|
|
19
|
+
|
|
20
|
+
This package sends [Slowpoke](https://github.com/christiancannata/slowpoke) the file and line of **your**
|
|
21
|
+
code behind every query — for every request, every Celery task and every command cron runs:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
GET /orders/<int:pk>/ 820 ms · 34 queries
|
|
25
|
+
SELECT * FROM orders WHERE id = %s 4 ms shop/views.py:42
|
|
26
|
+
SELECT * FROM customers WHERE id = %s 3 ms shop/models.py:88 ← ×31, one per order
|
|
27
|
+
SELECT SUM(total) FROM invoices WHERE order_id=%s 9 ms shop/serializers.py:17
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That last column is the whole point. Slowpoke turns it into N+1 detection and missions that name a file,
|
|
31
|
+
each with a price in seconds of waiting per day — so the argument about what to fix first is over.
|
|
32
|
+
|
|
33
|
+
**One query, however the driver writes it.** psycopg2 sends `%(id)s`, MySQLdb sends `%s`, SQLite sends `?`:
|
|
34
|
+
the same statement is one query in Slowpoke, not three.
|
|
35
|
+
|
|
36
|
+
**Cron and workers too.** A Celery task and a management command are not endpoints, and this package does
|
|
37
|
+
not pretend they are: they go to the Jobs page with how long they took, how often they failed and the same
|
|
38
|
+
`file:line` for their queries. Nobody is waiting for them, which is exactly why nobody notices when they get
|
|
39
|
+
slower.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install slowpoke-python
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The name on PyPI carries the language, like the other packages of this project; what you import is
|
|
48
|
+
`slowpoke`. (Plain `slowpoke` on PyPI is a different thing, published once in 2012 and never again.)
|
|
49
|
+
|
|
50
|
+
No SDK, no extension, no key to carry, no account anywhere. The package talks to the Slowpoke agent on the
|
|
51
|
+
same machine, which needs one line in `/etc/slowpoke/agent.yaml`:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
sources:
|
|
55
|
+
- type: otlp # the agent listens on 127.0.0.1:4318
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Django
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
INSTALLED_APPS = [..., "slowpoke.django"]
|
|
62
|
+
MIDDLEWARE = ["slowpoke.django.SlowpokeMiddleware", ...] # first, so the timing covers the others
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Every database connection is hooked with `connection.execute_wrapper`, including the ones opened later in
|
|
66
|
+
other threads. Sync and async views both work. **Management commands are traced as well** — that is cron —
|
|
67
|
+
except the ones that never end (`runserver`, `runworker`, `rqworker`, `qcluster`…); `SLOWPOKE_COMMANDS=false`
|
|
68
|
+
turns them off. File paths are relative to `BASE_DIR`.
|
|
69
|
+
|
|
70
|
+
### SQLAlchemy (with Flask, FastAPI, or a script)
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import slowpoke.sqlalchemy
|
|
74
|
+
|
|
75
|
+
slowpoke.sqlalchemy.instrument(engine) # an Engine or an AsyncEngine
|
|
76
|
+
slowpoke.sqlalchemy.instrument() # or every Engine, including the ones created later
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Flask · FastAPI / Starlette
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import slowpoke.flask
|
|
83
|
+
slowpoke.flask.init_app(app)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from slowpoke.asgi import SlowpokeMiddleware
|
|
88
|
+
app.add_middleware(SlowpokeMiddleware) # add it last: it becomes the outermost middleware
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Celery
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import slowpoke.celery
|
|
95
|
+
|
|
96
|
+
slowpoke.celery.install() # wherever the Celery app is created
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
One trace per task the worker runs, with its queue and whether it failed. A task dispatched from a request
|
|
100
|
+
is traced where it runs, not where it was sent from.
|
|
101
|
+
|
|
102
|
+
### Scripts and anything else
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import slowpoke
|
|
106
|
+
|
|
107
|
+
@slowpoke.command("import_orders") # cron, a script: nobody is waiting
|
|
108
|
+
def import_orders(): ...
|
|
109
|
+
|
|
110
|
+
with slowpoke.job("rebuild_search_index", queue="nightly"): # your own worker loop
|
|
111
|
+
...
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A short script can call `slowpoke.flush()` before exiting; otherwise the last traces get at most half a
|
|
115
|
+
second at interpreter exit.
|
|
116
|
+
|
|
117
|
+
## Performance
|
|
118
|
+
|
|
119
|
+
The rule this package is built on is the one the whole project follows: **never make the application
|
|
120
|
+
slower**. Measured, not claimed, and you can run it yourself with `./bin/bench` — everything the package
|
|
121
|
+
does *while a request is running*: recording each query, finding the line behind it, building the trace.
|
|
122
|
+
|
|
123
|
+
| | Python 3.9 | Python 3.13 |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| per query | 4.6 µs | 3.5 µs |
|
|
126
|
+
| **a request with 50 queries** | **0.23 ms** | **0.17 ms** |
|
|
127
|
+
|
|
128
|
+
For scale: a request that spends 800 ms in your code and your database pays about **two ten-thousandths** of
|
|
129
|
+
that to be measured. Everything else happens off the request:
|
|
130
|
+
|
|
131
|
+
| | |
|
|
132
|
+
|---|---|
|
|
133
|
+
| **Sent from a background thread** | the request only appends to a list and drops the finished trace into a bounded queue with `put_nowait`. Encoding and the HTTP call happen on one daemon thread |
|
|
134
|
+
| **Never waits** | a hard time budget (`SLOWPOKE_TIMEOUT`, 0.1 s) and every error swallowed: an agent that is missing, slow or broken costs one trace, never a request. A full queue drops the trace |
|
|
135
|
+
| **Never copies your data** | the stack is walked with `sys._getframe` to a bounded depth: no argument, no local variable, ever |
|
|
136
|
+
| **Bounded** | 500 queries described per request at most, the rest counted; statements over 10 000 characters cut; a bounded per-file cache |
|
|
137
|
+
| **Quiet when idle** | queries outside a request, a task or a command — a worker polling its broker, a shell you opened — cost one context variable lookup and are not recorded |
|
|
138
|
+
| **Async-safe** | the trace lives in `contextvars`: it follows `sync_to_async`, `run_in_threadpool` and asyncio tasks, and a statement run in a worker thread is attributed to the line that awaited it |
|
|
139
|
+
|
|
140
|
+
About 1000 lines of Python. **No runtime dependency at all**: only the standard library. A test in the suite fails the day that stops being true,
|
|
141
|
+
and another one fails if anything but source and documentation ends up in a published copy.
|
|
142
|
+
|
|
143
|
+
## What is sent, and what never is
|
|
144
|
+
|
|
145
|
+
Sent only to the agent on your machine or private network:
|
|
146
|
+
|
|
147
|
+
- **per request** — method, route template (`/orders/<int:pk>/`, `/orders/{order_id}`), status code, start
|
|
148
|
+
and end time. When no route matched, the path without its query string;
|
|
149
|
+
- **per task** — the task name, the queue it came from, whether it failed;
|
|
150
|
+
- **per command** — the command name (`close_orders`), how long it took, whether it raised;
|
|
151
|
+
- **per query** — the SQL **with placeholders** exactly as the driver receives it, the database engine, the
|
|
152
|
+
real duration, and the first application file and line on the stack, outside `site-packages/`,
|
|
153
|
+
`dist-packages/`, the standard library and this package.
|
|
154
|
+
|
|
155
|
+
**Never sent** — parameter values, request parameters, headers, cookies, session, the user, exception
|
|
156
|
+
messages. If you write literal values into raw SQL yourself, they are part of the statement, and the agent
|
|
157
|
+
redacts them before anything leaves the machine.
|
|
158
|
+
|
|
159
|
+
## Configuration
|
|
160
|
+
|
|
161
|
+
Everything has a default that works. Nothing has to be set.
|
|
162
|
+
|
|
163
|
+
| Variable | Default | |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `SLOWPOKE_ENABLED` | `true` | `false` turns everything off: nothing recorded, nothing sent |
|
|
166
|
+
| `SLOWPOKE_OTLP_ENDPOINT` | `http://127.0.0.1:4318/v1/traces` | plain http to a local or private host only (private IPs, `localhost`, a Docker service name, `.local`/`.internal`); anything else disables the package |
|
|
167
|
+
| `SLOWPOKE_TIMEOUT` | `0.1` | seconds the background thread gives the agent, connect and write together |
|
|
168
|
+
| `SLOWPOKE_SERVICE` | the code root folder's name | the name of this application in Slowpoke |
|
|
169
|
+
| `SLOWPOKE_COMMANDS` | `true` | trace Django management commands |
|
|
170
|
+
| `SLOWPOKE_MAX_QUERIES` | `500` | queries described per request, task or command; the rest are counted |
|
|
171
|
+
| `SLOWPOKE_MAX_SQL_LENGTH` | `10000` | longer statements are cut |
|
|
172
|
+
| `SLOWPOKE_BACKTRACE_LIMIT` | `100` | stack frames inspected to find your line |
|
|
173
|
+
| `SLOWPOKE_CODE_ROOT` | `BASE_DIR` in Django, else the working directory | file paths are sent relative to it |
|
|
174
|
+
| `SLOWPOKE_QUEUE_SIZE` | `256` | traces waiting for the agent; past that they are dropped |
|
|
175
|
+
|
|
176
|
+
The same settings can be passed in code: `slowpoke.configure(service="shop", code_root="/srv/app")`.
|
|
177
|
+
|
|
178
|
+
## Compatibility
|
|
179
|
+
|
|
180
|
+
| Python | Django | SQLAlchemy | Flask | FastAPI / Starlette | Celery |
|
|
181
|
+
|---|---|---|---|---|---|
|
|
182
|
+
| 3.9 – 3.13 | 4.2, 5.2, 6 | 1.4, 2.x | 2.x, 3.x | current | 5.x |
|
|
183
|
+
|
|
184
|
+
The oldest combination (Python 3.9 with Django 4.2, SQLAlchemy 1.4, Flask 2.3, FastAPI 0.100) and the newest
|
|
185
|
+
both run the full suite on every push and every week.
|
|
186
|
+
|
|
187
|
+
## Quality
|
|
188
|
+
|
|
189
|
+
| | |
|
|
190
|
+
|---|---|
|
|
191
|
+
| **96 tests** | unit tests and integration tests on real Django, Flask, FastAPI and Celery applications, on every combination above |
|
|
192
|
+
| **Same wire, both sides** | `spec/python_otlp_fixtures.json` in the Slowpoke repository holds payloads exactly as this package sends them, with what the agent must read from each — including the fingerprint that folds `%s`, `%(name)s` and `?` into one query. The agent's Go tests replay that file: a change here the agent cannot read fails there |
|
|
193
|
+
| **ruff** | in CI, on every push |
|
|
194
|
+
| **CodeQL** and **OpenSSF Scorecard** | on the code and on the workflows, which are pinned by commit |
|
|
195
|
+
| **Signed provenance** | every release archive carries a Sigstore attestation |
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
./bin/test 3.13 # newest Django, SQLAlchemy, Flask, FastAPI
|
|
199
|
+
./bin/test 3.9 'Django~=4.2.0' 'SQLAlchemy~=1.4.0' 'Flask~=2.3.0' 'Werkzeug<3' 'fastapi~=0.100.0' 'httpx<0.28'
|
|
200
|
+
./bin/test 3.13 -k origin tests/test_django.py # arguments starting with "-" or containing "/" go to pytest
|
|
201
|
+
./bin/test-matrix # every supported combination, one after the other
|
|
202
|
+
./bin/bench # the numbers in Performance, on your machine
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Everything runs in Docker. Nothing is installed on your machine.
|
|
206
|
+
|
|
207
|
+
## Security
|
|
208
|
+
|
|
209
|
+
It reads no request data, sends nothing outside your machine or private network, and cannot break or slow a
|
|
210
|
+
request. What it does and never does, how to report a vulnerability and how to verify a release are in
|
|
211
|
+
[SECURITY.md](SECURITY.md).
|
|
212
|
+
|
|
213
|
+
Do not run it together with OpenTelemetry auto-instrumentation (`opentelemetry-instrument`,
|
|
214
|
+
`DjangoInstrumentor`, `SQLAlchemyInstrumentor`) pointed at the same agent: every request and query would
|
|
215
|
+
arrive twice. This package replaces it, and gives the origin it cannot.
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT, see [LICENSE](LICENSE). Slowpoke itself is free and self-hosted: the measures stay on your machines,
|
|
220
|
+
and nothing about your application ever leaves them.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "slowpoke-python"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Tells Slowpoke which line of your Python app ran each query: Django, SQLAlchemy, FastAPI, Flask"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Framework :: Django",
|
|
17
|
+
"Framework :: Django :: 4.2",
|
|
18
|
+
"Framework :: Django :: 5.2",
|
|
19
|
+
"Framework :: FastAPI",
|
|
20
|
+
"Framework :: Flask",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
django = ["Django>=4.2"]
|
|
25
|
+
sqlalchemy = ["SQLAlchemy>=1.4"]
|
|
26
|
+
flask = ["Flask>=2.0"]
|
|
27
|
+
fastapi = ["starlette"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.dynamic]
|
|
30
|
+
version = { attr = "slowpoke._tracer.VERSION" }
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 130 # the width this package is written at
|
|
40
|
+
target-version = "py39"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "W", "I", "UP", "B"]
|
|
44
|
+
# %-formatting is deliberate: the tracer runs on every query and % is cheaper than str.format,
|
|
45
|
+
# and B904 would ask for "raise ... from" where the package swallows errors on purpose.
|
|
46
|
+
ignore = ["B904", "UP031", "UP032"]
|