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.

@@ -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
@@ -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