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.
- asgi_profiler-0.1.0/.gitignore +18 -0
- asgi_profiler-0.1.0/.pre-commit-config.yaml +83 -0
- asgi_profiler-0.1.0/CHANGELOG.md +62 -0
- asgi_profiler-0.1.0/CONTRIBUTING.md +63 -0
- asgi_profiler-0.1.0/LICENSE +21 -0
- asgi_profiler-0.1.0/PKG-INFO +436 -0
- asgi_profiler-0.1.0/README.md +396 -0
- asgi_profiler-0.1.0/assets/logo-480.png +0 -0
- asgi_profiler-0.1.0/assets/logo-960.png +0 -0
- asgi_profiler-0.1.0/assets/logo-dark-480.png +0 -0
- asgi_profiler-0.1.0/assets/logo-dark-960.png +0 -0
- asgi_profiler-0.1.0/assets/logo-dark.svg +12 -0
- asgi_profiler-0.1.0/assets/logo-icon-128.png +0 -0
- asgi_profiler-0.1.0/assets/logo-icon-16.png +0 -0
- asgi_profiler-0.1.0/assets/logo-icon-256.png +0 -0
- asgi_profiler-0.1.0/assets/logo-icon-32.png +0 -0
- asgi_profiler-0.1.0/assets/logo-icon-512.png +0 -0
- asgi_profiler-0.1.0/assets/logo-icon-64.png +0 -0
- asgi_profiler-0.1.0/assets/logo-icon.svg +21 -0
- asgi_profiler-0.1.0/assets/logo.svg +12 -0
- asgi_profiler-0.1.0/examples/fastapi_sqlmodel_app.py +97 -0
- asgi_profiler-0.1.0/examples/starlette_app.py +78 -0
- asgi_profiler-0.1.0/pyproject.toml +122 -0
- asgi_profiler-0.1.0/src/asgi_profiler/__init__.py +187 -0
- asgi_profiler-0.1.0/src/asgi_profiler/__main__.py +72 -0
- asgi_profiler-0.1.0/src/asgi_profiler/config.py +64 -0
- asgi_profiler-0.1.0/src/asgi_profiler/instrument.py +233 -0
- asgi_profiler-0.1.0/src/asgi_profiler/middleware.py +148 -0
- asgi_profiler-0.1.0/src/asgi_profiler/models.py +363 -0
- asgi_profiler-0.1.0/src/asgi_profiler/py.typed +0 -0
- asgi_profiler-0.1.0/src/asgi_profiler/routing.py +191 -0
- asgi_profiler-0.1.0/src/asgi_profiler/static/profiler.css +272 -0
- asgi_profiler-0.1.0/src/asgi_profiler/storage.py +982 -0
- asgi_profiler-0.1.0/src/asgi_profiler/templates/base.html +39 -0
- asgi_profiler-0.1.0/src/asgi_profiler/templates/detail.html +144 -0
- asgi_profiler-0.1.0/src/asgi_profiler/templates/requests.html +118 -0
- asgi_profiler-0.1.0/src/asgi_profiler/templates/statements.html +63 -0
- asgi_profiler-0.1.0/src/asgi_profiler/templates/summary.html +62 -0
- asgi_profiler-0.1.0/src/asgi_profiler/viewer.py +274 -0
- asgi_profiler-0.1.0/tests/conftest.py +23 -0
- asgi_profiler-0.1.0/tests/test_async_and_engines.py +162 -0
- asgi_profiler-0.1.0/tests/test_concurrency.py +137 -0
- asgi_profiler-0.1.0/tests/test_diagnostics.py +494 -0
- asgi_profiler-0.1.0/tests/test_profiler.py +262 -0
- asgi_profiler-0.1.0/tests/test_regressions.py +427 -0
- asgi_profiler-0.1.0/tests/test_review_findings.py +533 -0
- asgi_profiler-0.1.0/tests/test_round_three.py +532 -0
- asgi_profiler-0.1.0/tests/test_storage_and_viewer.py +373 -0
- asgi_profiler-0.1.0/tests/test_trust.py +387 -0
|
@@ -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.
|