Coverage for src/asgi_profiler/instrument.py: 88%

99 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-22 16:59 +0000

1"""SQL capture. 

2 

3Listeners are attached to SQLAlchemy's `Engine` **class**, so every engine in 

4the process is covered without the application handing us one. That is what 

5makes SQLModel work for free: `sqlmodel.create_engine` returns a SQLAlchemy 

6`Engine`, and `sqlmodel.Session` subclasses `sqlalchemy.orm.Session`, so the 

7same cursor events fire. Async engines are covered too -- `AsyncEngine` drives 

8a sync `Engine` underneath, and that is where the events live. 

9""" 

10 

11from __future__ import annotations 

12 

13import contextlib 

14import contextvars 

15import threading 

16import time 

17import traceback 

18from collections.abc import Iterator 

19from typing import Any 

20 

21from sqlalchemy import event 

22from sqlalchemy.engine import Engine 

23 

24from .models import MAX_SQL_CHARS, Query 

25 

26try: # SQLAlchemy pulls greenlet in for its asyncio support 

27 import greenlet 

28except ImportError: # pragma: no cover - sync-only install 

29 greenlet = None 

30 

31#: The list being filled for the in-flight request. 

32#: 

33#: It has to be a *mutable* object rather than a value: SQLAlchemy's sync work 

34#: often runs in a worker thread (anyio copies the context into it), so appends 

35#: made there must land in an object the request task already holds. 

36current_queries: contextvars.ContextVar[list[Query] | None] = contextvars.ContextVar( 

37 "asgi_profiler_queries", default=None 

38) 

39 

40#: `(perf_counter at the start of the request, id of the event loop thread)`. 

41#: 

42#: The first turns an absolute timestamp into an offset from the start of the 

43#: request, which is what makes two statements comparable: whether they 

44#: overlapped, how long the gap between them was, whether six of them ran 

45#: together or one after another. 

46#: 

47#: The second is how a blocking call is recognised. See `_blocks_the_loop`. 

48current_origin: contextvars.ContextVar[tuple[float, int] | None] = contextvars.ContextVar( 

49 "asgi_profiler_origin", default=None 

50) 

51 

52_IGNORED_FRAME_PARTS = ( 

53 "/sqlalchemy/", 

54 "\\sqlalchemy\\", 

55 "/sqlmodel/", 

56 "\\sqlmodel\\", 

57 "/asgi_profiler/", 

58 "\\asgi_profiler\\", 

59 "/anyio/", 

60 "\\anyio\\", 

61 "/asyncio/", 

62 "\\asyncio\\", 

63 "/greenlet/", 

64 "\\greenlet\\", 

65 "/gevent/", 

66 "\\gevent\\", 

67 "/concurrent/futures/", 

68 "\\concurrent\\futures\\", 

69 "/threading.py", 

70 "\\threading.py", 

71) 

72 

73#: Live capture settings, read by the listeners at event time. 

74#: 

75#: A dict rather than closure arguments so that a later `install()` can change 

76#: them. The listeners are attached to the `Engine` class exactly once, but the 

77#: settings they honour must stay reconfigurable -- otherwise the first 

78#: `install()` in a process would silently dictate `capture_stacks` for every 

79#: application in it, including across tests. 

80_settings: dict[str, Any] = {"capture_stacks": True, "stack_depth": 8} 

81 

82_installed = False 

83_listeners: list[tuple[str, Any]] = [] 

84 

85#: Cap on how far up the stack to walk before giving up on finding 

86#: application frames. Guards against pathological recursion. 

87_MAX_STACK_WALK = 250 

88 

89 

90def install(*, capture_stacks: bool = True, stack_depth: int = 8) -> None: 

91 """Attach the cursor listeners. Safe to call more than once. 

92 

93 Calling it again updates `capture_stacks` and `stack_depth` in place; the 

94 listeners themselves are only attached the first time. 

95 """ 

96 global _installed 

97 _settings["capture_stacks"] = capture_stacks 

