asgi-profiler 0.1.0__tar.gz

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.
Files changed (49) hide show
  1. asgi_profiler-0.1.0/.gitignore +18 -0
  2. asgi_profiler-0.1.0/.pre-commit-config.yaml +83 -0
  3. asgi_profiler-0.1.0/CHANGELOG.md +62 -0
  4. asgi_profiler-0.1.0/CONTRIBUTING.md +63 -0
  5. asgi_profiler-0.1.0/LICENSE +21 -0
  6. asgi_profiler-0.1.0/PKG-INFO +436 -0
  7. asgi_profiler-0.1.0/README.md +396 -0
  8. asgi_profiler-0.1.0/assets/logo-480.png +0 -0
  9. asgi_profiler-0.1.0/assets/logo-960.png +0 -0
  10. asgi_profiler-0.1.0/assets/logo-dark-480.png +0 -0
  11. asgi_profiler-0.1.0/assets/logo-dark-960.png +0 -0
  12. asgi_profiler-0.1.0/assets/logo-dark.svg +12 -0
  13. asgi_profiler-0.1.0/assets/logo-icon-128.png +0 -0
  14. asgi_profiler-0.1.0/assets/logo-icon-16.png +0 -0
  15. asgi_profiler-0.1.0/assets/logo-icon-256.png +0 -0
  16. asgi_profiler-0.1.0/assets/logo-icon-32.png +0 -0
  17. asgi_profiler-0.1.0/assets/logo-icon-512.png +0 -0
  18. asgi_profiler-0.1.0/assets/logo-icon-64.png +0 -0
  19. asgi_profiler-0.1.0/assets/logo-icon.svg +21 -0
  20. asgi_profiler-0.1.0/assets/logo.svg +12 -0
  21. asgi_profiler-0.1.0/examples/fastapi_sqlmodel_app.py +97 -0
  22. asgi_profiler-0.1.0/examples/starlette_app.py +78 -0
  23. asgi_profiler-0.1.0/pyproject.toml +122 -0
  24. asgi_profiler-0.1.0/src/asgi_profiler/__init__.py +187 -0
  25. asgi_profiler-0.1.0/src/asgi_profiler/__main__.py +72 -0
  26. asgi_profiler-0.1.0/src/asgi_profiler/config.py +64 -0
  27. asgi_profiler-0.1.0/src/asgi_profiler/instrument.py +233 -0
  28. asgi_profiler-0.1.0/src/asgi_profiler/middleware.py +148 -0
  29. asgi_profiler-0.1.0/src/asgi_profiler/models.py +363 -0
  30. asgi_profiler-0.1.0/src/asgi_profiler/py.typed +0 -0
  31. asgi_profiler-0.1.0/src/asgi_profiler/routing.py +191 -0
  32. asgi_profiler-0.1.0/src/asgi_profiler/static/profiler.css +272 -0
  33. asgi_profiler-0.1.0/src/asgi_profiler/storage.py +982 -0
  34. asgi_profiler-0.1.0/src/asgi_profiler/templates/base.html +39 -0
  35. asgi_profiler-0.1.0/src/asgi_profiler/templates/detail.html +144 -0
  36. asgi_profiler-0.1.0/src/asgi_profiler/templates/requests.html +118 -0
  37. asgi_profiler-0.1.0/src/asgi_profiler/templates/statements.html +63 -0
  38. asgi_profiler-0.1.0/src/asgi_profiler/templates/summary.html +62 -0
  39. asgi_profiler-0.1.0/src/asgi_profiler/viewer.py +274 -0
  40. asgi_profiler-0.1.0/tests/conftest.py +23 -0
  41. asgi_profiler-0.1.0/tests/test_async_and_engines.py +162 -0
  42. asgi_profiler-0.1.0/tests/test_concurrency.py +137 -0
  43. asgi_profiler-0.1.0/tests/test_diagnostics.py +494 -0
  44. asgi_profiler-0.1.0/tests/test_profiler.py +262 -0
  45. asgi_profiler-0.1.0/tests/test_regressions.py +427 -0
  46. asgi_profiler-0.1.0/tests/test_review_findings.py +533 -0
  47. asgi_profiler-0.1.0/tests/test_round_three.py +532 -0
  48. asgi_profiler-0.1.0/tests/test_storage_and_viewer.py +373 -0
  49. asgi_profiler-0.1.0/tests/test_trust.py +387 -0
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ .ruff_cache/
9
+ .pytest_cache/
10
+ .ty_cache/
11
+ example.db
12
+ profiler.db
13
+ *.db-wal
14
+ *.db-shm
15
+ .coverage
16
+ htmlcov/
17
+ _to_delete/
18
+ REVIEW.md
@@ -0,0 +1,83 @@
1
+ # Runs the same checks CI runs, before the commit exists rather than after.
2
+ #
3
+ # Install once per clone:
4
+ #
5
+ # uv run --all-extras pre-commit install
6
+ #
7
+ # `default_install_hook_types` means that one command wires up both stages:
8
+ # the fast checks on every commit, and the test suite on push.
9
+ #
10
+ # Why the linters are `repo: local` rather than the upstream mirrors:
11
+ # `astral-sh/ruff-pre-commit` pins its own ruff version, so the pinned `rev`
12
+ # here and the version in `uv.lock` drift apart the moment either is bumped --
13
+ # and then a commit passes locally and the identical code fails in CI, which
14
+ # is the one failure mode a pre-commit hook exists to prevent. Going through
15
+ # `uv run` means the hook and CI are running the same build of the same tool,
16
+ # resolved from the same lockfile, always.
17
+
18
+ default_install_hook_types: [pre-commit, pre-push]
19
+
20
+ repos:
21
+ # ------------------------------------------------------- format and lint
22
+ - repo: local
23
+ hooks:
24
+ - id: ruff-format
25
+ name: ruff format
26
+ # Formats rather than checks: the point of the hook is that you never
27
+ # see the CI formatting failure at all. Also reformats Python inside
28
+ # fenced blocks in the markdown, which is how README.md broke CI once.
29
+ entry: uv run --all-extras ruff format
30
+ language: system
31
+ types_or: [python, markdown]
32
+ require_serial: true
33
+
34
+ - id: ruff-check
35
+ name: ruff check
36
+ entry: uv run --all-extras ruff check --fix
37
+ language: system
38
+ types_or: [python, pyi]
39
+ require_serial: true
40
+
41
+ # ------------------------------------------------ types and security
42
+ # Whole-package, so no filenames are passed: `ty` needs the imports to
43
+ # resolve, and a bandit finding can sit in a file the commit did not
44
+ # touch. `files:` still limits *when* they fire.
45
+ - id: ty
46
+ name: ty check
47
+ entry: uv run --all-extras ty check src/asgi_profiler
48
+ language: system
49
+ pass_filenames: false
50
+ files: ^src/
51
+ require_serial: true
52
+
53
+ - id: bandit
54
+ name: bandit
55
+ # src only: the suite is full of asserts and temp paths, which bandit
56
+ # flags by design and which say nothing about the shipped package.
57
+ entry: uv run --all-extras bandit -q -r src/asgi_profiler
58
+ language: system
59
+ pass_filenames: false
60
+ files: ^src/
61
+ require_serial: true
62
+
63
+ - id: uv-lock
64
+ name: uv lock is in sync with pyproject.toml
65
+ # The check CI makes with `uv sync --locked`. Catching it here saves a
66
+ # round trip, because a stale lock fails every job at once.
67
+ entry: uv lock --check
68
+ language: system
69
+ pass_filenames: false
70
+ files: ^(pyproject\.toml|uv\.lock)$
71
+
72
+ # ------------------------------------------------------- on push: tests
73
+ - repo: local
74
+ hooks:
75
+ - id: pytest
76
+ name: pytest
77
+ # Push, not commit: the suite takes seconds rather than milliseconds,
78
+ # and a hook slow enough to be worth skipping gets skipped.
79
+ entry: uv run --all-extras pytest -q
80
+ language: system
81
+ pass_filenames: false
82
+ always_run: true
83
+ stages: [pre-push]
@@ -0,0 +1,62 @@
1
+ <!-- markdownlint-disable MD024 -->
2
+
3
+ # Changelog
4
+
5
+ All notable changes to `asgi-profiler` are documented here. The format is
6
+ based on [Keep a Changelog](https://keepachangelog.com/) and this project
7
+ follows [Semantic Versioning](https://semver.org/).
8
+
9
+ ## [0.1.0] - 11.09.2026
10
+
11
+ First release. Request and SQL profiling for **ASGI** applications —
12
+ **Starlette**, **FastAPI** — with **SQLAlchemy** and **SQLModel**.
13
+ `install(app)` is the whole integration: no settings module, no database
14
+ table, no migration.
15
+
16
+ ### Added
17
+
18
+ - `install(app)` — adds a pure-ASGI middleware and mounts the viewer. Returns a
19
+ `Profiler` handle with `slowest()`, `search()`, `summary()`, `statements()`
20
+ and `close()`.
21
+ - SQL capture through SQLAlchemy's `before_cursor_execute`,
22
+ `after_cursor_execute` and `handle_error` events, attached to the `Engine`
23
+ class — so every engine in the process is covered, SQLModel needs no special
24
+ support, and async engines work unchanged.
25
+ - Per-query application stacks, walked across the greenlet boundary that
26
+ SQLAlchemy's async support puts between the query and your code.
27
+ - Statements that raise are recorded with their error and filterable.
28
+ - Route-pattern grouping: `/users/1` and `/users/2` aggregate as
29
+ `/users/{user_id}`, recovered by replaying the router where the framework
30
+ does not expose it, and correct under mounts and typed converters.
31
+ - **Requests** page — filterable and paginated, with `N+1 ×n` and `SQL error`
32
+ badges.
33
+ - **Request detail** page — repeated statements collapse into one row with a
34
+ `×N` badge, the spread of their timings, a sample of the parameters, and one
35
+ copy of the stack that issued them.
36
+ - **Summary** page — grouped by route, with p50/p95/p99 rather than an average.
37
+ - **Statements** page — every statement aggregated across all requests.
38
+ - JSON for every page (`/requests.json`, `/request/<id>.json`,
39
+ `/summary.json`, `/statements.json`), so a test can fail a build when an
40
+ endpoint regresses to an N+1.
41
+ - `X-Profiler-Id` response header, linking a slow response to its trace.
42
+ - `MemoryStorage` (default) and `SQLiteStorage`, the latter shared across
43
+ `uvicorn` workers and written from a background thread so no `fsync` or lock
44
+ wait lands on the event loop.
45
+ - `Storage` protocol plus a `BaseStorage` that supplies searching, paging,
46
+ summarising and statement aggregation, so a custom backend needs five
47
+ methods.
48
+ - `python -m asgi_profiler profiler.db` — browse a capture file offline,
49
+ read-only, with no application.
50
+ - `authorize=` hook, sync or async. `POST /clear` rejects cross-site requests.
51
+ - `py.typed`, checked in CI with both `mypy` and `ty`, on Python 3.10–3.13.
52
+
53
+ ### Known limitations
54
+
55
+ - No Python-side profiling yet: when an endpoint is slow and the SQL is not,
56
+ "time in Python" is a single number with nothing behind it.
57
+ - Request and response bodies are never recorded.
58
+ - WebSockets are not recorded.
59
+ - The `Engine`-class listeners are process-wide, so the profiler cannot be
60
+ scoped to one engine and the most recent `install()` decides
61
+ `capture_stacks`.
62
+ - `max_requests` bounds the number of requests kept, not their total size.
@@ -0,0 +1,63 @@
1
+ # Contributing
2
+
3
+ ```console
4
+ uv sync --all-extras
5
+ uv run pre-commit install
6
+ ```
7
+
8
+ That is the whole setup. `pre-commit install` wires up both stages at once:
9
+ formatting, linting, types, security and the lockfile check on every commit,
10
+ and the test suite on push.
11
+
12
+ `--all-extras` matters: without it FastAPI is missing and the tests covering
13
+ FastAPI route handling skip in silence.
14
+
15
+ To run things by hand, or to see what the hooks will do before committing:
16
+
17
+ ```console
18
+ uv run pre-commit run --all-files # everything the commit hook runs
19
+ uv run pre-commit run --all-files --hook-stage pre-push # the above, plus pytest
20
+ ```
21
+
22
+ The individual commands, if you want one of them on its own:
23
+
24
+ ```console
25
+ uv run pytest
26
+ uv run ruff check . && uv run ruff format --check .
27
+ uv run ty check src/asgi_profiler
28
+ uv run bandit -q -r src/asgi_profiler
29
+ ```
30
+
31
+ Two things worth knowing about the hooks:
32
+
33
+ - The linters are `repo: local` and go through `uv run`, so they are the
34
+ versions in `uv.lock` — the same ones CI uses. The upstream `ruff-pre-commit`
35
+ mirror pins its own version, which drifts from the lockfile and lets a commit
36
+ pass locally and fail in CI on identical code.
37
+ - `ruff format` also formats the Python inside fenced blocks in the markdown
38
+ files, so a README snippet can fail the format check. The hook fixes it in
39
+ place rather than just reporting it.
40
+
41
+ ## Ground rules
42
+
43
+ - **Every bug fix gets a regression test that fails without the fix.**
44
+ `tests/test_regressions.py` is organised that way; keep it that way.
45
+ - **Behaviour that differs between storage backends is a bug.**
46
+ `tests/test_storage_and_viewer.py` parametrises the same assertions over
47
+ `MemoryStorage` and `SQLiteStorage`. A new backend should join that fixture.
48
+ - **Comments explain *why*, not *what*.** The non-obvious choices in this
49
+ codebase — pure ASGI over `BaseHTTPMiddleware`, a mutable contextvar, class-
50
+ level engine listeners, walking the stack by hand — are all load-bearing, and
51
+ each is commented where it lives. If you change one of them, change the
52
+ comment in the same commit.
53
+ - Keep the profiler's own overhead in mind: anything added to the per-query
54
+ path is paid for on every statement your users run.
55
+
56
+ ## Releasing
57
+
58
+ 1. `uv version --bump patch` (or `minor` / `major`). Nothing to mirror:
59
+ `__version__` is read from the installed distribution.
60
+ 2. Date the new section in `CHANGELOG.md`.
61
+ 3. Tag `X.Y.Z` (no `v` prefix) and push. `release.yml` re-runs the suite on every supported
62
+ Python, builds with `uv build --no-sources`, verifies the tag matches the
63
+ packaged version, and publishes with `uv publish` over Trusted Publishing.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miradil Zeynalli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,436 @@
1
+ Metadata-Version: 2.5
2
+ Name: asgi-profiler
3
+ Version: 0.1.0
4
+ Summary: Request and SQL profiling for ASGI apps: Starlette, FastAPI, SQLAlchemy
5
+ Project-URL: Source, https://github.com/mmzeynalli/asgi-profiler
6
+ Project-URL: Issues, https://github.com/mmzeynalli/asgi-profiler/issues
7
+ Project-URL: Changelog, https://github.com/mmzeynalli/asgi-profiler/blob/main/CHANGELOG.md
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: asgi,fastapi,n+1,profiler,profiling,silk,sql,sqlalchemy,sqlmodel,starlette
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Debuggers
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: jinja2>=3.0
22
+ Requires-Dist: sqlalchemy>=2.0
23
+ Requires-Dist: starlette>=0.35
24
+ Provides-Extra: dev
25
+ Requires-Dist: bandit; extra == 'dev'
26
+ Requires-Dist: pre-commit; extra == 'dev'
27
+ Requires-Dist: ruff; extra == 'dev'
28
+ Requires-Dist: ty; extra == 'dev'
29
+ Requires-Dist: uvicorn; extra == 'dev'
30
+ Provides-Extra: test
31
+ Requires-Dist: aiosqlite; extra == 'test'
32
+ Requires-Dist: anyio; extra == 'test'
33
+ Requires-Dist: fastapi; extra == 'test'
34
+ Requires-Dist: httpx; extra == 'test'
35
+ Requires-Dist: httpx2; extra == 'test'
36
+ Requires-Dist: pytest-cov; extra == 'test'
37
+ Requires-Dist: pytest>=8; extra == 'test'
38
+ Requires-Dist: sqlmodel; extra == 'test'
39
+ Description-Content-Type: text/markdown
40
+
41
+ <!-- markdownlint-disable MD033 -->
42
+ <p align="center">
43
+ <picture>
44
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
45
+ <img alt="asgi-profiler" src="assets/logo.svg" width="380">
46
+ </picture>
47
+ </p>
48
+
49
+ <p align="center">
50
+ <a href="https://pypi.org/project/asgi-profiler/"><img alt="PyPI package" src="https://img.shields.io/pypi/v/asgi-profiler?color=%2334D058&label=pypi%20package"></a>
51
+ <a href="https://pypi.org/project/asgi-profiler/"><img alt="Supported Python versions" src="https://img.shields.io/pypi/pyversions/asgi-profiler.svg?color=%2334D058"></a>
52
+ <a href="https://pepy.tech/project/asgi-profiler"><img alt="Downloads" src="https://static.pepy.tech/badge/asgi-profiler"></a>
53
+ <a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/mmzeynalli/asgi-profiler"><img alt="Coverage" src="https://coverage-badge.samuelcolvin.workers.dev/mmzeynalli/asgi-profiler.svg"></a>
54
+ <br>
55
+ <a href="https://github.com/mmzeynalli/asgi-profiler/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mmzeynalli/asgi-profiler/actions/workflows/ci.yml/badge.svg"></a>
56
+ <a href="https://www.gnu.org/licenses/mit.en.html"><img alt="License" src="https://img.shields.io/badge/license-MIT-16A34A"></a>
57
+ <a href="https://github.com/astral-sh/ruff"><img alt="Ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json"></a>
58
+ <a href="https://github.com/mmzeynalli/asgi-profiler"><img alt="Typed" src="https://img.shields.io/badge/typed-py.typed-0F766E"></a>
59
+ </p>
60
+ <!-- markdownlint-enable MD033 -->
61
+
62
+ ---
63
+
64
+ Request and SQL profiling for **ASGI** applications — **Starlette** and
65
+ **FastAPI**, with **SQLAlchemy** and **SQLModel**. It records every request your
66
+ application handles along with the SQL each one ran, and serves a browsable UI
67
+ that tells you which endpoint is slow, whether it is the database, and which
68
+ line of your code issued the query.
69
+
70
+ ```python
71
+ from fastapi import FastAPI
72
+ from asgi_profiler import install
73
+
74
+ app = FastAPI()
75
+ install(app) # viewer at /profiler
76
+ ```
77
+
78
+ That is the whole integration. No settings module, no database table, no
79
+ migration.
80
+
81
+ ## Table of Contents
82
+
83
+ - [Key features](#key-features)
84
+ - [Installation](#installation)
85
+ - [Usage](#usage)
86
+ - [What it shows](#what-it-shows)
87
+ - [JSON](#json)
88
+ - [Configuration](#configuration)
89
+ - [Storage](#storage)
90
+ - [Security](#security)
91
+ - [How it works](#how-it-works)
92
+ - [Limits](#limits)
93
+ - [Prior art](#prior-art)
94
+ - [Development](#development)
95
+ - [Licence](#licence)
96
+
97
+ ---
98
+
99
+ ## Key features
100
+
101
+ - **One line to install.** `install(app)` adds the middleware and mounts the
102
+ viewer. You never hand the profiler an engine.
103
+ - **N+1 detection that names the line.** Repeated statements collapse into a
104
+ single row with a `×N` badge and the stack that issued them — a 500-row N+1
105
+ is one line to read, not 500 to scroll.
106
+ - **Grouped by route, not by path.** `/users/1` and `/users/2` aggregate as
107
+ `/users/{user_id}`, with p50/p95/p99 rather than an average.
108
+ - **Cross-request statement view.** Which SQL costs you application-wide, not
109
+ just on the endpoint you happen to be looking at.
110
+ - **JSON for every page**, so a test can fail a build when an endpoint
111
+ regresses.
112
+ - **Works with async engines**, sync engines, several engines at once, and
113
+ SQLModel — with no configuration for any of them.
114
+ - **Two storage backends**, one of them shared across `uvicorn` workers.
115
+ - Fully typed and `py.typed`, checked in CI with `ty`, and scanned with `bandit`.
116
+
117
+ | | Supported |
118
+ |---|---|
119
+ | Starlette | ✅ |
120
+ | FastAPI | ✅ |
121
+ | SQLAlchemy 2.x (sync) | ✅ |
122
+ | SQLAlchemy 2.x (async) | ✅ |
123
+ | SQLModel | ✅ |
124
+ | Python 3.10 – 3.13 | ✅ |
125
+ | Litestar | planned |
126
+
127
+ ## Installation
128
+
129
+ ```console
130
+ pip install asgi-profiler
131
+ ```
132
+
133
+ Dependencies are `starlette`, `sqlalchemy` and `jinja2` — all of which you
134
+ already have.
135
+
136
+ ## Usage
137
+
138
+ ```python
139
+ from fastapi import FastAPI
140
+ from asgi_profiler import install
141
+
142
+ app = FastAPI()
143
+ install(app)
144
+ ```
145
+
146
+ Make some requests, then open `/profiler`.
147
+
148
+ `install()` returns a handle if you want the data programmatically:
149
+
150
+ ```python
151
+ profiler = install(app)
152
+ ...
153
+ for profile in profiler.slowest(5):
154
+ print(profile.route, profile.query_count, profile.duplicate_count)
155
+
156
+ for statement in profiler.statements(10):
157
+ print(f"{statement.total_ms:.0f}ms x{statement.count} {statement.sql[:60]}")
158
+ ```
159
+
160
+ > [!Note]
161
+ > `profiler.profiles` returns everything, which on a large `SQLiteStorage`
162
+ > means deserialising every statement of every request. Prefer `slowest()`,
163
+ > `search()`, `summary()` and `statements()`, which the backend can answer
164
+ > without hydrating the history. Call `profiler.close()` on shutdown to
165
+ > release a file-backed store.
166
+
167
+ ## What it shows
168
+
169
+ **Requests** — every request, newest first: method, path, status, wall time,
170
+ time in SQL, query count. Rows that repeat a statement carry an `N+1 ×n`
171
+ badge; rows with a statement that raised carry a `SQL error` badge. Filter by
172
+ path substring, method, status class, route or a minimum duration; sort by most
173
+ recent, slowest, most queries or most SQL time; or narrow to N+1s or failures
174
+ only. Paginated.
175
+
176
+ **Request detail** — status, total time, time in SQL, time in Python, query
177
+ count, duplicate count and error count, then the statements it ran. Repeated
178
+ statements collapse into one row carrying a `×N` badge, the total/average/max
179
+ time across those runs, a sample of the parameters, and *one* copy of the stack
180
+ that issued them all. Slow and failed statements are marked, and
181
+ request/response headers are shown with `authorization`, `cookie` and friends
182
+ redacted.
183
+
184
+ **Summary** — every request grouped by method and route pattern, so `/users/1`
185
+ and `/users/2` are one row rather than two: calls, p50/p95/p99, max, total
186
+ time, average queries, total duplicates and total SQL errors, heaviest first.
187
+ Percentiles rather than the average, which one outlier ruins.
188
+
189
+ **Statements** — every statement grouped by its SQL, across *all* requests:
190
+ runs, how many requests ran it, runs-per-request, total, average and max time,
191
+ and how many routes it appears on. The per-request view tells you why *this*
192
+ endpoint is slow; this tells you what is costing you application-wide — the
193
+ query that runs three times on eleven endpoints is invisible in the former and
194
+ obvious here.
195
+
196
+ Every response also carries an `X-Profiler-Id` header, so you can go straight
197
+ from a slow response to its trace at `/profiler/request/<id>`.
198
+
199
+ ## JSON
200
+
201
+ Every page has a JSON twin — `/profiler/requests.json` (honours the same
202
+ filters), `/profiler/request/<id>.json`, `/profiler/summary.json`,
203
+ `/profiler/statements.json`. Which makes the profiler scriptable, and lets a
204
+ test fail a build when an endpoint regresses:
205
+
206
+ ```python
207
+ def test_the_dashboard_has_no_n_plus_one(client):
208
+ client.get("/dashboard")
209
+ trace = client.get("/profiler/requests.json").json()["requests"][0]
210
+ assert trace["duplicate_count"] == 0, "N+1 reintroduced"
211
+ assert trace["query_count"] <= 5
212
+ ```
213
+
214
+ ## Configuration
215
+
216
+ ```python
217
+ from asgi_profiler import install
218
+
219
+ install(
220
+ app,
221
+ mount_path="/_perf", # anywhere; every link is relative to the mount
222
+ max_requests=1000,
223
+ exclude_paths=["/healthz"], # matched on segment boundaries
224
+ capture_stacks=True, # the per-query stacks; costs a little per query
225
+ stack_depth=8,
226
+ slow_request_ms=500,
227
+ slow_query_ms=50,
228
+ capture_headers=True,
229
+ response_header="x-profiler-id", # None to add nothing
230
+ page_size=50,
231
+ statement_limit=100, # rows on the Statements page
232
+ authorize=lambda request: request.headers.get("x-key") == "...",
233
+ )
234
+ ```
235
+
236
+ ## Storage
237
+
238
+ The default is an in-memory ring buffer: zero configuration, lost on restart,
239
+ and **per process** — under `uvicorn --workers 4` the viewer shows you one
240
+ worker's quarter of the traffic. If that matters, use SQLite:
241
+
242
+ ```python
243
+ from asgi_profiler import SQLiteStorage, install
244
+
245
+ install(app, storage=SQLiteStorage("profiler.db", max_requests=5000))
246
+ ```
247
+
248
+ History then survives restarts and every worker writes to the same file.
249
+
250
+ Writes go through a background thread, because `add()` is called while the
251
+ event loop is running: a synchronous insert there means an `fsync`, and with
252
+ several workers on one WAL file a lock wait that `sqlite3` implements as a
253
+ blocking sleep. Measured at up to 230 ms of stall in a single `add()` before
254
+ this changed; it is now ~1 µs to enqueue. Reads flush first, so you never see a
255
+ stale page. Pass `background=False` for synchronous writes.
256
+
257
+ Paging and ordering are pushed down into SQL and stay flat as history grows.
258
+ The `?q=` path filter is a substring match, which no index can serve, so it
259
+ scans — about 8 ms over 20,000 rows. Fine for a development tool; it is not a
260
+ log search engine.
261
+
262
+ Any object satisfying the `Storage` protocol works. Subclass `BaseStorage` and
263
+ you only need five methods — searching, paging, summarising and statement
264
+ aggregation are supplied, and you can override them where your backend can do
265
+ better:
266
+
267
+ ```python
268
+ from asgi_profiler import BaseStorage
269
+
270
+
271
+ class RedisStorage(BaseStorage):
272
+ def add(self, profile): ...
273
+ def get(self, profile_id): ...
274
+ def list(self, *, limit=None, offset=0): ...
275
+ def count(self): ...
276
+ def clear(self): ...
277
+ ```
278
+
279
+ ### Reading a capture without the app
280
+
281
+ ```console
282
+ python -m asgi_profiler profiler.db # viewer on :8080
283
+ ```
284
+
285
+ Capture in staging, read the file on your laptop. No application required, and
286
+ the file is opened read-only.
287
+
288
+ ## Security
289
+
290
+ > [!Caution]
291
+ > **The viewer has no authentication of its own.** It exposes SQL, parameters
292
+ > and request headers. Either keep it off outside development, or pass
293
+ > `authorize=`.
294
+
295
+ ```python
296
+ install(app, authorize=lambda request: request.user.is_staff)
297
+
298
+
299
+ # async is fine too -- and any realistic guard is async
300
+ async def only_staff(request):
301
+ return await is_staff(request.user)
302
+
303
+
304
+ install(app, authorize=only_staff)
305
+ ```
306
+
307
+ A common pattern is to install it conditionally:
308
+
309
+ ```python
310
+ if settings.DEBUG:
311
+ install(app)
312
+ ```
313
+
314
+ `POST /clear` only accepts `Sec-Fetch-Site: same-origin` (or a same-origin
315
+ `Origin`), so another site — including a sibling subdomain — cannot make your
316
+ browser wipe your history. That is not authentication, though, and it is not a
317
+ substitute for `authorize=`.
318
+
319
+ ## How it works
320
+
321
+ **Requests** are captured by a pure-ASGI middleware. Pure ASGI rather than
322
+ `BaseHTTPMiddleware` on purpose: the latter runs the downstream app in a
323
+ separate task, which breaks the contextvar that query attribution depends on.
324
+
325
+ **Queries** are captured with SQLAlchemy's own `before_cursor_execute`,
326
+ `after_cursor_execute` and `handle_error` events, attached to the `Engine`
327
+ **class**. That is why you never hand the profiler an engine, and why SQLModel
328
+ needs no special support: `sqlmodel.create_engine` returns a SQLAlchemy
329
+ `Engine` and `sqlmodel.Session` subclasses `sqlalchemy.orm.Session`, so the
330
+ same events fire. Async engines are covered too — `AsyncEngine` drives a sync
331
+ `Engine` underneath, and that is where the events live.
332
+
333
+ **Attribution** uses a contextvar holding a mutable list. It has to be mutable:
334
+ SQLAlchemy's sync work often runs in a worker thread, and anyio copies the
335
+ context into that thread, so appends made there must land in an object the
336
+ request task already holds. The test suite fires concurrent requests with
337
+ different query counts and asserts none of them bleed.
338
+
339
+ **Route patterns** come from `scope["route"]` where the framework sets one
340
+ (FastAPI does, and that path is free). Plain Starlette sets no `route` key at
341
+ all, so the router is replayed once after the response to find which route
342
+ matched — exact where reconstructing from `path_params` is not, since a
343
+ `{uid:int}` matching `007` and a value that collides with a literal segment
344
+ both defeat string substitution. Patterns are returned absolute, so a sub-app
345
+ mounted at two prefixes does not collapse into one summary row.
346
+
347
+ **Stacks** are walked outward from the query and stop as soon as enough
348
+ application frames are found, crossing the greenlet boundary that SQLAlchemy's
349
+ async support puts between the query and your code.
350
+ `traceback.extract_stack()` would unwind the whole stack and read source lines
351
+ for every frame before discarding almost all of it, which on a realistic
352
+ 100-frame async stack costs ~20× more per query.
353
+
354
+ **URLs** in the UI are all relative to the mount prefix, taken from
355
+ `scope["root_path"]` when the server sets one — which is also what accounts for
356
+ a proxy prefix — and otherwise from the `prefix` passed to `build_viewer`.
357
+
358
+ ## Limits
359
+
360
+ - **Overhead is real.** Two event callbacks and a `perf_counter` pair per
361
+ query, plus a stack capture if enabled — around 20 µs per query in situ,
362
+ which on a 20-query endpoint is a ~70% increase in wall time. On plain
363
+ Starlette, a *parameterised* route also costs one replayed routing pass per
364
+ request to recover its pattern — about 11 µs at 50 routes, 42 µs at 200.
365
+ FastAPI does not pay this. Development tool, not production telemetry.
366
+ - **No Python-side profiling yet.** When an endpoint is slow and the SQL is
367
+ not, "time in Python" is a single number with nothing behind it. A cProfile
368
+ panel is the next thing to build.
369
+ - **No request or response bodies.** They carry credentials and can be large.
370
+ - **WebSockets are not recorded.** Only HTTP requests are.
371
+ - **Global hooks.** Listeners are on the `Engine` class, so every engine in the
372
+ process is captured. That is deliberate — it is what makes app-wide capture
373
+ work — but it means you cannot scope the profiler to one engine, and
374
+ `capture_stacks` / `stack_depth` are process-wide (the most recent
375
+ `install()` wins).
376
+ - **Unmatched requests and raw ASGI mounts group by path.** A 404 has no route
377
+ to name, and a mount to a bare ASGI app (StaticFiles, say) exposes none.
378
+ `exclude_paths` if that is noisy.
379
+ - **`max_requests` bounds requests, not bytes.** Statements over 4000
380
+ characters are truncated, but a request running thousands of queries still
381
+ retains all of them.
382
+
383
+ ## Prior art
384
+
385
+ There is no *actively maintained* equivalent of
386
+ [django-silk](https://github.com/jazzband/django-silk) for this stack, but
387
+ there is prior art worth knowing about:
388
+
389
+ | Project | Notes |
390
+ |---|---|
391
+ | [fastapi-debug-toolbar](https://github.com/mongkok/fastapi-debug-toolbar) | A django-debug-toolbar port with a SQLAlchemy panel. FastAPI only; last release May 2024. |
392
+ | [fastapi-sql-profiler](https://pypi.org/project/fastapi-sql-profiler/) | SQL profiling for FastAPI. |
393
+
394
+ This project differs in being Starlette-level rather than FastAPI-only, in not
395
+ injecting a toolbar into your responses, and in offering a cross-request
396
+ summary aggregated by route alongside per-query application stacks.
397
+
398
+ ## Development
399
+
400
+ ```console
401
+ uv sync --all-extras
402
+ uv run pre-commit install
403
+ uv run pytest
404
+ ```
405
+
406
+ `pre-commit install` is the whole check setup: `ruff format`, `ruff check`,
407
+ `ty`, `bandit` and a lockfile check run on every commit, and the suite runs on
408
+ push. To run them without committing:
409
+
410
+ ```console
411
+ uv run pre-commit run --all-files
412
+ ```
413
+
414
+ Building and publishing go through uv too:
415
+
416
+ ```console
417
+ uv build --no-sources
418
+ ```
419
+
420
+ Releases are cut by pushing a bare version tag (`0.1.0`); CI builds and
421
+ publishes with
422
+ `uv publish` over PyPI Trusted Publishing, so no token is stored anywhere.
423
+
424
+ The suite covers recording, SQL capture, N+1 detection and collapsing, failed
425
+ statements, stack capture and ordering across the greenlet boundary, header
426
+ redaction, history bounds, every viewer page parsed for well-formed HTML,
427
+ filters, pagination, the JSON endpoints, custom mount paths, proxy root paths,
428
+ the authorize hook (sync and async), CSRF on clear, route-pattern grouping
429
+ under mounts and typed converters, async engines, multiple engines in one
430
+ process, concurrent request attribution, both storage backends asserted against
431
+ the same expectations, a custom storage backend, and queries issued outside any
432
+ request.
433
+
434
+ ## Licence
435
+
436
+ MIT.