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.
Files changed (30) hide show
  1. fastapi_redline-0.2.0a1/.gitignore +21 -0
  2. fastapi_redline-0.2.0a1/LICENSE +21 -0
  3. fastapi_redline-0.2.0a1/PKG-INFO +267 -0
  4. fastapi_redline-0.2.0a1/README.md +229 -0
  5. fastapi_redline-0.2.0a1/pyproject.toml +94 -0
  6. fastapi_redline-0.2.0a1/src/fastapi_redline/__init__.py +38 -0
  7. fastapi_redline-0.2.0a1/src/fastapi_redline/_httpx.py +5 -0
  8. fastapi_redline-0.2.0a1/src/fastapi_redline/_middleware.py +264 -0
  9. fastapi_redline-0.2.0a1/src/fastapi_redline/_security.py +228 -0
  10. fastapi_redline-0.2.0a1/src/fastapi_redline/_serialize.py +108 -0
  11. fastapi_redline-0.2.0a1/src/fastapi_redline/_sqlalchemy.py +19 -0
  12. fastapi_redline-0.2.0a1/src/fastapi_redline/integration.py +236 -0
  13. fastapi_redline-0.2.0a1/src/fastapi_redline/py.typed +0 -0
  14. fastapi_redline-0.2.0a1/src/fastapi_redline/router.py +165 -0
  15. fastapi_redline-0.2.0a1/src/fastapi_redline/suggest.py +5 -0
  16. fastapi_redline-0.2.0a1/tests/__init__.py +0 -0
  17. fastapi_redline-0.2.0a1/tests/conftest.py +102 -0
  18. fastapi_redline-0.2.0a1/tests/shop_app.py +201 -0
  19. fastapi_redline-0.2.0a1/tests/test_access_policies.py +180 -0
  20. fastapi_redline-0.2.0a1/tests/test_api.py +352 -0
  21. fastapi_redline-0.2.0a1/tests/test_blocking_transactions.py +133 -0
  22. fastapi_redline-0.2.0a1/tests/test_middleware.py +240 -0
  23. fastapi_redline-0.2.0a1/tests/test_perf_paths.py +59 -0
  24. fastapi_redline-0.2.0a1/tests/test_security_redaction.py +82 -0
  25. fastapi_redline-0.2.0a1/tests/test_security_regressions.py +215 -0
  26. fastapi_redline-0.2.0a1/tests/test_serializer.py +119 -0
  27. fastapi_redline-0.2.0a1/tests/test_sqlalchemy.py +138 -0
  28. fastapi_redline-0.2.0a1/tests/test_sqlmodel.py +36 -0
  29. fastapi_redline-0.2.0a1/tests/test_suggest.py +173 -0
  30. 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
+ ]
@@ -0,0 +1,5 @@
1
+ """Compatibility shim: the implementation lives in :mod:`redline_core.contrib.httpx`."""
2
+
3
+ from redline_core.contrib.httpx import instrument_httpx, uninstrument_httpx
4
+
5
+ __all__ = ["instrument_httpx", "uninstrument_httpx"]