fastapi-radar 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of fastapi-radar might be problematic. Click here for more details.
- fastapi_radar/__init__.py +6 -0
- fastapi_radar/api.py +310 -0
- fastapi_radar/capture.py +105 -0
- fastapi_radar/dashboard/dist/assets/index-BK3IXW8U.css +1 -0
- fastapi_radar/dashboard/dist/assets/index-DS-t-RQ1.js +268 -0
- fastapi_radar/dashboard/dist/index.html +13 -0
- fastapi_radar/middleware.py +142 -0
- fastapi_radar/models.py +66 -0
- fastapi_radar/radar.py +295 -0
- fastapi_radar/utils.py +59 -0
- fastapi_radar-0.1.0.dist-info/METADATA +165 -0
- fastapi_radar-0.1.0.dist-info/RECORD +17 -0
- fastapi_radar-0.1.0.dist-info/WHEEL +5 -0
- fastapi_radar-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastapi_radar-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_radar.py +66 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>FastAPI Radar - Debugging Dashboard</title>
|
|
7
|
+
<script type="module" crossorigin src="/__radar/assets/index-DS-t-RQ1.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/__radar/assets/index-BK3IXW8U.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Middleware for capturing HTTP requests and responses."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import traceback
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Optional, Callable
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import Response, StreamingResponse
|
|
12
|
+
from .models import CapturedRequest, CapturedException
|
|
13
|
+
from .utils import serialize_headers, get_client_ip, truncate_body
|
|
14
|
+
|
|
15
|
+
request_context: ContextVar[Optional[str]] = ContextVar("request_id", default=None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RadarMiddleware(BaseHTTPMiddleware):
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
app,
|
|
22
|
+
get_session: Callable,
|
|
23
|
+
exclude_paths: list[str] = None,
|
|
24
|
+
max_body_size: int = 10000,
|
|
25
|
+
capture_response_body: bool = True,
|
|
26
|
+
):
|
|
27
|
+
super().__init__(app)
|
|
28
|
+
self.get_session = get_session
|
|
29
|
+
self.exclude_paths = exclude_paths or []
|
|
30
|
+
self.max_body_size = max_body_size
|
|
31
|
+
self.capture_response_body = capture_response_body
|
|
32
|
+
|
|
33
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
34
|
+
if self._should_skip(request):
|
|
35
|
+
return await call_next(request)
|
|
36
|
+
|
|
37
|
+
request_id = str(uuid.uuid4())
|
|
38
|
+
request_context.set(request_id)
|
|
39
|
+
start_time = time.time()
|
|
40
|
+
|
|
41
|
+
request_body = await self._get_request_body(request)
|
|
42
|
+
|
|
43
|
+
captured_request = CapturedRequest(
|
|
44
|
+
request_id=request_id,
|
|
45
|
+
method=request.method,
|
|
46
|
+
url=str(request.url),
|
|
47
|
+
path=request.url.path,
|
|
48
|
+
query_params=dict(request.query_params) if request.query_params else None,
|
|
49
|
+
headers=serialize_headers(request.headers),
|
|
50
|
+
body=(
|
|
51
|
+
truncate_body(request_body, self.max_body_size)
|
|
52
|
+
if request_body
|
|
53
|
+
else None
|
|
54
|
+
),
|
|
55
|
+
client_ip=get_client_ip(request),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
response = None
|
|
59
|
+
exception_occurred = False
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
response = await call_next(request)
|
|
63
|
+
|
|
64
|
+
captured_request.status_code = response.status_code
|
|
65
|
+
captured_request.response_headers = serialize_headers(response.headers)
|
|
66
|
+
|
|
67
|
+
if self.capture_response_body and not isinstance(
|
|
68
|
+
response, StreamingResponse
|
|
69
|
+
):
|
|
70
|
+
response_body = b""
|
|
71
|
+
async for chunk in response.body_iterator:
|
|
72
|
+
response_body += chunk
|
|
73
|
+
|
|
74
|
+
captured_request.response_body = truncate_body(
|
|
75
|
+
response_body.decode("utf-8", errors="ignore"), self.max_body_size
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
response = Response(
|
|
79
|
+
content=response_body,
|
|
80
|
+
status_code=response.status_code,
|
|
81
|
+
headers=dict(response.headers),
|
|
82
|
+
media_type=response.media_type,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
except Exception as e:
|
|
86
|
+
exception_occurred = True
|
|
87
|
+
self._capture_exception(request_id, e)
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
finally:
|
|
91
|
+
duration = (time.time() - start_time) * 1000
|
|
92
|
+
captured_request.duration_ms = duration
|
|
93
|
+
|
|
94
|
+
with self.get_session() as session:
|
|
95
|
+
session.add(captured_request)
|
|
96
|
+
if exception_occurred:
|
|
97
|
+
exception_data = self._get_exception_data(request_id)
|
|
98
|
+
if exception_data:
|
|
99
|
+
session.add(exception_data)
|
|
100
|
+
session.commit()
|
|
101
|
+
|
|
102
|
+
request_context.set(None)
|
|
103
|
+
|
|
104
|
+
return response
|
|
105
|
+
|
|
106
|
+
def _should_skip(self, request: Request) -> bool:
|
|
107
|
+
path = request.url.path
|
|
108
|
+
for exclude_path in self.exclude_paths:
|
|
109
|
+
if path.startswith(exclude_path):
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
async def _get_request_body(self, request: Request) -> Optional[str]:
|
|
114
|
+
try:
|
|
115
|
+
body = await request.body()
|
|
116
|
+
if body:
|
|
117
|
+
content_type = request.headers.get("content-type", "")
|
|
118
|
+
if "application/json" in content_type:
|
|
119
|
+
try:
|
|
120
|
+
return json.dumps(json.loads(body), indent=2)
|
|
121
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
122
|
+
pass
|
|
123
|
+
return body.decode("utf-8", errors="ignore")
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def _capture_exception(self, request_id: str, exception: Exception) -> None:
|
|
129
|
+
self._exception_cache = {
|
|
130
|
+
"request_id": request_id,
|
|
131
|
+
"exception_type": type(exception).__name__,
|
|
132
|
+
"exception_value": str(exception),
|
|
133
|
+
"traceback": traceback.format_exc(),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def _get_exception_data(self, request_id: str) -> Optional[CapturedException]:
|
|
137
|
+
if (
|
|
138
|
+
hasattr(self, "_exception_cache")
|
|
139
|
+
and self._exception_cache.get("request_id") == request_id
|
|
140
|
+
):
|
|
141
|
+
return CapturedException(**self._exception_cache)
|
|
142
|
+
return None
|
fastapi_radar/models.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Storage models for FastAPI Radar."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from sqlalchemy import Column, String, Integer, Float, Text, DateTime, ForeignKey, JSON
|
|
5
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
6
|
+
from sqlalchemy.orm import relationship
|
|
7
|
+
|
|
8
|
+
Base = declarative_base()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CapturedRequest(Base):
|
|
12
|
+
__tablename__ = "radar_requests"
|
|
13
|
+
|
|
14
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
15
|
+
request_id = Column(String(36), unique=True, index=True, nullable=False)
|
|
16
|
+
method = Column(String(10), nullable=False)
|
|
17
|
+
url = Column(String(500), nullable=False)
|
|
18
|
+
path = Column(String(500), nullable=False)
|
|
19
|
+
query_params = Column(JSON)
|
|
20
|
+
headers = Column(JSON)
|
|
21
|
+
body = Column(Text)
|
|
22
|
+
status_code = Column(Integer)
|
|
23
|
+
response_body = Column(Text)
|
|
24
|
+
response_headers = Column(JSON)
|
|
25
|
+
duration_ms = Column(Float)
|
|
26
|
+
client_ip = Column(String(50))
|
|
27
|
+
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
28
|
+
|
|
29
|
+
queries = relationship(
|
|
30
|
+
"CapturedQuery", back_populates="request", cascade="all, delete-orphan"
|
|
31
|
+
)
|
|
32
|
+
exceptions = relationship(
|
|
33
|
+
"CapturedException", back_populates="request", cascade="all, delete-orphan"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CapturedQuery(Base):
|
|
38
|
+
__tablename__ = "radar_queries"
|
|
39
|
+
|
|
40
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
41
|
+
request_id = Column(
|
|
42
|
+
String(36), ForeignKey("radar_requests.request_id", ondelete="CASCADE")
|
|
43
|
+
)
|
|
44
|
+
sql = Column(Text, nullable=False)
|
|
45
|
+
parameters = Column(JSON)
|
|
46
|
+
duration_ms = Column(Float)
|
|
47
|
+
rows_affected = Column(Integer)
|
|
48
|
+
connection_name = Column(String(100))
|
|
49
|
+
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
50
|
+
|
|
51
|
+
request = relationship("CapturedRequest", back_populates="queries")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CapturedException(Base):
|
|
55
|
+
__tablename__ = "radar_exceptions"
|
|
56
|
+
|
|
57
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
58
|
+
request_id = Column(
|
|
59
|
+
String(36), ForeignKey("radar_requests.request_id", ondelete="CASCADE")
|
|
60
|
+
)
|
|
61
|
+
exception_type = Column(String(100), nullable=False)
|
|
62
|
+
exception_value = Column(Text)
|
|
63
|
+
traceback = Column(Text, nullable=False)
|
|
64
|
+
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
65
|
+
|
|
66
|
+
request = relationship("CapturedRequest", back_populates="exceptions")
|
fastapi_radar/radar.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Main Radar class for FastAPI Radar."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from sqlalchemy import create_engine
|
|
8
|
+
from sqlalchemy.engine import Engine
|
|
9
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
10
|
+
|
|
11
|
+
from .models import Base
|
|
12
|
+
from .middleware import RadarMiddleware
|
|
13
|
+
from .capture import QueryCapture
|
|
14
|
+
from .api import create_api_router
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Radar:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
app: FastAPI,
|
|
21
|
+
db_engine: Engine,
|
|
22
|
+
storage_engine: Optional[Engine] = None,
|
|
23
|
+
dashboard_path: str = "/__radar",
|
|
24
|
+
max_requests: int = 1000,
|
|
25
|
+
retention_hours: int = 24,
|
|
26
|
+
slow_query_threshold: int = 100,
|
|
27
|
+
capture_sql_bindings: bool = True,
|
|
28
|
+
exclude_paths: Optional[List[str]] = None,
|
|
29
|
+
theme: str = "auto",
|
|
30
|
+
):
|
|
31
|
+
self.app = app
|
|
32
|
+
self.db_engine = db_engine
|
|
33
|
+
self.dashboard_path = dashboard_path
|
|
34
|
+
self.max_requests = max_requests
|
|
35
|
+
self.retention_hours = retention_hours
|
|
36
|
+
self.slow_query_threshold = slow_query_threshold
|
|
37
|
+
self.capture_sql_bindings = capture_sql_bindings
|
|
38
|
+
self.exclude_paths = exclude_paths or []
|
|
39
|
+
self.theme = theme
|
|
40
|
+
|
|
41
|
+
# Add dashboard path to excluded paths
|
|
42
|
+
self.exclude_paths.append(dashboard_path)
|
|
43
|
+
self.exclude_paths.append("/api/radar")
|
|
44
|
+
|
|
45
|
+
# Setup storage engine (default to SQLite)
|
|
46
|
+
if storage_engine:
|
|
47
|
+
self.storage_engine = storage_engine
|
|
48
|
+
else:
|
|
49
|
+
radar_db_path = Path.cwd() / "radar.db"
|
|
50
|
+
self.storage_engine = create_engine(
|
|
51
|
+
f"sqlite:///{radar_db_path}", connect_args={"check_same_thread": False}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Create session maker for storage
|
|
55
|
+
self.SessionLocal = sessionmaker(
|
|
56
|
+
autocommit=False, autoflush=False, bind=self.storage_engine
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Initialize components
|
|
60
|
+
self._setup_middleware()
|
|
61
|
+
self._setup_query_capture()
|
|
62
|
+
self._setup_api()
|
|
63
|
+
self._setup_dashboard()
|
|
64
|
+
|
|
65
|
+
@contextmanager
|
|
66
|
+
def get_session(self) -> Session:
|
|
67
|
+
"""Get a database session for radar storage."""
|
|
68
|
+
session = self.SessionLocal()
|
|
69
|
+
try:
|
|
70
|
+
yield session
|
|
71
|
+
finally:
|
|
72
|
+
session.close()
|
|
73
|
+
|
|
74
|
+
def _setup_middleware(self) -> None:
|
|
75
|
+
"""Add request capture middleware."""
|
|
76
|
+
self.app.add_middleware(
|
|
77
|
+
RadarMiddleware,
|
|
78
|
+
get_session=self.get_session,
|
|
79
|
+
exclude_paths=self.exclude_paths,
|
|
80
|
+
max_body_size=10000,
|
|
81
|
+
capture_response_body=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _setup_query_capture(self) -> None:
|
|
85
|
+
"""Setup SQLAlchemy query capture."""
|
|
86
|
+
self.query_capture = QueryCapture(
|
|
87
|
+
get_session=self.get_session,
|
|
88
|
+
capture_bindings=self.capture_sql_bindings,
|
|
89
|
+
slow_query_threshold=self.slow_query_threshold,
|
|
90
|
+
)
|
|
91
|
+
self.query_capture.register(self.db_engine)
|
|
92
|
+
|
|
93
|
+
def _setup_api(self) -> None:
|
|
94
|
+
"""Mount API endpoints."""
|
|
95
|
+
api_router = create_api_router(self.get_session)
|
|
96
|
+
self.app.include_router(api_router)
|
|
97
|
+
|
|
98
|
+
def _setup_dashboard(self) -> None:
|
|
99
|
+
"""Mount dashboard static files."""
|
|
100
|
+
from fastapi.responses import FileResponse
|
|
101
|
+
from fastapi import Request
|
|
102
|
+
|
|
103
|
+
dashboard_dir = Path(__file__).parent / "dashboard" / "dist"
|
|
104
|
+
|
|
105
|
+
if not dashboard_dir.exists():
|
|
106
|
+
# Create placeholder dashboard for development
|
|
107
|
+
dashboard_dir.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
self._create_placeholder_dashboard(dashboard_dir)
|
|
109
|
+
print("\n" + "=" * 60)
|
|
110
|
+
print("⚠️ FastAPI Radar: Dashboard not built")
|
|
111
|
+
print("=" * 60)
|
|
112
|
+
print("To use the full dashboard, build it with:")
|
|
113
|
+
print(" cd fastapi_radar/dashboard")
|
|
114
|
+
print(" npm install")
|
|
115
|
+
print(" npm run build")
|
|
116
|
+
print("=" * 60 + "\n")
|
|
117
|
+
|
|
118
|
+
# Add a catch-all route for the dashboard SPA
|
|
119
|
+
# This ensures all sub-routes under /__radar serve the index.html
|
|
120
|
+
@self.app.get(f"{self.dashboard_path}")
|
|
121
|
+
@self.app.get(f"{self.dashboard_path}/{{full_path:path}}")
|
|
122
|
+
async def serve_dashboard(request: Request, full_path: str = ""):
|
|
123
|
+
# Check if it's a request for a static asset
|
|
124
|
+
if full_path and any(
|
|
125
|
+
full_path.endswith(ext)
|
|
126
|
+
for ext in [
|
|
127
|
+
".js",
|
|
128
|
+
".css",
|
|
129
|
+
".ico",
|
|
130
|
+
".png",
|
|
131
|
+
".jpg",
|
|
132
|
+
".svg",
|
|
133
|
+
".woff",
|
|
134
|
+
".woff2",
|
|
135
|
+
".ttf",
|
|
136
|
+
]
|
|
137
|
+
):
|
|
138
|
+
file_path = dashboard_dir / full_path
|
|
139
|
+
if file_path.exists():
|
|
140
|
+
return FileResponse(file_path)
|
|
141
|
+
|
|
142
|
+
# For all other routes, serve index.html (SPA behavior)
|
|
143
|
+
index_path = dashboard_dir / "index.html"
|
|
144
|
+
if index_path.exists():
|
|
145
|
+
return FileResponse(index_path)
|
|
146
|
+
else:
|
|
147
|
+
return {"error": "Dashboard not found. Please build the dashboard."}
|
|
148
|
+
|
|
149
|
+
def _create_placeholder_dashboard(self, dashboard_dir: Path) -> None:
|
|
150
|
+
"""Create a placeholder dashboard for development."""
|
|
151
|
+
index_html = dashboard_dir / "index.html"
|
|
152
|
+
index_html.write_text(
|
|
153
|
+
"""
|
|
154
|
+
<!DOCTYPE html>
|
|
155
|
+
<html lang="en" data-theme="{theme}">
|
|
156
|
+
<head>
|
|
157
|
+
<meta charset="UTF-8">
|
|
158
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
159
|
+
<title>FastAPI Radar</title>
|
|
160
|
+
<style>
|
|
161
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
162
|
+
body {{
|
|
163
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial;
|
|
164
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
165
|
+
color: white;
|
|
166
|
+
min-height: 100vh;
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
justify-content: center;
|
|
170
|
+
padding: 20px;
|
|
171
|
+
}}
|
|
172
|
+
.container {{
|
|
173
|
+
text-align: center;
|
|
174
|
+
max-width: 600px;
|
|
175
|
+
}}
|
|
176
|
+
h1 {{
|
|
177
|
+
font-size: 3rem;
|
|
178
|
+
margin-bottom: 1rem;
|
|
179
|
+
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
|
180
|
+
}}
|
|
181
|
+
p {{
|
|
182
|
+
font-size: 1.2rem;
|
|
183
|
+
margin-bottom: 2rem;
|
|
184
|
+
opacity: 0.95;
|
|
185
|
+
}}
|
|
186
|
+
.stats {{
|
|
187
|
+
display: grid;
|
|
188
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
189
|
+
gap: 20px;
|
|
190
|
+
margin-top: 3rem;
|
|
191
|
+
}}
|
|
192
|
+
.stat {{
|
|
193
|
+
background: rgba(255, 255, 255, 0.1);
|
|
194
|
+
backdrop-filter: blur(10px);
|
|
195
|
+
padding: 20px;
|
|
196
|
+
border-radius: 10px;
|
|
197
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
198
|
+
}}
|
|
199
|
+
.stat-value {{
|
|
200
|
+
font-size: 2rem;
|
|
201
|
+
font-weight: bold;
|
|
202
|
+
margin-bottom: 5px;
|
|
203
|
+
}}
|
|
204
|
+
.stat-label {{
|
|
205
|
+
font-size: 0.9rem;
|
|
206
|
+
opacity: 0.8;
|
|
207
|
+
}}
|
|
208
|
+
.loading {{ animation: pulse 2s infinite; }}
|
|
209
|
+
@keyframes pulse {{
|
|
210
|
+
0%, 100% {{ opacity: 1; }}
|
|
211
|
+
50% {{ opacity: 0.5; }}
|
|
212
|
+
}}
|
|
213
|
+
</style>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
<div class="container">
|
|
217
|
+
<h1>🚀 FastAPI Radar</h1>
|
|
218
|
+
<p>Real-time debugging dashboard loading...</p>
|
|
219
|
+
<div class="stats">
|
|
220
|
+
<div class="stat">
|
|
221
|
+
<div class="stat-value loading">--</div>
|
|
222
|
+
<div class="stat-label">Requests</div>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="stat">
|
|
225
|
+
<div class="stat-value loading">--</div>
|
|
226
|
+
<div class="stat-label">Queries</div>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="stat">
|
|
229
|
+
<div class="stat-value loading">--</div>
|
|
230
|
+
<div class="stat-label">Avg Response</div>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="stat">
|
|
233
|
+
<div class="stat-value loading">--</div>
|
|
234
|
+
<div class="stat-label">Exceptions</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
<script>
|
|
239
|
+
// Fetch stats from API
|
|
240
|
+
async function loadStats() {{
|
|
241
|
+
try {{
|
|
242
|
+
const response = await fetch('/api/radar/stats?hours=1');
|
|
243
|
+
const data = await response.json();
|
|
244
|
+
|
|
245
|
+
document.querySelectorAll('.stat-value')[0].textContent = data.total_requests;
|
|
246
|
+
document.querySelectorAll('.stat-value')[1].textContent = data.total_queries;
|
|
247
|
+
document.querySelectorAll('.stat-value')[2].textContent =
|
|
248
|
+
data.avg_response_time ? `${{data.avg_response_time.toFixed(1)}}ms` : '--';
|
|
249
|
+
document.querySelectorAll('.stat-value')[3].textContent = data.total_exceptions;
|
|
250
|
+
|
|
251
|
+
document.querySelectorAll('.stat-value').forEach(el => {{
|
|
252
|
+
el.classList.remove('loading');
|
|
253
|
+
}});
|
|
254
|
+
}} catch (error) {{
|
|
255
|
+
console.error('Failed to load stats:', error);
|
|
256
|
+
}}
|
|
257
|
+
}}
|
|
258
|
+
|
|
259
|
+
// Load stats on page load
|
|
260
|
+
loadStats();
|
|
261
|
+
// Refresh stats every 5 seconds
|
|
262
|
+
setInterval(loadStats, 5000);
|
|
263
|
+
</script>
|
|
264
|
+
</body>
|
|
265
|
+
</html>
|
|
266
|
+
""".replace(
|
|
267
|
+
"{theme}", self.theme
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def create_tables(self) -> None:
|
|
272
|
+
"""Create radar storage tables."""
|
|
273
|
+
Base.metadata.create_all(bind=self.storage_engine)
|
|
274
|
+
|
|
275
|
+
def drop_tables(self) -> None:
|
|
276
|
+
"""Drop radar storage tables."""
|
|
277
|
+
Base.metadata.drop_all(bind=self.storage_engine)
|
|
278
|
+
|
|
279
|
+
def cleanup(self, older_than_hours: Optional[int] = None) -> None:
|
|
280
|
+
"""Clean up old captured data."""
|
|
281
|
+
from datetime import datetime, timedelta
|
|
282
|
+
from .models import CapturedRequest
|
|
283
|
+
|
|
284
|
+
with self.get_session() as session:
|
|
285
|
+
hours = older_than_hours or self.retention_hours
|
|
286
|
+
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
|
287
|
+
|
|
288
|
+
deleted = (
|
|
289
|
+
session.query(CapturedRequest)
|
|
290
|
+
.filter(CapturedRequest.created_at < cutoff)
|
|
291
|
+
.delete()
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
session.commit()
|
|
295
|
+
return deleted
|
fastapi_radar/utils.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Utility functions for FastAPI Radar."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.datastructures import Headers
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def serialize_headers(headers: Headers) -> Dict[str, str]:
|
|
9
|
+
"""Serialize headers to a dictionary, excluding sensitive data."""
|
|
10
|
+
sensitive_headers = {"authorization", "cookie", "x-api-key", "x-auth-token"}
|
|
11
|
+
result = {}
|
|
12
|
+
|
|
13
|
+
for key, value in headers.items():
|
|
14
|
+
if key.lower() in sensitive_headers:
|
|
15
|
+
result[key] = "***REDACTED***"
|
|
16
|
+
else:
|
|
17
|
+
result[key] = value
|
|
18
|
+
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_client_ip(request: Request) -> str:
|
|
23
|
+
"""Extract client IP from request."""
|
|
24
|
+
forwarded = request.headers.get("x-forwarded-for")
|
|
25
|
+
if forwarded:
|
|
26
|
+
return forwarded.split(",")[0].strip()
|
|
27
|
+
|
|
28
|
+
real_ip = request.headers.get("x-real-ip")
|
|
29
|
+
if real_ip:
|
|
30
|
+
return real_ip
|
|
31
|
+
|
|
32
|
+
if request.client:
|
|
33
|
+
return request.client.host
|
|
34
|
+
|
|
35
|
+
return "unknown"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def truncate_body(body: Optional[str], max_size: int) -> Optional[str]:
|
|
39
|
+
"""Truncate body content if it exceeds max size."""
|
|
40
|
+
if not body:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
if len(body) <= max_size:
|
|
44
|
+
return body
|
|
45
|
+
|
|
46
|
+
return body[:max_size] + f"... [truncated {len(body) - max_size} characters]"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_sql(sql: str, max_length: int = 5000) -> str:
|
|
50
|
+
"""Format SQL query for display."""
|
|
51
|
+
if not sql:
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
sql = sql.strip()
|
|
55
|
+
|
|
56
|
+
if len(sql) > max_length:
|
|
57
|
+
sql = sql[:max_length] + "... [truncated]"
|
|
58
|
+
|
|
59
|
+
return sql
|