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.
Files changed (31) hide show
  1. slowpoke_python-0.1.3/LICENSE +21 -0
  2. slowpoke_python-0.1.3/PKG-INFO +244 -0
  3. slowpoke_python-0.1.3/README.md +220 -0
  4. slowpoke_python-0.1.3/pyproject.toml +46 -0
  5. slowpoke_python-0.1.3/setup.cfg +4 -0
  6. slowpoke_python-0.1.3/src/slowpoke/__init__.py +174 -0
  7. slowpoke_python-0.1.3/src/slowpoke/_config.py +45 -0
  8. slowpoke_python-0.1.3/src/slowpoke/_origin.py +136 -0
  9. slowpoke_python-0.1.3/src/slowpoke/_sender.py +145 -0
  10. slowpoke_python-0.1.3/src/slowpoke/_tracer.py +235 -0
  11. slowpoke_python-0.1.3/src/slowpoke/asgi.py +77 -0
  12. slowpoke_python-0.1.3/src/slowpoke/celery.py +67 -0
  13. slowpoke_python-0.1.3/src/slowpoke/django/__init__.py +226 -0
  14. slowpoke_python-0.1.3/src/slowpoke/django/apps.py +12 -0
  15. slowpoke_python-0.1.3/src/slowpoke/flask.py +55 -0
  16. slowpoke_python-0.1.3/src/slowpoke/sqlalchemy.py +71 -0
  17. slowpoke_python-0.1.3/src/slowpoke_python.egg-info/PKG-INFO +244 -0
  18. slowpoke_python-0.1.3/src/slowpoke_python.egg-info/SOURCES.txt +29 -0
  19. slowpoke_python-0.1.3/src/slowpoke_python.egg-info/dependency_links.txt +1 -0
  20. slowpoke_python-0.1.3/src/slowpoke_python.egg-info/requires.txt +12 -0
  21. slowpoke_python-0.1.3/src/slowpoke_python.egg-info/top_level.txt +1 -0
  22. slowpoke_python-0.1.3/tests/test_celery.py +66 -0
  23. slowpoke_python-0.1.3/tests/test_django.py +254 -0
  24. slowpoke_python-0.1.3/tests/test_fastapi.py +158 -0
  25. slowpoke_python-0.1.3/tests/test_flask.py +144 -0
  26. slowpoke_python-0.1.3/tests/test_origin.py +94 -0
  27. slowpoke_python-0.1.3/tests/test_otlp_fixtures.py +178 -0
  28. slowpoke_python-0.1.3/tests/test_sender.py +151 -0
  29. slowpoke_python-0.1.3/tests/test_sqlalchemy.py +163 -0
  30. slowpoke_python-0.1.3/tests/test_tracer.py +360 -0
  31. 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+