flowsurgeon 0.2.0__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.
- flowsurgeon-0.2.0/PKG-INFO +114 -0
- flowsurgeon-0.2.0/README.md +91 -0
- flowsurgeon-0.2.0/pyproject.toml +49 -0
- flowsurgeon-0.2.0/src/flowsurgeon/__init__.py +39 -0
- flowsurgeon-0.2.0/src/flowsurgeon/core/__init__.py +4 -0
- flowsurgeon-0.2.0/src/flowsurgeon/core/config.py +44 -0
- flowsurgeon-0.2.0/src/flowsurgeon/core/records.py +21 -0
- flowsurgeon-0.2.0/src/flowsurgeon/middleware/__init__.py +3 -0
- flowsurgeon-0.2.0/src/flowsurgeon/middleware/asgi.py +276 -0
- flowsurgeon-0.2.0/src/flowsurgeon/middleware/wsgi.py +218 -0
- flowsurgeon-0.2.0/src/flowsurgeon/py.typed +0 -0
- flowsurgeon-0.2.0/src/flowsurgeon/storage/__init__.py +4 -0
- flowsurgeon-0.2.0/src/flowsurgeon/storage/async_sqlite.py +79 -0
- flowsurgeon-0.2.0/src/flowsurgeon/storage/base.py +29 -0
- flowsurgeon-0.2.0/src/flowsurgeon/storage/sqlite.py +139 -0
- flowsurgeon-0.2.0/src/flowsurgeon/ui/__init__.py +3 -0
- flowsurgeon-0.2.0/src/flowsurgeon/ui/assets/panel.css +120 -0
- flowsurgeon-0.2.0/src/flowsurgeon/ui/assets/panel.js +14 -0
- flowsurgeon-0.2.0/src/flowsurgeon/ui/panel.py +210 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: flowsurgeon
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: FlowSurgeon — framework-agnostic profiling middleware for Python (WSGI & ASGI).
|
|
5
|
+
Keywords: wsgi,asgi,middleware,profiling,debugging,fastapi,flask,starlette
|
|
6
|
+
Author: Samandar-Komilov
|
|
7
|
+
Author-email: Samandar-Komilov <voidpointer07@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
|
|
16
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Project-URL: Homepage, https://github.com/Samandar-Komilov/flowsurgeon
|
|
20
|
+
Project-URL: Repository, https://github.com/Samandar-Komilov/flowsurgeon
|
|
21
|
+
Project-URL: Issues, https://github.com/Samandar-Komilov/flowsurgeon/issues
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# FlowSurgeon
|
|
25
|
+
|
|
26
|
+
Framework-agnostic profiling middleware for Python — works with **Flask** (WSGI) and **FastAPI / Starlette** (ASGI) out of the box.
|
|
27
|
+
|
|
28
|
+
FlowSurgeon injects a lightweight debug panel into every HTML response and stores request history in a local SQLite database, giving you timing, headers, and status codes without touching your application code.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Zero required dependencies — pure stdlib
|
|
33
|
+
- WSGI middleware (`FlowSurgeonWSGI`) for Flask, Django, and any WSGI app
|
|
34
|
+
- ASGI middleware (`FlowSurgeonASGI`) for FastAPI, Starlette, and any ASGI app
|
|
35
|
+
- `FlowSurgeon()` factory — auto-detects WSGI vs ASGI
|
|
36
|
+
- Inline debug panel injected into HTML responses
|
|
37
|
+
- Built-in history UI at `/__flowsurgeon__/`
|
|
38
|
+
- SQLite persistence with auto-pruning
|
|
39
|
+
- Sensitive header redaction (`Authorization`, `Cookie`)
|
|
40
|
+
- `FLOWSURGEON_ENABLED` environment variable kill switch
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install flowsurgeon
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
### FastAPI (ASGI)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from fastapi import FastAPI
|
|
54
|
+
from flowsurgeon import FlowSurgeon, Config
|
|
55
|
+
|
|
56
|
+
_app = FastAPI()
|
|
57
|
+
app = FlowSurgeon(_app, config=Config(enabled=True))
|
|
58
|
+
|
|
59
|
+
@_app.get("/")
|
|
60
|
+
async def index():
|
|
61
|
+
return {"hello": "world"}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run with uvicorn:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uvicorn myapp:app
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Flask (WSGI)
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from flask import Flask
|
|
74
|
+
from flowsurgeon import FlowSurgeon, Config
|
|
75
|
+
|
|
76
|
+
flask_app = Flask(__name__)
|
|
77
|
+
flask_app.wsgi_app = FlowSurgeon(
|
|
78
|
+
flask_app.wsgi_app,
|
|
79
|
+
config=Config(enabled=True),
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from flowsurgeon import Config
|
|
87
|
+
|
|
88
|
+
Config(
|
|
89
|
+
enabled=True, # default: False (or FLOWSURGEON_ENABLED=1)
|
|
90
|
+
allowed_hosts=["127.0.0.1", "::1"], # hosts that see the panel
|
|
91
|
+
db_path="flowsurgeon.db", # SQLite file path
|
|
92
|
+
max_stored_requests=1000, # auto-prune threshold
|
|
93
|
+
debug_route="/__flowsurgeon__", # history UI prefix
|
|
94
|
+
strip_sensitive_headers=["authorization", "cookie", "set-cookie"],
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Debug UI
|
|
99
|
+
|
|
100
|
+
| Route | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `/__flowsurgeon__/` | Paginated request history |
|
|
103
|
+
| `/__flowsurgeon__/{id}` | Full detail for one request |
|
|
104
|
+
|
|
105
|
+
## Roadmap
|
|
106
|
+
|
|
107
|
+
- v0.3.0 — SQL query tracking (SQLAlchemy, DB-API 2.0)
|
|
108
|
+
- v0.4.0 — CPU and memory profiling panel
|
|
109
|
+
- v0.5.0 — Log capture and headers panel
|
|
110
|
+
- v0.6.0 — Improved history/search UI
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# FlowSurgeon
|
|
2
|
+
|
|
3
|
+
Framework-agnostic profiling middleware for Python — works with **Flask** (WSGI) and **FastAPI / Starlette** (ASGI) out of the box.
|
|
4
|
+
|
|
5
|
+
FlowSurgeon injects a lightweight debug panel into every HTML response and stores request history in a local SQLite database, giving you timing, headers, and status codes without touching your application code.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Zero required dependencies — pure stdlib
|
|
10
|
+
- WSGI middleware (`FlowSurgeonWSGI`) for Flask, Django, and any WSGI app
|
|
11
|
+
- ASGI middleware (`FlowSurgeonASGI`) for FastAPI, Starlette, and any ASGI app
|
|
12
|
+
- `FlowSurgeon()` factory — auto-detects WSGI vs ASGI
|
|
13
|
+
- Inline debug panel injected into HTML responses
|
|
14
|
+
- Built-in history UI at `/__flowsurgeon__/`
|
|
15
|
+
- SQLite persistence with auto-pruning
|
|
16
|
+
- Sensitive header redaction (`Authorization`, `Cookie`)
|
|
17
|
+
- `FLOWSURGEON_ENABLED` environment variable kill switch
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install flowsurgeon
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
### FastAPI (ASGI)
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from fastapi import FastAPI
|
|
31
|
+
from flowsurgeon import FlowSurgeon, Config
|
|
32
|
+
|
|
33
|
+
_app = FastAPI()
|
|
34
|
+
app = FlowSurgeon(_app, config=Config(enabled=True))
|
|
35
|
+
|
|
36
|
+
@_app.get("/")
|
|
37
|
+
async def index():
|
|
38
|
+
return {"hello": "world"}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Run with uvicorn:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uvicorn myapp:app
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Flask (WSGI)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from flask import Flask
|
|
51
|
+
from flowsurgeon import FlowSurgeon, Config
|
|
52
|
+
|
|
53
|
+
flask_app = Flask(__name__)
|
|
54
|
+
flask_app.wsgi_app = FlowSurgeon(
|
|
55
|
+
flask_app.wsgi_app,
|
|
56
|
+
config=Config(enabled=True),
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from flowsurgeon import Config
|
|
64
|
+
|
|
65
|
+
Config(
|
|
66
|
+
enabled=True, # default: False (or FLOWSURGEON_ENABLED=1)
|
|
67
|
+
allowed_hosts=["127.0.0.1", "::1"], # hosts that see the panel
|
|
68
|
+
db_path="flowsurgeon.db", # SQLite file path
|
|
69
|
+
max_stored_requests=1000, # auto-prune threshold
|
|
70
|
+
debug_route="/__flowsurgeon__", # history UI prefix
|
|
71
|
+
strip_sensitive_headers=["authorization", "cookie", "set-cookie"],
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Debug UI
|
|
76
|
+
|
|
77
|
+
| Route | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `/__flowsurgeon__/` | Paginated request history |
|
|
80
|
+
| `/__flowsurgeon__/{id}` | Full detail for one request |
|
|
81
|
+
|
|
82
|
+
## Roadmap
|
|
83
|
+
|
|
84
|
+
- v0.3.0 — SQL query tracking (SQLAlchemy, DB-API 2.0)
|
|
85
|
+
- v0.4.0 — CPU and memory profiling panel
|
|
86
|
+
- v0.5.0 — Log capture and headers panel
|
|
87
|
+
- v0.6.0 — Improved history/search UI
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "flowsurgeon"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "FlowSurgeon — framework-agnostic profiling middleware for Python (WSGI & ASGI)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [{ name = "Samandar-Komilov", email = "voidpointer07@gmail.com" }]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = []
|
|
10
|
+
keywords = ["wsgi", "asgi", "middleware", "profiling", "debugging", "fastapi", "flask", "starlette"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware",
|
|
19
|
+
"Topic :: Software Development :: Debuggers",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/Samandar-Komilov/flowsurgeon"
|
|
25
|
+
Repository = "https://github.com/Samandar-Komilov/flowsurgeon"
|
|
26
|
+
Issues = "https://github.com/Samandar-Komilov/flowsurgeon/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.9.17,<0.10.0"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
line-length = 100
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=9.0.2",
|
|
42
|
+
"pytest-asyncio>=1.3.0",
|
|
43
|
+
"pytest-cov>=7.0.0",
|
|
44
|
+
]
|
|
45
|
+
examples = [
|
|
46
|
+
"fastapi>=0.135.1",
|
|
47
|
+
"flask>=3.1.3",
|
|
48
|
+
"uvicorn>=0.41.0",
|
|
49
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""FlowSurgeon — framework-agnostic profiling middleware for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
from flowsurgeon.core.config import Config
|
|
8
|
+
from flowsurgeon.core.records import RequestRecord
|
|
9
|
+
from flowsurgeon.middleware.asgi import FlowSurgeonASGI
|
|
10
|
+
from flowsurgeon.middleware.wsgi import FlowSurgeonWSGI
|
|
11
|
+
from flowsurgeon.storage.async_sqlite import AsyncSQLiteBackend
|
|
12
|
+
from flowsurgeon.storage.base import StorageBackend
|
|
13
|
+
from flowsurgeon.storage.sqlite import SQLiteBackend
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def FlowSurgeon(app, *, config: Config | None = None, storage=None):
|
|
17
|
+
"""Auto-detect WSGI vs ASGI and wrap *app* with the appropriate middleware.
|
|
18
|
+
|
|
19
|
+
Detection is based on whether ``app.__call__`` is a coroutine function.
|
|
20
|
+
FastAPI / Starlette apps are detected as ASGI; plain WSGI callables
|
|
21
|
+
(Flask, Django, etc.) are wrapped with :class:`FlowSurgeonWSGI`.
|
|
22
|
+
"""
|
|
23
|
+
if inspect.iscoroutinefunction(app) or inspect.iscoroutinefunction(getattr(app, "__call__", None)):
|
|
24
|
+
return FlowSurgeonASGI(app, config=config, storage=storage)
|
|
25
|
+
return FlowSurgeonWSGI(app, config=config, storage=storage)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AsyncSQLiteBackend",
|
|
30
|
+
"Config",
|
|
31
|
+
"FlowSurgeon",
|
|
32
|
+
"FlowSurgeonASGI",
|
|
33
|
+
"FlowSurgeonWSGI",
|
|
34
|
+
"RequestRecord",
|
|
35
|
+
"SQLiteBackend",
|
|
36
|
+
"StorageBackend",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Config:
|
|
9
|
+
"""FlowSurgeon configuration.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
enabled:
|
|
14
|
+
Master switch. Defaults to False; can be overridden by the
|
|
15
|
+
FLOWSURGEON_ENABLED environment variable.
|
|
16
|
+
allowed_hosts:
|
|
17
|
+
Only serve the debug panel to requests from these hosts/IPs.
|
|
18
|
+
Defaults to localhost addresses only.
|
|
19
|
+
db_path:
|
|
20
|
+
Path to the SQLite database file used for persistence.
|
|
21
|
+
max_stored_requests:
|
|
22
|
+
Maximum number of request records to keep in the database.
|
|
23
|
+
Older records are pruned automatically.
|
|
24
|
+
debug_route:
|
|
25
|
+
URL prefix for the built-in debug UI.
|
|
26
|
+
strip_sensitive_headers:
|
|
27
|
+
Header names whose values will be redacted before storage.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
enabled: bool = field(default_factory=lambda: _env_bool("FLOWSURGEON_ENABLED", False))
|
|
31
|
+
allowed_hosts: list[str] = field(default_factory=lambda: ["127.0.0.1", "::1", "localhost"])
|
|
32
|
+
db_path: str = "flowsurgeon.db"
|
|
33
|
+
max_stored_requests: int = 1000
|
|
34
|
+
debug_route: str = "/__flowsurgeon__"
|
|
35
|
+
strip_sensitive_headers: list[str] = field(
|
|
36
|
+
default_factory=lambda: ["authorization", "cookie", "set-cookie"]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
41
|
+
val = os.environ.get(name)
|
|
42
|
+
if val is None:
|
|
43
|
+
return default
|
|
44
|
+
return val.lower() in ("1", "true", "yes")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RequestRecord:
|
|
10
|
+
"""Captured data for a single HTTP request."""
|
|
11
|
+
|
|
12
|
+
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
13
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
14
|
+
method: str = ""
|
|
15
|
+
path: str = ""
|
|
16
|
+
query_string: str = ""
|
|
17
|
+
status_code: int = 0
|
|
18
|
+
duration_ms: float = 0.0
|
|
19
|
+
request_headers: dict[str, str] = field(default_factory=dict)
|
|
20
|
+
response_headers: dict[str, str] = field(default_factory=dict)
|
|
21
|
+
client_host: str = ""
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from flowsurgeon.core.config import Config
|
|
7
|
+
from flowsurgeon.core.records import RequestRecord
|
|
8
|
+
from flowsurgeon.storage.async_sqlite import AsyncSQLiteBackend
|
|
9
|
+
from flowsurgeon.ui.panel import render_detail_page, render_history_page, render_panel
|
|
10
|
+
|
|
11
|
+
# ASGI type aliases
|
|
12
|
+
Scope = dict
|
|
13
|
+
Receive = Callable[[], Awaitable[dict]]
|
|
14
|
+
Send = Callable[[dict], Awaitable[None]]
|
|
15
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
16
|
+
|
|
17
|
+
_HTML_CONTENT_TYPES = ("text/html",)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FlowSurgeonASGI:
|
|
21
|
+
"""ASGI middleware that profiles requests and injects a debug panel.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
app:
|
|
26
|
+
The wrapped ASGI application.
|
|
27
|
+
config:
|
|
28
|
+
FlowSurgeon configuration. Defaults to :class:`Config` with all
|
|
29
|
+
defaults applied.
|
|
30
|
+
storage:
|
|
31
|
+
Async storage backend. Defaults to :class:`AsyncSQLiteBackend`
|
|
32
|
+
pointed at ``config.db_path``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
app: ASGIApp,
|
|
38
|
+
config: Config | None = None,
|
|
39
|
+
storage: AsyncSQLiteBackend | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._app = app
|
|
42
|
+
self._config = config or Config()
|
|
43
|
+
self._storage: AsyncSQLiteBackend = storage or AsyncSQLiteBackend(self._config.db_path)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# ASGI entry point
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
50
|
+
if scope["type"] == "lifespan":
|
|
51
|
+
await self._handle_lifespan(scope, receive, send)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if scope["type"] != "http":
|
|
55
|
+
await self._app(scope, receive, send)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if not self._config.enabled:
|
|
59
|
+
await self._app(scope, receive, send)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
client_host = _client_host(scope)
|
|
63
|
+
if not self._is_allowed(client_host):
|
|
64
|
+
await self._app(scope, receive, send)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
path = scope.get("path", "/")
|
|
68
|
+
debug_route = self._config.debug_route
|
|
69
|
+
|
|
70
|
+
if path == debug_route or path == debug_route + "/":
|
|
71
|
+
await self._serve_history(send)
|
|
72
|
+
return
|
|
73
|
+
if path.startswith(debug_route + "/"):
|
|
74
|
+
request_id = path[len(debug_route) + 1 :]
|
|
75
|
+
await self._serve_detail(request_id, send)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
await self._profile_request(scope, receive, send, client_host, path)
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Lifespan
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def _handle_lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
85
|
+
async def receive_wrapper() -> dict:
|
|
86
|
+
message = await receive()
|
|
87
|
+
if message["type"] == "lifespan.startup":
|
|
88
|
+
await self._storage.start()
|
|
89
|
+
return message
|
|
90
|
+
|
|
91
|
+
async def send_wrapper(message: dict) -> None:
|
|
92
|
+
if message["type"] == "lifespan.shutdown.complete":
|
|
93
|
+
await self._storage.close()
|
|
94
|
+
await send(message)
|
|
95
|
+
|
|
96
|
+
await self._app(scope, receive_wrapper, send_wrapper)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Debug UI routes
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
async def _serve_history(self, send: Send) -> None:
|
|
103
|
+
records = await self._storage.list_recent(limit=100)
|
|
104
|
+
body = render_history_page(records, self._config.debug_route).encode()
|
|
105
|
+
await send(
|
|
106
|
+
{
|
|
107
|
+
"type": "http.response.start",
|
|
108
|
+
"status": 200,
|
|
109
|
+
"headers": [
|
|
110
|
+
(b"content-type", b"text/html; charset=utf-8"),
|
|
111
|
+
(b"content-length", str(len(body)).encode()),
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
await send({"type": "http.response.body", "body": body})
|
|
116
|
+
|
|
117
|
+
async def _serve_detail(self, request_id: str, send: Send) -> None:
|
|
118
|
+
record = await self._storage.get(request_id)
|
|
119
|
+
if record is None:
|
|
120
|
+
body = b"<h1>Not found</h1>"
|
|
121
|
+
await send(
|
|
122
|
+
{
|
|
123
|
+
"type": "http.response.start",
|
|
124
|
+
"status": 404,
|
|
125
|
+
"headers": [
|
|
126
|
+
(b"content-type", b"text/html; charset=utf-8"),
|
|
127
|
+
(b"content-length", str(len(body)).encode()),
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
await send({"type": "http.response.body", "body": body})
|
|
132
|
+
return
|
|
133
|
+
body = render_detail_page(record, self._config.debug_route).encode()
|
|
134
|
+
await send(
|
|
135
|
+
{
|
|
136
|
+
"type": "http.response.start",
|
|
137
|
+
"status": 200,
|
|
138
|
+
"headers": [
|
|
139
|
+
(b"content-type", b"text/html; charset=utf-8"),
|
|
140
|
+
(b"content-length", str(len(body)).encode()),
|
|
141
|
+
],
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
await send({"type": "http.response.body", "body": body})
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Request profiling
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
async def _profile_request(
|
|
151
|
+
self,
|
|
152
|
+
scope: Scope,
|
|
153
|
+
receive: Receive,
|
|
154
|
+
send: Send,
|
|
155
|
+
client_host: str,
|
|
156
|
+
path: str,
|
|
157
|
+
) -> None:
|
|
158
|
+
raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
|
|
159
|
+
qs = scope.get("query_string", b"").decode("latin-1")
|
|
160
|
+
|
|
161
|
+
record = RequestRecord(
|
|
162
|
+
method=scope.get("method", "GET"),
|
|
163
|
+
path=path,
|
|
164
|
+
query_string=qs,
|
|
165
|
+
client_host=client_host,
|
|
166
|
+
request_headers=_asgi_headers_to_dict(
|
|
167
|
+
raw_headers, self._config.strip_sensitive_headers
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Capture response start + body chunks, detect if HTML to decide strategy
|
|
172
|
+
start_message: dict | None = None
|
|
173
|
+
is_html = False
|
|
174
|
+
forwarding = False # True when we're in pass-through mode (non-HTML)
|
|
175
|
+
body_chunks: list[bytes] = []
|
|
176
|
+
|
|
177
|
+
async def capturing_send(message: dict) -> None:
|
|
178
|
+
nonlocal start_message, is_html, forwarding
|
|
179
|
+
|
|
180
|
+
if message["type"] == "http.response.start":
|
|
181
|
+
start_message = message
|
|
182
|
+
ct = _get_asgi_header(message.get("headers", []), b"content-type") or b""
|
|
183
|
+
is_html = any(h.encode() in ct for h in _HTML_CONTENT_TYPES)
|
|
184
|
+
if not is_html:
|
|
185
|
+
# Non-HTML: forward start immediately and switch to pass-through
|
|
186
|
+
await send(message)
|
|
187
|
+
forwarding = True
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if message["type"] == "http.response.body":
|
|
191
|
+
if forwarding:
|
|
192
|
+
await send(message)
|
|
193
|
+
else:
|
|
194
|
+
body_chunks.append(message.get("body", b""))
|
|
195
|
+
|
|
196
|
+
t0 = time.perf_counter()
|
|
197
|
+
await self._app(scope, receive, capturing_send)
|
|
198
|
+
duration_ms = (time.perf_counter() - t0) * 1000
|
|
199
|
+
|
|
200
|
+
if start_message is None:
|
|
201
|
+
return # degenerate app
|
|
202
|
+
|
|
203
|
+
status_code: int = start_message["status"]
|
|
204
|
+
resp_raw_headers: list[tuple[bytes, bytes]] = start_message.get("headers", [])
|
|
205
|
+
|
|
206
|
+
record.status_code = status_code
|
|
207
|
+
record.duration_ms = duration_ms
|
|
208
|
+
record.response_headers = _asgi_headers_to_dict(
|
|
209
|
+
resp_raw_headers, self._config.strip_sensitive_headers
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Persist asynchronously (non-blocking)
|
|
213
|
+
await self._storage.enqueue(record, self._config.max_stored_requests)
|
|
214
|
+
|
|
215
|
+
if forwarding:
|
|
216
|
+
# Already forwarded — nothing more to do
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# HTML path: inject panel, then forward full response
|
|
220
|
+
body = b"".join(body_chunks)
|
|
221
|
+
panel_html = render_panel(record, self._config.debug_route).encode()
|
|
222
|
+
if b"</body>" in body:
|
|
223
|
+
body = body.replace(b"</body>", panel_html + b"</body>", 1)
|
|
224
|
+
else:
|
|
225
|
+
body += panel_html
|
|
226
|
+
|
|
227
|
+
# Rebuild headers with updated Content-Length
|
|
228
|
+
updated_headers = [(k, v) for k, v in resp_raw_headers if k.lower() != b"content-length"]
|
|
229
|
+
updated_headers.append((b"content-length", str(len(body)).encode()))
|
|
230
|
+
|
|
231
|
+
await send(
|
|
232
|
+
{
|
|
233
|
+
"type": "http.response.start",
|
|
234
|
+
"status": status_code,
|
|
235
|
+
"headers": updated_headers,
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
await send({"type": "http.response.body", "body": body})
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
# Helpers
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def _is_allowed(self, host: str) -> bool:
|
|
245
|
+
return host in self._config.allowed_hosts
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Module-level helpers
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _client_host(scope: Scope) -> str:
|
|
254
|
+
headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
|
|
255
|
+
for name, value in headers:
|
|
256
|
+
if name.lower() == b"x-forwarded-for":
|
|
257
|
+
return value.decode("latin-1").split(",")[0].strip()
|
|
258
|
+
client = scope.get("client")
|
|
259
|
+
return client[0] if client else ""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _asgi_headers_to_dict(headers: list[tuple[bytes, bytes]], strip: list[str]) -> dict[str, str]:
|
|
263
|
+
result: dict[str, str] = {}
|
|
264
|
+
for name_bytes, value_bytes in headers:
|
|
265
|
+
name = name_bytes.decode("latin-1").lower()
|
|
266
|
+
value = value_bytes.decode("latin-1")
|
|
267
|
+
result[name] = "[redacted]" if name in strip else value
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _get_asgi_header(headers: list[tuple[bytes, bytes]], name: bytes) -> bytes | None:
|
|
272
|
+
name_lower = name.lower()
|
|
273
|
+
for k, v in headers:
|
|
274
|
+
if k.lower() == name_lower:
|
|
275
|
+
return v
|
|
276
|
+
return None
|