fastapi-radar 0.1.8__py3-none-any.whl → 0.3.1__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.
- 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/dashboard/node_modules/flatted/python/flatted.py +149 -0
- fastapi_radar/middleware.py +26 -9
- fastapi_radar/models.py +41 -8
- fastapi_radar/radar.py +70 -25
- fastapi_radar/tracing.py +6 -6
- fastapi_radar/utils.py +24 -0
- {fastapi_radar-0.1.8.dist-info → fastapi_radar-0.3.1.dist-info}/METADATA +23 -2
- fastapi_radar-0.3.1.dist-info/RECORD +19 -0
- {fastapi_radar-0.1.8.dist-info → fastapi_radar-0.3.1.dist-info}/top_level.txt +0 -1
- fastapi_radar/dashboard/dist/assets/index-By5DXl8Z.js +0 -318
- fastapi_radar/dashboard/dist/assets/index-XlGcZj49.css +0 -1
- fastapi_radar-0.1.8.dist-info/RECORD +0 -18
- tests/__init__.py +0 -1
- tests/test_radar.py +0 -75
- {fastapi_radar-0.1.8.dist-info → fastapi_radar-0.3.1.dist-info}/WHEEL +0 -0
- {fastapi_radar-0.1.8.dist-info → fastapi_radar-0.3.1.dist-info}/licenses/LICENSE +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>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# ISC License
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2018-2025, Andrea Giammarchi, @WebReflection
|
|
4
|
+
#
|
|
5
|
+
# Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
# purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
# copyright notice and this permission notice appear in all copies.
|
|
8
|
+
#
|
|
9
|
+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
|
|
14
|
+
# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
# PERFORMANCE OF THIS SOFTWARE.
|
|
16
|
+
|
|
17
|
+
import json as _json
|
|
18
|
+
|
|
19
|
+
class _Known:
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.key = []
|
|
22
|
+
self.value = []
|
|
23
|
+
|
|
24
|
+
class _String:
|
|
25
|
+
def __init__(self, value):
|
|
26
|
+
self.value = value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _array_keys(value):
|
|
30
|
+
keys = []
|
|
31
|
+
i = 0
|
|
32
|
+
for _ in value:
|
|
33
|
+
keys.append(i)
|
|
34
|
+
i += 1
|
|
35
|
+
return keys
|
|
36
|
+
|
|
37
|
+
def _object_keys(value):
|
|
38
|
+
keys = []
|
|
39
|
+
for key in value:
|
|
40
|
+
keys.append(key)
|
|
41
|
+
return keys
|
|
42
|
+
|
|
43
|
+
def _is_array(value):
|
|
44
|
+
return isinstance(value, (list, tuple))
|
|
45
|
+
|
|
46
|
+
def _is_object(value):
|
|
47
|
+
return isinstance(value, dict)
|
|
48
|
+
|
|
49
|
+
def _is_string(value):
|
|
50
|
+
return isinstance(value, str)
|
|
51
|
+
|
|
52
|
+
def _index(known, input, value):
|
|
53
|
+
input.append(value)
|
|
54
|
+
index = str(len(input) - 1)
|
|
55
|
+
known.key.append(value)
|
|
56
|
+
known.value.append(index)
|
|
57
|
+
return index
|
|
58
|
+
|
|
59
|
+
def _loop(keys, input, known, output):
|
|
60
|
+
for key in keys:
|
|
61
|
+
value = output[key]
|
|
62
|
+
if isinstance(value, _String):
|
|
63
|
+
_ref(key, input[int(value.value)], input, known, output)
|
|
64
|
+
|
|
65
|
+
return output
|
|
66
|
+
|
|
67
|
+
def _ref(key, value, input, known, output):
|
|
68
|
+
if _is_array(value) and value not in known:
|
|
69
|
+
known.append(value)
|
|
70
|
+
value = _loop(_array_keys(value), input, known, value)
|
|
71
|
+
elif _is_object(value) and value not in known:
|
|
72
|
+
known.append(value)
|
|
73
|
+
value = _loop(_object_keys(value), input, known, value)
|
|
74
|
+
|
|
75
|
+
output[key] = value
|
|
76
|
+
|
|
77
|
+
def _relate(known, input, value):
|
|
78
|
+
if _is_string(value) or _is_array(value) or _is_object(value):
|
|
79
|
+
try:
|
|
80
|
+
return known.value[known.key.index(value)]
|
|
81
|
+
except:
|
|
82
|
+
return _index(known, input, value)
|
|
83
|
+
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
def _transform(known, input, value):
|
|
87
|
+
if _is_array(value):
|
|
88
|
+
output = []
|
|
89
|
+
for val in value:
|
|
90
|
+
output.append(_relate(known, input, val))
|
|
91
|
+
return output
|
|
92
|
+
|
|
93
|
+
if _is_object(value):
|
|
94
|
+
obj = {}
|
|
95
|
+
for key in value:
|
|
96
|
+
obj[key] = _relate(known, input, value[key])
|
|
97
|
+
return obj
|
|
98
|
+
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
def _wrap(value):
|
|
102
|
+
if _is_string(value):
|
|
103
|
+
return _String(value)
|
|
104
|
+
|
|
105
|
+
if _is_array(value):
|
|
106
|
+
i = 0
|
|
107
|
+
for val in value:
|
|
108
|
+
value[i] = _wrap(val)
|
|
109
|
+
i += 1
|
|
110
|
+
|
|
111
|
+
elif _is_object(value):
|
|
112
|
+
for key in value:
|
|
113
|
+
value[key] = _wrap(value[key])
|
|
114
|
+
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
def parse(value, *args, **kwargs):
|
|
118
|
+
json = _json.loads(value, *args, **kwargs)
|
|
119
|
+
wrapped = []
|
|
120
|
+
for value in json:
|
|
121
|
+
wrapped.append(_wrap(value))
|
|
122
|
+
|
|
123
|
+
input = []
|
|
124
|
+
for value in wrapped:
|
|
125
|
+
if isinstance(value, _String):
|
|
126
|
+
input.append(value.value)
|
|
127
|
+
else:
|
|
128
|
+
input.append(value)
|
|
129
|
+
|
|
130
|
+
value = input[0]
|
|
131
|
+
|
|
132
|
+
if _is_array(value):
|
|
133
|
+
return _loop(_array_keys(value), input, [value], value)
|
|
134
|
+
|
|
135
|
+
if _is_object(value):
|
|
136
|
+
return _loop(_object_keys(value), input, [value], value)
|
|
137
|
+
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def stringify(value, *args, **kwargs):
|
|
142
|
+
known = _Known()
|
|
143
|
+
input = []
|
|
144
|
+
output = []
|
|
145
|
+
i = int(_index(known, input, value))
|
|
146
|
+
while i < len(input):
|
|
147
|
+
output.append(_transform(known, input, input[i]))
|
|
148
|
+
i += 1
|
|
149
|
+
return _json.dumps(output, *args, **kwargs)
|
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
|
),
|
|
@@ -118,16 +124,27 @@ class RadarMiddleware(BaseHTTPMiddleware):
|
|
|
118
124
|
|
|
119
125
|
async def capture_response():
|
|
120
126
|
response_body = ""
|
|
127
|
+
capturing = True
|
|
121
128
|
async for chunk in original_response.body_iterator:
|
|
122
129
|
yield chunk
|
|
123
|
-
if
|
|
130
|
+
if capturing:
|
|
124
131
|
response_body += chunk.decode("utf-8", errors="ignore")
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
response_body
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
131
148
|
|
|
132
149
|
response = StreamingResponse(
|
|
133
150
|
content=capture_response(),
|
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
|
|
|
@@ -50,26 +74,26 @@ class Radar:
|
|
|
50
74
|
self.db_path = db_path
|
|
51
75
|
self.query_capture = None
|
|
52
76
|
|
|
53
|
-
# Exclude radar dashboard paths
|
|
54
77
|
if dashboard_path not in self.exclude_paths:
|
|
55
78
|
self.exclude_paths.append(dashboard_path)
|
|
56
79
|
self.exclude_paths.append("/favicon.ico")
|
|
57
80
|
|
|
58
|
-
# Setup storage engine
|
|
59
81
|
if storage_engine:
|
|
60
82
|
self.storage_engine = storage_engine
|
|
61
83
|
else:
|
|
62
84
|
storage_url = os.environ.get("RADAR_STORAGE_URL")
|
|
63
85
|
if storage_url:
|
|
64
|
-
|
|
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)
|
|
65
92
|
else:
|
|
66
|
-
# Use DuckDB for analytics-optimized storage
|
|
67
|
-
# Import duckdb_engine to register the dialect
|
|
68
93
|
import duckdb_engine # noqa: F401
|
|
69
94
|
|
|
70
95
|
if self.db_path:
|
|
71
96
|
try:
|
|
72
|
-
# Avoid shadowing the attribute name by using a different variable name
|
|
73
97
|
provided_path = Path(self.db_path).resolve()
|
|
74
98
|
if provided_path.suffix.lower() == ".duckdb":
|
|
75
99
|
radar_db_path = provided_path
|
|
@@ -79,7 +103,6 @@ class Radar:
|
|
|
79
103
|
provided_path.mkdir(parents=True, exist_ok=True)
|
|
80
104
|
|
|
81
105
|
except Exception as e:
|
|
82
|
-
# Fallback to current directory if path creation fails
|
|
83
106
|
import warnings
|
|
84
107
|
|
|
85
108
|
warnings.warn(
|
|
@@ -95,13 +118,33 @@ class Radar:
|
|
|
95
118
|
else:
|
|
96
119
|
radar_db_path = Path.cwd() / "radar.duckdb"
|
|
97
120
|
radar_db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
)
|
|
105
148
|
|
|
106
149
|
self.SessionLocal = sessionmaker(
|
|
107
150
|
autocommit=False, autoflush=False, bind=self.storage_engine
|
|
@@ -162,7 +205,6 @@ class Radar:
|
|
|
162
205
|
dashboard_dir = Path(__file__).parent / "dashboard" / "dist"
|
|
163
206
|
|
|
164
207
|
if not dashboard_dir.exists():
|
|
165
|
-
# Create placeholder dashboard for development
|
|
166
208
|
dashboard_dir.mkdir(parents=True, exist_ok=True)
|
|
167
209
|
self._create_placeholder_dashboard(dashboard_dir)
|
|
168
210
|
print("\n" + "=" * 60)
|
|
@@ -174,14 +216,11 @@ class Radar:
|
|
|
174
216
|
print(" npm run build")
|
|
175
217
|
print("=" * 60 + "\n")
|
|
176
218
|
|
|
177
|
-
# Add a catch-all route for the dashboard SPA
|
|
178
|
-
# This ensures all sub-routes under /__radar serve the index.html
|
|
179
219
|
@self.app.get(
|
|
180
220
|
f"{self.dashboard_path}/{{full_path:path}}",
|
|
181
221
|
include_in_schema=include_in_schema,
|
|
182
222
|
)
|
|
183
223
|
async def serve_dashboard(request: Request, full_path: str = ""):
|
|
184
|
-
# Check if it's a request for a static asset
|
|
185
224
|
if full_path and any(
|
|
186
225
|
full_path.endswith(ext)
|
|
187
226
|
for ext in [
|
|
@@ -200,7 +239,6 @@ class Radar:
|
|
|
200
239
|
if file_path.exists():
|
|
201
240
|
return FileResponse(file_path)
|
|
202
241
|
|
|
203
|
-
# For all other routes, serve index.html (SPA behavior)
|
|
204
242
|
index_path = dashboard_dir / "index.html"
|
|
205
243
|
if index_path.exists():
|
|
206
244
|
return FileResponse(index_path)
|
|
@@ -296,7 +334,6 @@ class Radar:
|
|
|
296
334
|
</div>
|
|
297
335
|
</div>
|
|
298
336
|
<script>
|
|
299
|
-
// Fetch stats from API
|
|
300
337
|
async function loadStats() {{
|
|
301
338
|
try {{
|
|
302
339
|
const response = await fetch('/__radar/api/stats?hours=1');
|
|
@@ -320,9 +357,7 @@ class Radar:
|
|
|
320
357
|
}}
|
|
321
358
|
}}
|
|
322
359
|
|
|
323
|
-
// Load stats on page load
|
|
324
360
|
loadStats();
|
|
325
|
-
// Refresh stats every 5 seconds
|
|
326
361
|
setInterval(loadStats, 5000);
|
|
327
362
|
</script>
|
|
328
363
|
</body>
|
|
@@ -333,19 +368,29 @@ class Radar:
|
|
|
333
368
|
)
|
|
334
369
|
|
|
335
370
|
def create_tables(self) -> None:
|
|
336
|
-
|
|
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
|
|
337
382
|
|
|
338
383
|
def drop_tables(self) -> None:
|
|
339
384
|
Base.metadata.drop_all(bind=self.storage_engine)
|
|
340
385
|
|
|
341
386
|
def cleanup(self, older_than_hours: Optional[int] = None) -> None:
|
|
342
|
-
from datetime import datetime, timedelta
|
|
387
|
+
from datetime import datetime, timedelta, timezone
|
|
343
388
|
|
|
344
389
|
from .models import CapturedRequest
|
|
345
390
|
|
|
346
391
|
with self.get_session() as session:
|
|
347
392
|
hours = older_than_hours or self.retention_hours
|
|
348
|
-
cutoff = datetime.
|
|
393
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
349
394
|
|
|
350
395
|
deleted = (
|
|
351
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,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-radar
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
7
7
|
Author-email: Arif Dogan <me@arif.sh>
|
|
8
|
-
License
|
|
8
|
+
License: MIT
|
|
9
9
|
Project-URL: Homepage, https://github.com/doganarif/fastapi-radar
|
|
10
10
|
Project-URL: Bug Reports, https://github.com/doganarif/fastapi-radar/issues
|
|
11
11
|
Project-URL: Source, https://github.com/doganarif/fastapi-radar
|
|
@@ -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
|
|
@@ -163,6 +167,23 @@ radar = Radar(app, db_path="./data")
|
|
|
163
167
|
|
|
164
168
|
If the specified path cannot be created, FastAPI Radar will fallback to using the current directory with a warning.
|
|
165
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
|
+
|
|
166
187
|
## What Gets Captured?
|
|
167
188
|
|
|
168
189
|
- ✅ HTTP requests and responses
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
fastapi_radar/__init__.py,sha256=dGuFHC_3pjoNIB6KsOXgrUA10lwzi3NI2yrcLmz_LeA,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/dashboard/node_modules/flatted/python/flatted.py,sha256=UYburBDqkySaTfSpntPCUJRxiBGcplusJM7ECX8FEgA,3860
|
|
15
|
+
fastapi_radar-0.3.1.dist-info/licenses/LICENSE,sha256=0ga4BB6q-nqx6xlDRhtrgKrYs0HgX02PQyIzNFRK09Q,1067
|
|
16
|
+
fastapi_radar-0.3.1.dist-info/METADATA,sha256=ntw4kL9zrUN55lkMjyl2gqWgKcBboM7yTq1YoXPewH4,7732
|
|
17
|
+
fastapi_radar-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
18
|
+
fastapi_radar-0.3.1.dist-info/top_level.txt,sha256=FESRhvz7hUtE4X5D2cF-tyWcMUGVWeJK0ep4SRGvmEU,14
|
|
19
|
+
fastapi_radar-0.3.1.dist-info/RECORD,,
|