fastapi-radar 0.1.7__py3-none-any.whl → 0.3.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 +3 -2
- fastapi_radar/api.py +217 -34
- fastapi_radar/background.py +120 -0
- fastapi_radar/capture.py +38 -10
- fastapi_radar/dashboard/dist/assets/index-8Om0PGu6.js +326 -0
- fastapi_radar/dashboard/dist/assets/index-D51YrvFG.css +1 -0
- fastapi_radar/dashboard/dist/assets/index-p3czTzXB.js +361 -0
- fastapi_radar/dashboard/dist/index.html +1 -1
- fastapi_radar/middleware.py +37 -16
- fastapi_radar/models.py +41 -8
- fastapi_radar/radar.py +98 -24
- fastapi_radar/tracing.py +6 -6
- fastapi_radar/utils.py +24 -0
- {fastapi_radar-0.1.7.dist-info → fastapi_radar-0.3.0.dist-info}/METADATA +43 -1
- fastapi_radar-0.3.0.dist-info/RECORD +21 -0
- tests/test_async_radar.py +56 -0
- fastapi_radar/dashboard/dist/assets/index-By5DXl8Z.js +0 -318
- fastapi_radar/dashboard/dist/assets/index-XlGcZj49.css +0 -1
- fastapi_radar-0.1.7.dist-info/RECORD +0 -18
- {fastapi_radar-0.1.7.dist-info → fastapi_radar-0.3.0.dist-info}/WHEEL +0 -0
- {fastapi_radar-0.1.7.dist-info → fastapi_radar-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {fastapi_radar-0.1.7.dist-info → fastapi_radar-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>FastAPI Radar - Debugging Dashboard</title>
|
|
7
|
-
<script type="module" crossorigin src="/__radar/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/__radar/assets/index-8Om0PGu6.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/__radar/assets/index-XlGcZj49.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
fastapi_radar/middleware.py
CHANGED
|
@@ -7,12 +7,18 @@ import uuid
|
|
|
7
7
|
from contextvars import ContextVar
|
|
8
8
|
from typing import Callable, Optional
|
|
9
9
|
|
|
10
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
10
11
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
11
12
|
from starlette.requests import Request
|
|
12
13
|
from starlette.responses import Response, StreamingResponse
|
|
13
14
|
|
|
14
15
|
from .models import CapturedRequest, CapturedException
|
|
15
|
-
from .utils import
|
|
16
|
+
from .utils import (
|
|
17
|
+
serialize_headers,
|
|
18
|
+
get_client_ip,
|
|
19
|
+
truncate_body,
|
|
20
|
+
redact_sensitive_data,
|
|
21
|
+
)
|
|
16
22
|
from .tracing import (
|
|
17
23
|
TraceContext,
|
|
18
24
|
TracingManager,
|
|
@@ -98,7 +104,7 @@ class RadarMiddleware(BaseHTTPMiddleware):
|
|
|
98
104
|
query_params=dict(request.query_params) if request.query_params else None,
|
|
99
105
|
headers=serialize_headers(request.headers),
|
|
100
106
|
body=(
|
|
101
|
-
truncate_body(request_body, self.max_body_size)
|
|
107
|
+
redact_sensitive_data(truncate_body(request_body, self.max_body_size))
|
|
102
108
|
if request_body
|
|
103
109
|
else None
|
|
104
110
|
),
|
|
@@ -109,24 +115,39 @@ class RadarMiddleware(BaseHTTPMiddleware):
|
|
|
109
115
|
exception_occurred = False
|
|
110
116
|
|
|
111
117
|
try:
|
|
112
|
-
response = await call_next(request)
|
|
118
|
+
response = original_response = await call_next(request)
|
|
113
119
|
|
|
114
120
|
captured_request.status_code = response.status_code
|
|
115
121
|
captured_request.response_headers = serialize_headers(response.headers)
|
|
116
122
|
|
|
117
|
-
if self.capture_response_body
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
if self.capture_response_body:
|
|
124
|
+
|
|
125
|
+
async def capture_response():
|
|
126
|
+
response_body = ""
|
|
127
|
+
capturing = True
|
|
128
|
+
async for chunk in original_response.body_iterator:
|
|
129
|
+
yield chunk
|
|
130
|
+
if capturing:
|
|
131
|
+
response_body += chunk.decode("utf-8", errors="ignore")
|
|
132
|
+
try:
|
|
133
|
+
with self.get_session() as session:
|
|
134
|
+
captured_request.response_body = (
|
|
135
|
+
redact_sensitive_data(
|
|
136
|
+
truncate_body(
|
|
137
|
+
response_body, self.max_body_size
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
session.add(captured_request)
|
|
142
|
+
session.commit()
|
|
143
|
+
except SQLAlchemyError:
|
|
144
|
+
# CapturedRequest record has been deleted.
|
|
145
|
+
capturing = False
|
|
146
|
+
else:
|
|
147
|
+
capturing = len(response_body) < self.max_body_size
|
|
148
|
+
|
|
149
|
+
response = StreamingResponse(
|
|
150
|
+
content=capture_response(),
|
|
130
151
|
status_code=response.status_code,
|
|
131
152
|
headers=dict(response.headers),
|
|
132
153
|
media_type=response.media_type,
|
fastapi_radar/models.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Storage models for FastAPI Radar."""
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
4
|
|
|
5
5
|
from sqlalchemy import (
|
|
6
6
|
Column,
|
|
@@ -40,7 +40,9 @@ class CapturedRequest(Base):
|
|
|
40
40
|
response_headers = Column(JSON)
|
|
41
41
|
duration_ms = Column(Float)
|
|
42
42
|
client_ip = Column(String(50))
|
|
43
|
-
created_at = Column(
|
|
43
|
+
created_at = Column(
|
|
44
|
+
DateTime, default=lambda: datetime.now(timezone.utc), index=True
|
|
45
|
+
)
|
|
44
46
|
|
|
45
47
|
queries = relationship(
|
|
46
48
|
"CapturedQuery",
|
|
@@ -68,7 +70,9 @@ class CapturedQuery(Base):
|
|
|
68
70
|
duration_ms = Column(Float)
|
|
69
71
|
rows_affected = Column(Integer)
|
|
70
72
|
connection_name = Column(String(100))
|
|
71
|
-
created_at = Column(
|
|
73
|
+
created_at = Column(
|
|
74
|
+
DateTime, default=lambda: datetime.now(timezone.utc), index=True
|
|
75
|
+
)
|
|
72
76
|
|
|
73
77
|
request = relationship(
|
|
74
78
|
"CapturedRequest",
|
|
@@ -87,7 +91,9 @@ class CapturedException(Base):
|
|
|
87
91
|
exception_type = Column(String(100), nullable=False)
|
|
88
92
|
exception_value = Column(Text)
|
|
89
93
|
traceback = Column(Text, nullable=False)
|
|
90
|
-
created_at = Column(
|
|
94
|
+
created_at = Column(
|
|
95
|
+
DateTime, default=lambda: datetime.now(timezone.utc), index=True
|
|
96
|
+
)
|
|
91
97
|
|
|
92
98
|
request = relationship(
|
|
93
99
|
"CapturedRequest",
|
|
@@ -104,13 +110,17 @@ class Trace(Base):
|
|
|
104
110
|
trace_id = Column(String(32), primary_key=True, index=True)
|
|
105
111
|
service_name = Column(String(100), index=True)
|
|
106
112
|
operation_name = Column(String(200))
|
|
107
|
-
start_time = Column(
|
|
113
|
+
start_time = Column(
|
|
114
|
+
DateTime, default=lambda: datetime.now(timezone.utc), index=True
|
|
115
|
+
)
|
|
108
116
|
end_time = Column(DateTime)
|
|
109
117
|
duration_ms = Column(Float)
|
|
110
118
|
span_count = Column(Integer, default=0)
|
|
111
119
|
status = Column(String(20), default="ok")
|
|
112
120
|
tags = Column(JSON)
|
|
113
|
-
created_at = Column(
|
|
121
|
+
created_at = Column(
|
|
122
|
+
DateTime, default=lambda: datetime.now(timezone.utc), index=True
|
|
123
|
+
)
|
|
114
124
|
|
|
115
125
|
spans = relationship(
|
|
116
126
|
"Span",
|
|
@@ -135,7 +145,9 @@ class Span(Base):
|
|
|
135
145
|
status = Column(String(20), default="ok")
|
|
136
146
|
tags = Column(JSON)
|
|
137
147
|
logs = Column(JSON)
|
|
138
|
-
created_at = Column(
|
|
148
|
+
created_at = Column(
|
|
149
|
+
DateTime, default=lambda: datetime.now(timezone.utc), index=True
|
|
150
|
+
)
|
|
139
151
|
|
|
140
152
|
trace = relationship(
|
|
141
153
|
"Trace",
|
|
@@ -154,4 +166,25 @@ class SpanRelation(Base):
|
|
|
154
166
|
parent_span_id = Column(String(16), index=True)
|
|
155
167
|
child_span_id = Column(String(16), index=True)
|
|
156
168
|
depth = Column(Integer, default=0)
|
|
157
|
-
created_at = Column(DateTime, default=datetime.
|
|
169
|
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class BackgroundTask(Base):
|
|
173
|
+
__tablename__ = "radar_background_tasks"
|
|
174
|
+
|
|
175
|
+
id = Column(
|
|
176
|
+
Integer, Sequence("radar_background_tasks_id_seq"), primary_key=True, index=True
|
|
177
|
+
)
|
|
178
|
+
task_id = Column(String(36), unique=True, index=True, nullable=False)
|
|
179
|
+
request_id = Column(String(36), index=True, nullable=True)
|
|
180
|
+
name = Column(String(200), nullable=False)
|
|
181
|
+
status = Column(
|
|
182
|
+
String(20), default="pending", index=True
|
|
183
|
+
) # pending, running, completed, failed
|
|
184
|
+
start_time = Column(DateTime, index=True)
|
|
185
|
+
end_time = Column(DateTime)
|
|
186
|
+
duration_ms = Column(Float)
|
|
187
|
+
error = Column(Text)
|
|
188
|
+
created_at = Column(
|
|
189
|
+
DateTime, default=lambda: datetime.now(timezone.utc), index=True
|
|
190
|
+
)
|
fastapi_radar/radar.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from contextlib import contextmanager
|
|
4
4
|
import os
|
|
5
|
+
import sys
|
|
6
|
+
import multiprocessing
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from typing import List, Optional
|
|
7
9
|
|
|
@@ -9,6 +11,7 @@ from fastapi import FastAPI
|
|
|
9
11
|
from sqlalchemy import create_engine
|
|
10
12
|
from sqlalchemy.engine import Engine
|
|
11
13
|
from sqlalchemy.orm import Session, sessionmaker
|
|
14
|
+
from sqlalchemy.pool import StaticPool
|
|
12
15
|
|
|
13
16
|
from .api import create_api_router
|
|
14
17
|
from .capture import QueryCapture
|
|
@@ -16,6 +19,27 @@ from .middleware import RadarMiddleware
|
|
|
16
19
|
from .models import Base
|
|
17
20
|
|
|
18
21
|
|
|
22
|
+
def is_reload_worker() -> bool:
|
|
23
|
+
"""Check if we're running in a reload worker process (used by fastapi dev)."""
|
|
24
|
+
if os.environ.get("UVICORN_RELOAD"):
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
if os.environ.get("WERKZEUG_RUN_MAIN"):
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
if hasattr(multiprocessing.current_process(), "name"):
|
|
31
|
+
process_name = multiprocessing.current_process().name
|
|
32
|
+
if process_name != "MainProcess" and "SpawnProcess" in process_name:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_windows() -> bool:
|
|
39
|
+
"""Check if we're running on Windows."""
|
|
40
|
+
return sys.platform.startswith("win")
|
|
41
|
+
|
|
42
|
+
|
|
19
43
|
class Radar:
|
|
20
44
|
query_capture: Optional[QueryCapture]
|
|
21
45
|
|
|
@@ -34,6 +58,7 @@ class Radar:
|
|
|
34
58
|
enable_tracing: bool = True,
|
|
35
59
|
service_name: str = "fastapi-app",
|
|
36
60
|
include_in_schema: bool = True,
|
|
61
|
+
db_path: Optional[str] = None,
|
|
37
62
|
):
|
|
38
63
|
self.app = app
|
|
39
64
|
self.db_engine = db_engine
|
|
@@ -46,33 +71,80 @@ class Radar:
|
|
|
46
71
|
self.theme = theme
|
|
47
72
|
self.enable_tracing = enable_tracing
|
|
48
73
|
self.service_name = service_name
|
|
74
|
+
self.db_path = db_path
|
|
49
75
|
self.query_capture = None
|
|
50
76
|
|
|
51
|
-
# Exclude radar dashboard paths
|
|
52
77
|
if dashboard_path not in self.exclude_paths:
|
|
53
78
|
self.exclude_paths.append(dashboard_path)
|
|
54
79
|
self.exclude_paths.append("/favicon.ico")
|
|
55
80
|
|
|
56
|
-
# Setup storage engine
|
|
57
81
|
if storage_engine:
|
|
58
82
|
self.storage_engine = storage_engine
|
|
59
83
|
else:
|
|
60
84
|
storage_url = os.environ.get("RADAR_STORAGE_URL")
|
|
61
85
|
if storage_url:
|
|
62
|
-
|
|
86
|
+
if "duckdb" in storage_url:
|
|
87
|
+
self.storage_engine = create_engine(
|
|
88
|
+
storage_url, poolclass=StaticPool
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
self.storage_engine = create_engine(storage_url)
|
|
63
92
|
else:
|
|
64
|
-
# Use DuckDB for analytics-optimized storage
|
|
65
|
-
# Import duckdb_engine to register the dialect
|
|
66
93
|
import duckdb_engine # noqa: F401
|
|
67
94
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
if self.db_path:
|
|
96
|
+
try:
|
|
97
|
+
provided_path = Path(self.db_path).resolve()
|
|
98
|
+
if provided_path.suffix.lower() == ".duckdb":
|
|
99
|
+
radar_db_path = provided_path
|
|
100
|
+
radar_db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
else:
|
|
102
|
+
radar_db_path = provided_path / "radar.duckdb"
|
|
103
|
+
provided_path.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
except Exception as e:
|
|
106
|
+
import warnings
|
|
107
|
+
|
|
108
|
+
warnings.warn(
|
|
109
|
+
(
|
|
110
|
+
f"Failed to create database path '{self.db_path}': {e}. "
|
|
111
|
+
f"Using current directory."
|
|
112
|
+
),
|
|
113
|
+
UserWarning,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
radar_db_path = Path.cwd() / "radar.duckdb"
|
|
117
|
+
radar_db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
else:
|
|
119
|
+
radar_db_path = Path.cwd() / "radar.duckdb"
|
|
120
|
+
radar_db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
|
|
122
|
+
if is_reload_worker():
|
|
123
|
+
import warnings
|
|
124
|
+
|
|
125
|
+
warnings.warn(
|
|
126
|
+
"FastAPI Radar: Detected development mode with auto-reload. "
|
|
127
|
+
"Using in-memory database to avoid file locking issues. "
|
|
128
|
+
"Data will not persist between reloads.",
|
|
129
|
+
UserWarning,
|
|
130
|
+
)
|
|
131
|
+
self.storage_engine = create_engine(
|
|
132
|
+
"duckdb:///:memory:",
|
|
133
|
+
connect_args={
|
|
134
|
+
"read_only": False,
|
|
135
|
+
"config": {"memory_limit": "500mb"},
|
|
136
|
+
},
|
|
137
|
+
poolclass=StaticPool,
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
self.storage_engine = create_engine(
|
|
141
|
+
f"duckdb:///{radar_db_path}",
|
|
142
|
+
connect_args={
|
|
143
|
+
"read_only": False,
|
|
144
|
+
"config": {"memory_limit": "500mb"},
|
|
145
|
+
},
|
|
146
|
+
poolclass=StaticPool,
|
|
147
|
+
)
|
|
76
148
|
|
|
77
149
|
self.SessionLocal = sessionmaker(
|
|
78
150
|
autocommit=False, autoflush=False, bind=self.storage_engine
|
|
@@ -133,7 +205,6 @@ class Radar:
|
|
|
133
205
|
dashboard_dir = Path(__file__).parent / "dashboard" / "dist"
|
|
134
206
|
|
|
135
207
|
if not dashboard_dir.exists():
|
|
136
|
-
# Create placeholder dashboard for development
|
|
137
208
|
dashboard_dir.mkdir(parents=True, exist_ok=True)
|
|
138
209
|
self._create_placeholder_dashboard(dashboard_dir)
|
|
139
210
|
print("\n" + "=" * 60)
|
|
@@ -145,14 +216,11 @@ class Radar:
|
|
|
145
216
|
print(" npm run build")
|
|
146
217
|
print("=" * 60 + "\n")
|
|
147
218
|
|
|
148
|
-
# Add a catch-all route for the dashboard SPA
|
|
149
|
-
# This ensures all sub-routes under /__radar serve the index.html
|
|
150
219
|
@self.app.get(
|
|
151
220
|
f"{self.dashboard_path}/{{full_path:path}}",
|
|
152
221
|
include_in_schema=include_in_schema,
|
|
153
222
|
)
|
|
154
223
|
async def serve_dashboard(request: Request, full_path: str = ""):
|
|
155
|
-
# Check if it's a request for a static asset
|
|
156
224
|
if full_path and any(
|
|
157
225
|
full_path.endswith(ext)
|
|
158
226
|
for ext in [
|
|
@@ -171,7 +239,6 @@ class Radar:
|
|
|
171
239
|
if file_path.exists():
|
|
172
240
|
return FileResponse(file_path)
|
|
173
241
|
|
|
174
|
-
# For all other routes, serve index.html (SPA behavior)
|
|
175
242
|
index_path = dashboard_dir / "index.html"
|
|
176
243
|
if index_path.exists():
|
|
177
244
|
return FileResponse(index_path)
|
|
@@ -267,7 +334,6 @@ class Radar:
|
|
|
267
334
|
</div>
|
|
268
335
|
</div>
|
|
269
336
|
<script>
|
|
270
|
-
// Fetch stats from API
|
|
271
337
|
async function loadStats() {{
|
|
272
338
|
try {{
|
|
273
339
|
const response = await fetch('/__radar/api/stats?hours=1');
|
|
@@ -291,9 +357,7 @@ class Radar:
|
|
|
291
357
|
}}
|
|
292
358
|
}}
|
|
293
359
|
|
|
294
|
-
// Load stats on page load
|
|
295
360
|
loadStats();
|
|
296
|
-
// Refresh stats every 5 seconds
|
|
297
361
|
setInterval(loadStats, 5000);
|
|
298
362
|
</script>
|
|
299
363
|
</body>
|
|
@@ -304,19 +368,29 @@ class Radar:
|
|
|
304
368
|
)
|
|
305
369
|
|
|
306
370
|
def create_tables(self) -> None:
|
|
307
|
-
|
|
371
|
+
"""Create database tables.
|
|
372
|
+
|
|
373
|
+
With dev mode (fastapi dev), this safely handles
|
|
374
|
+
multiple process attempts to create tables.
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
Base.metadata.create_all(bind=self.storage_engine)
|
|
378
|
+
except Exception as e:
|
|
379
|
+
error_msg = str(e).lower()
|
|
380
|
+
if "already exists" not in error_msg and "lock" not in error_msg:
|
|
381
|
+
raise
|
|
308
382
|
|
|
309
383
|
def drop_tables(self) -> None:
|
|
310
384
|
Base.metadata.drop_all(bind=self.storage_engine)
|
|
311
385
|
|
|
312
386
|
def cleanup(self, older_than_hours: Optional[int] = None) -> None:
|
|
313
|
-
from datetime import datetime, timedelta
|
|
387
|
+
from datetime import datetime, timedelta, timezone
|
|
314
388
|
|
|
315
389
|
from .models import CapturedRequest
|
|
316
390
|
|
|
317
391
|
with self.get_session() as session:
|
|
318
392
|
hours = older_than_hours or self.retention_hours
|
|
319
|
-
cutoff = datetime.
|
|
393
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
320
394
|
|
|
321
395
|
deleted = (
|
|
322
396
|
session.query(CapturedRequest)
|
fastapi_radar/tracing.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Tracing core functionality module."""
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
5
|
from typing import Optional, Dict, Any, List
|
|
6
6
|
from contextvars import ContextVar
|
|
7
7
|
from sqlalchemy.orm import Session
|
|
@@ -23,7 +23,7 @@ class TraceContext:
|
|
|
23
23
|
self.root_span_id: Optional[str] = None
|
|
24
24
|
self.current_span_id: Optional[str] = None
|
|
25
25
|
self.spans: Dict[str, Dict[str, Any]] = {}
|
|
26
|
-
self.start_time = datetime.
|
|
26
|
+
self.start_time = datetime.now(timezone.utc)
|
|
27
27
|
|
|
28
28
|
def create_span(
|
|
29
29
|
self,
|
|
@@ -42,7 +42,7 @@ class TraceContext:
|
|
|
42
42
|
"operation_name": operation_name,
|
|
43
43
|
"service_name": self.service_name,
|
|
44
44
|
"span_kind": span_kind,
|
|
45
|
-
"start_time": datetime.
|
|
45
|
+
"start_time": datetime.now(timezone.utc),
|
|
46
46
|
"tags": tags or {},
|
|
47
47
|
"logs": [],
|
|
48
48
|
"status": "ok",
|
|
@@ -64,7 +64,7 @@ class TraceContext:
|
|
|
64
64
|
return
|
|
65
65
|
|
|
66
66
|
span_data = self.spans[span_id]
|
|
67
|
-
span_data["end_time"] = datetime.
|
|
67
|
+
span_data["end_time"] = datetime.now(timezone.utc)
|
|
68
68
|
span_data["duration_ms"] = (
|
|
69
69
|
span_data["end_time"] - span_data["start_time"]
|
|
70
70
|
).total_seconds() * 1000
|
|
@@ -79,7 +79,7 @@ class TraceContext:
|
|
|
79
79
|
return
|
|
80
80
|
|
|
81
81
|
log_entry = {
|
|
82
|
-
"timestamp": datetime.
|
|
82
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
83
83
|
"level": level,
|
|
84
84
|
"message": message,
|
|
85
85
|
**fields,
|
|
@@ -108,7 +108,7 @@ class TraceContext:
|
|
|
108
108
|
error_count += 1
|
|
109
109
|
|
|
110
110
|
start_time = min(all_times) if all_times else self.start_time
|
|
111
|
-
end_time = max(all_times) if all_times else datetime.
|
|
111
|
+
end_time = max(all_times) if all_times else datetime.now(timezone.utc)
|
|
112
112
|
|
|
113
113
|
return {
|
|
114
114
|
"trace_id": self.trace_id,
|
fastapi_radar/utils.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Utility functions for FastAPI Radar."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from typing import Dict, Optional
|
|
4
5
|
|
|
5
6
|
from starlette.datastructures import Headers
|
|
@@ -58,3 +59,26 @@ def format_sql(sql: str, max_length: int = 5000) -> str:
|
|
|
58
59
|
sql = sql[:max_length] + "... [truncated]"
|
|
59
60
|
|
|
60
61
|
return sql
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def redact_sensitive_data(text: Optional[str]) -> Optional[str]:
|
|
65
|
+
"""Redact sensitive data from text (body content)."""
|
|
66
|
+
if not text:
|
|
67
|
+
return text
|
|
68
|
+
|
|
69
|
+
# Patterns for sensitive data
|
|
70
|
+
patterns = [
|
|
71
|
+
(r'"(password|passwd|pwd)"\s*:\s*"[^"]*"', r'"\1": "***REDACTED***"'),
|
|
72
|
+
(
|
|
73
|
+
r'"(token|api_key|apikey|secret|auth)"\s*:\s*"[^"]*"',
|
|
74
|
+
r'"\1": "***REDACTED***"',
|
|
75
|
+
),
|
|
76
|
+
(r'"(credit_card|card_number|cvv)"\s*:\s*"[^"]*"', r'"\1": "***REDACTED***"'),
|
|
77
|
+
(r"Bearer\s+[A-Za-z0-9\-_\.]+", "Bearer ***REDACTED***"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
result = text
|
|
81
|
+
for pattern, replacement in patterns:
|
|
82
|
+
result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
|
|
83
|
+
|
|
84
|
+
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-radar
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A debugging dashboard for FastAPI applications with real-time monitoring
|
|
5
5
|
Home-page: https://github.com/doganarif/fastapi-radar
|
|
6
6
|
Author: Arif Dogan
|
|
@@ -29,6 +29,7 @@ Requires-Dist: pydantic
|
|
|
29
29
|
Requires-Dist: starlette
|
|
30
30
|
Requires-Dist: duckdb==1.1.3
|
|
31
31
|
Requires-Dist: duckdb-engine==0.17.0
|
|
32
|
+
Requires-Dist: aiosqlite>=0.21.0
|
|
32
33
|
Provides-Extra: dev
|
|
33
34
|
Requires-Dist: pytest; extra == "dev"
|
|
34
35
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
@@ -38,6 +39,9 @@ Requires-Dist: isort; extra == "dev"
|
|
|
38
39
|
Requires-Dist: flake8; extra == "dev"
|
|
39
40
|
Requires-Dist: mypy; extra == "dev"
|
|
40
41
|
Requires-Dist: httpx; extra == "dev"
|
|
42
|
+
Provides-Extra: release
|
|
43
|
+
Requires-Dist: build; extra == "release"
|
|
44
|
+
Requires-Dist: twine; extra == "release"
|
|
41
45
|
Dynamic: author
|
|
42
46
|
Dynamic: home-page
|
|
43
47
|
Dynamic: license-file
|
|
@@ -139,9 +143,47 @@ radar = Radar(
|
|
|
139
143
|
capture_sql_bindings=True, # Capture SQL query parameters
|
|
140
144
|
exclude_paths=["/health"], # Paths to exclude from monitoring
|
|
141
145
|
theme="auto", # Dashboard theme: "light", "dark", or "auto"
|
|
146
|
+
db_path="/path/to/db", # Custom path for radar.duckdb file (default: current directory)
|
|
142
147
|
)
|
|
143
148
|
```
|
|
144
149
|
|
|
150
|
+
### Custom Database Location
|
|
151
|
+
|
|
152
|
+
By default, FastAPI Radar stores its monitoring data in a `radar.duckdb` file in your current working directory. You can customize this location using the `db_path` parameter:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# Store in a specific directory
|
|
156
|
+
radar = Radar(app, db_path="/var/data/monitoring")
|
|
157
|
+
# Creates: /var/data/monitoring/radar.duckdb
|
|
158
|
+
|
|
159
|
+
# Store with a specific filename
|
|
160
|
+
radar = Radar(app, db_path="/var/data/my_app_monitoring.duckdb")
|
|
161
|
+
# Creates: /var/data/my_app_monitoring.duckdb
|
|
162
|
+
|
|
163
|
+
# Use a relative path
|
|
164
|
+
radar = Radar(app, db_path="./data")
|
|
165
|
+
# Creates: ./data/radar.duckdb
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If the specified path cannot be created, FastAPI Radar will fallback to using the current directory with a warning.
|
|
169
|
+
|
|
170
|
+
### Development Mode with Auto-Reload
|
|
171
|
+
|
|
172
|
+
When running your FastAPI application with `fastapi dev` (which uses auto-reload), FastAPI Radar automatically switches to an in-memory database to avoid file locking issues. This means:
|
|
173
|
+
|
|
174
|
+
- **No file locking errors** - The dashboard will work seamlessly in development
|
|
175
|
+
- **Data doesn't persist between reloads** - Each reload starts with a fresh database
|
|
176
|
+
- **Production behavior unchanged** - When using `fastapi run` or deploying, the normal file-based database is used
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
# With fastapi dev (auto-reload enabled):
|
|
180
|
+
# Automatically uses in-memory database - no configuration needed!
|
|
181
|
+
radar = Radar(app)
|
|
182
|
+
radar.create_tables() # Safe to call - handles multiple processes gracefully
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
This behavior only applies when using the development server with auto-reload (`fastapi dev`). In production or when using `fastapi run`, the standard file-based DuckDB storage is used.
|
|
186
|
+
|
|
145
187
|
## What Gets Captured?
|
|
146
188
|
|
|
147
189
|
- ✅ HTTP requests and responses
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
fastapi_radar/__init__.py,sha256=SCCnfsp3PuaoHJhm3Kxvi2578-ztdTQaIjljZxfINAw,208
|
|
2
|
+
fastapi_radar/api.py,sha256=iWm8SUbnXR34JSRE_5lQjVW-5loTcV6MBybC0-sBRqo,22862
|
|
3
|
+
fastapi_radar/background.py,sha256=mTotE-K4H7rYZ1RIDn525XtoN-ZqDAlnG7sOBzvx4TI,4085
|
|
4
|
+
fastapi_radar/capture.py,sha256=weWpI2HBb-qp04SZMWXt-Lx3NYo44WBvH65-Yyvv_UI,6623
|
|
5
|
+
fastapi_radar/middleware.py,sha256=CSEX6bwj5uE9XMSHslNmwl1Ll6Yl8hLJFLGk8csjq2E,8711
|
|
6
|
+
fastapi_radar/models.py,sha256=fyiliKcvDv98q7X-CjWaOlEeX0xnJox3zEyZuwR_aBU,5819
|
|
7
|
+
fastapi_radar/radar.py,sha256=I_nF3YgKKloO9O4ycB27aLo2Y5AdG1xt8t22qVpysJk,13940
|
|
8
|
+
fastapi_radar/tracing.py,sha256=GNayJJaxZR68ZiT3Io9GUyd9SnbFrfXGnRRpQigLDL0,8798
|
|
9
|
+
fastapi_radar/utils.py,sha256=Btmie6I66eyib43jwVqAFZwnIbWDrysly-I8oCkcM_Q,2260
|
|
10
|
+
fastapi_radar/dashboard/dist/index.html,sha256=ACbU2M9oSEONogQcqLPeymIaxMXf2257_drDAHpTM7s,436
|
|
11
|
+
fastapi_radar/dashboard/dist/assets/index-8Om0PGu6.js,sha256=PJJmrtFlm4l3fgH_PIx5USypRSwWzqEpIzALol7b-30,925269
|
|
12
|
+
fastapi_radar/dashboard/dist/assets/index-D51YrvFG.css,sha256=voYJADvNg5vaG4sFp8C-RKIt3011JuvaeVSev3jkKMQ,36262
|
|
13
|
+
fastapi_radar/dashboard/dist/assets/index-p3czTzXB.js,sha256=P37rXeybVEj9sADBkQO7YBptwp747mZzdT4Xamb-9TM,948079
|
|
14
|
+
fastapi_radar-0.3.0.dist-info/licenses/LICENSE,sha256=0ga4BB6q-nqx6xlDRhtrgKrYs0HgX02PQyIzNFRK09Q,1067
|
|
15
|
+
tests/__init__.py,sha256=kAWaI50iJRZ4JlAdyt7FICgm8MsloZz0ZlsmhgLXBas,31
|
|
16
|
+
tests/test_async_radar.py,sha256=zsj2r6-q9WjjamLtzrIDWI8fxcBJ0oVOcynfvPlOhRE,1610
|
|
17
|
+
tests/test_radar.py,sha256=3F-_zdemPcgQnjP4kzCa7GhMxNJNYU0SgSWprctyXiA,2374
|
|
18
|
+
fastapi_radar-0.3.0.dist-info/METADATA,sha256=__ZM6RCDwt0JwUjXvFrn6iFoSs9letL3DZmZZ_9RcJc,7743
|
|
19
|
+
fastapi_radar-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
20
|
+
fastapi_radar-0.3.0.dist-info/top_level.txt,sha256=M-bALM-KDkiLcATq2aAx-BnG59Nv-GdFBzuzkUhiCa0,20
|
|
21
|
+
fastapi_radar-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from fastapi_radar import Radar
|
|
3
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
5
|
+
|
|
6
|
+
app = FastAPI()
|
|
7
|
+
engine = create_async_engine("sqlite+aiosqlite:///./app.db")
|
|
8
|
+
async_session: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
|
9
|
+
engine, expire_on_commit=False
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# 定义一个简单的测试表
|
|
13
|
+
metadata = MetaData()
|
|
14
|
+
users_table = Table(
|
|
15
|
+
"users",
|
|
16
|
+
metadata,
|
|
17
|
+
Column("id", Integer, primary_key=True, autoincrement=True),
|
|
18
|
+
Column("name", String(50), nullable=False),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
radar = Radar(app, db_engine=engine)
|
|
23
|
+
radar.create_tables()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.on_event("startup")
|
|
27
|
+
async def on_startup() -> None:
|
|
28
|
+
"""应用启动时创建测试表并写入示例数据。"""
|
|
29
|
+
|
|
30
|
+
async with engine.begin() as conn:
|
|
31
|
+
await conn.run_sync(metadata.create_all)
|
|
32
|
+
|
|
33
|
+
async with async_session() as session:
|
|
34
|
+
result = await session.execute(select(users_table.c.id).limit(1))
|
|
35
|
+
if result.first() is None:
|
|
36
|
+
await session.execute(
|
|
37
|
+
users_table.insert(),
|
|
38
|
+
[{"name": "Alice"}, {"name": "Bob"}, {"name": "Carol"}],
|
|
39
|
+
)
|
|
40
|
+
await session.commit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Your routes work unchanged
|
|
44
|
+
@app.get("/users")
|
|
45
|
+
async def get_users():
|
|
46
|
+
async with async_session() as session:
|
|
47
|
+
result = await session.execute(select(users_table))
|
|
48
|
+
rows = result.mappings().all()
|
|
49
|
+
|
|
50
|
+
return {"users": [dict(row) for row in rows]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
import uvicorn
|
|
55
|
+
|
|
56
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|