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.
Files changed (33) hide show
  1. flask_redline-0.2.0a1/.gitignore +21 -0
  2. flask_redline-0.2.0a1/LICENSE +21 -0
  3. flask_redline-0.2.0a1/PKG-INFO +289 -0
  4. flask_redline-0.2.0a1/README.md +252 -0
  5. flask_redline-0.2.0a1/pyproject.toml +85 -0
  6. flask_redline-0.2.0a1/src/flask_redline/__init__.py +43 -0
  7. flask_redline-0.2.0a1/src/flask_redline/_hooks.py +137 -0
  8. flask_redline-0.2.0a1/src/flask_redline/_httpx.py +5 -0
  9. flask_redline-0.2.0a1/src/flask_redline/_middleware.py +392 -0
  10. flask_redline-0.2.0a1/src/flask_redline/_requests.py +22 -0
  11. flask_redline-0.2.0a1/src/flask_redline/_security.py +162 -0
  12. flask_redline-0.2.0a1/src/flask_redline/_sqlalchemy.py +19 -0
  13. flask_redline-0.2.0a1/src/flask_redline/blueprint.py +186 -0
  14. flask_redline-0.2.0a1/src/flask_redline/cli.py +141 -0
  15. flask_redline-0.2.0a1/src/flask_redline/integration.py +292 -0
  16. flask_redline-0.2.0a1/src/flask_redline/py.typed +0 -0
  17. flask_redline-0.2.0a1/src/flask_redline/suggest.py +5 -0
  18. flask_redline-0.2.0a1/tests/__init__.py +0 -0
  19. flask_redline-0.2.0a1/tests/conftest.py +97 -0
  20. flask_redline-0.2.0a1/tests/fsa_app.py +70 -0
  21. flask_redline-0.2.0a1/tests/shop_app.py +279 -0
  22. flask_redline-0.2.0a1/tests/templates/broken.html +1 -0
  23. flask_redline-0.2.0a1/tests/templates/customers.html +5 -0
  24. flask_redline-0.2.0a1/tests/templates/orders.html +1 -0
  25. flask_redline-0.2.0a1/tests/test_access_policies.py +124 -0
  26. flask_redline-0.2.0a1/tests/test_api.py +357 -0
  27. flask_redline-0.2.0a1/tests/test_cli.py +83 -0
  28. flask_redline-0.2.0a1/tests/test_middleware.py +362 -0
  29. flask_redline-0.2.0a1/tests/test_security_redaction.py +69 -0
  30. flask_redline-0.2.0a1/tests/test_security_regressions.py +81 -0
  31. flask_redline-0.2.0a1/tests/test_sqlalchemy.py +243 -0
  32. flask_redline-0.2.0a1/tests/test_suggest.py +97 -0
  33. 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"