fastapi-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.
- fastapi_redline-0.2.0a1/.gitignore +21 -0
- fastapi_redline-0.2.0a1/LICENSE +21 -0
- fastapi_redline-0.2.0a1/PKG-INFO +267 -0
- fastapi_redline-0.2.0a1/README.md +229 -0
- fastapi_redline-0.2.0a1/pyproject.toml +94 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/__init__.py +38 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/_httpx.py +5 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/_middleware.py +264 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/_security.py +228 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/_serialize.py +108 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/_sqlalchemy.py +19 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/integration.py +236 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/py.typed +0 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/router.py +165 -0
- fastapi_redline-0.2.0a1/src/fastapi_redline/suggest.py +5 -0
- fastapi_redline-0.2.0a1/tests/__init__.py +0 -0
- fastapi_redline-0.2.0a1/tests/conftest.py +102 -0
- fastapi_redline-0.2.0a1/tests/shop_app.py +201 -0
- fastapi_redline-0.2.0a1/tests/test_access_policies.py +180 -0
- fastapi_redline-0.2.0a1/tests/test_api.py +352 -0
- fastapi_redline-0.2.0a1/tests/test_blocking_transactions.py +133 -0
- fastapi_redline-0.2.0a1/tests/test_middleware.py +240 -0
- fastapi_redline-0.2.0a1/tests/test_perf_paths.py +59 -0
- fastapi_redline-0.2.0a1/tests/test_security_redaction.py +82 -0
- fastapi_redline-0.2.0a1/tests/test_security_regressions.py +215 -0
- fastapi_redline-0.2.0a1/tests/test_serializer.py +119 -0
- fastapi_redline-0.2.0a1/tests/test_sqlalchemy.py +138 -0
- fastapi_redline-0.2.0a1/tests/test_sqlmodel.py +36 -0
- fastapi_redline-0.2.0a1/tests/test_suggest.py +173 -0
- fastapi_redline-0.2.0a1/tests/test_v02_adapter.py +204 -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,267 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fastapi-redline
|
|
3
|
+
Version: 0.2.0a1
|
|
4
|
+
Summary: A modern request profiler for FastAPI 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/fastapi/
|
|
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,fastapi,flamegraph,n+1,performance,profiler,sqlalchemy,starlette
|
|
15
|
+
Classifier: Development Status :: 3 - Alpha
|
|
16
|
+
Classifier: Environment :: Web Environment
|
|
17
|
+
Classifier: Framework :: AsyncIO
|
|
18
|
+
Classifier: Framework :: FastAPI
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
28
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
29
|
+
Classifier: Topic :: System :: Monitoring
|
|
30
|
+
Classifier: Typing :: Typed
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Requires-Dist: fastapi<1.0,>=0.110
|
|
33
|
+
Requires-Dist: redline-core==0.2.0a1
|
|
34
|
+
Requires-Dist: sqlalchemy[asyncio]<3.0,>=2.0
|
|
35
|
+
Provides-Extra: profile
|
|
36
|
+
Requires-Dist: redline-core[profile]==0.2.0a1; extra == 'profile'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<a href="https://pythondeveloper6.github.io/redline/">
|
|
41
|
+
<picture>
|
|
42
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Pythondeveloper6/redline/main/docs/assets/logo-dark.svg">
|
|
43
|
+
<img alt="Redline" src="https://raw.githubusercontent.com/Pythondeveloper6/redline/main/docs/assets/logo-light.svg" height="56">
|
|
44
|
+
</picture>
|
|
45
|
+
</a>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
# fastapi-redline
|
|
49
|
+
|
|
50
|
+
The FastAPI + SQLAlchemy adapter for [Redline](https://github.com/Pythondeveloper6/redline#readme): a request profiler and
|
|
51
|
+
backend analyzer. Every request becomes a trace with a span timeline, the SQL it ran, N+1
|
|
52
|
+
and duplicate-query detection with **concrete fix diffs**, one-click `EXPLAIN`, optional
|
|
53
|
+
flamegraphs, and a fast web UI served from your own app.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fastapi import FastAPI
|
|
57
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
58
|
+
from fastapi_redline import install
|
|
59
|
+
|
|
60
|
+
app = FastAPI()
|
|
61
|
+
engine = create_async_engine("sqlite+aiosqlite:///app.db")
|
|
62
|
+
|
|
63
|
+
# access="local": localhost only (unset, Redline answers 404 outside debug mode)
|
|
64
|
+
install(app, engine=engine, settings={"access": "local"}) # UI at http://localhost:8000/_redline/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Traces are stored in their own SQLite file (`.redline/redline.db`), never in your database.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install \
|
|
73
|
+
"fastapi-redline @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/fastapi-redline" \
|
|
74
|
+
"redline-core @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Not on PyPI yet: install from GitHub, naming `redline-core` in the same command (the adapter
|
|
78
|
+
pins it exactly). Docs: <https://pythondeveloper6.github.io/redline/>.
|
|
79
|
+
|
|
80
|
+
Add the extra to the core requirement for flamegraphs:
|
|
81
|
+
`"redline-core[profile] @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"`.
|
|
82
|
+
|
|
83
|
+
Python 3.10+, FastAPI 0.110+, SQLAlchemy 2.x.
|
|
84
|
+
|
|
85
|
+
## `install()`
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
install(
|
|
89
|
+
app,
|
|
90
|
+
engine=None, # Engine | AsyncEngine | [engines] | {"alias": engine}
|
|
91
|
+
settings=None, # dict | redline_core.Settings
|
|
92
|
+
prefix=None, # default: the mount_path setting, else "/_redline"
|
|
93
|
+
dependencies=None, # auth dependencies for the UI + API (see below)
|
|
94
|
+
models=None, # Base / registry for fix suggestions (default: auto-discover)
|
|
95
|
+
store=None, # custom Store (default: SQLite file at storage_path)
|
|
96
|
+
) -> Redline
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
What it does:
|
|
100
|
+
|
|
101
|
+
- configures the recorder and the store, and **flushes and closes the store on shutdown**
|
|
102
|
+
(it wraps your lifespan; an existing `lifespan=` keeps working);
|
|
103
|
+
- adds a pure ASGI middleware (not `BaseHTTPMiddleware`);
|
|
104
|
+
- instruments the engines;
|
|
105
|
+
- mounts the UI and JSON API under `prefix` (ahead of any catch-all route you declared).
|
|
106
|
+
|
|
107
|
+
Call it after your app is built, before it starts. `settings` takes any
|
|
108
|
+
[Redline setting](../redline-core/README.md) with case-insensitive keys:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
install(app, engine=engine, settings={
|
|
112
|
+
"sample_rate": 0.1, # keep 10% of requests ...
|
|
113
|
+
"always_keep_slower_than_ms": 300, # ... plus every slow one and every error
|
|
114
|
+
"slow_query_ms": 50,
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Multiple engines
|
|
119
|
+
|
|
120
|
+
The first engine of a list is the `"default"` db alias; others are named after their
|
|
121
|
+
database file, or pass a dict. The alias is stored on each query so EXPLAIN runs on the right
|
|
122
|
+
engine.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
install(app, engine=[async_engine, sync_engine])
|
|
126
|
+
install(app, engine={"main": main_engine, "analytics": analytics_engine})
|
|
127
|
+
redline = install(app)
|
|
128
|
+
redline.add_engine(late_engine) # instrument an engine created later
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### SQLModel
|
|
132
|
+
|
|
133
|
+
Nothing to do: SQLModel tables live in a regular SQLAlchemy registry, which the fix
|
|
134
|
+
suggester discovers (or pass `models=SQLModel`).
|
|
135
|
+
|
|
136
|
+
## What is recorded
|
|
137
|
+
|
|
138
|
+
- **Request / response**: method, path, route template (`/orders/{order_id}`), endpoint
|
|
139
|
+
dotted name, status, headers, query string and body. Secrets are redacted by the core
|
|
140
|
+
(`Authorization`, cookies, `password`/`token`/`card` keys...).
|
|
141
|
+
- **Bodies are never consumed or buffered unboundedly.** The request body is copied as the
|
|
142
|
+
app reads it; response bodies, including streams, are kept only up to `max_body_bytes`.
|
|
143
|
+
- **SQL** through `before/after_cursor_execute`: statement, bound params (redacted),
|
|
144
|
+
duration, rowcount, vendor, executemany, `db_alias`, and the project line that issued the
|
|
145
|
+
query, also through `AsyncSession` (the greenlet boundary is bridged).
|
|
146
|
+
- **Unhandled exceptions** are recorded and re-raised untouched.
|
|
147
|
+
- Spans from `def` endpoints running in Starlette's threadpool attach to the right request
|
|
148
|
+
(context is copied to the worker thread; tested under concurrency).
|
|
149
|
+
- The trace is finished when the last response chunk is sent, so background tasks do not
|
|
150
|
+
inflate latency. Work that starts a bare `threading.Thread` is not attributed to any
|
|
151
|
+
request.
|
|
152
|
+
- Your own spans: `with redline_core.span("pricing.compute"): ...` (also `async with` and
|
|
153
|
+
as a decorator).
|
|
154
|
+
- `X-Redline-Run: <id>;step=<n>` becomes tags (used by the load tester).
|
|
155
|
+
|
|
156
|
+
### Outbound HTTP (httpx)
|
|
157
|
+
|
|
158
|
+
Opt-in, once at startup:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from fastapi_redline import instrument_httpx
|
|
162
|
+
|
|
163
|
+
instrument_httpx() # every httpx.Client / AsyncClient call becomes an http_out span
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
URLs are redacted (`?api_key=...` is masked). Use `uninstrument_httpx()` to undo it.
|
|
167
|
+
|
|
168
|
+
### Profiling
|
|
169
|
+
|
|
170
|
+
Flamegraphs are opt-in per request: add `?_redline=profile` or the header
|
|
171
|
+
`X-Redline-Profile: 1` (needs the `[profile]` extra). They are recorded with pyinstrument in
|
|
172
|
+
async mode, so concurrent requests do not pollute each other.
|
|
173
|
+
|
|
174
|
+
## Fix suggestions
|
|
175
|
+
|
|
176
|
+
For an N+1, `SQLAlchemyFixSuggester` maps the repeated query back to your models and tells
|
|
177
|
+
you how to eager-load it:
|
|
178
|
+
|
|
179
|
+
| Repeated query | Suggestion |
|
|
180
|
+
|---|---|
|
|
181
|
+
| child table filtered by its foreign key | `selectinload(Parent.children)` |
|
|
182
|
+
| parent looked up by primary key, once per row | `joinedload(Child.parent)` |
|
|
183
|
+
| association (secondary) table filtered by the parent | `selectinload(Parent.tags)` |
|
|
184
|
+
|
|
185
|
+
It then walks the call stack to find the `select(Parent)` or `session.query(Parent)` in
|
|
186
|
+
your source and produces a real unified diff:
|
|
187
|
+
|
|
188
|
+
```diff
|
|
189
|
+
- orders = (await session.scalars(select(Order).order_by(Order.id).limit(30))).all()
|
|
190
|
+
+ orders = (await session.scalars(select(Order).order_by(Order.id).limit(30).options(selectinload(Order.items)))).all()
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
plus the `from sqlalchemy.orm import selectinload` line when it is missing. When the
|
|
194
|
+
statement cannot be located (it lives in another module, or is built dynamically) you get
|
|
195
|
+
the one-line code hint with lower confidence instead. Both the lazy-load form
|
|
196
|
+
(`order.items`) and explicit per-row queries are recognised.
|
|
197
|
+
|
|
198
|
+
## Access control
|
|
199
|
+
|
|
200
|
+
The UI exposes your SQL, request bodies and EXPLAIN, so treat it like an admin tool.
|
|
201
|
+
|
|
202
|
+
**Default.** With no `dependencies`, 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`)
|
|
203
|
+
that address the server by a loopback name (`Host: localhost`, `127.0.0.1`, `[::1]`, any port;
|
|
204
|
+
this blocks DNS rebinding)
|
|
205
|
+
are served and a warning is logged at startup. Refusals, and `HTTPException`s from your own
|
|
206
|
+
dependencies, are JSON `{"error": {"code": "forbidden" | "unauthorized" | ..., "message"}}`.
|
|
207
|
+
Add other host names with the `allowed_hosts` setting. Passing `dependencies=` implies
|
|
208
|
+
`access="framework"`. A loopback peer sending `X-Forwarded-For` without being in
|
|
209
|
+
`trusted_proxies` is refused; behind a reverse proxy use `access="token"` and
|
|
210
|
+
`trusted_proxies`. Tokens, basic auth, `allowed_ips` and `read_only` are described in the
|
|
211
|
+
[access control docs](https://pythondeveloper6.github.io/redline/access-control/).
|
|
212
|
+
|
|
213
|
+
**Your existing auth dependency:**
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from fastapi import Depends
|
|
217
|
+
|
|
218
|
+
install(app, engine=engine, dependencies=[Depends(require_admin_user)])
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**HTTP Basic:**
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
import secrets
|
|
225
|
+
from fastapi import Depends, HTTPException
|
|
226
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
227
|
+
|
|
228
|
+
basic = HTTPBasic()
|
|
229
|
+
|
|
230
|
+
def redline_auth(creds: HTTPBasicCredentials = Depends(basic)) -> None:
|
|
231
|
+
ok = secrets.compare_digest(creds.username, "admin") and secrets.compare_digest(
|
|
232
|
+
creds.password, os.environ["REDLINE_PASSWORD"]
|
|
233
|
+
)
|
|
234
|
+
if not ok:
|
|
235
|
+
raise HTTPException(401, headers={"WWW-Authenticate": "Basic"})
|
|
236
|
+
|
|
237
|
+
install(app, engine=engine, dependencies=[Depends(redline_auth)])
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
(`dependencies=[]` is the same as passing none. A dependency that returns `False` is refused with 403; raising `HTTPException` is the normal way.)
|
|
241
|
+
|
|
242
|
+
**CSRF.** The mutating endpoints (`DELETE` traces, `POST explain`) reject browser requests
|
|
243
|
+
whose `Origin` does not match the request host, `Origin: null`, and
|
|
244
|
+
`Sec-Fetch-Site: cross-site`. Browsers always send `Origin` on those methods, so another
|
|
245
|
+
website cannot drive your Redline through a logged-in browser. Non-browser clients (curl,
|
|
246
|
+
scripts) send no `Origin` and are only subject to your auth dependencies.
|
|
247
|
+
|
|
248
|
+
## EXPLAIN
|
|
249
|
+
|
|
250
|
+
`POST /_redline/api/explain` runs `EXPLAIN` for a recorded SELECT on the engine it came
|
|
251
|
+
from (matched by `db_alias`) inside a transaction that is always rolled back (and
|
|
252
|
+
`READ ONLY` on PostgreSQL). The captured SQL is in the driver's paramstyle and goes
|
|
253
|
+
through `exec_driver_sql`. Async engines run on the application's event loop. `EXPLAIN
|
|
254
|
+
ANALYZE` executes the query, so it is off unless `explain_analyze=True`.
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
uv sync --all-packages
|
|
260
|
+
cd packages/fastapi-redline && uv run pytest
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
A runnable demo with deliberately bad endpoints lives in [`examples/fastapi`](https://github.com/Pythondeveloper6/redline/blob/main/examples/fastapi).
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT
|
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
# fastapi-redline
|
|
11
|
+
|
|
12
|
+
The FastAPI + 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 fastapi import FastAPI
|
|
19
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
20
|
+
from fastapi_redline import install
|
|
21
|
+
|
|
22
|
+
app = FastAPI()
|
|
23
|
+
engine = create_async_engine("sqlite+aiosqlite:///app.db")
|
|
24
|
+
|
|
25
|
+
# access="local": localhost only (unset, Redline answers 404 outside debug mode)
|
|
26
|
+
install(app, engine=engine, settings={"access": "local"}) # UI at http://localhost:8000/_redline/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Traces are stored in their own SQLite file (`.redline/redline.db`), never in your database.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install \
|
|
35
|
+
"fastapi-redline @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/fastapi-redline" \
|
|
36
|
+
"redline-core @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Not on PyPI yet: install from GitHub, naming `redline-core` in the same command (the adapter
|
|
40
|
+
pins it exactly). Docs: <https://pythondeveloper6.github.io/redline/>.
|
|
41
|
+
|
|
42
|
+
Add the extra to the core requirement for flamegraphs:
|
|
43
|
+
`"redline-core[profile] @ git+https://github.com/Pythondeveloper6/redline#subdirectory=packages/redline-core"`.
|
|
44
|
+
|
|
45
|
+
Python 3.10+, FastAPI 0.110+, SQLAlchemy 2.x.
|
|
46
|
+
|
|
47
|
+
## `install()`
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
install(
|
|
51
|
+
app,
|
|
52
|
+
engine=None, # Engine | AsyncEngine | [engines] | {"alias": engine}
|
|
53
|
+
settings=None, # dict | redline_core.Settings
|
|
54
|
+
prefix=None, # default: the mount_path setting, else "/_redline"
|
|
55
|
+
dependencies=None, # auth dependencies for the UI + API (see below)
|
|
56
|
+
models=None, # Base / registry for fix suggestions (default: auto-discover)
|
|
57
|
+
store=None, # custom Store (default: SQLite file at storage_path)
|
|
58
|
+
) -> Redline
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
What it does:
|
|
62
|
+
|
|
63
|
+
- configures the recorder and the store, and **flushes and closes the store on shutdown**
|
|
64
|
+
(it wraps your lifespan; an existing `lifespan=` keeps working);
|
|
65
|
+
- adds a pure ASGI middleware (not `BaseHTTPMiddleware`);
|
|
66
|
+
- instruments the engines;
|
|
67
|
+
- mounts the UI and JSON API under `prefix` (ahead of any catch-all route you declared).
|
|
68
|
+
|
|
69
|
+
Call it after your app is built, before it starts. `settings` takes any
|
|
70
|
+
[Redline setting](../redline-core/README.md) with case-insensitive keys:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
install(app, engine=engine, settings={
|
|
74
|
+
"sample_rate": 0.1, # keep 10% of requests ...
|
|
75
|
+
"always_keep_slower_than_ms": 300, # ... plus every slow one and every error
|
|
76
|
+
"slow_query_ms": 50,
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Multiple engines
|
|
81
|
+
|
|
82
|
+
The first engine of a list is the `"default"` db alias; others are named after their
|
|
83
|
+
database file, or pass a dict. The alias is stored on each query so EXPLAIN runs on the right
|
|
84
|
+
engine.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
install(app, engine=[async_engine, sync_engine])
|
|
88
|
+
install(app, engine={"main": main_engine, "analytics": analytics_engine})
|
|
89
|
+
redline = install(app)
|
|
90
|
+
redline.add_engine(late_engine) # instrument an engine created later
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### SQLModel
|
|
94
|
+
|
|
95
|
+
Nothing to do: SQLModel tables live in a regular SQLAlchemy registry, which the fix
|
|
96
|
+
suggester discovers (or pass `models=SQLModel`).
|
|
97
|
+
|
|
98
|
+
## What is recorded
|
|
99
|
+
|
|
100
|
+
- **Request / response**: method, path, route template (`/orders/{order_id}`), endpoint
|
|
101
|
+
dotted name, status, headers, query string and body. Secrets are redacted by the core
|
|
102
|
+
(`Authorization`, cookies, `password`/`token`/`card` keys...).
|
|
103
|
+
- **Bodies are never consumed or buffered unboundedly.** The request body is copied as the
|
|
104
|
+
app reads it; response bodies, including streams, are kept only up to `max_body_bytes`.
|
|
105
|
+
- **SQL** through `before/after_cursor_execute`: statement, bound params (redacted),
|
|
106
|
+
duration, rowcount, vendor, executemany, `db_alias`, and the project line that issued the
|
|
107
|
+
query, also through `AsyncSession` (the greenlet boundary is bridged).
|
|
108
|
+
- **Unhandled exceptions** are recorded and re-raised untouched.
|
|
109
|
+
- Spans from `def` endpoints running in Starlette's threadpool attach to the right request
|
|
110
|
+
(context is copied to the worker thread; tested under concurrency).
|
|
111
|
+
- The trace is finished when the last response chunk is sent, so background tasks do not
|
|
112
|
+
inflate latency. Work that starts a bare `threading.Thread` is not attributed to any
|
|
113
|
+
request.
|
|
114
|
+
- Your own spans: `with redline_core.span("pricing.compute"): ...` (also `async with` and
|
|
115
|
+
as a decorator).
|
|
116
|
+
- `X-Redline-Run: <id>;step=<n>` becomes tags (used by the load tester).
|
|
117
|
+
|
|
118
|
+
### Outbound HTTP (httpx)
|
|
119
|
+
|
|
120
|
+
Opt-in, once at startup:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from fastapi_redline import instrument_httpx
|
|
124
|
+
|
|
125
|
+
instrument_httpx() # every httpx.Client / AsyncClient call becomes an http_out span
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
URLs are redacted (`?api_key=...` is masked). Use `uninstrument_httpx()` to undo it.
|
|
129
|
+
|
|
130
|
+
### Profiling
|
|
131
|
+
|
|
132
|
+
Flamegraphs are opt-in per request: add `?_redline=profile` or the header
|
|
133
|
+
`X-Redline-Profile: 1` (needs the `[profile]` extra). They are recorded with pyinstrument in
|
|
134
|
+
async mode, so concurrent requests do not pollute each other.
|
|
135
|
+
|
|
136
|
+
## Fix suggestions
|
|
137
|
+
|
|
138
|
+
For an N+1, `SQLAlchemyFixSuggester` maps the repeated query back to your models and tells
|
|
139
|
+
you how to eager-load it:
|
|
140
|
+
|
|
141
|
+
| Repeated query | Suggestion |
|
|
142
|
+
|---|---|
|
|
143
|
+
| child table filtered by its foreign key | `selectinload(Parent.children)` |
|
|
144
|
+
| parent looked up by primary key, once per row | `joinedload(Child.parent)` |
|
|
145
|
+
| association (secondary) table filtered by the parent | `selectinload(Parent.tags)` |
|
|
146
|
+
|
|
147
|
+
It then walks the call stack to find the `select(Parent)` or `session.query(Parent)` in
|
|
148
|
+
your source and produces a real unified diff:
|
|
149
|
+
|
|
150
|
+
```diff
|
|
151
|
+
- orders = (await session.scalars(select(Order).order_by(Order.id).limit(30))).all()
|
|
152
|
+
+ orders = (await session.scalars(select(Order).order_by(Order.id).limit(30).options(selectinload(Order.items)))).all()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
plus the `from sqlalchemy.orm import selectinload` line when it is missing. When the
|
|
156
|
+
statement cannot be located (it lives in another module, or is built dynamically) you get
|
|
157
|
+
the one-line code hint with lower confidence instead. Both the lazy-load form
|
|
158
|
+
(`order.items`) and explicit per-row queries are recognised.
|
|
159
|
+
|
|
160
|
+
## Access control
|
|
161
|
+
|
|
162
|
+
The UI exposes your SQL, request bodies and EXPLAIN, so treat it like an admin tool.
|
|
163
|
+
|
|
164
|
+
**Default.** With no `dependencies`, 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`)
|
|
165
|
+
that address the server by a loopback name (`Host: localhost`, `127.0.0.1`, `[::1]`, any port;
|
|
166
|
+
this blocks DNS rebinding)
|
|
167
|
+
are served and a warning is logged at startup. Refusals, and `HTTPException`s from your own
|
|
168
|
+
dependencies, are JSON `{"error": {"code": "forbidden" | "unauthorized" | ..., "message"}}`.
|
|
169
|
+
Add other host names with the `allowed_hosts` setting. Passing `dependencies=` implies
|
|
170
|
+
`access="framework"`. A loopback peer sending `X-Forwarded-For` without being in
|
|
171
|
+
`trusted_proxies` is refused; behind a reverse proxy use `access="token"` and
|
|
172
|
+
`trusted_proxies`. Tokens, basic auth, `allowed_ips` and `read_only` are described in the
|
|
173
|
+
[access control docs](https://pythondeveloper6.github.io/redline/access-control/).
|
|
174
|
+
|
|
175
|
+
**Your existing auth dependency:**
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from fastapi import Depends
|
|
179
|
+
|
|
180
|
+
install(app, engine=engine, dependencies=[Depends(require_admin_user)])
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**HTTP Basic:**
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
import secrets
|
|
187
|
+
from fastapi import Depends, HTTPException
|
|
188
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
189
|
+
|
|
190
|
+
basic = HTTPBasic()
|
|
191
|
+
|
|
192
|
+
def redline_auth(creds: HTTPBasicCredentials = Depends(basic)) -> None:
|
|
193
|
+
ok = secrets.compare_digest(creds.username, "admin") and secrets.compare_digest(
|
|
194
|
+
creds.password, os.environ["REDLINE_PASSWORD"]
|
|
195
|
+
)
|
|
196
|
+
if not ok:
|
|
197
|
+
raise HTTPException(401, headers={"WWW-Authenticate": "Basic"})
|
|
198
|
+
|
|
199
|
+
install(app, engine=engine, dependencies=[Depends(redline_auth)])
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
(`dependencies=[]` is the same as passing none. A dependency that returns `False` is refused with 403; raising `HTTPException` is the normal way.)
|
|
203
|
+
|
|
204
|
+
**CSRF.** The mutating endpoints (`DELETE` traces, `POST explain`) reject browser requests
|
|
205
|
+
whose `Origin` does not match the request host, `Origin: null`, and
|
|
206
|
+
`Sec-Fetch-Site: cross-site`. Browsers always send `Origin` on those methods, so another
|
|
207
|
+
website cannot drive your Redline through a logged-in browser. Non-browser clients (curl,
|
|
208
|
+
scripts) send no `Origin` and are only subject to your auth dependencies.
|
|
209
|
+
|
|
210
|
+
## EXPLAIN
|
|
211
|
+
|
|
212
|
+
`POST /_redline/api/explain` runs `EXPLAIN` for a recorded SELECT on the engine it came
|
|
213
|
+
from (matched by `db_alias`) inside a transaction that is always rolled back (and
|
|
214
|
+
`READ ONLY` on PostgreSQL). The captured SQL is in the driver's paramstyle and goes
|
|
215
|
+
through `exec_driver_sql`. Async engines run on the application's event loop. `EXPLAIN
|
|
216
|
+
ANALYZE` executes the query, so it is off unless `explain_analyze=True`.
|
|
217
|
+
|
|
218
|
+
## Development
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
uv sync --all-packages
|
|
222
|
+
cd packages/fastapi-redline && uv run pytest
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
A runnable demo with deliberately bad endpoints lives in [`examples/fastapi`](https://github.com/Pythondeveloper6/redline/blob/main/examples/fastapi).
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fastapi-redline"
|
|
7
|
+
version = "0.2.0a1"
|
|
8
|
+
description = "A modern request profiler for FastAPI 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
|
+
"fastapi",
|
|
17
|
+
"starlette",
|
|
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 :: FastAPI",
|
|
29
|
+
"Framework :: AsyncIO",
|
|
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
|
+
"fastapi>=0.110,<1.0",
|
|
47
|
+
"sqlalchemy[asyncio]>=2.0,<3.0",
|
|
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/fastapi/"
|
|
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
|
+
"pytest-asyncio>=0.24",
|
|
64
|
+
"ruff>=0.8",
|
|
65
|
+
"httpx>=0.27",
|
|
66
|
+
"aiosqlite>=0.20",
|
|
67
|
+
"greenlet>=3.0",
|
|
68
|
+
"uvicorn>=0.29",
|
|
69
|
+
"pyinstrument>=5.0",
|
|
70
|
+
"sqlmodel>=0.0.48",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.uv.sources]
|
|
74
|
+
redline-core = { workspace = true }
|
|
75
|
+
|
|
76
|
+
[tool.hatch.build.targets.wheel]
|
|
77
|
+
packages = ["src/fastapi_redline"]
|
|
78
|
+
exclude = ["**/__pycache__", "**/*.py[cod]", "**/.DS_Store"]
|
|
79
|
+
|
|
80
|
+
[tool.hatch.build.targets.sdist]
|
|
81
|
+
include = ["/src/fastapi_redline", "/tests", "/README.md", "/LICENSE"]
|
|
82
|
+
exclude = ["**/__pycache__", "**/*.py[cod]", "**/.DS_Store"]
|
|
83
|
+
|
|
84
|
+
[tool.pytest.ini_options]
|
|
85
|
+
testpaths = ["tests"]
|
|
86
|
+
addopts = "--strict-markers -q"
|
|
87
|
+
asyncio_mode = "auto"
|
|
88
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
89
|
+
filterwarnings = [
|
|
90
|
+
"ignore:Using `httpx` with `starlette.testclient` is deprecated",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
[tool.ruff]
|
|
94
|
+
extend = "../../pyproject.toml"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""fastapi-redline: the FastAPI + SQLAlchemy adapter for Redline.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
7
|
+
from fastapi_redline import install
|
|
8
|
+
|
|
9
|
+
app = FastAPI()
|
|
10
|
+
engine = create_async_engine("sqlite+aiosqlite:///app.db")
|
|
11
|
+
install(app, engine=engine) # 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 .integration import Redline, install
|
|
21
|
+
from .suggest import SQLAlchemyFixSuggester
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0a1"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Redline",
|
|
27
|
+
"RedlineHTTPError",
|
|
28
|
+
"RedlineMiddleware",
|
|
29
|
+
"SQLAlchemyFixSuggester",
|
|
30
|
+
"__version__",
|
|
31
|
+
"csrf_guard",
|
|
32
|
+
"install",
|
|
33
|
+
"instrument_engine",
|
|
34
|
+
"instrument_httpx",
|
|
35
|
+
"local_only",
|
|
36
|
+
"loopback_guard",
|
|
37
|
+
"uninstrument_httpx",
|
|
38
|
+
]
|