Fort. Audit
(fort_audit v0.1.0)
Copy Markdown
Atomic audit logging with dual routing: persists to PostgreSQL and emits
structured JSON via :logger.
Two paths
- Transactional (
transact/4) — audit row committed atomically with business steps inside anEcto.Multi. Logger emission is secondary. - Standalone (
log/1) — single audit insert outside a Multi. Logger emission is synchronous with the DB write.
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
Existing Multi
Use wrap/1 to attach audit to an already-assembled Ecto.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)Greenfield
Use new/0 when starting from scratch:
Fort.Audit.new()
|> then(fn %Fort.AuditedMulti{multi: multi} ->
%{multi | multi: Multi.insert(multi, :org, org_changeset)}
end)
|> Fort.Audit.append_to_multi(:audit, %{
actor_id: actor.id,
actor_type: "admin_user",
action: "org.created"
})
|> Fort.Audit.transact("org.created", actor.id)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}
})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.
Derives before_data, after_data, and changes directly from an
Ecto.Changeset.
Standalone audit log insert outside a transaction.
Returns a fresh AuditedMulti wrapping an empty Ecto.Multi.
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 existing 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 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 new() :: Fort.AuditedMulti.t()
Returns a fresh AuditedMulti wrapping an empty Ecto.Multi.
@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 existing Ecto.Multi in an AuditedMulti.