From 9f325da77618552cbbdf8de924d32ba6bfa23ba6 Mon Sep 17 00:00:00 2001 From: WiktorB2004 Date: Sun, 27 Sep 2026 09:00:17 +0200 Subject: [PATCH] Link the benchmark and example app from the README --- README.md | 22 +++++++++++++++++++++- examples/fastapi_app/auth.py | 10 +++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e46cf7c..c8c27a9 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,22 @@ audit = AuditTrail(engine, events=[AuthEvent]) audit.log(session, AuthEvent.LOGIN_FAILED, payload={"reason": "bad password"}) ``` -Docs: [wiktorb2004.github.io/sqlalchemy-audit-trail](https://wiktorb2004.github.io/sqlalchemy-audit-trail/). Runnable, tested quickstarts for sync, async and FastAPI: [`examples/`](examples/). +Docs: [wiktorb2004.github.io/sqlalchemy-audit-trail](https://wiktorb2004.github.io/sqlalchemy-audit-trail/). Runnable, tested quickstarts for sync, async and FastAPI: [`examples/`](https://github.com/WiktorB2004/sqlalchemy-audit-trail/tree/main/examples). A complete multi-tenant FastAPI app with login events, history, an activity feed and GDPR erasure, with a curl walkthrough: [`examples/fastapi_app`](https://github.com/WiktorB2004/sqlalchemy-audit-trail/blob/main/examples/fastapi_app/README.md). Audit rows are written **in your transaction**: roll back and they are gone with the change. A failed audit write does not fail your transaction by default (`on_error="log"`). `durable` events are committed on their own connection and survive a rollback; `fail_closed` events raise `AuditWriteError` when they cannot be written. +## Results + +Measured on a laptop: PostgreSQL 18 in Docker with stock settings, one connection, synthetic data. Protocol, environment and limits: [`benchmark/README.md`](https://github.com/WiktorB2004/sqlalchemy-audit-trail/blob/main/benchmark/README.md). + +| What | Plain | Audited | Added | +| --- | ---: | ---: | ---: | +| flush of 1 object (p50) | 3.4 ms | 5.8 ms | **+2.4 ms** (+1.5 ms with `on_error="raise"`) | +| flush of 100 objects (p50) | 8.0 ms | 19.4 ms | **+11.4 ms**, about 114 µs per object | +| durable `log()` (p50) | | | +6.7 ms | + +Reads on 5M entries in 80 partitions: the first page of `list_groups` takes **18.5 ms** at p50 (15.1 ms with a 30-day window), of which PostgreSQL spends under 1 ms; a next page reads **5 of 80** partitions. Queries with no time bound (`object_history`, `access_summary`, by `actor_id`) read every monthly partition and take 10 to 22 ms here. + ## Why this instead of sqlalchemy-continuum, triggers or pgaudit [sqlalchemy-continuum](https://github.com/kvesteri/sqlalchemy-continuum) copies every changed row into a version table per model. That answers "what did this row look like then", but a login, a download or someone viewing sensitive data has no row to version. Database triggers and pgaudit see statements, not the application's user, request or intent. @@ -149,6 +161,14 @@ uv run mkdocs serve Published at [wiktorb2004.github.io/sqlalchemy-audit-trail](https://wiktorb2004.github.io/sqlalchemy-audit-trail/). +## Benchmark + +The numbers above come from [`benchmark/`](https://github.com/WiktorB2004/sqlalchemy-audit-trail/tree/main/benchmark). It needs Docker (or `--database-url`) and takes about 12 minutes: + +```bash +uv run python -m benchmark.run_benchmark +``` + ## Contributing Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). To report a vulnerability, use [SECURITY.md](SECURITY.md). diff --git a/examples/fastapi_app/auth.py b/examples/fastapi_app/auth.py index cb69acb..1cedcb9 100644 --- a/examples/fastapi_app/auth.py +++ b/examples/fastapi_app/auth.py @@ -51,12 +51,12 @@ def issue_token(user: User) -> str: def record_user(user: User, auth_method: str) -> None: """Make `user` the actor, and their tenant the scope, of this request.""" - context = set_actor( - Actor(type="user", id=user.id, label=user.email), auth_method=auth_method + # Entries without a model scope (auth.login) get the tenant from here. + set_actor( + Actor(type="user", id=user.id, label=user.email), + auth_method=auth_method, + scope_id=user.tenant_id, ) - # AuditContext is mutable on purpose: entries without a model scope - # (auth.login) get the tenant from here. - context.scope_id = user.tenant_id def current_user(