AuditTrailEx

CI Hex.pm Hexdocs.pm License: MIT

AuditTrailEx is a transaction-safe audit logging and change-history library for Elixir applications using Ecto.

Status: early release (0.x). The API may change before 1.0. Please read Limitations before relying on it for compliance.

It provides automated field-level diff calculation, granular sensitive-field exclusion and redaction, flexible actor identification, structured change formatting, and seamless Ecto.Multi pipeline integration.


Why AuditTrailEx?


Installation

Add audit_trail_ex to your list of dependencies in mix.exs:

def deps do
[
{:audit_trail_ex, "~> 0.1.0"}
]
end

Setup & Database Migration

Generate a new Ecto migration:

mix ecto.gen.migration create_audit_events

Invoke AuditTrailEx.Migration.up/1 inside your migration:

defmodule MyApp.Repo.Migrations.CreateAuditEvents do
use Ecto.Migration
def up do
AuditTrailEx.Migration.up()
end
def down do
AuditTrailEx.Migration.down()
end
end

Then run:

mix ecto.migrate

Custom Primary Key or Table Name

The table name and primary key type are set in config, and both AuditTrailEx.Event and AuditTrailEx.Migration read them, so the schema and table always match:

# config/config.exs
config :audit_trail_ex,
table_name: "system_audit_logs", # default: "audit_events"
primary_key_type: :bigserial # default: :binary_id (UUID)

These settings are read at compile time. After changing them, recompile the dependency:

mix deps.compile audit_trail_ex --force

Configuration

In your config/config.exs:

config :audit_trail_ex,
excluded_fields: [:password, :password_hash, :reset_token, :api_secret],
redacted_fields: [:ssn, :credit_card]

Usage

1. Basic Mutations

AuditTrailEx provides drop-in replacements for standard Repo mutations that execute atomically within a database transaction:

# Insert
{:ok, user} =
AuditTrailEx.insert(Repo, User.changeset(%User{}, user_params),
actor: current_user,
metadata: %{ip: "127.0.0.1", source: "admin_portal"}
)
# Update (only records modified fields!)
{:ok, updated_user} =
AuditTrailEx.update(Repo, User.changeset(user, %{name: "Johnny"}),
actor: current_user
)
# Delete (records previous values snapshot)
{:ok, deleted_user} =
AuditTrailEx.delete(Repo, user,
actor: current_user
)

Returning the Audit Event

To receive both the mutated record and the generated %AuditTrailEx.Event{}:

{:ok, user, audit_event} =
AuditTrailEx.update(Repo, changeset, actor: current_user, return_audit: true)

2. Ecto.Multi Integration

AuditTrailEx integrates natively into Ecto.Multi pipelines:

Ecto.Multi.new()
|> AuditTrailEx.Multi.update(:user, user_changeset, actor: current_user)
|> AuditTrailEx.Multi.insert(:profile, profile_changeset, actor: current_user)
|> Repo.transaction()

If any mutation or audit log fails, the entire transaction rolls back cleanly.

You can also attach an audit log to an existing step in an Ecto.Multi:

Ecto.Multi.new()
|> Ecto.Multi.update(:user, user_changeset)
|> AuditTrailEx.Multi.audit(:user_audit, :user, action: :update, changeset: user_changeset, actor: current_user)
|> Repo.transaction()

3. Actor Tracking

The :actor option accepts:

You can also pass explicit overrides:

AuditTrailEx.update(Repo, changeset,
actor_id: "sec-bot-9",
actor_type: "security_agent"
)

Custom Actor Protocol

Implement AuditTrailEx.Actor for your custom domain structs:

defimpl AuditTrailEx.Actor, for: MyApp.Accounts.Admin do
def identify(%MyApp.Accounts.Admin{id: id, role: role}) do
{to_string(id), "admin:\#{role}"}
end
end

4. Metadata

Attach arbitrary contextual metadata to any audit event:

AuditTrailEx.update(Repo, changeset,
actor: current_user,
metadata: %{
request_id: "req_xyz123",
client_ip: "203.0.113.42",
user_agent: "Mozilla/5.0 ...",
reason: "Requested by customer via support ticket #1234"
}
)

Optional Web / Plug Integration

AuditTrailEx includes a helper module (AuditTrailEx.Web) to extract request metadata safely without hard Plug dependencies:

# In a controller or plug:
metadata = AuditTrailEx.Web.extract_metadata(conn)
AuditTrailEx.update(Repo, changeset, actor: current_user, metadata: metadata)

