Coverage for src/asgi_profiler/storage.py: 97%

380 statements  

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

1"""Where recorded profiles live. 

2 

3Two backends ship: :class:`MemoryStorage`, the zero-config default, and 

4:class:`SQLiteStorage`, which survives a restart and is shared by every worker 

5in a multi-process deployment. 

6 

7`Storage` is a Protocol, so a Redis or Postgres backend can be dropped in 

8without touching the middleware or the viewer. Subclass :class:`BaseStorage` 

9and you only have to implement the five core methods -- searching, paging, 

10summarising and statement aggregation come for free, and can be overridden 

11where the backend can do them better than Python can. 

12""" 

13 

14from __future__ import annotations 

15 

16import atexit 

17import contextlib 

18import json 

19import logging 

20import math 

21import queue 

22import sqlite3 

23import threading 

24import time 

25import urllib.parse 

26import weakref 

27from collections import defaultdict, deque 

28from collections.abc import Iterable, Mapping 

29from dataclasses import dataclass, field 

30from datetime import datetime, timezone 

31from pathlib import Path 

32from typing import Any, Protocol 

33 

34from .models import PathSummary, Problem, Profile, Query, StatementSummary 

35 

36logger = logging.getLogger("asgi_profiler") 

37 

38#: Aliases resolved at module scope. Inside the storage classes the name 

39#: `list` is bound to the method, so a bare `-> list[Profile]` annotation 

40#: resolves to that method instead of the builtin -- which type checkers 

41#: rightly reject, and which made the shipped `py.typed` marker a lie. 

42Profiles = list[Profile] 

43PathSummaries = list[PathSummary] 

44StatementSummaries = list[StatementSummary] 

45 

46_ORDERINGS: dict[str, Any] = { 

47 "recent": None, 

48 "slowest": lambda p: -p.duration_ms, 

49 "queries": lambda p: -p.query_count, 

50 "sql": lambda p: -p.query_ms, 

51} 

52 

53 

54@dataclass(frozen=True) 

55class Filters: 

56 """The viewer's filter state, in one object both backends understand.""" 

57 

58 q: str = "" 

59 method: str = "" 

60 status: str = "" # "ok" | "warn" | "err" 

61 route: str = "" # exact match on the route pattern 

62 min_ms: float | None = None 

63 only_duplicates: bool = False 

64 only_errors: bool = False 

65 only_problems: bool = False 

66 order: str = "recent" 

67 

68 @classmethod 

69 def from_params(cls, params: Mapping[str, str]) -> Filters: 

70 raw = (params.get("min_ms") or "").strip() 

71 try: 

72 min_ms = float(raw) if raw else None 

73 except ValueError: 

74 min_ms = None 

75 if min_ms is not None and not math.isfinite(min_ms): 

76 # `?min_ms=nan` compares False everywhere in Python and True 

77 # nowhere in SQL, so the two backends would disagree. 

78 min_ms = None 

79 only = params.get("only", "") 

80 order = params.get("order", "recent") 

81 return cls( 

82 # Capped: SQLite raises "LIKE or GLOB pattern too complex" past a 

83 # few thousand characters, which would 500 the page rather than 

84 # return nothing. 

85 q=(params.get("q") or "").strip()[:200], 

86 method=(params.get("method") or "").strip().upper(), 

87 status=(params.get("status") or "").strip(), 

88 route=(params.get("route") or "").strip(), 

89 min_ms=min_ms, 

90 only_duplicates=only == "duplicates", 

91 only_errors=only == "errors", 

92 only_problems=only == "problems", 

93 order=order if order in _ORDERINGS else "recent", 

94 ) 

95 

96 @property 

97 def active(self) -> bool: 

98 return bool( 

99 self.q 

100 or self.method 

101 or self.status 

102 or self.route 

103 or self.min_ms is not None 

104 or self.only_duplicates 

105 or self.only_errors 

106 or self.only_problems 

107 ) 

108 

109 def matches(self, profile: Profile) -> bool: 

110 if self.q and self.q.lower() not in profile.full_path.lower(): 

111 return False 

112 if self.method and profile.method != self.method: 

113 return False 

114 if self.route and profile.group != self.route: 

115 return False 

116 if self.status == "err" and profile.status_code < 500: 

117 return False 

118 if self.status == "warn" and not (400 <= profile.status_code < 500): 

119 return False 

120 if self.status == "ok" and not (200 <= profile.status_code < 300): 

121 return False 

122 if self.only_problems and not profile.problems: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 return False 

124 if self.only_duplicates and not profile.duplicate_count: 

125 return False 

126 if self.only_errors and not profile.error_count: 

127 return False 

128 return not (self.min_ms is not None and profile.duration_ms < self.min_ms) 

129 

130 

131@dataclass 

132class Page: 

133 """One page of results, plus enough context to render the pager. 

134 

135 The items are *listing* rows: counters are always populated, but a backend 

136 may leave `queries` and the header dicts empty because the request list 

137 never shows them. Use `Storage.get()` for a complete profile. 

138 """ 

139 

140 items: Profiles = field(default_factory=list) 

141 #: Matching the filters, across all pages. 

142 total: int = 0 

143 number: int = 1 

144 size: int = 50 

145 