98 _settings["stack_depth"] = stack_depth 

99 if _installed: 

100 return 

101 

102 # ANN001 missing-type-function-argument, ARG001 unused-function-argument: 

103 # SQLAlchemy calls this with a fixed positional signature we do not choose. 

104 def _before(conn, cursor, statement, parameters, context, executemany): # noqa: ANN001, ARG001 

105 # A stack: a single connection can nest executions. 

106 conn.info.setdefault("_profiler_started", []).append(time.perf_counter()) 

107 

108 # ANN001 missing-type-function-argument, ARG001 unused-function-argument: 

109 # same fixed signature as `_before`. 

110 def _after(conn, cursor, statement, parameters, context, executemany): # noqa: ANN001, ARG001 

111 started = conn.info.get("_profiler_started") 

112 if not started: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true

113 return 

114 _record(statement, parameters, started.pop(), time.perf_counter()) 

115 

116 # ANN001 missing-type-function-argument: SQLAlchemy does not export 

117 # `ExceptionContext` as a public name to annotate this with. 

118 def _error(context) -> None: # noqa: ANN001 

119 """A statement that raised never reaches `after_cursor_execute`. 

120 

121 Without this the failure is invisible in the UI *and* its start time 

122 is stranded on the connection forever, which on a pooled connection 

123 that sees repeated errors is an unbounded list. 

124 """ 

125 conn = getattr(context, "connection", None) 

126 if conn is None or context.statement is None: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 return # a connect-level failure: no cursor execution to close out 

128 started = conn.info.get("_profiler_started") 

129 if not started: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 return 

131 _record( 

132 context.statement, 

133 context.parameters, 

134 started.pop(), 

135 time.perf_counter(), 

136 error=_format_error(context.original_exception), 

137 ) 

138 

139 for name, listener in ( 

140 ("before_cursor_execute", _before), 

141 ("after_cursor_execute", _after), 

142 ("handle_error", _error), 

143 ): 

144 event.listen(Engine, name, listener) 

145 _listeners.append((name, listener)) 

146 

147 _installed = True 

148 

149 

150def uninstall() -> None: 

151 """Detach the listeners. Mainly for test isolation.""" 

152 global _installed 

153 for name, listener in _listeners: 

154 with contextlib.suppress(Exception): # already gone 

155 event.remove(Engine, name, listener) 

156 _listeners.clear() 

157 _installed = False 

158 

159 

160def _blocks_the_loop(loop_thread: int) -> bool: 

161 """Did this statement run *on* the event loop, stopping it? 

162 

163 Three situations to tell apart, and only one of them is a bug: 

164 

165 * A different thread. Starlette runs `def` endpoints in a worker thread, 

166 and `asyncio.to_thread` puts anything else there on request. The loop 

167 keeps serving other requests throughout, so this is fine. 

168 * The loop thread, inside a greenlet. SQLAlchemy's async bridge runs the 

169 driver call in a greenlet it spawned and switches back to the loop while 

170 it waits. Also fine, and the common case for `create_async_engine`. 

171 * The loop thread, in the main greenlet. Nothing yielded: a synchronous 

172 driver call inside an `async def` endpoint, holding the only thread the 

173 server has for the entire duration of the query. Every other request 

174 in flight is frozen behind it. 

175 

176 The greenlet test is on `parent` rather than on any SQLAlchemy internal, 

177 because the bridge has been reorganised more than once and an attribute 

178 name that moves would turn this into a silent false positive on every 

179 async query. A spawned greenlet always has a parent; the main one never 

180 does. 

181 

182 Under gevent everything runs in a greenlet, so this reports nothing. A 

183 detector that misses a real problem is a disappointment; one that invents 

184 them is the reason people turn detectors off. 

185 """ 

186 if threading.get_ident() != loop_thread: 

187 return False 

188 if greenlet is not None and getattr(greenlet.getcurrent(), "parent", None) is not None: 

189 return False 

190 return True 

191 

192 