5. Sensitive-Field Filtering & Redaction

Protection rules follow a 3-tier precedence hierarchy:

  1. Per-Call Options: Passed directly to AuditTrailEx.update(Repo, changeset, excluded_fields: [:pin]).
  2. Per-Schema Options: Defined via __audit_trail_options__/0 on the schema module.
  3. Global Config: Defined in config :audit_trail_ex.
defmodule MyApp.Accounts.User do
use Ecto.Schema
schema "users" do
field :name, :string
field :email, :string
field :pin_code, :string
end
def __audit_trail_options__ do
[
excluded_fields: [:pin_code],
redacted_fields: [:email]
]
end
end

6. Querying History

AuditTrailEx provides composable Ecto query builders:

# Fetch all events for a specific record
history = AuditTrailEx.history(Repo, User, user.id)
# Filter by action, date range, or limit
recent_updates =
AuditTrailEx.history(Repo, User, user.id,
action: :update,
since: ~U[2026-09-01 00:00:00Z],
limit: 10
)
# Query events by actor
actor_events = AuditTrailEx.by_actor(Repo, "admin-1", actor_type: "superadmin")
# Query events by action
all_deletions = AuditTrailEx.by_action(Repo, :delete)

Composing Custom Queries

import Ecto.Query
AuditTrailEx.Query.base()
|> AuditTrailEx.Query.by_action(:update)
|> AuditTrailEx.Query.recent(20)
|> Repo.all()

7. Human-Readable Descriptions

Convert raw diff maps or %AuditTrailEx.Event{} structs into structured descriptions or formatted text:

# Structured change descriptions (ideal for LiveView or React UIs):
descriptions = AuditTrailEx.describe(event)
#=> [%AuditTrailEx.ChangeDescription{field: "name", from: "Alice", to: "Alicia", kind: :changed, summary: "Name changed from \"Alice\" to \"Alicia\""}]
# Plain-text formatting (ideal for logs, Slack alerts, or email receipts):
IO.puts(AuditTrailEx.describe_text(event))
# Output:
# Update MyApp.Accounts.User [42]
#
# Name changed
# From: "Alice"
# To: "Alicia"

8. Telemetry

AuditTrailEx dispatches standard :telemetry events:

Security Note: Telemetry metadata never includes raw field values, previous values, or new values. Only field names and structural metadata are emitted.

9. Capturing Changes Made Outside AuditTrailEx (PostgreSQL)

By default only writes made through AuditTrailEx are audited. To also capture plain Repo calls, update_all/insert_all/delete_all, raw SQL and manual database changes, add audit triggers (PostgreSQL 13+):

defmodule MyApp.Repo.Migrations.AddAuditTriggers do
use Ecto.Migration
def up do
AuditTrailEx.Trigger.install()
AuditTrailEx.Trigger.create(MyApp.Accounts.User)
end
def down do
AuditTrailEx.Trigger.drop(MyApp.Accounts.User)
AuditTrailEx.Trigger.uninstall()
end
end

The trigger uses the schema's primary key and the same excluded and redacted fields as AuditTrailEx. These are fixed when the migration runs, so after changing them, drop and re-create the trigger in a new migration.

Turn on trigger_capture so writes made through AuditTrailEx are not recorded twice:

config :audit_trail_ex, trigger_capture: true

Set the actor and metadata for triggered events with with_context/3 (or put_context/2 inside an existing transaction):

AuditTrailEx.with_context(Repo, [actor: current_user, metadata: %{reason: "cleanup"}], fn ->
Repo.update_all(User, set: [role: "member"])
end)

Without a context, triggered events have actor_type: "system". See AuditTrailEx.Trigger for how triggered events differ (e.g. decimals are stored as JSON numbers).


Limitations


Performance & Maintenance


Running Tests

Ensure PostgreSQL is running:

docker run --name audit_trail_postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_DB=audit_trail_ex_test \
-p 5432:5432 -d postgres:16-alpine

Run the ExUnit test suite:

mix test

Run the executable demo:

MIX_ENV=test mix run examples/demo.exs

Run code quality tools:

mix format --check-formatted
mix credo --strict
mix docs

Roadmap


Contributing

Pull requests are welcome! Please check out CONTRIBUTING.md for details on our code standards and verification processes.


License

AuditTrailEx is open-source software licensed under the MIT License.