An audit trail for your data models.
auditlog records every create / update / destroy of your models into a single polymorphic
audits table, capturing what changed (a diff), who changed it, when, from where,
and an optional comment — then lets you query that history and reconstruct any past revision.
auditlog is ORM-agnostic: implement the small Auditable trait for your model and hand it a
Backend. A ready-to-use async SqlxBackend (SQLite & Postgres) is included, plus an in-memory
MemoryBackend for tests.
[dependencies]
auditlog = { version = "0.1", features = ["sqlite"] } # or "postgres"use auditlog::{Auditable, AuditOptions, AuditId, ValueMap, SqlxBackend};
use serde_json::json;
struct Post { id: i64, title: String, body: String }
impl Auditable for Post {
fn auditable_type() -> &'static str { "Post" }
fn auditable_id(&self) -> AuditId { self.id.into() }
fn audited_attributes(&self) -> ValueMap {
let mut m = ValueMap::new();
m.insert("id".into(), json!(self.id));
m.insert("title".into(), json!(self.title));
m.insert("body".into(), json!(self.body));
m
}
fn audit_options() -> AuditOptions { AuditOptions::default() }
}
// inside an async fn, returning auditlog::Result<()>:
let backend = SqlxBackend::connect_sqlite("sqlite::memory:").await?;
backend.migrate().await?;
// create
let mut post = Post { id: 1, title: "Hello".into(), body: "...".into() };
post.audited_create(&backend).await?;
// update — diff the old state against the new
let old = Post { id: 1, title: "Hello".into(), body: "...".into() };
post.title = "Hello, world".into();
post.audited_update(&backend, &old).await?;
// destroy
post.audited_destroy(&backend).await?;
for audit in Post::audits(&backend, 1).await? {
println!("v{} {} {:?}", audit.version, audit.action, audit.audited_changes);
}You give the crate the before and after state; it computes the diff, applies your config,
and writes the audit. That is the whole ORM-agnostic contract — call audited_create after you
persist, and audited_update / audited_destroy before.
See examples/blog.rs for a complete runnable walkthrough
(cargo run --example blog).
Wrap a unit of work in an as_user scope. The acting user — and remote address / request id —
live in a tokio task-local, so they are isolated per task and restored when the scope ends:
use auditlog::{as_user, Actor};
// a record user → user_id + user_type
as_user(Actor::record("User", 7), async {
post.audited_create(&backend).await
}).await?;
// or a plain string → username
as_user(Actor::name("import job"), async {
post.audited_create(&backend).await
}).await?;For full control (user + IP + request id together, e.g. from web middleware) use
with_context(AuditContext::new().with_user(..).with_remote_address(..).with_request_uuid(..), fut).
All audit options are available via AuditOptions::builder():
| Builder method | Option | Effect |
|---|---|---|
.only(["a","b"]) |
only: |
Audit only these columns |
.except(["password"]) |
except: |
Audit everything except these (plus the defaults) |
.on([Action::Create, Action::Update]) |
on: |
Which actions produce audits |
.comment_required(true) |
comment_required |
Require a comment (else the op errors / aborts) |
.update_with_comment_only(false) |
update_with_comment_only |
A comment alone won't create an update audit |
.max_audits(10) |
max_audits |
Cap retained audits; older ones are combined |
.redacted(["password"]) |
redacted: |
Log that a column changed, but not its value |
.redaction_value(json!("***")) |
redaction_value |
Custom redaction placeholder (default [REDACTED]) |
.encrypted(["ssn"]) |
(encrypted attrs) | Mask as [FILTERED] |
.associated_with("Company") |
associated_with: |
Record a parent record on each audit |
Instance conditions map to trait methods you override: audit_if(&self) -> bool (if:) and
audit_unless(&self) -> bool (unless:). The default-ignored columns
(id, lock_version, created_at, updated_at, created_on, updated_on) and global settings
are configurable via auditlog::config(|c| ...).
The shape of audited_changes is:
- create →
{ "title": "Hello" }(single values, full filtered snapshot) - update →
{ "title": ["Hello", "Hello, world"] }([old, new]pairs) - destroy →
{ "title": "Hello, world", ... }(single values, full snapshot)
let all = Post::audits(&backend, 1).await?; // ascending by version
let updates = Post::query(&backend, 1).updates().fetch().await?; // creates()/updates()/destroys()
let recent = Post::query(&backend, 1).descending().limit(5).fetch().await?;
let since_v3 = Post::query(&backend, 1).from_version(3).fetch().await?;Reconstruct historical state by folding the change sets:
let all = Post::revisions(&backend, 1).await?; // one per audit
let v2 = Post::revision(&backend, 1, 2).await?; // None if out of range
let prev = Post::revision_previous(&backend, 1).await?; // second-most-recentA revision returns the folded attribute map plus a new_record flag (true when the record was
destroyed at that point — saving it would re-insert the row). Because the crate doesn't own your
persistence, you apply the revision to your own model/ORM. Audit::undo_plan() similarly returns
an UndoPlan (Delete / Recreate / Restore) describing how to reverse a change.
| Scope | API |
|---|---|
| Process-global master switch | auditlog::set_auditing_enabled(false) |
| Per type (persistent) | Post::disable_auditing() / Post::enable_auditing() |
| Per scope (task-local, restored on exit) | without_auditing(fut).await / with_auditing(fut).await |
Effective auditing = global master and the type's flag and no active without_auditing
scope and the instance if/unless checks. with_auditing cannot re-enable auditing when the
global master switch is off.
use auditlog::SqlxBackend;
// convenience (single shared connection — required for :memory:)
let backend = SqlxBackend::connect_sqlite("sqlite://audits.db").await?;
backend.migrate().await?; // creates the `audits` table + indexes if absent
// or bring your own pool:
// let backend = SqlxBackend::sqlite(my_pool);
// let backend = SqlxBackend::postgres(my_pg_pool);Need a different store (another ORM, a queue, a remote service)? Implement the Backend trait —
six methods — and everything else works unchanged. MemoryBackend is provided for tests.
The audits table schema: auditable_type/auditable_id,
associated_type/associated_id, user_type/user_id/username, action, audited_changes
(JSON), version, comment, remote_address, request_uuid, created_at, with the five
indexes (auditable_index, associated_index, user_index, request-uuid, created-at) plus a
unique index on (auditable_type, auditable_id, version) to guard version races. All polymorphic
ids are stored as TEXT, so integer and UUID primary keys work uniformly.
sqlite(default) —SqlxBackendon SQLite.postgres—SqlxBackendon Postgres.
The core (trait, change tracking, context, revisions, MemoryBackend) has no DB dependency.
auditlog's behavior is defined in docs/SPEC.md, the authoritative behavior
specification. A few intentional, idiomatic-Rust design choices:
- You call the audit methods explicitly (
audited_create/_update/_destroy) — there is no single ORM to hook, so audits are recorded by an explicit call rather than an automatic save callback.audited_updatetakes the prior state so the crate can diff it. - Current user/request context is a
tokiotask-local, which keeps it isolated per task instead of leaking across the pooled threads of an async runtime. audited_changesis stored as JSON.- Enum representation is whatever your
audited_attributes()returns — there is no global enum-introspection step, since the crate doesn't know your column types. undo/ revisions return a plan / attribute map for you to apply, rather than mutating your records directly.
cargo test # 14 unit + 37 behavior + 1 global-state + 7 doc tests (SQLite, no Docker)
cargo test --features pg-tests # additionally runs tests/postgres.rs against a real Postgres container
cargo run --example blogThe tests/behavior.rs suite covers the full behavior surface (change-set shapes, versioning,
on/only/except, comments, redaction, max_audits combine, user attribution, enable/disable,
revisions, undo, associated audits).
tests/postgres.rs exercises the real Postgres backend end-to-end — transactional version
assignment, $n-placeholder SQL, JSON audited_changes round-trip, RFC 3339 created_at
ordering, the max_audits combine transaction, and associated audits — using
testcontainers to spin up a throwaway Postgres
container. It is gated behind the pg-tests feature, so the (heavy) Docker-client dependency is
only compiled when you opt in; normal cargo test never pulls it. If Docker isn't running the
test prints a SKIP notice and passes, so it is CI-matrix friendly.
Released under the MIT License. See LICENSE.