193def _record( 

194 statement: Any, 

195 parameters: Any, 

196 started: float, 

197 ended: float, 

198 error: str | None = None, 

199) -> None: 

200 queries = current_queries.get() 

201 if queries is None: 

202 return # outside any request: startup, migrations, a shell 

203 

204 origin = current_origin.get() 

205 queries.append( 

206 Query( 

207 sql=_normalise(statement), 

208 params=_format_params(parameters), 

209 duration_ms=(ended - started) * 1000, 

210 started_ms=(started - origin[0]) * 1000 if origin else 0.0, 

211 blocking=_blocks_the_loop(origin[1]) if origin else False, 

212 stack=(_capture_stack(_settings["stack_depth"]) if _settings["capture_stacks"] else []), 

213 error=error, 

214 ) 

215 ) 

216 

217 

218def _normalise(statement: Any) -> str: 

219 text = " ".join(str(statement).split()) 

220 # `max_requests` bounds how many requests are kept, not how big they are. 

221 # An ORM bulk insert is a single statement tens of kilobytes long, and one 

222 # endpoint doing that can hold hundreds of megabytes in the ring buffer. 

223 if len(text) > MAX_SQL_CHARS: 

224 return text[:MAX_SQL_CHARS] + f" ... [truncated, {len(text)} chars]" 

225 return text 

226 

227 

228def _format_params(parameters: Any) -> str: 

229 try: 

230 text = repr(parameters) 

231 except Exception: # a parameter with a hostile __repr__ 

232 return "<unrepresentable>" 

233 return text if len(text) <= 500 else text[:500] + " ..." 

234 

235 

236def _format_error(exc: BaseException | None) -> str: 

237 if exc is None: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true

238 return "error" 

239 text = f"{type(exc).__name__}: {exc}" 

240 return text if len(text) <= 500 else text[:500] + " ..." 

241 

242 

243def _walk_all_frames() -> Iterator[tuple[str, int, str]]: 

244 """Every frame from here outwards, crossing greenlet boundaries. 

245 

246 Under an async engine, SQLAlchemy runs the DBAPI call inside a greenlet it 

247 spawned. That greenlet's stack is five frames of SQLAlchemy and nothing 

248 else -- the application frame that issued the query lives in the *parent* 

249 greenlet, which `walk_stack` cannot see. Without this, per-query stacks are 

250 silently empty for every async user, which is most of them. 

251 """ 

252 for here, lineno in traceback.walk_stack(None): 

253 yield here.f_code.co_filename, lineno, here.f_code.co_name 

254 

255 if greenlet is None: 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true

256 return 

257 current = getattr(greenlet.getcurrent(), "parent", None) 

258 while current is not None: 

259 frame: Any = getattr(current, "gr_frame", None) 

260 while frame is not None: 260 ↛ 263line 260 didn't jump to line 263 because the condition on line 260 was always true

261 yield frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name 

262 frame = frame.f_back 

263 current = getattr(current, "parent", None) 

264 

265 

266def _capture_stack(depth: int) -> list[str]: 

267 """Application frames that led to this query, innermost last. 

268 

269 This is the thing that makes an N+1 actionable: it names the line in *your* 

270 code that triggered it. 

271 

272 Walks outward from the caller and stops as soon as `depth` application 

273 frames have been found. `traceback.extract_stack()` would be the obvious 

274 call, but it unwinds the *entire* stack and does a `linecache` source 

275 lookup per frame, then throws almost all of it away -- which in a real 

276 async request stack of 100+ frames costs an order of magnitude more per 

277 query than this does. 

278 """ 

279 frames: list[str] = [] 

280 for seen, (filename, lineno, name) in enumerate(_walk_all_frames()): 

281 if seen >= _MAX_STACK_WALK: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true

282 break 

283 if any(part in filename for part in _IGNORED_FRAME_PARTS): 

284 continue 

285 frames.append(f"{filename}:{lineno} in {name}") 

286 if len(frames) >= depth: 

287 break 

288 frames.reverse() # innermost last, matching a traceback 

289 return frames