Some actions in an application are security-sensitive or compliance-relevant:
deleting a post, changing a user's role, exporting personal data, issuing a
refund. When someone later asks "who deleted this, and when?" an ordinary
tracing::info! line buried in your request logs is not a good enough answer.
Autumn ships a small, dedicated audit log for exactly these events —
append-only, structured, and kept separate from ordinary application logging.
An audit write records who did what, to which resource, from where,
and whether it succeeded — one immutable [AuditEvent] fanned out to one or
more [AuditSink]s. The "who" is filled in for you: Autumn's request-scoped
current actor carries the authenticated principal, so a handler auto-attributes
the acting user with no per-call session re-extraction.
Audit logging is new in 0.6.0. For a runnable end-to-end demo, see the
examples/reddit-clone example, whose delete_post
moderation handler records one actor-attributed post.delete event.
Prerequisites
- An
autumn-webapplication (see Getting Started). - The audit types live in
autumn_web::audit; [AuditEvent] and [AuditStatus] are also re-exported fromautumn_web::prelude, souse autumn_web::prelude::*;is enough for the event-construction API. - Actor auto-attribution builds on the authorization layer:
it is the auth resolution that publishes the current principal, so the acting
user is only auto-attributed on authenticated (
#[secured]/ policy-guarded) requests. Unauthenticated requests simply have no actor.
Recording an event
An [AuditEvent] is a plain struct — actor, action, target, optional caller IP,
and a [AuditStatus] outcome. Build one with [AuditEvent::new] (it stamps the
UTC timestamp for you) and hand it to the [AuditLogger] installed in
application state via the [write_from_state] helper:
use autumn_web::prelude::*; // AuditEvent, AuditStatus
let event = AuditEvent::new(
actor, // who — a user id / service-account id / API-key id
"post.delete", // action — a canonical, dotted verb string
post.id.to_string(), // target resource id
None, // caller IP, if you have it (Option<IpAddr>)
AuditStatus::Success, // outcome
);
// Best-effort: a sink hiccup should not fail an action the user already
// performed. `write_from_state` is a no-op (returns Ok) if no logger is
// installed, so it is always safe to call.
let _ = autumn_web::audit::write_from_state(&state, event).await;
Use a stable, greppable action vocabulary (post.delete, user.role.update,
export.create) — these strings are what a compliance query filters on months
later. Record failures too: pass [AuditStatus::Failure] when an authorized
attempt was made but the action did not complete, so a burst of failures is
itself an auditable signal.
In the reddit-clone example this lives at the end of the delete_post handler
in examples/reddit-clone/src/routes/posts.rs, right after the row is deleted:
#[secured]
#[delete("/r/{sub_slug}/posts/{post_slug}")]
pub async fn delete_post(/* … */) -> AutumnResult<Response> {
let post = load_post_and_authorize(/* … */, "delete").await?;
repo.delete_by_id(post.id).await?;
// Audit this moderation action (actor auto-attributed — see below).
let actor =
autumn_web::current::Current::actor().unwrap_or_else(|| "unknown".to_string());
let _ = autumn_web::audit::write_from_state(
&state,
AuditEvent::new(actor, "post.delete", post.id.to_string(), None, AuditStatus::Success),
)
.await;
// … broadcast + redirect …
}
Actor auto-attribution
You should almost never pass a hand-plucked user id as the actor. Autumn keeps
a request-scoped current actor — the same ambient value that seeds
VersionEntry.actor for versioned repository writes. When the auth layer
resolves an authenticated principal (during #[secured] / policy checks) it
publishes that id onto the request scope, and any code later in the request reads
it with [Current::actor]:
use autumn_web::current::Current;
// Inside an authenticated handler, this returns the resolved principal's id.
let actor = Current::actor().unwrap_or_else(|| "unknown".to_string());
This means the handler never re-extracts the Session just to name the actor —
the "who" is already resolved and ambiently available. [Current::actor] returns
Option<String>:
Some(id)inside an authenticated request — the resolved principal.Noneinside a request that has not resolved a principal (an anonymous request), so an audit call on an unauthenticated path attributes no user.- Outside any request (a background job, the scheduler, a CLI task) it falls back to the process-wide default actor token, if one is configured.
For work that runs on behalf of a specific identity outside a request — a job
runner, a scheduled task, an "acting as" flow — set the actor explicitly rather
than defaulting to "unknown":
use autumn_web::current::{Current, with_actor};
// A whole scope acts as one identity (background jobs, "on behalf of"):
with_actor("scheduler", async {
// Current::actor() == Some("scheduler") in here.
run_retention_sweep().await;
})
.await;
// Or a process-wide default for all non-request contexts:
Current::set_default_actor(Some("scheduler".to_string()));
Sinks and stores
An [AuditLogger] fans one event out to every registered [AuditSink]; each sink
is an append-only destination. Autumn ships three, so you rarely write your own:
- [
TracingAuditSink] — emits each event as structured JSON fields on a dedicatedautumn.audittracing target. No schema, no migration — the lowest-risk sink and the one the reddit-clone example uses. Route theautumn.audittarget to its own file/collector to keep audit records out of your ordinary log stream. - [
JsonlFileAuditSink] — appends one JSON object per line to a file in append-only mode with an fsync per write, suitable for an immutable on-disk archive. ChannelAuditSink(behind thewsfeature) — broadcasts events to a WebSocket/SSE channel, useful for a live admin dashboard.
Implement the [AuditSink] trait yourself for anything else — a database table,
an S3-backed archive, or a SIEM adapter (Splunk, Datadog, an OpenTelemetry
collector). Every event is treated as an immutable, append-only record; the
logger attempts all sinks even if one fails, aggregating errors so a single
broken destination never silently drops the event from the others.
Install the logger once at startup by inserting an [AuditLogger] into
application state. The reddit-clone example does this in its state_initializer
(examples/reddit-clone/src/main.rs):
use std::sync::Arc;
use autumn_web::audit::{AuditLogger, TracingAuditSink};
let app = autumn_web::app()
// …
.state_initializer(move |state| {
state.insert_extension(
AuditLogger::new().with_sink(Arc::new(TracingAuditSink)),
);
});
[write_from_state] looks the logger up by type from state, so this one
registration is all a handler needs. Chain .with_sink(...) more than once to
fan out to several destinations at the same time — for example, a tracing sink
for live observability and a JSONL file for the durable archive:
AuditLogger::new()
.with_sink(Arc::new(TracingAuditSink))
.with_sink(Arc::new(JsonlFileAuditSink::new("/var/log/app/audit.jsonl")));
Querying and retention
Because audit events are append-only, where they land dictates how you query and how long you keep them:
- Tracing sink → query with whatever your log pipeline offers, filtering on
the
autumn.audittarget and theaction/actor_idfields; retention is your log store's retention. - JSONL file → each line is a self-describing JSON object, so
jq,grep, or bulk-loading into a warehouse all work; rotate/retain the file with your normal log-rotation tooling, or setretention.audit_archivesand let Autumn prune stale entries in place — see Data Retention for Framework-Owned Data.
Writing a custom
AuditSink? Two things arrived with framework data retention:
AuditEventcarries ametadata: BTreeMap<String, String>map of action-specific detail, set withAuditEvent::new(..).with_metadata(k, v). It is empty for most events; a retention sweep uses it to record the dataset, the cutoff, and the rows removed. Persist it if your sink has somewhere to put it — it is#[serde(default)], so a sink that ignores it still round-trips older archives.AuditSink::purge_before(cutoff, dry_run)is a provided method that defaults to reportingunsupported, so your existing sink keeps compiling andautumn db retentionhonestly says the destination cannot be pruned from here. Override it if your sink stores events somewhere prunable (a database table, a rotated file set) and you wantretention.audit_archivesto reach it. A purge must never drop a record it merely failed to parse.
- Custom DB sink → query with SQL. Keep audit rows immutable by default:
grant the app
INSERT-only on the audit table, and neverUPDATE/DELETEan existing event from request-handling code. If you also want automatic retention instead of hand-writing a scheduled purge job, declareretention(after = "...", basis = created_at)on the audit table's#[repository(...)]— see Data-Retention Sweeps — but know that the sweep runs with the app's own DB credentials, so this requires granting that roleDELETE(andUPDATE, if the repository is alsosoft_delete) on that one table. Everything else staysINSERT-only; only the generated sweep gets the extra grant.
Audit logging answers who did what. It is a natural foundation for adjacent compliance features such as GDPR data export and right-to-erasure workflows — those build on the same actor/action record — but they are separate concerns and out of scope for this guide.