litestar-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.
- litestar_redline-0.2.0a1/.gitignore +21 -0
- litestar_redline-0.2.0a1/LICENSE +21 -0
- litestar_redline-0.2.0a1/PKG-INFO +228 -0
- litestar_redline-0.2.0a1/README.md +191 -0
- litestar_redline-0.2.0a1/pyproject.toml +94 -0
- litestar_redline-0.2.0a1/src/litestar_redline/__init__.py +37 -0
- litestar_redline-0.2.0a1/src/litestar_redline/_httpx.py +5 -0
- litestar_redline-0.2.0a1/src/litestar_redline/_middleware.py +298 -0
- litestar_redline-0.2.0a1/src/litestar_redline/_security.py +200 -0
- litestar_redline-0.2.0a1/src/litestar_redline/_sqlalchemy.py +19 -0
- litestar_redline-0.2.0a1/src/litestar_redline/plugin.py +200 -0
- litestar_redline-0.2.0a1/src/litestar_redline/py.typed +0 -0
- litestar_redline-0.2.0a1/src/litestar_redline/router.py +199 -0
- litestar_redline-0.2.0a1/src/litestar_redline/suggest.py +5 -0
- litestar_redline-0.2.0a1/tests/__init__.py +0 -0
- litestar_redline-0.2.0a1/tests/conftest.py +119 -0
- litestar_redline-0.2.0a1/tests/shop_app.py +235 -0
- litestar_redline-0.2.0a1/tests/test_access_policies.py +110 -0
- litestar_redline-0.2.0a1/tests/test_api.py +372 -0
- litestar_redline-0.2.0a1/tests/test_middleware.py +299 -0
- litestar_redline-0.2.0a1/tests/test_security_redaction.py +71 -0
- litestar_redline-0.2.0a1/tests/test_security_regressions.py +144 -0
- litestar_redline-0.2.0a1/tests/test_sqlalchemy.py +137 -0
- litestar_redline-0.2.0a1/tests/test_sqlalchemy_plugin.py +86 -0
- litestar_redline-0.2.0a1/tests/test_suggest.py +173 -0
- litestar_redline-0.2.0a1/tests/test_v02_adapter.py +301 -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,228 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: litestar-redline
|
|
3
|
+
Version: 0.2.0a1
|
|
4
|
+
Summary: A modern request profiler for Litestar 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/litestar/
|
|
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: asgi,explain,flamegraph,litestar,n+1,performance,profiler,sqlalchemy
|
|
15
|
+
Classifier: Development Status :: 3 - Alpha
|
|
16
|
+
Classifier: Environment :: Web Environment
|
|
17
|
+
Classifier: Framework :: AsyncIO
|
|
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: litestar<3,>=2.12
|
|
32
|
+
Requires-Dist: redline-core==0.2.0a1
|
|
33
|
+
Requires-Dist: sqlalchemy[asyncio]<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
|
+
# litestar-redline
|
|
48
|
+
|
|
49
|
+
The Litestar + 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 litestar import Litestar
|
|
56
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
57
|
+
from litestar_redline import RedlinePlugin
|
|
58
|
+
|
|
59
|
+
engine = create_async_engine("sqlite+aiosqlite:///app.db")
|
|
60
|
+
|
|
61
|
+
app = Litestar(
|
|
62
|
+
route_handlers=[...],
|
|
63
|
+
# access="local": localhost only (unset, Redline answers 404 outside debug mode)
|
|
64
|
+
plugins=[RedlinePlugin(engine=engine, settings={"access": "local"})], # UI at http://localhost:8000/_redline/
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Traces are stored in their own SQLite file (`.redline/redline.db`), never in your database.
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install \
|
|
74
|
+
"litestar-redline @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/litestar-redline" \
|
|
75
|
+
"redline-core @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Not on PyPI yet: install from GitHub, naming `redline-core` in the same command (the adapter
|
|
79
|
+
pins it exactly). Docs: <https://pythondeveloper6.github.io/redline/>.
|
|
80
|
+
|
|
81
|
+
Add the extra to the core requirement for flamegraphs:
|
|
82
|
+
`"redline-core[profile] @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"`.
|
|
83
|
+
|
|
84
|
+
Python 3.10+, Litestar 2.12+ (< 3), SQLAlchemy 2.x.
|
|
85
|
+
|
|
86
|
+
## `RedlinePlugin`
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
RedlinePlugin(
|
|
90
|
+
settings=None, # dict | redline_core.Settings
|
|
91
|
+
engine=None, # Engine | AsyncEngine | [engines] | {"alias": engine}
|
|
92
|
+
path=None, # default: the mount_path setting, else "/_redline"; never recorded
|
|
93
|
+
guards=None, # Litestar guards for the UI + API (see "Access control")
|
|
94
|
+
store=None, # custom Store (default: SQLite file at storage_path)
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
What it does when the app is built:
|
|
99
|
+
|
|
100
|
+
- adds a pure ASGI middleware (capture, route template, status, bodies, SQL spans);
|
|
101
|
+
- records exceptions through Litestar's `after_exception` hook (see below);
|
|
102
|
+
- mounts the UI and JSON API under `path` as a Litestar `Router`;
|
|
103
|
+
- instruments the engines, and **flushes and closes the store on shutdown**
|
|
104
|
+
(`on_shutdown`; your own lifespan hooks keep working).
|
|
105
|
+
|
|
106
|
+
`settings` takes any [Redline setting](../redline-core/README.md) with case-insensitive keys:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
RedlinePlugin(engine=engine, settings={
|
|
110
|
+
"sample_rate": 0.1, # keep 10% of requests ...
|
|
111
|
+
"always_keep_slower_than_ms": 300, # ... plus every slow one and every error
|
|
112
|
+
"slow_query_ms": 50,
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The plugin object is also the handle: `plugin.recorder`, `plugin.store`, `plugin.settings`,
|
|
117
|
+
`plugin.add_engine(engine)` for an engine created later, `plugin.close()`.
|
|
118
|
+
|
|
119
|
+
### Engines
|
|
120
|
+
|
|
121
|
+
With Litestar's own SQLAlchemy plugin (`advanced-alchemy`) and no `engine=`, the plugin's
|
|
122
|
+
engine(s) are found and instrumented automatically:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin
|
|
126
|
+
|
|
127
|
+
db = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///app.db")
|
|
128
|
+
app = Litestar([...], plugins=[SQLAlchemyPlugin(config=db), RedlinePlugin()])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Otherwise pass `engine=` yourself. The first engine of a list is the `"default"` db alias
|
|
132
|
+
(used by EXPLAIN); others are named after their database file, or pass a dict:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
RedlinePlugin(engine=[async_engine, sync_engine])
|
|
136
|
+
RedlinePlugin(engine={"main": main_engine, "analytics": analytics_engine})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## What is recorded
|
|
140
|
+
|
|
141
|
+
- **Request / response**: method, path, route template (`/orders/{order_id}`), handler
|
|
142
|
+
dotted name (`shop.routes.get_order`, `shop.routes.ShopController.by_sku`), status,
|
|
143
|
+
headers, query string and body. Secrets are redacted by the core.
|
|
144
|
+
- **Bodies are never consumed or buffered unboundedly.** The request body is copied as the
|
|
145
|
+
app reads it; response bodies, including streams, are kept only up to `max_body_bytes`.
|
|
146
|
+
- **SQL** through `before/after_cursor_execute`: statement, bound params (redacted),
|
|
147
|
+
duration, rowcount, vendor, executemany, `db_alias`, and the project line that issued the
|
|
148
|
+
query, also through `AsyncSession`.
|
|
149
|
+
- **Exceptions.** Litestar turns an exception into a response *inside* the middleware, so
|
|
150
|
+
the plugin registers an `after_exception` hook. A 5xx caused by an exception gets
|
|
151
|
+
`error` (type, message, traceback, locals); a 4xx `HTTPException` (404, 401, validation)
|
|
152
|
+
is a client error and is not an error trace.
|
|
153
|
+
- Sync handlers (`sync_to_thread=True`) are attributed to the right request.
|
|
154
|
+
- Your own spans: `with redline_core.span("pricing.compute"): ...`.
|
|
155
|
+
- `X-Redline-Run: <id>;step=<n>` becomes tags (used by the load tester).
|
|
156
|
+
|
|
157
|
+
Requests that match no route (404) and `405 Method Not Allowed` never reach a route-level
|
|
158
|
+
middleware, so Litestar does not run Redline for them.
|
|
159
|
+
|
|
160
|
+
### Profiling
|
|
161
|
+
|
|
162
|
+
Flamegraphs are opt-in per request: `?_redline=profile` or the header
|
|
163
|
+
`X-Redline-Profile: 1` (needs the `[profile]` extra).
|
|
164
|
+
|
|
165
|
+
### Outbound HTTP (httpx)
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from litestar_redline import instrument_httpx
|
|
169
|
+
|
|
170
|
+
instrument_httpx() # every httpx.Client / AsyncClient call becomes an http_out span
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Fix suggestions
|
|
174
|
+
|
|
175
|
+
For an N+1, `SQLAlchemyFixSuggester` maps the repeated query back to your models and
|
|
176
|
+
produces a real unified diff (`selectinload(Parent.children)`, `joinedload(Child.parent)`),
|
|
177
|
+
exactly as in the FastAPI adapter.
|
|
178
|
+
|
|
179
|
+
## Access control
|
|
180
|
+
|
|
181
|
+
The UI exposes your SQL, request bodies and EXPLAIN, so treat it like an admin tool.
|
|
182
|
+
|
|
183
|
+
**Default.** With no `guards`, 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`) that
|
|
184
|
+
address the server by a loopback name (`Host: localhost`, `127.0.0.1`, `[::1]`, any port;
|
|
185
|
+
this blocks DNS rebinding) are served, and a warning is logged at startup. Refusals are
|
|
186
|
+
JSON `{"error": {"code": "forbidden" | "unauthorized" | ..., "message"}}`. Use
|
|
187
|
+
the `allowed_hosts` setting to allow another host name. Passing `guards=` implies
|
|
188
|
+
`access="framework"`. A loopback peer sending `X-Forwarded-For` without being in
|
|
189
|
+
`trusted_proxies` is refused; behind a reverse proxy use `access="token"` and
|
|
190
|
+
`trusted_proxies`. See the
|
|
191
|
+
[access control docs](https://pythondeveloper6.github.io/redline/access-control/).
|
|
192
|
+
|
|
193
|
+
**Your own guard** (any Litestar guard; it *replaces* the loopback default):
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from litestar.exceptions import NotAuthorizedException
|
|
197
|
+
|
|
198
|
+
def require_admin(connection, _handler):
|
|
199
|
+
if not connection.user or not connection.user.is_admin:
|
|
200
|
+
raise NotAuthorizedException()
|
|
201
|
+
|
|
202
|
+
RedlinePlugin(engine=engine, guards=[require_admin])
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`guards=[]` is the same as passing none.
|
|
206
|
+
|
|
207
|
+
**CSRF.** The mutating endpoints (`DELETE` traces, `POST explain`) reject browser requests
|
|
208
|
+
whose `Origin` does not match the request host, `Origin: null`, and
|
|
209
|
+
`Sec-Fetch-Site: cross-site` or `same-site`. This is always on, whatever `guards` you pass.
|
|
210
|
+
|
|
211
|
+
Redline's own responses carry `X-Frame-Options: DENY`.
|
|
212
|
+
|
|
213
|
+
## EXPLAIN
|
|
214
|
+
|
|
215
|
+
`POST /_redline/api/explain` runs `EXPLAIN` for a recorded SELECT on the engine it came
|
|
216
|
+
from inside a transaction that is always rolled back (`READ ONLY` on PostgreSQL). `EXPLAIN
|
|
217
|
+
ANALYZE` executes the query, so it is off unless `explain_analyze=True`.
|
|
218
|
+
|
|
219
|
+
## Development
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
uv sync --all-packages
|
|
223
|
+
cd packages/litestar-redline && uv run pytest
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
# litestar-redline
|
|
11
|
+
|
|
12
|
+
The Litestar + 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 litestar import Litestar
|
|
19
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
20
|
+
from litestar_redline import RedlinePlugin
|
|
21
|
+
|
|
22
|
+
engine = create_async_engine("sqlite+aiosqlite:///app.db")
|
|
23
|
+
|
|
24
|
+
app = Litestar(
|
|
25
|
+
route_handlers=[...],
|
|
26
|
+
# access="local": localhost only (unset, Redline answers 404 outside debug mode)
|
|
27
|
+
plugins=[RedlinePlugin(engine=engine, settings={"access": "local"})], # UI at http://localhost:8000/_redline/
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Traces are stored in their own SQLite file (`.redline/redline.db`), never in your database.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install \
|
|
37
|
+
"litestar-redline @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/litestar-redline" \
|
|
38
|
+
"redline-core @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Not on PyPI yet: install from GitHub, naming `redline-core` in the same command (the adapter
|
|
42
|
+
pins it exactly). Docs: <https://pythondeveloper6.github.io/redline/>.
|
|
43
|
+
|
|
44
|
+
Add the extra to the core requirement for flamegraphs:
|
|
45
|
+
`"redline-core[profile] @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"`.
|
|
46
|
+
|
|
47
|
+
Python 3.10+, Litestar 2.12+ (< 3), SQLAlchemy 2.x.
|
|
48
|
+
|
|
49
|
+
## `RedlinePlugin`
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
RedlinePlugin(
|
|
53
|
+
settings=None, # dict | redline_core.Settings
|
|
54
|
+
engine=None, # Engine | AsyncEngine | [engines] | {"alias": engine}
|
|
55
|
+
path=None, # default: the mount_path setting, else "/_redline"; never recorded
|
|
56
|
+
guards=None, # Litestar guards for the UI + API (see "Access control")
|
|
57
|
+
store=None, # custom Store (default: SQLite file at storage_path)
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
What it does when the app is built:
|
|
62
|
+
|
|
63
|
+
- adds a pure ASGI middleware (capture, route template, status, bodies, SQL spans);
|
|
64
|
+
- records exceptions through Litestar's `after_exception` hook (see below);
|
|
65
|
+
- mounts the UI and JSON API under `path` as a Litestar `Router`;
|
|
66
|
+
- instruments the engines, and **flushes and closes the store on shutdown**
|
|
67
|
+
(`on_shutdown`; your own lifespan hooks keep working).
|
|
68
|
+
|
|
69
|
+
`settings` takes any [Redline setting](../redline-core/README.md) with case-insensitive keys:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
RedlinePlugin(engine=engine, settings={
|
|
73
|
+
"sample_rate": 0.1, # keep 10% of requests ...
|
|
74
|
+
"always_keep_slower_than_ms": 300, # ... plus every slow one and every error
|
|
75
|
+
"slow_query_ms": 50,
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The plugin object is also the handle: `plugin.recorder`, `plugin.store`, `plugin.settings`,
|
|
80
|
+
`plugin.add_engine(engine)` for an engine created later, `plugin.close()`.
|
|
81
|
+
|
|
82
|
+
### Engines
|
|
83
|
+
|
|
84
|
+
With Litestar's own SQLAlchemy plugin (`advanced-alchemy`) and no `engine=`, the plugin's
|
|
85
|
+
engine(s) are found and instrumented automatically:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin
|
|
89
|
+
|
|
90
|
+
db = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///app.db")
|
|
91
|
+
app = Litestar([...], plugins=[SQLAlchemyPlugin(config=db), RedlinePlugin()])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Otherwise pass `engine=` yourself. The first engine of a list is the `"default"` db alias
|
|
95
|
+
(used by EXPLAIN); others are named after their database file, or pass a dict:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
RedlinePlugin(engine=[async_engine, sync_engine])
|
|
99
|
+
RedlinePlugin(engine={"main": main_engine, "analytics": analytics_engine})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## What is recorded
|
|
103
|
+
|
|
104
|
+
- **Request / response**: method, path, route template (`/orders/{order_id}`), handler
|
|
105
|
+
dotted name (`shop.routes.get_order`, `shop.routes.ShopController.by_sku`), status,
|
|
106
|
+
headers, query string and body. Secrets are redacted by the core.
|
|
107
|
+
- **Bodies are never consumed or buffered unboundedly.** The request body is copied as the
|
|
108
|
+
app reads it; response bodies, including streams, are kept only up to `max_body_bytes`.
|
|
109
|
+
- **SQL** through `before/after_cursor_execute`: statement, bound params (redacted),
|
|
110
|
+
duration, rowcount, vendor, executemany, `db_alias`, and the project line that issued the
|
|
111
|
+
query, also through `AsyncSession`.
|
|
112
|
+
- **Exceptions.** Litestar turns an exception into a response *inside* the middleware, so
|
|
113
|
+
the plugin registers an `after_exception` hook. A 5xx caused by an exception gets
|
|
114
|
+
`error` (type, message, traceback, locals); a 4xx `HTTPException` (404, 401, validation)
|
|
115
|
+
is a client error and is not an error trace.
|
|
116
|
+
- Sync handlers (`sync_to_thread=True`) are attributed to the right request.
|
|
117
|
+
- Your own spans: `with redline_core.span("pricing.compute"): ...`.
|
|
118
|
+
- `X-Redline-Run: <id>;step=<n>` becomes tags (used by the load tester).
|
|
119
|
+
|
|
120
|
+
Requests that match no route (404) and `405 Method Not Allowed` never reach a route-level
|
|
121
|
+
middleware, so Litestar does not run Redline for them.
|
|
122
|
+
|
|
123
|
+
### Profiling
|
|
124
|
+
|
|
125
|
+
Flamegraphs are opt-in per request: `?_redline=profile` or the header
|
|
126
|
+
`X-Redline-Profile: 1` (needs the `[profile]` extra).
|
|
127
|
+
|
|
128
|
+
### Outbound HTTP (httpx)
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from litestar_redline import instrument_httpx
|
|
132
|
+
|
|
133
|
+
instrument_httpx() # every httpx.Client / AsyncClient call becomes an http_out span
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Fix suggestions
|
|
137
|
+
|
|
138
|
+
For an N+1, `SQLAlchemyFixSuggester` maps the repeated query back to your models and
|
|
139
|
+
produces a real unified diff (`selectinload(Parent.children)`, `joinedload(Child.parent)`),
|
|
140
|
+
exactly as in the FastAPI adapter.
|
|
141
|
+
|
|
142
|
+
## Access control
|
|
143
|
+
|
|
144
|
+
The UI exposes your SQL, request bodies and EXPLAIN, so treat it like an admin tool.
|
|
145
|
+
|
|
146
|
+
**Default.** With no `guards`, 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`) that
|
|
147
|
+
address the server by a loopback name (`Host: localhost`, `127.0.0.1`, `[::1]`, any port;
|
|
148
|
+
this blocks DNS rebinding) are served, and a warning is logged at startup. Refusals are
|
|
149
|
+
JSON `{"error": {"code": "forbidden" | "unauthorized" | ..., "message"}}`. Use
|
|
150
|
+
the `allowed_hosts` setting to allow another host name. Passing `guards=` implies
|
|
151
|
+
`access="framework"`. A loopback peer sending `X-Forwarded-For` without being in
|
|
152
|
+
`trusted_proxies` is refused; behind a reverse proxy use `access="token"` and
|
|
153
|
+
`trusted_proxies`. See the
|
|
154
|
+
[access control docs](https://pythondeveloper6.github.io/redline/access-control/).
|
|
155
|
+
|
|
156
|
+
**Your own guard** (any Litestar guard; it *replaces* the loopback default):
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from litestar.exceptions import NotAuthorizedException
|
|
160
|
+
|
|
161
|
+
def require_admin(connection, _handler):
|
|
162
|
+
if not connection.user or not connection.user.is_admin:
|
|
163
|
+
raise NotAuthorizedException()
|
|
164
|
+
|
|
165
|
+
RedlinePlugin(engine=engine, guards=[require_admin])
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`guards=[]` is the same as passing none.
|
|
169
|
+
|
|
170
|
+
**CSRF.** The mutating endpoints (`DELETE` traces, `POST explain`) reject browser requests
|
|
171
|
+
whose `Origin` does not match the request host, `Origin: null`, and
|
|
172
|
+
`Sec-Fetch-Site: cross-site` or `same-site`. This is always on, whatever `guards` you pass.
|
|
173
|
+
|
|
174
|
+
Redline's own responses carry `X-Frame-Options: DENY`.
|
|
175
|
+
|
|
176
|
+
## EXPLAIN
|
|
177
|
+
|
|
178
|
+
`POST /_redline/api/explain` runs `EXPLAIN` for a recorded SELECT on the engine it came
|
|
179
|
+
from inside a transaction that is always rolled back (`READ ONLY` on PostgreSQL). `EXPLAIN
|
|
180
|
+
ANALYZE` executes the query, so it is off unless `explain_analyze=True`.
|
|
181
|
+
|
|
182
|
+
## Development
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
uv sync --all-packages
|
|
186
|
+
cd packages/litestar-redline && uv run pytest
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "litestar-redline"
|
|
7
|
+
version = "0.2.0a1"
|
|
8
|
+
description = "A modern request profiler for Litestar 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
|
+
"litestar",
|
|
17
|
+
"asgi",
|
|
18
|
+
"sqlalchemy",
|
|
19
|
+
"profiler",
|
|
20
|
+
"performance",
|
|
21
|
+
"n+1",
|
|
22
|
+
"explain",
|
|
23
|
+
"flamegraph",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Environment :: Web Environment",
|
|
28
|
+
"Framework :: AsyncIO",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Programming Language :: Python :: 3.14",
|
|
38
|
+
"Topic :: Software Development :: Debuggers",
|
|
39
|
+
"Topic :: System :: Monitoring",
|
|
40
|
+
"Typing :: Typed",
|
|
41
|
+
]
|
|
42
|
+
# Lockstep: the adapter pins the exact core release it was tested with.
|
|
43
|
+
dependencies = [
|
|
44
|
+
"redline-core==0.2.0a1",
|
|
45
|
+
"litestar>=2.12,<3",
|
|
46
|
+
"sqlalchemy[asyncio]>=2.0,<3",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[project.optional-dependencies]
|
|
50
|
+
profile = ["redline-core[profile]==0.2.0a1"]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://github.com/Pythondeveloper6/redline"
|
|
54
|
+
Documentation = "https://pythondeveloper6.github.io/redline/litestar/"
|
|
55
|
+
Source = "https://github.com/Pythondeveloper6/redline"
|
|
56
|
+
Issues = "https://github.com/Pythondeveloper6/redline/issues"
|
|
57
|
+
Changelog = "https://github.com/Pythondeveloper6/redline/blob/main/CHANGELOG.md"
|
|
58
|
+
|
|
59
|
+
[dependency-groups]
|
|
60
|
+
dev = [
|
|
61
|
+
"pytest>=8.3",
|
|
62
|
+
"pytest-asyncio>=0.24",
|
|
63
|
+
"ruff>=0.8",
|
|
64
|
+
"httpx>=0.27",
|
|
65
|
+
"aiosqlite>=0.20",
|
|
66
|
+
"greenlet>=3.0",
|
|
67
|
+
"pyinstrument>=5.0",
|
|
68
|
+
"advanced-alchemy>=0.20",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.uv.sources]
|
|
72
|
+
redline-core = { workspace = true }
|
|
73
|
+
|
|
74
|
+
[tool.hatch.build.targets.wheel]
|
|
75
|
+
packages = ["src/litestar_redline"]
|
|
76
|
+
exclude = ["**/__pycache__", "**/*.py[cod]", "**/.DS_Store"]
|
|
77
|
+
|
|
78
|
+
[tool.hatch.build.targets.sdist]
|
|
79
|
+
include = ["/src/litestar_redline", "/tests", "/README.md", "/LICENSE"]
|
|
80
|
+
exclude = ["**/__pycache__", "**/*.py[cod]", "**/.DS_Store"]
|
|
81
|
+
|
|
82
|
+
[tool.pytest.ini_options]
|
|
83
|
+
testpaths = ["tests"]
|
|
84
|
+
addopts = "--strict-markers -q"
|
|
85
|
+
asyncio_mode = "auto"
|
|
86
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
87
|
+
filterwarnings = [
|
|
88
|
+
"ignore:The base_url:UserWarning",
|
|
89
|
+
"ignore:.*deprecated inferred style",
|
|
90
|
+
"ignore::sqlalchemy.exc.SADeprecationWarning",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
[tool.ruff]
|
|
94
|
+
extend = "../../pyproject.toml"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""litestar-redline: the Litestar + SQLAlchemy adapter for Redline.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from litestar import Litestar
|
|
6
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
7
|
+
from litestar_redline import RedlinePlugin
|
|
8
|
+
|
|
9
|
+
engine = create_async_engine("sqlite+aiosqlite:///app.db")
|
|
10
|
+
app = Litestar(route_handlers=[...], plugins=[RedlinePlugin(engine=engine)])
|
|
11
|
+
# UI at /_redline/
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ._httpx import instrument_httpx, uninstrument_httpx
|
|
17
|
+
from ._middleware import RedlineMiddleware
|
|
18
|
+
from ._security import RedlineHTTPError, csrf_guard, local_only, loopback_guard
|
|
19
|
+
from ._sqlalchemy import instrument_engine
|
|
20
|
+
from .plugin import RedlinePlugin
|
|
21
|
+
from .suggest import SQLAlchemyFixSuggester
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0a1"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"RedlineHTTPError",
|
|
27
|
+
"RedlineMiddleware",
|
|
28
|
+
"RedlinePlugin",
|
|
29
|
+
"SQLAlchemyFixSuggester",
|
|
30
|
+
"__version__",
|
|
31
|
+
"csrf_guard",
|
|
32
|
+
"instrument_engine",
|
|
33
|
+
"instrument_httpx",
|
|
34
|
+
"local_only",
|
|
35
|
+
"loopback_guard",
|
|
36
|
+
"uninstrument_httpx",
|
|
37
|
+
]
|