146 @property 

147 def pages(self) -> int: 

148 return max(1, -(-self.total // self.size)) if self.size else 1 

149 

150 @property 

151 def has_previous(self) -> bool: 

152 return self.number > 1 

153 

154 @property 

155 def has_next(self) -> bool: 

156 return self.number < self.pages 

157 

158 @property 

159 def first_index(self) -> int: 

160 return (self.number - 1) * self.size + 1 if self.total else 0 

161 

162 @property 

163 def last_index(self) -> int: 

164 return min(self.number * self.size, self.total) 

165 

166 

167class Storage(Protocol): 

168 def add(self, profile: Profile) -> None: ... 

169 

170 def get(self, profile_id: str) -> Profile | None: ... 

171 

172 def list(self, *, limit: int | None = None, offset: int = 0) -> Profiles: ... 

173 

174 def count(self) -> int: ... 

175 

176 def clear(self) -> None: ... 

177 

178 def search(self, filters: Filters, *, page: int = 1, size: int = 50) -> Page: ... 

179 

180 def summarise(self) -> PathSummaries: ... 

181 

182 def statements(self, limit: int = 100) -> StatementSummaries: ... 

183 

184 

185class BaseStorage: 

186 """Generic `search`, `summarise` and `statements` on top of `list()`. 

187 

188 Correct for any backend; a backend that can push the work down to a query 

189 engine should override them. 

190 """ 

191 

192 def list( # pragma: no cover - overridden 

193 self, *, limit: int | None = None, offset: int = 0 

194 ) -> Profiles: 

195 raise NotImplementedError 

196 

197 def count(self) -> int: # pragma: no cover - overridden 

198 raise NotImplementedError 

199 

200 def search(self, filters: Filters, *, page: int = 1, size: int = 50) -> Page: 

201 matched = [p for p in self.list() if filters.matches(p)] 

202 key = _ORDERINGS.get(filters.order) 

203 if key is not None: 

204 matched.sort(key=key) 

205 return _paginate(matched, page=page, size=size) 

206 

207 def summarise(self) -> PathSummaries: 

208 return summarise(self.list()) 

209 

210 def statements(self, limit: int = 100) -> StatementSummaries: 

211 return aggregate_statements(self.list(), limit=limit) 

212 

213 

214def _paginate(profiles: Profiles, *, page: int, size: int) -> Page: 

215 size = max(1, size) 

216 total = len(profiles) 

217 pages = max(1, -(-total // size)) 

218 number = min(max(1, page), pages) 

219 start = (number - 1) * size 

220 return Page(items=profiles[start : start + size], total=total, number=number, size=size) 

221 

222 

223class MemoryStorage(BaseStorage): 

224 """Bounded ring buffer, newest first. Thread-safe. 

225 

226 Per-process: with multiple workers each holds its own history. Use 

227 :class:`SQLiteStorage` if that matters. 

228 """ 

229 

230 def __init__(self, max_requests: int = 500) -> None: 

231 self._items: deque[Profile] = deque(maxlen=max_requests) 

232 self._lock = threading.Lock() 

233 

234 def add(self, profile: Profile) -> None: 

235 with self._lock: 

236 self._items.appendleft(profile) 

237 

238 def get(self, profile_id: str) -> Profile | None: 

239 with self._lock: 

240 for item in self._items: 

241 if item.id == profile_id: 

242 return item 

243 return None 

244 

245 def list(self, *, limit: int | None = None, offset: int = 0) -> Profiles: 

246 with self._lock: 

247 items = list(self._items) 

248 if offset: 

249 items = items[offset:] 

250 return items[:limit] if limit is not None else items 

251 

252 def count(self) -> int: 

253 with self._lock: 

254 return len(self._items) 

255 

256 def clear(self) -> None: 

257 with self._lock: 

258 self._items.clear() 

259 

260 def __len__(self) -> int: 

261 return self.count() 

262 

263 

264#: Stores with a live writer thread, so the interpreter can flush them on the 

265#: way out. A daemon thread is killed at exit with whatever is still queued, 

266#: and nothing calls `close()` for a store handed to `install()` -- without 

267#: this the tail of every capture is lost, which for "profile in staging, read 

268#: the file later" is exactly the part you were waiting for. 

269#: 

270#: One handler for the process over a weak set, rather than one registration 

271#: per store: a per-store hook has to be unregistered on close, and a leaked 

272#: `atexit` entry per store is its own slow problem. 

273_OPEN_STORES: weakref.WeakSet[Any] = weakref.WeakSet() 

274 

275 

276def _flush_open_stores() -> None: 

277 for store in list(_OPEN_STORES): 

278 with contextlib.suppress(Exception): 

279 store.close() 

280 

281 

282atexit.register(_flush_open_stores) 

283 

284 

285class IncompatibleCapture(RuntimeError): 

286 """A capture file written by an incompatible schema version.""" 

287 

288 

289#: Bumped whenever the table layout changes. A mismatch rebuilds the file 

290#: rather than failing with an opaque OperationalError from inside `add()`, 

291#: after the response has already gone out. 

292SCHEMA_VERSION = 2 

293 

294_SCHEMA = """ 

295CREATE TABLE IF NOT EXISTS profiles ( 

296 seq INTEGER PRIMARY KEY AUTOINCREMENT, 

297 id TEXT NOT NULL UNIQUE, 

298 method TEXT NOT NULL, 

299 path TEXT NOT NULL, 

300 route TEXT NOT NULL DEFAULT '', 

301 grp TEXT NOT NULL DEFAULT '', 

302 query_string TEXT NOT NULL DEFAULT '', 

303 search_path TEXT NOT NULL DEFAULT '', 

304 status_code INTEGER NOT NULL DEFAULT 0, 

305 duration_ms REAL NOT NULL DEFAULT 0, 

306 query_ms REAL NOT NULL DEFAULT 0, 

307 query_count INTEGER NOT NULL DEFAULT 0, 

308 duplicate_count INTEGER NOT NULL DEFAULT 0, 

309 error_count INTEGER NOT NULL DEFAULT 0, 

310 blocking_count INTEGER NOT NULL DEFAULT 0, 

311 problem_count INTEGER NOT NULL DEFAULT 0, 

312 recorded_at TEXT NOT NULL, 

313 client TEXT NOT NULL DEFAULT '', 

314 -- Its own column rather than part of `payload`, because the request list 

315 -- reads it on every row and deliberately does not load the payload: the 

316 -- statements are most of a profile's bytes and the listing shows none of 

317 -- them. Problems are a handful of small objects, so they can be afforded 

318 -- on a page of fifty rows and the badges come for free. 

319 problems TEXT NOT NULL DEFAULT '[]', 

320 payload TEXT NOT NULL 

321); 

322CREATE INDEX IF NOT EXISTS profiles_seq ON profiles (seq DESC); 

323CREATE INDEX IF NOT EXISTS profiles_group ON profiles (method, grp); 

324 

325CREATE TABLE IF NOT EXISTS statements ( 

326 profile_seq INTEGER NOT NULL, 

327 sql TEXT NOT NULL, 

328 sql_hash TEXT NOT NULL DEFAULT '', 

329 grp TEXT NOT NULL DEFAULT '', 

330 method TEXT NOT NULL DEFAULT 'GET', 

331 duration_ms REAL NOT NULL DEFAULT 0, 

332 failed INTEGER NOT NULL DEFAULT 0 

333); 

334CREATE INDEX IF NOT EXISTS statements_profile ON statements (profile_seq); 

335-- On the hash, not the text. The text is what gets displayed; the hash is 

336-- what gets grouped, and grouping a 4000-character statement by its text is 

337-- an index entry the size of the statement. 

338CREATE INDEX IF NOT EXISTS statements_hash ON statements (sql_hash); 

339 

340CREATE TABLE IF NOT EXISTS meta ( 

341 key TEXT PRIMARY KEY, 

342 value INTEGER NOT NULL 

343); 

344INSERT OR IGNORE INTO meta (key, value) VALUES ('rows', 0); 

345""" 

346 

347_INSERT = ( 

348 "INSERT OR REPLACE INTO profiles (id, method, path, route, grp," 

349 " query_string, search_path, status_code, duration_ms, query_ms," 

350 " query_count, duplicate_count, error_count, blocking_count," 

351 " problem_count, recorded_at, client, problems, payload)" 

352 " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" 

353) 

354 

355 

356class SQLiteStorage(BaseStorage): 

357 """Profiles in a SQLite file. 

358 

359 Why you would want it: history survives a restart, and every `uvicorn` 

360 worker writes to the same file, so the viewer shows *all* of your traffic 

361 rather than the fraction that happened to hit the worker you are talking 

362 to. That single fact is what makes the profiler trustworthy under the 

363 default multi-worker deployment. 

364 

365 **Writes go through a background thread.** `add()` is called from the 

366 middleware while the event loop is running, and a synchronous insert there 

367 means an `fsync` -- and, with several workers on one WAL file, a lock wait 

368 that `sqlite3` implements as a blocking sleep -- happening *on the loop*. 

369 Measured at up to 800 ms of stall in a single `add()`. Enqueueing instead 

370 keeps the request path at a few microseconds, and batching the drain makes 

371 the writes themselves cheaper. Reads flush first, so you never see a stale 

372 page. 

373 

374 Pass ``background=False`` for synchronous writes (simpler in tests, and 

375 fine for a single-worker development server). 

376 """ 

377 

378 def __init__( 

379 self, 

380 path: str | Path = "profiler.db", 

381 max_requests: int = 5000, 

382 *, 

383 background: bool = True, 

384 batch_size: int = 64, 

385 linger_seconds: float = 0.02, 

386 read_only: bool = False, 

387 ) -> None: 

388 self.path = str(path) 

389 self.read_only = read_only 

390 self.max_requests = max_requests 

391 self.batch_size = max(1, batch_size) 

392 self.linger_seconds = max(0.0, linger_seconds) 

393 self._lock = threading.RLock() 

394 self._closed = False 

395 

396 if read_only: 

397 uri = f"file:{urllib.parse.quote(self.path)}?mode=ro" 

398 self._conn = sqlite3.connect(uri, uri=True, check_same_thread=False) 

399 else: 

400 self._conn = sqlite3.connect(self.path, check_same_thread=False) 

401 self._conn.row_factory = sqlite3.Row 

402 try: 

403 with self._lock: 

404 self._conn.create_function("py_lower", 1, _lower, deterministic=True) 

405 if read_only: 

406 self._check_schema() 

407 else: 

408 # WAL so several worker processes can write concurrently. 

409 self._conn.execute("PRAGMA journal_mode=WAL") 

410 self._conn.execute("PRAGMA synchronous=NORMAL") 

411 self._prepare_schema() 

412 except BaseException: 

413 # An __init__ that raises leaves no object for the caller to close, 

414 # so the connection would leak -- visible as a ResourceWarning 

415 # under `-W error` and as a held file handle on Windows. 

416 self._conn.close() 

417 self._closed = True 

418 raise 

419 

420 self._queue: queue.Queue[Profile | None] | None = None 

421 self._writer: threading.Thread | None = None 

422 if background: 

423 self._queue = queue.Queue() 

424 self._writer = threading.Thread( 

425 target=self._drain_forever, 

426 name="asgi-profiler-writer", 

427 daemon=True, 

428 ) 

429 self._writer.start() 

430 # A daemon thread is killed at interpreter exit with whatever is 

431 # still queued. Nothing calls close() for a store handed to 

432 # install(), so without this the tail of every capture is lost -- 

433 # which for "profile in staging, read the file later" is the part 

434 # you were waiting for. 

435 _OPEN_STORES.add(self) 

436 

437 # -- schema ----------------------------------------------------------- 

438 def _check_schema(self) -> None: 

439 """Read-only: report an incompatible file, never rewrite it. 

440 

441 `_prepare_schema` drops and recreates on a version mismatch, which is 

442 the right call for a live capture and catastrophic for the offline 

443 viewer -- opening someone's staging capture to look at it must not be 

444 able to delete it. 

445 """ 

446 version = self._conn.execute("PRAGMA user_version").fetchone()[0] 

447 if version != SCHEMA_VERSION: 

448 raise IncompatibleCapture( 

449 f"{self.path} was written by a different version of " 

450 f"asgi-profiler (schema {version}, expected " 

451 f"{SCHEMA_VERSION}). Re-capture with this version." 

452 ) 

453 

454 def _prepare_schema(self) -> None: 

455 version = self._conn.execute("PRAGMA user_version").fetchone()[0] 

456 existing = self._conn.execute( 

457 "SELECT name FROM sqlite_master WHERE type='table' AND name='profiles'" 

458 ).fetchone() 

459 if existing and version != SCHEMA_VERSION: 

460 # An old file from a previous release. Profiling history is 

461 # disposable by nature, so rebuild rather than write a migration 

462 # for data nobody would miss. 

463 self._conn.executescript( 

464 "DROP TABLE IF EXISTS profiles; DROP TABLE IF EXISTS statements;" 

465 ) 

466 self._conn.executescript(_SCHEMA) 

467 self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") 

468 self._conn.execute( 

469 "INSERT OR REPLACE INTO meta (key, value) VALUES" 

470 " ('rows', (SELECT COUNT(*) FROM profiles))" 

471 ) 

472 self._conn.commit() 

473 

474 # -- writing ---------------------------------------------------------- 

475 def add(self, profile: Profile) -> None: 

476 if self._closed or self.read_only: 

477 return 

478 if self._queue is not None: 

479 self._queue.put(profile) 

480 return 

481 with self._lock: 

482 self._write([profile]) 

483 

484 def flush(self, timeout: float = 30.0) -> None: 

485 """Block until every queued profile has been written. 

486 

487 Bounded: `queue.join()` alone waits forever, and this is called from 

488 the viewer's request handlers, so a wedged writer would take the 

489 application down rather than showing a stale page. 

490 """ 

491 if self._queue is None or self._closed: 

492 return 

493 deadline = time.monotonic() + timeout 

494 # `unfinished_tasks` is undocumented but stable since 2.5 -- reading it 

495 # is what lets this poll with a deadline instead of `join()`, which 

496 # cannot time out. 

497 while self._queue.unfinished_tasks: 

498 if time.monotonic() > deadline or not self._alive(): 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true

499 logger.warning("Timed out flushing profiles to %s", self.path) 

500 return 

501 time.sleep(0.001) 

502 

503 def _alive(self) -> bool: 

504 return self._writer is None or self._writer.is_alive() 

505 

506 def _drain_forever(self) -> None: # pragma: no cover - thread body 

507 """Drain the queue until the sentinel arrives. 

508 

509 Every exit path has to call `task_done()` for each item taken, and no 

510 exception may escape: `flush()` is a `queue.join()` with no timeout, so 

511 a writer that dies with items outstanding parks every future flush -- 

512 and every viewer request -- forever. 

513 """ 

514 # Not an `assert`: `python -O` strips those, and this one is the 

515 # narrowing the rest of the body relies on. A real check costs one 

516 # comparison once per thread. 

517 if self._queue is None: # pragma: no cover - unreachable by construction 

518 return 

519 while True: 

520 batch: Profiles = [] 

521 stop = False 

522 first = self._queue.get() 

523 if first is None: 

524 stop = True 

525 else: 

526 batch.append(first) 

527 # Linger briefly to coalesce. Without it the writer wakes for 

528 # every single profile whenever it is keeping up, and each wake 

529 # is its own transaction -- which puts the producer back in 

530 # contention with SQLite work and throws away the whole point 

531 # of writing off the request path. 

532 deadline = time.monotonic() + self.linger_seconds 

533 while len(batch) < self.batch_size: 

534 remaining = deadline - time.monotonic() 

535 if remaining <= 0: 

536 break 

537 try: 

538 nxt = self._queue.get(timeout=remaining) 

539 except queue.Empty: 

540 break 

541 if nxt is None: 

542 stop = True 

543 self._queue.task_done() 

544 break 

545 batch.append(nxt) 

546 

547 taken = len(batch) 

548 try: 

549 if batch: 

550 with self._lock: 

551 self._write(batch) 

552 except Exception: 

553 # Losing a batch is bad; losing the writer is worse. Say so -- 

554 # a diagnostic tool that under-reports traffic in silence is 

555 # the one failure nobody would think to check for. 

556 logger.warning( 

557 "Could not write %d profile(s) to %s", 

558 len(batch), 

559 self.path, 

560 exc_info=True, 

561 ) 

562 finally: 

563 for _ in range(taken): 

564 self._queue.task_done() 

565 if first is None: 

566 self._queue.task_done() 

567 if stop: 

568 return 

569 

570 def _write(self, profiles: Profiles) -> None: 

571 # Last write wins within a batch, so the row-count bookkeeping below 

572 # matches what SQLite will actually store. 

573 deduped = {p.id: p for p in profiles} 

574 already = { 

575 r["id"] 

576 for r in self._conn.execute( 

577 f"SELECT id FROM profiles WHERE id IN ({','.join('?' * len(deduped))})", # nosec hardcoded_sql_expressions 

578 list(deduped), 

579 ) 

580 } 

581 rows = [(_row_values(p), p) for p in deduped.values()] 

582 if already: 

583 # INSERT OR REPLACE allocates a *new* seq, so the replaced 

584 # profile's statement rows survive it -- unreachable, but still 

585 # aggregated, inflating every count on the statements page. 

586 self._conn.execute( 

587 "DELETE FROM statements WHERE profile_seq IN" # nosec hardcoded_sql_expressions 

588 f" (SELECT seq FROM profiles WHERE id IN" 

589 f" ({','.join('?' * len(already))}))", 

590 list(already), 

591 ) 

592 self._conn.executemany(_INSERT, [values for values, _ in rows]) 

593 seqs = { 

594 r["id"]: r["seq"] 

595 for r in self._conn.execute( 

596 "SELECT id, seq FROM profiles WHERE id IN" # nosec hardcoded_sql_expressions 

597 f" ({','.join('?' * len(rows))})", 

598 [p.id for _, p in rows], 

599 ) 

600 } 

601 self._conn.executemany( 

602 "INSERT INTO statements" 

603 " (profile_seq, sql, sql_hash, grp, method, duration_ms, failed)" 

604 " VALUES (?,?,?,?,?,?,?)", 

605 [ 

606 ( 

607 seqs[p.id], 

608 q.sql, 

609 q.sql_hash, 

610 p.group, 

611 p.method, 

612 q.duration_ms, 

613 1 if q.failed else 0, 

614 ) 

615 for _, p in rows 

616 if p.id in seqs 

617 for q in p.queries 

618 ], 

619 ) 

620 

621 # An INSERT OR REPLACE over an existing id does not add a row. Adding 

622 # one to the count anyway makes the trim evict a row too many, and the 

623 # history shrinks a little every time an id repeats. 

624 # An INSERT OR REPLACE over an existing id does not add a row. Adding 

625 # one to the count anyway makes the trim evict a row too many, and the 

626 # history shrinks a little every time an id repeats. 

627 self._bump_rows(len(deduped) - len(already)) 

628 self._trim() 

629 self._conn.commit() 

630 

631 def _bump_rows(self, delta: int) -> int: 

632 if delta: 

633 self._conn.execute( 

634 "UPDATE meta SET value = MAX(0, value + ?) WHERE key = 'rows'", 

635 (delta,), 

636 ) 

637 row = self._conn.execute("SELECT value FROM meta WHERE key = 'rows'").fetchone() 

638 return int(row["value"]) if row else 0 

639 

640 def _trim(self) -> None: 

641 """Drop the oldest rows above the cap, in O(overshoot). 

642 

643 The obvious `DELETE ... WHERE seq < (SELECT MIN(seq) FROM (... ORDER BY 

644 seq DESC LIMIT n))` walks the whole retention window on every insert -- 

645 390 us per request at the default cap of 5000, paid on the event loop. 

646 This walks only as far as the number of rows actually being evicted, 

647 which in the steady state is one per insert, while keeping the cap 

648 exact: a soft cap that overshoots by a batch is a worse trade than a 

649 cheap exact one. 

650 

651 The count it works from lives in the file rather than in this process. 

652 A per-process counter goes stale the moment another worker calls 

653 `clear()`, and a stale-high counter makes every subsequent insert 

654 delete the row it just wrote -- a worker that silently stops recording 

655 anything at all. 

656 """ 

657 excess = self._bump_rows(0) - self.max_requests 

658 if excess <= 0: 

659 return 

660 row = self._conn.execute( 

661 "SELECT seq FROM profiles ORDER BY seq ASC LIMIT 1 OFFSET ?", 

662 (excess - 1,), 

663 ).fetchone() 

664 if row is None: # pragma: no cover - another process trimmed first 

665 return 

666 cutoff = row["seq"] 

667 self._conn.execute("DELETE FROM statements WHERE profile_seq <= ?", (cutoff,)) 

668 deleted = self._conn.execute("DELETE FROM profiles WHERE seq <= ?", (cutoff,)).rowcount 

669 self._bump_rows(-max(0, deleted)) 

670 

671 def clear(self) -> None: 

672 if self.read_only: 

673 return 

674 self.flush() 

675 with self._lock: 

676 self._conn.execute("DELETE FROM profiles") 

677 self._conn.execute("DELETE FROM statements") 

678 self._conn.execute("UPDATE meta SET value = 0 WHERE key = 'rows'") 

679 self._conn.commit() 

680 

681 def close(self) -> None: 

682 """Flush pending writes, stop the writer thread and close the file.""" 

683 if self._closed: 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true

684 return 

685 _OPEN_STORES.discard(self) 

686 if self._queue is not None: 

687 self._queue.put(None) 

688 if self._writer is not None: 688 ↛ 690line 688 didn't jump to line 690 because the condition on line 688 was always true

689 self._writer.join(timeout=10) 

690 self._closed = True 

691 with self._lock: 

692 self._conn.close() 

693 

694 def __enter__(self) -> SQLiteStorage: 

695 return self 

696 

697 def __exit__(self, *exc: object) -> None: 

698 self.close() 

699 

700 # -- reading ---------------------------------------------------------- 

701 def count(self) -> int: 

702 self.flush() 

703 with self._lock: 

704 row = self._conn.execute("SELECT COUNT(*) AS n FROM profiles").fetchone() 

705 return int(row["n"]) 

706 

707 def get(self, profile_id: str) -> Profile | None: 

708 self.flush() 

709 with self._lock: 

710 row = self._conn.execute( 

711 "SELECT * FROM profiles WHERE id = ?", (profile_id,) 

712 ).fetchone() 

713 return _row_to_profile(row, with_queries=True) if row else None 

714 

715 def list(self, *, limit: int | None = None, offset: int = 0) -> Profiles: 

716 self.flush() 

717 sql = "SELECT * FROM profiles ORDER BY seq DESC LIMIT ? OFFSET ?" 

718 with self._lock: 

719 rows = self._conn.execute(sql, (-1 if limit is None else limit, offset)).fetchall() 

720 return [_row_to_profile(r, with_queries=True) for r in rows] 

721 

722 def search(self, filters: Filters, *, page: int = 1, size: int = 50) -> Page: 

723 self.flush() 

724 where, params = _where(filters) 

725 with self._lock: 

726 total = int( 

727 self._conn.execute( 

728 f"SELECT COUNT(*) AS n FROM profiles {where}", # nosec hardcoded_sql_expressions 

729 params, 

730 ).fetchone()["n"] 

731 ) 

732 size = max(1, size) 

733 pages = max(1, -(-total // size)) 

734 number = min(max(1, page), pages) 

735 # `seq DESC` as a tiebreaker everywhere: without it, equal 

736 # durations page in an arbitrary order here and newest-first in 

737 # MemoryStorage, so the same data reads differently per backend. 

738 order = { 

739 "recent": "seq DESC", 

740 "slowest": "duration_ms DESC, seq DESC", 

741 "queries": "query_count DESC, seq DESC", 

742 "sql": "query_ms DESC, seq DESC", 

743 }.get(filters.order, "seq DESC") 

744 rows = self._conn.execute( 

745 f"SELECT * FROM profiles {where} ORDER BY {order} LIMIT ? OFFSET ?", # nosec hardcoded_sql_expressions 

746 (*params, size, (number - 1) * size), 

747 ).fetchall() 

748 # The listing shows counters, never individual statements, so skip the 

749 # JSON payload entirely -- that is most of the row. 

750 return Page( 

751 items=[_row_to_profile(r, with_queries=False) for r in rows], 

752 total=total, 

753 number=number, 

754 size=size, 

755 ) 

756 

757 def summarise(self) -> PathSummaries: 

758 self.flush() 

759 with self._lock: 

760 rows = self._conn.execute( 

761 "SELECT method, grp, duration_ms, query_count, duplicate_count," 

762 " error_count, problem_count FROM profiles" 

763 ).fetchall() 

764 # Aggregated in Python rather than SQL because percentiles need the 

765 # whole distribution and SQLite has no PERCENTILE_CONT. One narrow 

766 # column scan is cheaper than a correlated subquery per group. 

767 buckets: dict[tuple[str, str], PathSummary] = {} 

768 durations: dict[tuple[str, str], list[float]] = defaultdict(list) 

769 for row in rows: 

770 key = (row["method"], row["grp"]) 

771 summary = buckets.get(key) 

772 if summary is None: 

773 summary = buckets[key] = PathSummary(path=row["grp"], method=key[0]) 

774 duration = float(row["duration_ms"]) 

775 summary.count += 1 

776 summary.total_ms += duration 

777 summary.max_ms = max(summary.max_ms, duration) 

778 summary.total_queries += int(row["query_count"]) 

779 summary.total_duplicates += int(row["duplicate_count"]) 

780 summary.total_errors += int(row["error_count"]) 

781 summary.total_problems += int(row["problem_count"]) 

782 durations[key].append(duration) 

783 for key, summary in buckets.items(): 

784 summary.set_percentiles(durations[key]) 

785 return sorted(buckets.values(), key=lambda r: r.total_ms, reverse=True) 

786 

787 def statements(self, limit: int = 100) -> StatementSummaries: 

788 self.flush() 

789 with self._lock: 

790 rows = self._conn.execute( 

791 "SELECT MIN(sql) AS sql, sql_hash, COUNT(*) AS n," 

792 " SUM(duration_ms) AS total," 

793 " MAX(duration_ms) AS worst, SUM(failed) AS failures," 

794 " COUNT(DISTINCT profile_seq) AS requests," 

795 " COUNT(DISTINCT grp) AS routes," 

796 " MIN(grp) AS a_route, MIN(method) AS a_method" 

797 " FROM statements GROUP BY sql_hash" 

798 # `sql` as a tiebreaker so that a LIMIT returns the same rows 

799 # here as in MemoryStorage. Equal totals are common with 

800 # coarse timers, and a limit that depends on the backend is 

801 # not an interchangeable API. 

802 " ORDER BY total DESC, sql ASC LIMIT ?", 

803 (max(0, limit),), 

804 ).fetchall() 

805 return [ 

806 StatementSummary( 

807 sql=r["sql"], 

808 sql_hash=r["sql_hash"], 

809 count=int(r["n"]), 

810 total_ms=float(r["total"] or 0.0), 

811 max_ms=float(r["worst"] or 0.0), 

812 requests=int(r["requests"]), 

813 route_count=int(r["routes"]), 

814 sample_route=r["a_route"] or "", 

815 sample_method=r["a_method"] or "GET", 

816 failures=int(r["failures"] or 0), 

817 ) 

818 for r in rows 

819 ] 

820 

821 

822def _lower(value: Any) -> Any: 

823 return value.lower() if isinstance(value, str) else value 

824 

825 

826def _search_text(profile: Profile) -> str: 

827 return profile.full_path.lower() 

828 

829 

830def _row_values(profile: Profile) -> tuple[Any, ...]: 

831 payload = json.dumps( 

832 { 

833 "request_headers": profile.request_headers, 

834 "response_headers": profile.response_headers, 

835 "queries": [ 

836 { 

837 "sql": q.sql, 

838 "params": q.params, 

839 "duration_ms": q.duration_ms, 

840 "started_ms": q.started_ms, 

841 "blocking": q.blocking, 

842 "stack": q.stack, 

843 "is_duplicate": q.is_duplicate, 

844 "error": q.error, 

845 } 

846 for q in profile.queries 

847 ], 

848 } 

849 ) 

850 return ( 

851 profile.id, 

852 profile.method, 

853 profile.path, 

854 profile.route, 

855 profile.group, 

856 profile.query_string, 

857 _search_text(profile), 

858 profile.status_code, 

859 profile.duration_ms, 

860 profile.query_ms, 

861 profile.query_count, 

862 profile.duplicate_count, 

863 profile.error_count, 

864 profile.blocking_count, 

865 len(profile.problems), 

866 profile.recorded_at.isoformat(), 

867 profile.client, 

868 json.dumps([problem.as_dict() for problem in profile.problems]), 

869 payload, 

870 ) 

871 

872 

873def _where(filters: Filters) -> tuple[str, tuple[Any, ...]]: 

874 clauses: list[str] = [] 

875 params: list[Any] = [] 

876 if filters.q: 

877 # Matched against a column folded at insert time. Calling py_lower per 

878 # row turned this into a Python callback over a full table scan -- 

879 # 16 ms at 20k rows against 3.7 ms for the pure-Python backend, which 

880 # is the opposite of the point of pushing the filter down. 

881 clauses.append("search_path LIKE ? ESCAPE '\\'") 

882 needle = filters.q.lower().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") 

883 params.append(f"%{needle}%") 

884 if filters.method: 

885 clauses.append("method = ?") 

886 params.append(filters.method) 

887 if filters.route: 

888 clauses.append("grp = ?") 

889 params.append(filters.route) 

890 if filters.status == "err": 

891 clauses.append("status_code >= 500") 

892 elif filters.status == "warn": 

893 clauses.append("status_code >= 400 AND status_code < 500") 

894 elif filters.status == "ok": 

895 clauses.append("status_code >= 200 AND status_code < 300") 

896 if filters.only_problems: 

897 clauses.append("problem_count > 0") 

898 if filters.only_duplicates: 

899 clauses.append("duplicate_count > 0") 

900 if filters.only_errors: 

901 clauses.append("error_count > 0") 

902 if filters.min_ms is not None: 

903 clauses.append("duration_ms >= ?") 

904 params.append(filters.min_ms) 

905 return ("WHERE " + " AND ".join(clauses)) if clauses else "", tuple(params) 

906 

907 

908def _row_to_profile(row: sqlite3.Row, *, with_queries: bool) -> Profile: 

909 payload = json.loads(row["payload"]) if with_queries else {} 

910 profile = Profile( 

911 id=row["id"], 

912 method=row["method"], 

913 path=row["path"], 

914 route=row["route"], 

915 query_string=row["query_string"], 

916 status_code=int(row["status_code"]), 

917 duration_ms=float(row["duration_ms"]), 

918 recorded_at=_parse_datetime(row["recorded_at"]), 

919 request_headers=payload.get("request_headers", {}), 

920 response_headers=payload.get("response_headers", {}), 

921 client=row["client"], 

922 queries=[ 

923 Query( 

924 sql=q["sql"], 

925 params=q["params"], 

926 duration_ms=q["duration_ms"], 

927 started_ms=q.get("started_ms", 0.0), 

928 blocking=q.get("blocking", False), 

929 stack=q.get("stack", []), 

930 is_duplicate=q.get("is_duplicate", False), 

931 error=q.get("error"), 

932 ) 

933 for q in payload.get("queries", []) 

934 ], 

935 # Read whether or not the statements were: this is what puts a badge 

936 # on a listing row, and the listing never loads the payload. 

937 problems=[Problem.from_dict(d) for d in json.loads(row["problems"] or "[]")], 

938 ) 

939 # Read the counters back rather than recomputing them, so a listing row 

940 # stays correct even though its statements were not loaded. 

941 profile.query_count = int(row["query_count"]) 

942 profile.query_ms = float(row["query_ms"]) 

943 profile.duplicate_count = int(row["duplicate_count"]) 

944 profile.error_count = int(row["error_count"]) 

945 profile.blocking_count = int(row["blocking_count"]) 

946 return profile 

947 

948 

949def _parse_datetime(text: str) -> datetime: 

950 try: 

951 parsed = datetime.fromisoformat(text) 

952 except ValueError: # pragma: no cover - defensive 

953 return datetime.now(tz=timezone.utc) 

954 return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) 

955 

956 

957def summarise(profiles: Iterable[Profile]) -> PathSummaries: 

958 """Group profiles for the summary page. 

959 

960 Keyed by the route *pattern* where one is known, so `/users/1` and 

961 `/users/2` land on one row instead of two. Grouping by the literal path 

962 turns the summary into a deduplicated request log, which is exactly what 

963 the page is not for. 

964 """ 

965 rows: dict[tuple[str, str], PathSummary] = {} 

966 durations: dict[tuple[str, str], list[float]] = defaultdict(list) 

967 for profile in profiles: 

968 key = (profile.method, profile.group) 

969 row = rows.get(key) 

970 if row is None: 

971 row = rows[key] = PathSummary(path=profile.group, method=profile.method) 

972 row.count += 1 

973 row.total_ms += profile.duration_ms 

974 row.max_ms = max(row.max_ms, profile.duration_ms) 

975 row.total_queries += profile.query_count 

976 row.total_duplicates += profile.duplicate_count 

977 row.total_errors += profile.error_count 

978 row.total_problems += len(profile.problems) 

979 durations[key].append(profile.duration_ms) 

980 for key, row in rows.items(): 

981 row.set_percentiles(durations[key]) 

982 return sorted(rows.values(), key=lambda r: r.total_ms, reverse=True) 

983 

984 

985def aggregate_statements(profiles: Iterable[Profile], limit: int = 100) -> StatementSummaries: 

986 """Group every recorded statement by its SQL, across all requests. 

987 

988 The per-request view answers "why is *this* endpoint slow". This answers 

989 "what is costing me across the whole application" -- the statement that 

990 runs three times on eleven different endpoints is invisible to the former 

991 and obvious here. 

992 """ 

993 rows: dict[str, StatementSummary] = {} 

994 routes: dict[str, set[str]] = defaultdict(set) 

995 methods: dict[str, set[str]] = defaultdict(set) 

996 requests: dict[str, set[str]] = defaultdict(set) 

997 for profile in profiles: 

998 for query in profile.queries: 

999 key = query.sql_hash 

1000 row = rows.get(key) 

1001 if row is None: 

1002 row = rows[key] = StatementSummary(sql=query.sql, sql_hash=key) 

1003 row.count += 1 

1004 row.total_ms += query.duration_ms 

1005 row.max_ms = max(row.max_ms, query.duration_ms) 

1006 # `min` throughout, never "the first one seen": SQLite groups with 

1007 # MIN(), and a sample that depends on insertion order is not the 

1008 # same API. The displayed text is one real execution of the group, 

1009 # chosen the same way in both backends. 

1010 row.sql = min(row.sql, query.sql) 

1011 if query.failed: 

1012 row.failures += 1 

1013 routes[key].add(profile.group) 

1014 methods[key].add(profile.method) 

1015 requests[key].add(profile.id) 

1016 for key, row in rows.items(): 

1017 row.route_count = len(routes[key]) 

1018 row.requests = len(requests[key]) 

1019 row.sample_route = min(routes[key]) if routes[key] else "" 

1020 row.sample_method = min(methods[key]) if methods[key] else "GET" 

1021 ordered = sorted(rows.values(), key=lambda r: (-r.total_ms, r.sql)) 

1022 return ordered[: max(0, limit)]