Fort. Audit
(fort_audit v0.1.1)
Copy Markdown
Atomic audit logging with dual routing: persists to PostgreSQL and emits
structured JSON via :logger.
Three paths
| Function | Guarantee | Cost |
|---|---|---|
audited_transaction/4 / transact/4 | Atomic — no business success without a durable audit row | Extra insert inside the business transaction |
log_best_effort/4 | None — audit event can be lost on crash between commit and this call | Zero added cost to the business transaction |
log/1 | Durable — synchronous DB insert + Logger emission | Standalone write, no transaction |
Configuration
config :fort_audit, :repo, MyApp.Repo
config :fort_audit, :logger_label_fields, [:outcome, :action]:repo (required)
The Ecto repo used for all audit log persistence.
:logger_label_fields (optional)
Controls which metadata fields are emitted as top-level Logger metadata keys (indexed as labels by Loki Promtail, Datadog, Elasticsearch, etc.).
Default: [:outcome, :actor_type, :subject_type] — the three fields whose
cardinality is bounded by design. All remaining fields (actor_id,
subject_id, category, audit_log_id) nest under a single :details key
to prevent accidental label-cardinality explosions.
See Fort.Audit.Emitter for the full rationale.
Usage
Quick start (recommended)
For a single business Ecto.Multi with one audit step — the common case:
Fort.Audit.audited_transaction(multi, "user.created",
actor_id: actor.id,
actor_type: "admin_user",
audit_attrs: %{subject_id: user.id, subject_type: "user"}
)Actor identity (actor_id, actor_type) and action are stated exactly once.
The audit step is constructed correctly inside — MissingAuditStepError is
structurally unreachable through this entry point.
Standalone
Use log/1 outside a transaction:
Fort.Audit.log(%{
actor_id: actor.id,
actor_type: "admin_user",
action: "user.registration.rejected",
outcome: "failure",
metadata: %{reason: reason}
})Advanced: multiple audit steps in one Multi
When you need more than one audit step inside a single transaction, use
wrap/1, append_to_multi/3, and transact/4 directly.
Wrapping a Multi (existing or new)
Build a plain Ecto.Multi first, then wrap it — same path whether
starting from scratch or wrapping an already-assembled Multi:
Multi.new()
|> Multi.insert(:user, user_changeset)
|> Fort.Audit.wrap()
|> Fort.Audit.append_to_multi(:audit, %{
actor_id: actor.id,
actor_type: "admin_user",
action: "user.created"
})
|> Fort.Audit.transact("user.created", actor.id)Logger metadata structure
Logger lines emitted by transact/4 and log/1 follow a two-tier metadata
structure to prevent accidental label-cardinality explosions:
- Labels — top-level metadata keys (configured via
:logger_label_fields, default[:outcome, :actor_type, :subject_type]) - Body — everything else nested under a single
:detailskey (one JSON object, not N individual indexed fields)
See Fort.Audit.Emitter for full rationale and configuration.
Shipment to external systems
Fort's responsibility ends at emitting a well-structured Elixir Logger line.
Getting logs into Loki, Elasticsearch, Datadog, or any other collector is the
job of the host application's existing observability pipeline — Promtail,
Vector, Fluent Bit, logger_json, or any Elixir Logger backend already in use.
Fort does not ship a dedicated Loki/Elasticsearch/Datadog client for the same
reason it does not ship a web framework adapter in the core library: those are
integration concerns best solved by the community or the host app against
stable Logger output.
Reconciliation
Rows that were persisted but never emitted to Logger (e.g., a crash between
the DB commit and the emitted_at stamp) can be recovered via:
mix fort.reconcileThis queries unemitted rows using the partial index idx_audit_logs_unemitted,
re-emits each via Logger, and stamps emitted_at. The function
Fort.Audit.reconcile/2 is also public for host apps that want to
schedule reconciliation from Oban, Quantum, or a :timer.send_interval.
At-least-once
Logger emission is at-least-once — a crash between the Logger call and
the emitted_at DB stamp may cause re-emission on restart. Downstream
consumers should dedupe on audit_logs.id for exactly-once processing. The
mix fort.reconcile task is the recovery mechanism for the permanent case
(crash before the stamp ever happens).
Summary
Functions
Appends a success audit step to an AuditedMulti.
Accepts a static map or a 1-arity function from accumulated changes.
Collapsed entry point for the common case: one business Multi, one audit step.
Derives before_data, after_data, and changes directly from an
Ecto.Changeset.
Standalone audit log insert outside a transaction.
Records an audit event after a plain Repo.transaction/1, without atomicity.
Re-processes audit log rows that were never emitted to Logger.
Runs the transaction with audit guarantees.
Raises MissingAuditStepError if no audit steps were appended.
Writes a failure audit log when the Multi fails.
Wraps an Ecto.Multi in an AuditedMulti.
Functions
@spec append_to_multi(Fort.AuditedMulti.t(), atom(), map() | (map() -> map())) :: Fort.AuditedMulti.t()
Appends a success audit step to an AuditedMulti.
Accepts a static map or a 1-arity function from accumulated changes.
@spec audited_transaction(Ecto.Multi.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
Collapsed entry point for the common case: one business Multi, one audit step.
Actor identity (actor_id, actor_type) and action are stated exactly once
in the call — the opts keyword never accepts a second copy. audit_attrs
carries only extra fields (subject_id, subject_type, metadata, etc.).
Internally wrap/1 |> append_to_multi/3 |> transact/4 — MissingAuditStepError
is structurally unreachable through this path.
Example
Fort.Audit.audited_transaction(multi, "user.created",
actor_id: actor.id,
actor_type: "admin_user",
audit_attrs: %{subject_id: user.id, subject_type: "user"}
)Failure path
A failing business step produces the same failure-audit row as transact/4,
with error formatted via format_error/1.
@spec from_changeset(Ecto.Changeset.t()) :: %{ before_data: map(), after_data: map(), changes: map() }
Derives before_data, after_data, and changes directly from an
Ecto.Changeset.
Scoped to changeset.data.__struct__.__schema__(:fields) — this naturally
excludes associations (which live in __schema__(:associations)), preventing
%Ecto.Association.NotLoaded{} structs from reaching the jsonb column where
they would crash with a Jason.EncodeError at insert time.
Embeds (embeds_one / embeds_many) are included in :fields and pass
through this filter — see the moduledoc for tested edge-case behaviour.
Returns a plain map that plugs directly into append_to_multi/3 or log/1:
changeset
|> Fort.Audit.from_changeset()
|> Map.merge(%{actor_id: actor.id, actor_type: "admin_user", action: "user.updated"})
|> then(&Fort.Audit.append_to_multi(multi, :audit, &1))
@spec log(map()) :: {:ok, Fort.Schemas.AuditLog.t()} | {:error, Ecto.Changeset.t()}
Standalone audit log insert outside a transaction.
@spec log_best_effort( {:ok, map()} | {:error, Ecto.Multi.name(), term(), map()}, String.t(), String.t(), keyword() ) :: {:ok, map()} | {:error, term()}
Records an audit event after a plain Repo.transaction/1, without atomicity.
Takes the raw Repo.transaction/1 result as the first argument and writes the
appropriate audit row (success or failure) outside the business transaction.
Reuses format_error/1 for failure formatting — identical to transact/4's
failure path.
Guarantee gap
A crash between Repo.transaction/1 returning {:ok, _} and this call
executing permanently loses the audit event. No row was ever durably
written — mix fort.reconcile has nothing to recover. Only use this when
the business value of the audit event does not justify the latency or lock
contention of an extra insert inside the transaction.
Examples
Repo.transaction(multi)
|> Fort.Audit.log_best_effort("user.created", actor.id,
actor_type: "admin_user",
audit_attrs: %{subject_id: user.id, subject_type: "user"}
)
Repo.transaction(multi)
|> Fort.Audit.log_best_effort("user.created", actor.id,
actor_type: "admin_user"
)
# => {:error, :oops} (failure path)
@spec reconcile(Ecto.Repo.t(), pos_integer()) :: {:ok, non_neg_integer()}
Re-processes audit log rows that were never emitted to Logger.
Queries rows where emitted_at IS NULL (using the partial index
idx_audit_logs_unemitted) in inserted_at order, up to batch_size
rows per call. Re-emits each row via Emitter.emit_and_stamp/2.
Idempotent — rows already stamped are excluded by the query, so a second call with no new unemitted rows is a no-op.
Returns {:ok, count} where count is the number of rows successfully
re-processed. Individual row failures are logged at error level but
do not halt the batch.
@spec transact(Fort.AuditedMulti.t(), String.t(), String.t() | nil, keyword()) :: {:ok, map()} | {:error, term()}
Runs the transaction with audit guarantees.
Raises MissingAuditStepError if no audit steps were appended.
Writes a failure audit log when the Multi fails.
@spec wrap(Ecto.Multi.t()) :: Fort.AuditedMulti.t()
Wraps an Ecto.Multi in an AuditedMulti.
Starting from scratch? Build a plain Ecto.Multi first and wrap it
the same way:
Ecto.Multi.new()
|> Multi.insert(:org, org_changeset)
|> Fort.Audit.wrap()This is also the entry point for the existing-Multi case — both greenfield and pre-assembled multis use the same path.