fastapi-radar 0.1.6__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 +383 -37
- fastapi_radar/background.py +120 -0
- fastapi_radar/capture.py +101 -16
- 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 +2 -2
- fastapi_radar/dashboard/node_modules/flatted/python/flatted.py +149 -0
- fastapi_radar/middleware.py +115 -17
- fastapi_radar/models.py +143 -19
- fastapi_radar/radar.py +138 -44
- fastapi_radar/tracing.py +258 -0
- fastapi_radar/utils.py +26 -1
- {fastapi_radar-0.1.6.dist-info → fastapi_radar-0.3.1.dist-info}/METADATA +58 -15
- fastapi_radar-0.3.1.dist-info/RECORD +19 -0
- {fastapi_radar-0.1.6.dist-info → fastapi_radar-0.3.1.dist-info}/top_level.txt +0 -1
- fastapi_radar/dashboard/dist/assets/index-BJa0l2JD.js +0 -313
- fastapi_radar/dashboard/dist/assets/index-DCxkDBhr.css +0 -1
- fastapi_radar-0.1.6.dist-info/RECORD +0 -17
- tests/__init__.py +0 -1
- tests/test_radar.py +0 -65
- {fastapi_radar-0.1.6.dist-info → fastapi_radar-0.3.1.dist-info}/WHEEL +0 -0
- {fastapi_radar-0.1.6.dist-info → fastapi_radar-0.3.1.dist-info}/licenses/LICENSE +0 -0
fastapi_radar/capture.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
"""SQLAlchemy query capture for FastAPI Radar."""
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
-
from typing import Any, Optional,
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
5
5
|
from sqlalchemy import event
|
|
6
6
|
from sqlalchemy.engine import Engine
|
|
7
7
|
|
|
8
|
+
try: # SQLAlchemy async support is optional
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
10
|
+
except Exception: # pragma: no cover - module might not exist in older SQLAlchemy
|
|
11
|
+
AsyncEngine = None # type: ignore[assignment]
|
|
8
12
|
from .middleware import request_context
|
|
9
13
|
from .models import CapturedQuery
|
|
14
|
+
|
|
15
|
+
|
|
10
16
|
from .utils import format_sql
|
|
17
|
+
from .tracing import get_current_trace_context
|
|
11
18
|
|
|
12
19
|
|
|
13
20
|
class QueryCapture:
|
|
@@ -21,16 +28,20 @@ class QueryCapture:
|
|
|
21
28
|
self.capture_bindings = capture_bindings
|
|
22
29
|
self.slow_query_threshold = slow_query_threshold
|
|
23
30
|
self._query_start_times = {}
|
|
31
|
+
self._registered_engines: Dict[int, Engine] = {}
|
|
24
32
|
|
|
25
33
|
def register(self, engine: Engine) -> None:
|
|
26
|
-
|
|
27
|
-
event.listen(
|
|
28
|
-
event.listen(
|
|
34
|
+
sync_engine = self._resolve_engine(engine)
|
|
35
|
+
event.listen(sync_engine, "before_cursor_execute", self._before_cursor_execute)
|
|
36
|
+
event.listen(sync_engine, "after_cursor_execute", self._after_cursor_execute)
|
|
37
|
+
self._registered_engines[id(engine)] = sync_engine
|
|
29
38
|
|
|
30
39
|
def unregister(self, engine: Engine) -> None:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
sync_engine = self._registered_engines.pop(id(engine), None)
|
|
41
|
+
if not sync_engine:
|
|
42
|
+
sync_engine = self._resolve_engine(engine)
|
|
43
|
+
event.remove(sync_engine, "before_cursor_execute", self._before_cursor_execute)
|
|
44
|
+
event.remove(sync_engine, "after_cursor_execute", self._after_cursor_execute)
|
|
34
45
|
|
|
35
46
|
def _before_cursor_execute(
|
|
36
47
|
self,
|
|
@@ -44,8 +55,25 @@ class QueryCapture:
|
|
|
44
55
|
request_id = request_context.get()
|
|
45
56
|
if not request_id:
|
|
46
57
|
return
|
|
47
|
-
|
|
48
|
-
self._query_start_times[
|
|
58
|
+
context_id = id(context)
|
|
59
|
+
self._query_start_times[context_id] = time.time()
|
|
60
|
+
setattr(context, "_radar_request_id", request_id)
|
|
61
|
+
trace_ctx = get_current_trace_context()
|
|
62
|
+
if trace_ctx:
|
|
63
|
+
formatted_sql = format_sql(statement)
|
|
64
|
+
operation_type = self._get_operation_type(statement)
|
|
65
|
+
db_tags = self._get_db_tags(conn)
|
|
66
|
+
span_id = trace_ctx.create_span(
|
|
67
|
+
operation_name=f"DB {operation_type}",
|
|
68
|
+
span_kind="client",
|
|
69
|
+
tags={
|
|
70
|
+
"db.statement": formatted_sql[:500], # limit SQL length
|
|
71
|
+
"db.operation_type": operation_type,
|
|
72
|
+
"component": "database",
|
|
73
|
+
**db_tags,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
setattr(context, "_radar_span_id", span_id)
|
|
49
77
|
|
|
50
78
|
def _after_cursor_execute(
|
|
51
79
|
self,
|
|
@@ -58,15 +86,32 @@ class QueryCapture:
|
|
|
58
86
|
) -> None:
|
|
59
87
|
request_id = request_context.get()
|
|
60
88
|
if not request_id:
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
request_id = getattr(context, "_radar_request_id", None)
|
|
90
|
+
if not request_id:
|
|
91
|
+
return
|
|
63
92
|
start_time = self._query_start_times.pop(id(context), None)
|
|
64
93
|
if start_time is None:
|
|
65
94
|
return
|
|
66
95
|
|
|
67
96
|
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
68
97
|
|
|
69
|
-
|
|
98
|
+
trace_ctx = get_current_trace_context()
|
|
99
|
+
if trace_ctx and hasattr(context, "_radar_span_id"):
|
|
100
|
+
span_id = getattr(context, "_radar_span_id")
|
|
101
|
+
additional_tags = {
|
|
102
|
+
"db.duration_ms": duration_ms,
|
|
103
|
+
"db.rows_affected": (
|
|
104
|
+
cursor.rowcount if hasattr(cursor, "rowcount") else None
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
status = "ok"
|
|
109
|
+
if duration_ms >= self.slow_query_threshold:
|
|
110
|
+
status = "slow"
|
|
111
|
+
additional_tags["db.slow_query"] = True
|
|
112
|
+
|
|
113
|
+
trace_ctx.finish_span(span_id, status=status, tags=additional_tags)
|
|
114
|
+
|
|
70
115
|
if "radar_" in statement:
|
|
71
116
|
return
|
|
72
117
|
|
|
@@ -90,16 +135,56 @@ class QueryCapture:
|
|
|
90
135
|
session.add(captured_query)
|
|
91
136
|
session.commit()
|
|
92
137
|
except Exception:
|
|
93
|
-
pass
|
|
94
|
-
|
|
95
|
-
def
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def _get_operation_type(self, statement: str) -> str:
|
|
141
|
+
if not statement:
|
|
142
|
+
return "unknown"
|
|
143
|
+
|
|
144
|
+
statement = statement.strip().upper()
|
|
145
|
+
|
|
146
|
+
if statement.startswith("SELECT"):
|
|
147
|
+
return "SELECT"
|
|
148
|
+
elif statement.startswith("INSERT"):
|
|
149
|
+
return "INSERT"
|
|
150
|
+
elif statement.startswith("UPDATE"):
|
|
151
|
+
return "UPDATE"
|
|
152
|
+
elif statement.startswith("DELETE"):
|
|
153
|
+
return "DELETE"
|
|
154
|
+
elif statement.startswith("CREATE"):
|
|
155
|
+
return "CREATE"
|
|
156
|
+
elif statement.startswith("DROP"):
|
|
157
|
+
return "DROP"
|
|
158
|
+
elif statement.startswith("ALTER"):
|
|
159
|
+
return "ALTER"
|
|
160
|
+
else:
|
|
161
|
+
return "OTHER"
|
|
162
|
+
|
|
163
|
+
def _serialize_parameters(
|
|
164
|
+
self, parameters: Any
|
|
165
|
+
) -> Union[Dict[str, str], List[str], None]:
|
|
96
166
|
"""Serialize query parameters for storage."""
|
|
97
167
|
if not parameters:
|
|
98
168
|
return None
|
|
99
169
|
|
|
100
170
|
if isinstance(parameters, (list, tuple)):
|
|
101
|
-
return [str(p) for p in parameters[:100]]
|
|
171
|
+
return [str(p) for p in parameters[:100]]
|
|
102
172
|
elif isinstance(parameters, dict):
|
|
103
173
|
return {k: str(v) for k, v in list(parameters.items())[:100]}
|
|
104
174
|
|
|
105
175
|
return [str(parameters)]
|
|
176
|
+
|
|
177
|
+
def _resolve_engine(self, engine: Engine) -> Engine:
|
|
178
|
+
if AsyncEngine is not None and isinstance(engine, AsyncEngine):
|
|
179
|
+
return engine.sync_engine
|
|
180
|
+
return engine
|
|
181
|
+
|
|
182
|
+
def _get_db_tags(self, conn: Any) -> Dict[str, Optional[str]]:
|
|
183
|
+
tags: Dict[str, Optional[str]] = {}
|
|
184
|
+
engine = getattr(conn, "engine", None)
|
|
185
|
+
if engine and getattr(engine, "dialect", None):
|
|
186
|
+
tags["db.system"] = engine.dialect.name
|
|
187
|
+
url = getattr(engine, "url", None)
|
|
188
|
+
if url is not None:
|
|
189
|
+
tags["db.name"] = getattr(url, "database", None)
|
|
190
|
+
return tags
|