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

FunctionGuaranteeCost
audited_transaction/4 / transact/4Atomic — no business success without a durable audit rowExtra insert inside the business transaction
log_best_effort/4None — audit event can be lost on crash between commit and this callZero added cost to the business transaction
log/1Durable — synchronous DB insert + Logger emissionStandalone 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

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 :details key (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.reconcile

This 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

append_to_multi(audited, name, attrs_or_fn)

@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.

audited_transaction(multi, action, opts \\ [])

@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/4MissingAuditStepError 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.

from_changeset(changeset)

@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))

log(attrs)

@spec log(map()) :: {:ok, Fort.Schemas.AuditLog.t()} | {:error, Ecto.Changeset.t()}

Standalone audit log insert outside a transaction.

log_best_effort(result, action, actor_id, opts \\ [])

@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)

reconcile(repo, batch_size \\ 100)

@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.

transact(audited_multi, action, actor_id \\ nil, opts \\ [])

@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.

wrap(multi)

@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.