Structured audit logging for Sigra.
See .planning/phases/09-audit-logging/09-CONTEXT.md for the 28 decisions
that shape this module. Summary:
- Direct
Ecto.Multiwrites (D-01) — not telemetry subscribers (D-02) - Public API enforces reserved prefixes (D-17..D-18); internal
__log_internal__/3bypasses this check for library-owned events - Metadata size cap 8KB default (D-20); forbidden keys rejected (D-23)
- Cursor pagination, no offset, or-expanded tiebreak (D-13)
- Telemetry passthrough
[:sigra, :audit, :log]on successful commit (D-24)
Telemetry responsibility for log_multi/3 and log_multi_safe/3
Standalone log/3 fires telemetry automatically from its {:ok, _} branch.
Callers of log_multi/3 / log_multi_safe/3 / __log_internal__/3 own the
enclosing repo.transaction/1 and must invoke
Sigra.Audit.emit_telemetry_from_changes/2 from the {:ok, changes} branch.
This guarantees telemetry NEVER fires when the enclosing transaction rolls
back. See Plan 03 integration sites for examples.
By default emit_telemetry_from_changes/2 looks for the :audit step only
(the default Ecto.Multi operation name used when :audit_multi_step is
omitted). When you append multiple internal audit inserts, pass a distinct
:audit_multi_step atom per log_multi_safe/3 / __log_internal__/3 call,
then pass the same atoms as the second argument to emit_telemetry_from_changes/2
so each committed audit row emits [:sigra, :audit, :log] exactly once.
Summary
Functions
Deletes audit events older than the configured retention window.
Returns the number of rows matching the given filters.
Emits [:sigra, :audit, :log] telemetry for committed audit rows in an
Ecto.Multi success changes map.
Returns a cursor-paginated result map.
Writes a single audit event in its own transaction/insert.
Appends an :audit step to an existing Ecto.Multi.
Safe Multi-append for library-internal integration sites.
Safe standalone log for library-internal integration sites.
Library-internal safe audit emission with optional scope.
Returns a composable Ecto.Query filtered by the given keyword filters.
Returns an Enumerable.t() suitable for use inside the caller's
repo.transaction/1 block.
Types
@type opts() :: keyword()
Functions
@spec cleanup(keyword()) :: :ok
Deletes audit events older than the configured retention window.
@spec count(keyword(), keyword()) :: non_neg_integer()
Returns the number of rows matching the given filters.
Emits [:sigra, :audit, :log] telemetry for committed audit rows in an
Ecto.Multi success changes map.
audit_steps is the ordered list of Ecto.Multi operation names used with
:audit_multi_step (defaulting to a single :audit insert). For each step
that resolves to a struct in changes, emits one telemetry event.
Callers must invoke this only from the {:ok, changes} branch of their own
repo.transaction/1 so telemetry never fires on rollback.
Returns a cursor-paginated result map.
Options:
:repo(required):limit— default 50, capped at 500:cursor— opaque Base64URL cursor from a previous result
@spec log(String.t(), opts()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()}
Writes a single audit event in its own transaction/insert.
Returns {:ok, event} on success, {:error, changeset} on validation
failure. Fires [:sigra, :audit, :log] telemetry exactly once on success,
never on failure.
@spec log_multi(Ecto.Multi.t(), String.t(), opts()) :: Ecto.Multi.t()
Appends an :audit step to an existing Ecto.Multi.
Raises ArgumentError at composition time if action uses a reserved
Sigra prefix — developers must not log library-owned events. Internal
callers must use __log_internal__/3 instead.
Callers own the enclosing repo.transaction/1 call. To emit telemetry on
successful commit, invoke emit_telemetry_from_changes/1 in the
{:ok, changes} branch after the transaction returns. Telemetry MUST NOT
fire when the enclosing transaction rolls back.
@spec log_multi_safe(Ecto.Multi.t(), String.t(), opts()) :: Ecto.Multi.t()
Safe Multi-append for library-internal integration sites.
Returns the multi unchanged when :audit_schema is nil or absent.
Otherwise appends an audit insert step via __log_internal__/3.
Options
:audit_multi_step—Ecto.Multioperation name for this insert (default:audit). Use distinct atoms when composing multiple audit rows in one transaction (for examplemfa.verify.success+mfa.backup_code_used).multi = Ecto.Multi.new() |> Sigra.Audit.log_multi_safe( "mfa.verify.success", Keyword.merge(opts, audit_multi_step: :audit_mfa_verify) ) |> Sigra.Audit.log_multi_safe( "mfa.backup_code_used", Keyword.merge(opts, audit_multi_step: :audit_mfa_backup) ) {:ok, changes} = repo.transaction(multi) Sigra.Audit.emit_telemetry_from_changes(changes, [:audit_mfa_verify, :audit_mfa_backup]):target_resolver— arity-1 callback receiving the Multichangesmap (same as:actor_resolver) to populatetarget_idwhen it cannot be known at composition time.:organization_id_resolver/:effective_user_id_resolver— same arity-1changescallbacks for scope-derived columns when the subject row is only known after an earlier Multi step (e.g. email-change confirm).
Safe standalone log for library-internal integration sites.
No-ops (returns :ok) when :audit_schema is nil or absent. This lets
integration call sites (Plan 03) add audit writes without breaking host
apps that have not configured an audit schema. Always bypasses the
reserved-prefix check (library-owned events).
Returns :ok in all cases (successful insert, disabled, or insert error)
because integration call sites must not change their return shape on
audit failure. Errors are logged via telemetry metadata on the separate
[:sigra, :audit, :log_safe_error] event for observability.
Library-internal safe audit emission with optional scope.
scope is the second positional argument — mirrors the Phoenix 1.8
scopes idiom. Pass nil explicitly for pre-authentication or truly
anonymous call sites.
Scope is duck-typed on %{user, active_organization, impersonating_from} —
it does NOT pattern-match on %Sigra.Scope{} because that struct is
generated into the host app, not defined in the library.
effective_user_id is the authenticated principal (or v1.2 impersonation
target); target_id is the subject of the event. They diverge for
anonymous-actor events (failed login, magic link request) and for admin
actions on other users (v1.2).
Caller-supplied :organization_id / :effective_user_id / :actor_id
in opts always win over scope-derived values (D-06 caller-wins merge).
@spec query(keyword()) :: Ecto.Query.t()
Returns a composable Ecto.Query filtered by the given keyword filters.
Required key: :audit_schema. Supported filters are listed in
Sigra.Audit.Query.
@spec stream(keyword(), keyword()) :: Enumerable.t()
Returns an Enumerable.t() suitable for use inside the caller's
repo.transaction/1 block.
The repo must implement stream/1. Raises ArgumentError otherwise so
that large audit tables can never be loaded entirely into memory via a
silent repo.all/1 fallback. Callers without a streaming repo should use
list/2 (cursor-paginated) instead.