AuditLog
AuditLog records explicit semantic audit events with Ecto and PostgreSQL. An event states who performed a business action, what the action affected, why it happened, and which operation it belonged to.
The application chooses when to record an event. AuditLog does not infer intent from changesets or database writes.
Example
Write the domain change and its event in the same database transaction:
actor =
AuditLog.Actor.new!("user", current_user.id,
label: current_user.email
)
context =
AuditLog.Context.new(actor,
correlation_id: request_id
)
Repo.transaction(fn ->
shareholder =
shareholder
|> Shareholder.changeset(%{address: corrected_address})
|> Repo.update!()
subject =
AuditLog.Subject.from_schema!(shareholder,
type: "shareholder"
)
AuditLog.record!(
Repo,
"shareholder.address_corrected",
context,
subject,
reason: "Corrected from the signed source document",
metadata: %{fields: ["address"]}
)
shareholder
end)
The transaction commits or rolls back both writes. AuditLog does not start a
hidden transaction. If an application path omits record/5, no audit event is
created.
Requirements
AuditLog supports:
- Elixir 1.15 or later;
- Ecto SQL 3.11 through 3.14;
- Postgrex 0.19 or later, before 1.0; and
- PostgreSQL.
The CI matrix tests PostgreSQL 14, 16, and 17. AuditLog uses PostgreSQL-specific SQL and does not support other Ecto adapters.
Installation
Add audit_log to mix.exs:
def deps do
[
{:audit_log, "~> 0.1.0"}
]
end
Fetch the dependency, generate the migration, and migrate the database:
mix deps.get
mix audit_log.gen.migration
mix ecto.migrate
The generator accepts the options supported by mix ecto.gen.migration. Select
a repository explicitly when the application has more than one:
mix audit_log.gen.migration -r MyApp.Repo
For a PostgreSQL schema prefix, edit the generated migration:
def up, do: AuditLog.Migration.up(prefix: :audit)
def down, do: AuditLog.Migration.down(prefix: :audit)
The schema must already exist. A prefix may contain lowercase ASCII letters, digits, and underscores, but it cannot start with a digit.
Event model
AuditLog stores one append-only audit_events row for each event:
| Value | Meaning |
|---|---|
action | Stable, application-owned business code such as invoice.sent |
actor_type, actor_id | Durable identity of the person or service responsible |
actor_label | Optional display-label snapshot |
subject_type, subject_id | Durable identity of the affected business object |
subject_version_at | Optional lower boundary of an exact temporal row version |
reason | Optional operator prose explaining why the action occurred |
correlation_id | UUID shared by events in one request or business operation |
causation_id | UUID of the event that directly caused this event, when known |
metadata | Small JSON object containing application-owned facts |
occurred_at | PostgreSQL transaction start time |
Actor and subject identifiers are stored as text and have no foreign keys. They remain meaningful after an application deletes or renames the source record.
Actors
An actor is a durable snapshot, not a join to a user table:
user = AuditLog.Actor.new!("user", user.id, label: user.email)
worker = AuditLog.Actor.new!("service", "billing-worker")
The application owns actor types and identifiers. The optional label records what an operator would have recognised when the event occurred.
Subjects
A subject identifies the affected business object:
subject = AuditLog.Subject.new!("invoice", invoice.id)
subject =
AuditLog.Subject.from_schema!(invoice,
type: "invoice"
)
AuditLog.Subject.from_schema!/2 accepts a loaded or deleted Ecto struct with
one primary key. It rejects missing and composite keys instead of inventing an
identity. Use an explicit type when a future table rename must not change the
subject type.
Context
A context carries values shared by related events:
correlation_idgroups one request or business operation;causation_ididentifies the event that directly caused later work; andmetadatacontains small shared facts.
AuditLog.Context.new/2 generates a correlation UUID when the caller does not
supply one. Pass the context explicitly through functions and tasks. When a job
crosses a serialization boundary, store its primitive fields and rebuild the
context in the worker. AuditLog uses no process dictionary, PostgreSQL session
setting, ETS table, or server process.
For follow-up work, retain the correlation ID but identify the actor that performs the new action:
child_context =
AuditLog.Context.child(
parent_context,
AuditLog.Actor.new!("service", "billing-worker"),
causing_event
)
Event metadata shallowly overrides context metadata:
context = AuditLog.Context.new(actor, metadata: %{tenant: "acme"})
AuditLog.record!(
Repo,
"invoice.sent",
context,
subject,
metadata: %{channel: "email"}
)
Keep metadata small and bounded. Do not store complete records, secrets, access tokens, request bodies, or unbounded payloads.
reason is optional operator prose. AuditLog preserves a non-empty reason
verbatim and converts an empty or whitespace-only reason to nil. Application
logic should use action; it should never parse reason.
Transactions and Ecto.Multi
AuditLog.record/5 returns the result of Repo.insert/2.
AuditLog.record!/5 returns the event or raises the corresponding Ecto
exception. Neither function starts a transaction.
Use Ecto.Multi.run/3 when the subject depends on an earlier operation:
Ecto.Multi.new()
|> Ecto.Multi.update(:shareholder, changeset)
|> Ecto.Multi.run(:audit_event, fn repo, %{shareholder: shareholder} ->
subject =
AuditLog.Subject.from_schema!(shareholder,
type: "shareholder"
)
AuditLog.record(
repo,
"shareholder.address_corrected",
context,
subject,
reason: reason
)
end)
|> Repo.transaction()
A standalone event, such as a failed login, does not need a domain-write transaction.
Supply an event UUID when a workflow needs stable event identity across retries:
AuditLog.record!(
Repo,
"invoice.sent",
context,
subject,
id: event_id
)
Reusing the UUID fails. AuditLog deliberately does not use
on_conflict: :nothing: treating a different event with the same ID as a
success would corrupt the audit record.
Queries
AuditLog.Query returns ordinary, composable Ecto queries:
events =
subject
|> AuditLog.Query.for_subject(order: :desc)
|> where([event], event.action in ^visible_actions)
|> limit(50)
|> Repo.all()
The helpers query by subject, actor, correlation UUID, or a half-open
[from, to) time window. Every query orders by occurred_at, id. The UUID
provides a stable display tie-breaker for events in one transaction; UUID order
does not prove insertion or causal order.
Use AuditLog.Subject.cast_id!/2 before querying an application schema whose
primary key is not text:
shareholder_id =
AuditLog.Subject.cast_id!(Shareholder, event.subject_id)
Temporal subjects
AuditLog can link an event to an exact row version without depending on a
temporal-table library. For a compatible schema,
AuditLog.Subject.from_schema!/2 reads sys_period.from into
subject_version_at.
That boundary identifies the affected row version. occurred_at does not: it
is the audit-event transaction's start time. See
Temporal subjects for insert, update, delete, and
ID-casting guidance.
Guarantees and limits
AuditLog is not database-wide change capture. Direct SQL, migrations, ETL,
replication workers, and application paths that omit record/5 create no
semantic event.
It is also not:
- event sourcing or state reconstruction;
- a transactional outbox or message publisher;
- authentication or authorisation;
- a field-diff engine;
- tamper evidence; or
- retention automation.
An ENABLE ALWAYS trigger rejects ordinary UPDATE and DELETE, including
DML performed with session_replication_role = replica. A table owner or
superuser can still use DDL, disable the trigger, truncate or drop the table, or
otherwise administer the data.
Read Limits and operations before deploying AuditLog.
Project
See the changelog for releases and the roadmap for planned companion packages.
AuditLog is released under the MIT License.