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/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, Callable
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
- """Register SQLAlchemy event listeners."""
27
- event.listen(engine, "before_cursor_execute", self._before_cursor_execute)
28
- event.listen(engine, "after_cursor_execute", self._after_cursor_execute)
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
- """Unregister SQLAlchemy event listeners."""
32
- event.remove(engine, "before_cursor_execute", self._before_cursor_execute)
33
- event.remove(engine, "after_cursor_execute", self._after_cursor_execute)
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[id(context)] = time.time()
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
- return
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
- # Skip radar's own queries
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 # Silently ignore storage errors
94
-
95
- def _serialize_parameters(self, parameters: Any) -> Optional[list]:
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]] # Limit to 100 params
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