flask-redline 0.2.0a1__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.
- flask_redline-0.2.0a1/.gitignore +21 -0
- flask_redline-0.2.0a1/LICENSE +21 -0
- flask_redline-0.2.0a1/PKG-INFO +289 -0
- flask_redline-0.2.0a1/README.md +252 -0
- flask_redline-0.2.0a1/pyproject.toml +85 -0
- flask_redline-0.2.0a1/src/flask_redline/__init__.py +43 -0
- flask_redline-0.2.0a1/src/flask_redline/_hooks.py +137 -0
- flask_redline-0.2.0a1/src/flask_redline/_httpx.py +5 -0
- flask_redline-0.2.0a1/src/flask_redline/_middleware.py +392 -0
- flask_redline-0.2.0a1/src/flask_redline/_requests.py +22 -0
- flask_redline-0.2.0a1/src/flask_redline/_security.py +162 -0
- flask_redline-0.2.0a1/src/flask_redline/_sqlalchemy.py +19 -0
- flask_redline-0.2.0a1/src/flask_redline/blueprint.py +186 -0
- flask_redline-0.2.0a1/src/flask_redline/cli.py +141 -0
- flask_redline-0.2.0a1/src/flask_redline/integration.py +292 -0
- flask_redline-0.2.0a1/src/flask_redline/py.typed +0 -0
- flask_redline-0.2.0a1/src/flask_redline/suggest.py +5 -0
- flask_redline-0.2.0a1/tests/__init__.py +0 -0
- flask_redline-0.2.0a1/tests/conftest.py +97 -0
- flask_redline-0.2.0a1/tests/fsa_app.py +70 -0
- flask_redline-0.2.0a1/tests/shop_app.py +279 -0
- flask_redline-0.2.0a1/tests/templates/broken.html +1 -0
- flask_redline-0.2.0a1/tests/templates/customers.html +5 -0
- flask_redline-0.2.0a1/tests/templates/orders.html +1 -0
- flask_redline-0.2.0a1/tests/test_access_policies.py +124 -0
- flask_redline-0.2.0a1/tests/test_api.py +357 -0
- flask_redline-0.2.0a1/tests/test_cli.py +83 -0
- flask_redline-0.2.0a1/tests/test_middleware.py +362 -0
- flask_redline-0.2.0a1/tests/test_security_redaction.py +69 -0
- flask_redline-0.2.0a1/tests/test_security_regressions.py +81 -0
- flask_redline-0.2.0a1/tests/test_sqlalchemy.py +243 -0
- flask_redline-0.2.0a1/tests/test_suggest.py +97 -0
- flask_redline-0.2.0a1/tests/test_v02_adapter.py +285 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
site/
|
|
8
|
+
.redline/
|
|
9
|
+
*.db
|
|
10
|
+
*.sqlite3
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
htmlcov/
|
|
15
|
+
node_modules/
|
|
16
|
+
frontend/dist/
|
|
17
|
+
.DS_Store
|
|
18
|
+
frontend/screenshots/
|
|
19
|
+
|
|
20
|
+
# agent worktrees
|
|
21
|
+
.claude/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mahmoud Ahmed
|
|
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,289 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flask-redline
|
|
3
|
+
Version: 0.2.0a1
|
|
4
|
+
Summary: A modern request profiler for Flask and SQLAlchemy: span timelines, N+1 detection with fix diffs, one-click EXPLAIN, flamegraphs and a fast web UI.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Pythondeveloper6/redline
|
|
6
|
+
Project-URL: Documentation, https://pythondeveloper6.github.io/redline/flask/
|
|
7
|
+
Project-URL: Source, https://github.com/Pythondeveloper6/redline
|
|
8
|
+
Project-URL: Issues, https://github.com/Pythondeveloper6/redline/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Pythondeveloper6/redline/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Mahmoud Ahmed <pythondeveloper6@gmail.com>
|
|
11
|
+
Maintainer-email: Mahmoud Ahmed <pythondeveloper6@gmail.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: explain,flamegraph,flask,flask-sqlalchemy,n+1,performance,profiler,sqlalchemy,wsgi
|
|
15
|
+
Classifier: Development Status :: 3 - Alpha
|
|
16
|
+
Classifier: Environment :: Web Environment
|
|
17
|
+
Classifier: Framework :: Flask
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
27
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
28
|
+
Classifier: Topic :: System :: Monitoring
|
|
29
|
+
Classifier: Typing :: Typed
|
|
30
|
+
Requires-Python: >=3.10
|
|
31
|
+
Requires-Dist: flask<4,>=3.0
|
|
32
|
+
Requires-Dist: redline-core==0.2.0a1
|
|
33
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
34
|
+
Provides-Extra: profile
|
|
35
|
+
Requires-Dist: redline-core[profile]==0.2.0a1; extra == 'profile'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
<p align="center">
|
|
39
|
+
<a href="https://pythondeveloper6.github.io/redline/">
|
|
40
|
+
<picture>
|
|
41
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Pythondeveloper6/redline/main/docs/assets/logo-dark.svg">
|
|
42
|
+
<img alt="Redline" src="https://raw.githubusercontent.com/Pythondeveloper6/redline/main/docs/assets/logo-light.svg" height="56">
|
|
43
|
+
</picture>
|
|
44
|
+
</a>
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
# flask-redline
|
|
48
|
+
|
|
49
|
+
The Flask + SQLAlchemy adapter for [Redline](https://github.com/Pythondeveloper6/redline#readme): a request profiler and
|
|
50
|
+
backend analyzer. Every request becomes a trace with a span timeline, the SQL it ran, N+1
|
|
51
|
+
and duplicate-query detection with **concrete fix diffs**, one-click `EXPLAIN`, optional
|
|
52
|
+
flamegraphs, and a fast web UI served from your own app.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from flask import Flask
|
|
56
|
+
from sqlalchemy import create_engine
|
|
57
|
+
from flask_redline import Redline
|
|
58
|
+
|
|
59
|
+
app = Flask(__name__)
|
|
60
|
+
engine = create_engine("sqlite:///app.db")
|
|
61
|
+
|
|
62
|
+
# access="local": localhost only (unset, Redline answers 404 outside debug mode)
|
|
63
|
+
Redline(app, engine=engine, settings={"access": "local"}) # UI at http://localhost:5000/_redline/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
With **Flask-SQLAlchemy** you do not even pass the engine: its engines (including
|
|
67
|
+
`SQLALCHEMY_BINDS`) are found and instrumented automatically.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
db = SQLAlchemy(app)
|
|
71
|
+
Redline(app)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Traces are stored in their own SQLite file (`.redline/redline.db`), never in your database.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install \
|
|
80
|
+
"flask-redline @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/flask-redline" \
|
|
81
|
+
"redline-core @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"
|
|
82
|
+
pip install "flask[async]" # only if you use `async def` views
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Not on PyPI yet: install from GitHub, naming `redline-core` in the same command (the adapter
|
|
86
|
+
pins it exactly). Docs: <https://pythondeveloper6.github.io/redline/>.
|
|
87
|
+
|
|
88
|
+
Add the extra to the core requirement for flamegraphs:
|
|
89
|
+
`"redline-core[profile] @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"`.
|
|
90
|
+
|
|
91
|
+
Python 3.10+, Flask 3.0+, SQLAlchemy 2.x.
|
|
92
|
+
|
|
93
|
+
## `Redline`
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
Redline(
|
|
97
|
+
app=None,
|
|
98
|
+
*,
|
|
99
|
+
engine=None, # Engine | [engines] | {"alias": engine} (default: Flask-SQLAlchemy's)
|
|
100
|
+
settings=None, # dict | redline_core.Settings
|
|
101
|
+
url_prefix=None, # default: the mount_path setting, else "/_redline"
|
|
102
|
+
permission=None, # callable(request) -> bool (see "Access control")
|
|
103
|
+
store=None, # custom Store (default: SQLite file at storage_path)
|
|
104
|
+
)
|
|
105
|
+
redline.init_app(app) # application factories
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
What it does:
|
|
109
|
+
|
|
110
|
+
- wraps `app.wsgi_app` in a WSGI middleware that times the request and records status,
|
|
111
|
+
headers and (size-capped) bodies;
|
|
112
|
+
- connects Flask hooks for the route template (`/orders/<int:order_id>`), the view
|
|
113
|
+
function, templates and unhandled errors;
|
|
114
|
+
- instruments the engines;
|
|
115
|
+
- registers a blueprint with the UI and JSON API under `url_prefix`, and the
|
|
116
|
+
`flask redline` command group.
|
|
117
|
+
|
|
118
|
+
`settings` takes any [Redline setting](../redline-core/README.md) with case-insensitive
|
|
119
|
+
keys. They can also live in `app.config["REDLINE"]` (a dict); keys passed to `Redline`
|
|
120
|
+
win. A `Settings` instance passed to `Redline` is used as-is.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
Redline(app, engine=engine, settings={
|
|
124
|
+
"sample_rate": 0.1, # keep 10% of requests ...
|
|
125
|
+
"always_keep_slower_than_ms": 300, # ... plus every slow one and every error
|
|
126
|
+
"slow_query_ms": 50,
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Application factory
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
redline = Redline(engine=engine)
|
|
134
|
+
|
|
135
|
+
def create_app():
|
|
136
|
+
app = Flask(__name__)
|
|
137
|
+
redline.init_app(app)
|
|
138
|
+
return app
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`redline.store`, `.settings`, `.engines`, `.add_engine(engine, alias=None)` and `.close()`
|
|
142
|
+
act on the current app (inside an app context) or on the last app initialised. The
|
|
143
|
+
store is flushed and closed at interpreter exit.
|
|
144
|
+
|
|
145
|
+
### Engines
|
|
146
|
+
|
|
147
|
+
The first engine of a list is the `"default"` db alias; others are named after their
|
|
148
|
+
database file, or pass a dict. The alias is stored on each query so EXPLAIN runs on the
|
|
149
|
+
right engine.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
Redline(app, engine=[main_engine, analytics_engine])
|
|
153
|
+
Redline(app, engine={"main": main_engine, "analytics": analytics_engine})
|
|
154
|
+
redline.add_engine(late_engine) # an engine created later
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
When `engine` is omitted and Flask-SQLAlchemy 3.x is set up on the app, its engines are
|
|
158
|
+
discovered on the first request (they may not exist yet when `Redline(app)` runs), named
|
|
159
|
+
`default` and after each bind key.
|
|
160
|
+
|
|
161
|
+
## What is recorded
|
|
162
|
+
|
|
163
|
+
- **Request / response**: method, path, route template, view dotted name, status, headers,
|
|
164
|
+
query string and body. Secrets are redacted by the core (`Authorization`, cookies,
|
|
165
|
+
`password`/`token`/`card` keys...).
|
|
166
|
+
- **Bodies are never consumed or buffered unboundedly.** The request body is copied as the
|
|
167
|
+
view reads it; response bodies, including streams, are kept only up to `max_body_bytes`.
|
|
168
|
+
A `stream_with_context` generator is not read ahead, and the trace ends when the last
|
|
169
|
+
chunk is sent, so its work counts towards the latency. `send_file` and anything the
|
|
170
|
+
server serves through `wsgi.file_wrapper` (`direct_passthrough`) is handed back
|
|
171
|
+
untouched: only its size is recorded.
|
|
172
|
+
- **SQL** through `before/after_cursor_execute`: statement, bound params (redacted),
|
|
173
|
+
duration, rowcount, vendor, executemany, `db_alias`, and the project line that issued the
|
|
174
|
+
query.
|
|
175
|
+
- **Spans**: a `view` span around the view function (not the `before_request` hooks), and a
|
|
176
|
+
`template` span per `render_template` / `render_template_string`; queries a template
|
|
177
|
+
triggers (lazy loading) nest under it.
|
|
178
|
+
- **Unhandled exceptions** are recorded (Flask's `got_request_exception`) and re-raised or
|
|
179
|
+
turned into a 500 exactly as before. `abort(404)` and other 4xx `HTTPException`s are
|
|
180
|
+
client errors, not failures: the trace has no `error`, and `always_keep_errors` does not
|
|
181
|
+
keep it. A 5xx status is an error.
|
|
182
|
+
- **`async def` views** (Flask's asgiref bridge) keep the trace: queries and spans created
|
|
183
|
+
inside the coroutine land in the request's trace. Work that starts a bare
|
|
184
|
+
`threading.Thread` is not attributed to any request.
|
|
185
|
+
- Your own spans: `with redline_core.span("pricing.compute"): ...` (also as a decorator).
|
|
186
|
+
- `X-Redline-Run: <id>;step=<n>` becomes tags (used by the load tester).
|
|
187
|
+
- Requests under `url_prefix` are never recorded.
|
|
188
|
+
|
|
189
|
+
If you put `werkzeug.middleware.proxy_fix.ProxyFix` (or any other WSGI middleware) around
|
|
190
|
+
the app, wrap *after* creating `Redline` so the recorded client address is the real one.
|
|
191
|
+
Apps mounted under a `SCRIPT_NAME` (`DispatcherMiddleware`) record the full path and route.
|
|
192
|
+
|
|
193
|
+
### Outbound HTTP (opt-in)
|
|
194
|
+
|
|
195
|
+
Nothing is patched unless you ask:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from flask_redline import instrument_httpx, instrument_requests
|
|
199
|
+
|
|
200
|
+
instrument_httpx() # every httpx.Client / AsyncClient call becomes an http_out span
|
|
201
|
+
instrument_requests() # same for requests.Session.send (needs `requests` installed)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
URLs are redacted (`?api_key=...` is masked). `uninstrument_httpx()` and
|
|
205
|
+
`uninstrument_requests()` undo it.
|
|
206
|
+
|
|
207
|
+
### Profiling
|
|
208
|
+
|
|
209
|
+
Flamegraphs are opt-in per request: add `?_redline=profile` or the header
|
|
210
|
+
`X-Redline-Profile: 1` (needs the `[profile]` extra).
|
|
211
|
+
|
|
212
|
+
## Fix suggestions
|
|
213
|
+
|
|
214
|
+
For an N+1, `SQLAlchemyFixSuggester` maps the repeated query back to your models and tells
|
|
215
|
+
you how to eager-load it:
|
|
216
|
+
|
|
217
|
+
| Repeated query | Suggestion |
|
|
218
|
+
|---|---|
|
|
219
|
+
| child table filtered by its foreign key | `selectinload(Parent.children)` |
|
|
220
|
+
| parent looked up by primary key, once per row | `joinedload(Child.parent)` |
|
|
221
|
+
| association (secondary) table filtered by the parent | `selectinload(Parent.tags)` |
|
|
222
|
+
|
|
223
|
+
It then walks the call stack to find the `select(Parent)` or `session.query(Parent)` in
|
|
224
|
+
your source and produces a real unified diff. This works the same with Flask-SQLAlchemy's
|
|
225
|
+
`db.session` and `db.Model`. When the statement cannot be located you get the one-line
|
|
226
|
+
code hint with lower confidence instead.
|
|
227
|
+
|
|
228
|
+
## Access control
|
|
229
|
+
|
|
230
|
+
The UI exposes your SQL, request bodies and EXPLAIN, so treat it like an admin tool.
|
|
231
|
+
|
|
232
|
+
**Default.** With no `permission`, the `access` setting decides (default `local`, served only in debug mode until you set `access`): only loopback clients (127.0.0.0/8, `::1`; unix sockets only with `trust_unix_socket`)
|
|
233
|
+
that address the server by a loopback name (`Host: localhost`, `127.0.0.1`, `[::1]`, any
|
|
234
|
+
port; this blocks DNS rebinding) are served, and a warning is logged at startup. Refusals
|
|
235
|
+
are JSON `{"error": {"code": "forbidden", "message"}}`. Add other host names with the
|
|
236
|
+
`allowed_hosts` setting. A loopback peer sending `X-Forwarded-For` without being in
|
|
237
|
+
`trusted_proxies` is refused; behind a reverse proxy use `access="token"` and
|
|
238
|
+
`trusted_proxies`. Tokens, basic auth, `allowed_ips` and `read_only` are described in the
|
|
239
|
+
[access control docs](https://pythondeveloper6.github.io/redline/access-control/).
|
|
240
|
+
|
|
241
|
+
**Your own check.** `permission` is any `callable(request) -> bool`, called with Flask's
|
|
242
|
+
`request`; passing it implies `access="framework"` and replaces the loopback default, so the
|
|
243
|
+
`Host` check becomes yours (the core gate still applies `allowed_ips`, `read_only`, features
|
|
244
|
+
and CSRF).
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from flask_login import current_user
|
|
248
|
+
|
|
249
|
+
Redline(app, engine=engine, permission=lambda request: current_user.is_admin)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Raise `werkzeug.exceptions.abort(401)` (or any `HTTPException`) inside it to answer with
|
|
253
|
+
that status; it is rendered as `{"error": {"code": "unauthorized", ...}}`, keeping a
|
|
254
|
+
`WWW-Authenticate` header.
|
|
255
|
+
|
|
256
|
+
**CSRF.** Every non-GET request is refused with `403 csrf_failed` when it carries
|
|
257
|
+
`Sec-Fetch-Site: cross-site` or `same-site`, or an `Origin` (including `null`) that does not
|
|
258
|
+
match the request host. Browsers always send `Origin` on those methods, so another website
|
|
259
|
+
cannot drive your Redline through a logged-in browser. Non-browser clients (curl, scripts)
|
|
260
|
+
send no `Origin` and are only subject to the access policy.
|
|
261
|
+
|
|
262
|
+
Redline's own responses carry `X-Frame-Options: DENY`.
|
|
263
|
+
|
|
264
|
+
## EXPLAIN
|
|
265
|
+
|
|
266
|
+
`POST /_redline/api/explain` runs `EXPLAIN` for a recorded SELECT on the engine it came
|
|
267
|
+
from (matched by `db_alias`) inside a transaction that is always rolled back (and
|
|
268
|
+
`READ ONLY` on PostgreSQL). `EXPLAIN ANALYZE` executes the query, so it is off unless
|
|
269
|
+
`explain_analyze=True`.
|
|
270
|
+
|
|
271
|
+
## Command line
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
flask redline clear [--yes] # delete every recorded trace
|
|
275
|
+
flask redline prune [--max-age-days N] [--max-rows N] # apply retention now (0 disables a rule)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Development
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
uv sync --all-packages
|
|
282
|
+
cd packages/flask-redline && uv run pytest
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
A runnable demo with deliberately bad views lives in [`examples/flask`](https://github.com/Pythondeveloper6/redline/blob/main/examples/flask).
|
|
286
|
+
|
|
287
|
+
## License
|
|
288
|
+
|
|
289
|
+
MIT
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://pythondeveloper6.github.io/redline/">
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Pythondeveloper6/redline/main/docs/assets/logo-dark.svg">
|
|
5
|
+
<img alt="Redline" src="https://raw.githubusercontent.com/Pythondeveloper6/redline/main/docs/assets/logo-light.svg" height="56">
|
|
6
|
+
</picture>
|
|
7
|
+
</a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
# flask-redline
|
|
11
|
+
|
|
12
|
+
The Flask + SQLAlchemy adapter for [Redline](https://github.com/Pythondeveloper6/redline#readme): a request profiler and
|
|
13
|
+
backend analyzer. Every request becomes a trace with a span timeline, the SQL it ran, N+1
|
|
14
|
+
and duplicate-query detection with **concrete fix diffs**, one-click `EXPLAIN`, optional
|
|
15
|
+
flamegraphs, and a fast web UI served from your own app.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from flask import Flask
|
|
19
|
+
from sqlalchemy import create_engine
|
|
20
|
+
from flask_redline import Redline
|
|
21
|
+
|
|
22
|
+
app = Flask(__name__)
|
|
23
|
+
engine = create_engine("sqlite:///app.db")
|
|
24
|
+
|
|
25
|
+
# access="local": localhost only (unset, Redline answers 404 outside debug mode)
|
|
26
|
+
Redline(app, engine=engine, settings={"access": "local"}) # UI at http://localhost:5000/_redline/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
With **Flask-SQLAlchemy** you do not even pass the engine: its engines (including
|
|
30
|
+
`SQLALCHEMY_BINDS`) are found and instrumented automatically.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
db = SQLAlchemy(app)
|
|
34
|
+
Redline(app)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Traces are stored in their own SQLite file (`.redline/redline.db`), never in your database.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install \
|
|
43
|
+
"flask-redline @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/flask-redline" \
|
|
44
|
+
"redline-core @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"
|
|
45
|
+
pip install "flask[async]" # only if you use `async def` views
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Not on PyPI yet: install from GitHub, naming `redline-core` in the same command (the adapter
|
|
49
|
+
pins it exactly). Docs: <https://pythondeveloper6.github.io/redline/>.
|
|
50
|
+
|
|
51
|
+
Add the extra to the core requirement for flamegraphs:
|
|
52
|
+
`"redline-core[profile] @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"`.
|
|
53
|
+
|
|
54
|
+
Python 3.10+, Flask 3.0+, SQLAlchemy 2.x.
|
|
55
|
+
|
|
56
|
+
## `Redline`
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
Redline(
|
|
60
|
+
app=None,
|
|
61
|
+
*,
|
|
62
|
+
engine=None, # Engine | [engines] | {"alias": engine} (default: Flask-SQLAlchemy's)
|
|
63
|
+
settings=None, # dict | redline_core.Settings
|
|
64
|
+
url_prefix=None, # default: the mount_path setting, else "/_redline"
|
|
65
|
+
permission=None, # callable(request) -> bool (see "Access control")
|
|
66
|
+
store=None, # custom Store (default: SQLite file at storage_path)
|
|
67
|
+
)
|
|
68
|
+
redline.init_app(app) # application factories
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
What it does:
|
|
72
|
+
|
|
73
|
+
- wraps `app.wsgi_app` in a WSGI middleware that times the request and records status,
|
|
74
|
+
headers and (size-capped) bodies;
|
|
75
|
+
- connects Flask hooks for the route template (`/orders/<int:order_id>`), the view
|
|
76
|
+
function, templates and unhandled errors;
|
|
77
|
+
- instruments the engines;
|
|
78
|
+
- registers a blueprint with the UI and JSON API under `url_prefix`, and the
|
|
79
|
+
`flask redline` command group.
|
|
80
|
+
|
|
81
|
+
`settings` takes any [Redline setting](../redline-core/README.md) with case-insensitive
|
|
82
|
+
keys. They can also live in `app.config["REDLINE"]` (a dict); keys passed to `Redline`
|
|
83
|
+
win. A `Settings` instance passed to `Redline` is used as-is.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
Redline(app, engine=engine, settings={
|
|
87
|
+
"sample_rate": 0.1, # keep 10% of requests ...
|
|
88
|
+
"always_keep_slower_than_ms": 300, # ... plus every slow one and every error
|
|
89
|
+
"slow_query_ms": 50,
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Application factory
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
redline = Redline(engine=engine)
|
|
97
|
+
|
|
98
|
+
def create_app():
|
|
99
|
+
app = Flask(__name__)
|
|
100
|
+
redline.init_app(app)
|
|
101
|
+
return app
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`redline.store`, `.settings`, `.engines`, `.add_engine(engine, alias=None)` and `.close()`
|
|
105
|
+
act on the current app (inside an app context) or on the last app initialised. The
|
|
106
|
+
store is flushed and closed at interpreter exit.
|
|
107
|
+
|
|
108
|
+
### Engines
|
|
109
|
+
|
|
110
|
+
The first engine of a list is the `"default"` db alias; others are named after their
|
|
111
|
+
database file, or pass a dict. The alias is stored on each query so EXPLAIN runs on the
|
|
112
|
+
right engine.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
Redline(app, engine=[main_engine, analytics_engine])
|
|
116
|
+
Redline(app, engine={"main": main_engine, "analytics": analytics_engine})
|
|
117
|
+
redline.add_engine(late_engine) # an engine created later
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
When `engine` is omitted and Flask-SQLAlchemy 3.x is set up on the app, its engines are
|
|
121
|
+
discovered on the first request (they may not exist yet when `Redline(app)` runs), named
|
|
122
|
+
`default` and after each bind key.
|
|
123
|
+
|
|
124
|
+
## What is recorded
|
|
125
|
+
|
|
126
|
+
- **Request / response**: method, path, route template, view dotted name, status, headers,
|
|
127
|
+
query string and body. Secrets are redacted by the core (`Authorization`, cookies,
|
|
128
|
+
`password`/`token`/`card` keys...).
|
|
129
|
+
- **Bodies are never consumed or buffered unboundedly.** The request body is copied as the
|
|
130
|
+
view reads it; response bodies, including streams, are kept only up to `max_body_bytes`.
|
|
131
|
+
A `stream_with_context` generator is not read ahead, and the trace ends when the last
|
|
132
|
+
chunk is sent, so its work counts towards the latency. `send_file` and anything the
|
|
133
|
+
server serves through `wsgi.file_wrapper` (`direct_passthrough`) is handed back
|
|
134
|
+
untouched: only its size is recorded.
|
|
135
|
+
- **SQL** through `before/after_cursor_execute`: statement, bound params (redacted),
|
|
136
|
+
duration, rowcount, vendor, executemany, `db_alias`, and the project line that issued the
|
|
137
|
+
query.
|
|
138
|
+
- **Spans**: a `view` span around the view function (not the `before_request` hooks), and a
|
|
139
|
+
`template` span per `render_template` / `render_template_string`; queries a template
|
|
140
|
+
triggers (lazy loading) nest under it.
|
|
141
|
+
- **Unhandled exceptions** are recorded (Flask's `got_request_exception`) and re-raised or
|
|
142
|
+
turned into a 500 exactly as before. `abort(404)` and other 4xx `HTTPException`s are
|
|
143
|
+
client errors, not failures: the trace has no `error`, and `always_keep_errors` does not
|
|
144
|
+
keep it. A 5xx status is an error.
|
|
145
|
+
- **`async def` views** (Flask's asgiref bridge) keep the trace: queries and spans created
|
|
146
|
+
inside the coroutine land in the request's trace. Work that starts a bare
|
|
147
|
+
`threading.Thread` is not attributed to any request.
|
|
148
|
+
- Your own spans: `with redline_core.span("pricing.compute"): ...` (also as a decorator).
|
|
149
|
+
- `X-Redline-Run: <id>;step=<n>` becomes tags (used by the load tester).
|
|
150
|
+
- Requests under `url_prefix` are never recorded.
|
|
151
|
+
|
|
152
|
+
If you put `werkzeug.middleware.proxy_fix.ProxyFix` (or any other WSGI middleware) around
|
|
153
|
+
the app, wrap *after* creating `Redline` so the recorded client address is the real one.
|
|
154
|
+
Apps mounted under a `SCRIPT_NAME` (`DispatcherMiddleware`) record the full path and route.
|
|
155
|
+
|
|
156
|
+
### Outbound HTTP (opt-in)
|
|
157
|
+
|
|
158
|
+
Nothing is patched unless you ask:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from flask_redline import instrument_httpx, instrument_requests
|
|
162
|
+
|
|
163
|
+
instrument_httpx() # every httpx.Client / AsyncClient call becomes an http_out span
|
|
164
|
+
instrument_requests() # same for requests.Session.send (needs `requests` installed)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
URLs are redacted (`?api_key=...` is masked). `uninstrument_httpx()` and
|
|
168
|
+
`uninstrument_requests()` undo it.
|
|
169
|
+
|
|
170
|
+
### Profiling
|
|
171
|
+
|
|
172
|
+
Flamegraphs are opt-in per request: add `?_redline=profile` or the header
|
|
173
|
+
`X-Redline-Profile: 1` (needs the `[profile]` extra).
|
|
174
|
+
|
|
175
|
+
## Fix suggestions
|
|
176
|
+
|
|
177
|
+
For an N+1, `SQLAlchemyFixSuggester` maps the repeated query back to your models and tells
|
|
178
|
+
you how to eager-load it:
|
|
179
|
+
|
|
180
|
+
| Repeated query | Suggestion |
|
|
181
|
+
|---|---|
|
|
182
|
+
| child table filtered by its foreign key | `selectinload(Parent.children)` |
|
|
183
|
+
| parent looked up by primary key, once per row | `joinedload(Child.parent)` |
|
|
184
|
+
| association (secondary) table filtered by the parent | `selectinload(Parent.tags)` |
|
|
185
|
+
|
|
186
|
+
It then walks the call stack to find the `select(Parent)` or `session.query(Parent)` in
|
|
187
|
+
your source and produces a real unified diff. This works the same with Flask-SQLAlchemy's
|
|
188
|
+
`db.session` and `db.Model`. When the statement cannot be located you get the one-line
|
|
189
|
+
code hint with lower confidence instead.
|
|
190
|
+
|
|
191
|
+
## Access control
|
|
192
|
+
|
|
193
|
+
The UI exposes your SQL, request bodies and EXPLAIN, so treat it like an admin tool.
|
|
194
|
+
|
|
195
|
+
**Default.** With no `permission`, the `access` setting decides (default `local`, served only in debug mode until you set `access`): only loopback clients (127.0.0.0/8, `::1`; unix sockets only with `trust_unix_socket`)
|
|
196
|
+
that address the server by a loopback name (`Host: localhost`, `127.0.0.1`, `[::1]`, any
|
|
197
|
+
port; this blocks DNS rebinding) are served, and a warning is logged at startup. Refusals
|
|
198
|
+
are JSON `{"error": {"code": "forbidden", "message"}}`. Add other host names with the
|
|
199
|
+
`allowed_hosts` setting. A loopback peer sending `X-Forwarded-For` without being in
|
|
200
|
+
`trusted_proxies` is refused; behind a reverse proxy use `access="token"` and
|
|
201
|
+
`trusted_proxies`. Tokens, basic auth, `allowed_ips` and `read_only` are described in the
|
|
202
|
+
[access control docs](https://pythondeveloper6.github.io/redline/access-control/).
|
|
203
|
+
|
|
204
|
+
**Your own check.** `permission` is any `callable(request) -> bool`, called with Flask's
|
|
205
|
+
`request`; passing it implies `access="framework"` and replaces the loopback default, so the
|
|
206
|
+
`Host` check becomes yours (the core gate still applies `allowed_ips`, `read_only`, features
|
|
207
|
+
and CSRF).
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from flask_login import current_user
|
|
211
|
+
|
|
212
|
+
Redline(app, engine=engine, permission=lambda request: current_user.is_admin)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Raise `werkzeug.exceptions.abort(401)` (or any `HTTPException`) inside it to answer with
|
|
216
|
+
that status; it is rendered as `{"error": {"code": "unauthorized", ...}}`, keeping a
|
|
217
|
+
`WWW-Authenticate` header.
|
|
218
|
+
|
|
219
|
+
**CSRF.** Every non-GET request is refused with `403 csrf_failed` when it carries
|
|
220
|
+
`Sec-Fetch-Site: cross-site` or `same-site`, or an `Origin` (including `null`) that does not
|
|
221
|
+
match the request host. Browsers always send `Origin` on those methods, so another website
|
|
222
|
+
cannot drive your Redline through a logged-in browser. Non-browser clients (curl, scripts)
|
|
223
|
+
send no `Origin` and are only subject to the access policy.
|
|
224
|
+
|
|
225
|
+
Redline's own responses carry `X-Frame-Options: DENY`.
|
|
226
|
+
|
|
227
|
+
## EXPLAIN
|
|
228
|
+
|
|
229
|
+
`POST /_redline/api/explain` runs `EXPLAIN` for a recorded SELECT on the engine it came
|
|
230
|
+
from (matched by `db_alias`) inside a transaction that is always rolled back (and
|
|
231
|
+
`READ ONLY` on PostgreSQL). `EXPLAIN ANALYZE` executes the query, so it is off unless
|
|
232
|
+
`explain_analyze=True`.
|
|
233
|
+
|
|
234
|
+
## Command line
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
flask redline clear [--yes] # delete every recorded trace
|
|
238
|
+
flask redline prune [--max-age-days N] [--max-rows N] # apply retention now (0 disables a rule)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Development
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
uv sync --all-packages
|
|
245
|
+
cd packages/flask-redline && uv run pytest
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
A runnable demo with deliberately bad views lives in [`examples/flask`](https://github.com/Pythondeveloper6/redline/blob/main/examples/flask).
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
MIT
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flask-redline"
|
|
7
|
+
version = "0.2.0a1"
|
|
8
|
+
description = "A modern request profiler for Flask and SQLAlchemy: span timelines, N+1 detection with fix diffs, one-click EXPLAIN, flamegraphs and a fast web UI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Mahmoud Ahmed", email = "pythondeveloper6@gmail.com" }]
|
|
14
|
+
maintainers = [{ name = "Mahmoud Ahmed", email = "pythondeveloper6@gmail.com" }]
|
|
15
|
+
keywords = [
|
|
16
|
+
"flask",
|
|
17
|
+
"wsgi",
|
|
18
|
+
"sqlalchemy",
|
|
19
|
+
"flask-sqlalchemy",
|
|
20
|
+
"profiler",
|
|
21
|
+
"performance",
|
|
22
|
+
"n+1",
|
|
23
|
+
"explain",
|
|
24
|
+
"flamegraph",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 3 - Alpha",
|
|
28
|
+
"Environment :: Web Environment",
|
|
29
|
+
"Framework :: Flask",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"Operating System :: OS Independent",
|
|
32
|
+
"Programming Language :: Python :: 3",
|
|
33
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Programming Language :: Python :: 3.14",
|
|
39
|
+
"Topic :: Software Development :: Debuggers",
|
|
40
|
+
"Topic :: System :: Monitoring",
|
|
41
|
+
"Typing :: Typed",
|
|
42
|
+
]
|
|
43
|
+
# Lockstep: the adapter pins the exact core release it was tested with.
|
|
44
|
+
dependencies = [
|
|
45
|
+
"redline-core==0.2.0a1",
|
|
46
|
+
"flask>=3.0,<4",
|
|
47
|
+
"sqlalchemy>=2.0,<3",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.optional-dependencies]
|
|
51
|
+
profile = ["redline-core[profile]==0.2.0a1"]
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://github.com/Pythondeveloper6/redline"
|
|
55
|
+
Documentation = "https://pythondeveloper6.github.io/redline/flask/"
|
|
56
|
+
Source = "https://github.com/Pythondeveloper6/redline"
|
|
57
|
+
Issues = "https://github.com/Pythondeveloper6/redline/issues"
|
|
58
|
+
Changelog = "https://github.com/Pythondeveloper6/redline/blob/main/CHANGELOG.md"
|
|
59
|
+
|
|
60
|
+
[dependency-groups]
|
|
61
|
+
dev = [
|
|
62
|
+
"pytest>=8.3",
|
|
63
|
+
"flask-sqlalchemy>=3.1",
|
|
64
|
+
"asgiref>=3.8",
|
|
65
|
+
"pyinstrument>=5.0",
|
|
66
|
+
"ruff>=0.8",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.uv.sources]
|
|
70
|
+
redline-core = { workspace = true }
|
|
71
|
+
|
|
72
|
+
[tool.hatch.build.targets.wheel]
|
|
73
|
+
packages = ["src/flask_redline"]
|
|
74
|
+
exclude = ["**/__pycache__", "**/*.py[cod]", "**/.DS_Store"]
|
|
75
|
+
|
|
76
|
+
[tool.hatch.build.targets.sdist]
|
|
77
|
+
include = ["/src/flask_redline", "/tests", "/README.md", "/LICENSE"]
|
|
78
|
+
exclude = ["**/__pycache__", "**/*.py[cod]", "**/.DS_Store"]
|
|
79
|
+
|
|
80
|
+
[tool.pytest.ini_options]
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
addopts = "--strict-markers -q"
|
|
83
|
+
|
|
84
|
+
[tool.ruff]
|
|
85
|
+
extend = "../../pyproject.toml"
|