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 an Ecto.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 :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.

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

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.

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.

new()

@spec new() :: Fort.AuditedMulti.t()

Returns a fresh AuditedMulti wrapping an empty Ecto.Multi.

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 existing Ecto.Multi in an AuditedMulti.