AuditTrail

Structured audit logging, database change tracking, and crash reporting for Elixir/Phoenix applications. Supports multiple storage backends — logs can be shipped to Grafana Loki, stored in your existing PostgreSQL database, or stored in TimescaleDB for automatic time partitioning and compression.


Why you need it

Most apps bolt on audit logging as an afterthought — scattered Logger.info calls, a logs database table that grows unbounded, or nothing at all. AuditTrail gives you:


Table of contents


Quick start (5 minutes, no Loki required)

The fastest way to see a real audit log end to end: store it in the Postgres you already have. No Loki, no Grafana, no docker-compose.

# 1. mix.exs
{:audit_trail, "~> 0.1.2"} # or git: — see Installation below
# 2. config/config.exs (or runtime.exs)
config :audit_trail,
app_name: "my_app",
storage_adapter: {AuditTrail.Adapters.Postgres, repo: MyApp.Repo}
# 3. Fetch, generate the migration, migrate
mix deps.get
mix audit_trail.gen.migration
mix ecto.migrate
# 4. Log something from anywhere — a controller, a LiveView, an Oban job, iex
AuditTrail.emit("user:signed_up", %{resource_id: user.id, plan: "free"})
# 5. Read it back
{:ok, logs} = AuditTrail.get_logs(%{type: "user:signed_up"})

That's it — a real, queryable audit log with five lines of config. Once this feels good, keep reading:


Core concepts

Three ideas cover almost everything below — read this before the deep-dive sections if you're new to the library.

Everything else in this README is either "how do I produce an event" (the five instrumentation levels) or "how do I configure/query where events go" (adapters, filters).

Which instrumentation level do I need?

You want to...UseEffort
Track every change to a DB row automaticallySchema leveluse AuditTrail.SchemaAdd one line to the schema, one to the Repo
Log specific controller actions ("item approved", "user logged in")Controller levelplug AuditTrail.ControllerAuditOne plug declaration, zero code in the action
Same, but for LiveView handle_eventLiveView levelon_mount {AuditTrail.LiveViewAudit, ...}One on_mount, zero code in the callback
Log something with no schema and no controller — a background job, a webhook, a third-party API callAPI / service levelAuditTrail.emit/2One explicit call at the point it happens
Log literally every HTTP request, unconditionallyPipeline levelplug AuditTrail.PlugOne plug in your router pipeline

These aren't mutually exclusive — most real apps use two or three together (e.g. schema tracking on a couple of sensitive tables, the controller plug for actions that matter, emit/2 for one-off business events like a payment webhook). Pick based on where the event naturally happens in your code, not on trying to standardize on one mechanism.

Which storage adapter do I need?

If you...Use
Don't want to stand up new infrastructurePostgres — logs live in your existing DB, see Quick start
Expect high volume, or want old logs auto-compressedTimescaleDB — same as Postgres, plus time partitioning
Already run Grafana/Loki, or want LogQL + dashboardsLoki (the default) — see Infrastructure requirements
Are writing tests and want to assert what got loggedTest — in-memory, zero setup, see Testing

Full setup for each is under Storage adapters.


Infrastructure requirements

Skip this section entirely if you're using the Postgres, TimescaleDB, or Test adapter — it only applies to the Loki adapter (the default, but not required).

AuditTrail defaults to Grafana Loki as its storage backend, but this is optional. If you configure the Postgres or TimescaleDB adapter, no Loki instance is needed. See Storage adapters for how to switch.

ComponentPurpose
LokiLog storage and query engine
Grafana (optional)Visual log explorer over Loki
MinIO / S3 (optional)Object storage backend for Loki in production

With S3 / MinIO (production)

common:
storage:
s3:
endpoint: http://minio:9000
bucketnames: loki-data # ← "bucketnames" not "buckets" (Loki 3.x)
access_key_id: YOUR_KEY
secret_access_key: YOUR_SECRET
s3forcepathstyle: true
insecure: true # set false in real production

Loki 3.x gotcha: the S3 field is bucketnames, not buckets. The wrong name causes Loki to crash-loop on startup with field buckets not found in type aws.S3Config.


Installation

1. Add to mix.exs

# Monorepo / local path
{:audit_trail, path: "../audit_trail"}
# Git
{:audit_trail, git: "https://github.com/your-org/audit_trail.git", tag: "v0.1.2"}
mix deps.get
mix deps.compile audit_trail
mix compile

Recompile tip: when you add new source files to the library, run mix deps.compile audit_trail --force && mix compile --force. The plug macro calls init/1 at compile time, so the library must be fully compiled before the host app compiles its controllers.

2. Do NOT add to your supervision tree

AuditTrail is an OTP application — it starts itself when listed as a mix dependency. Adding it to application.ex causes {:already_started, pid}:

# ✗ Wrong
children = [
{AuditTrail, []}, # remove this
MyApp.Repo,
]
# ✓ Correct — just the mix dep, nothing in children

Configuration

All config lives under :audit_trail in your config/*.exs files.

Required

config :audit_trail,
app_name: "my-app",
loki_push_url: System.get_env("LOKI_PUSH_URL"), # http://localhost:3100/loki/api/v1/push
loki_read_url: System.get_env("LOKI_READ_URL") # http://localhost:3100/loki/api/v1/query_range

Buffer tuning (optional)

config :audit_trail,
enabled: true, # set false to disable all shipping (dev/test)
partition_count: 8, # GenServer buffer partitions (default 8)
batch_size: 100, # flush after this many entries (default 100)
flush_interval: 2_000, # flush every N ms even if batch not reached (default 2000)
max_buffer: 5_000, # drop logs when a partition's buffer exceeds this (default 5000, PER PARTITION — real cap is partition_count * max_buffer)
max_dead_letter: 10_000, # dead-letter capacity (default 10000)
# Off by default (in-memory ETS, lost on restart). Set true to back the
# dead-letter queue with :dets instead, so entries queued when Loki is
# down survive a node restart/deploy:
dead_letter_persistent: false,
dead_letter_path: "audit_trail_dead_letter.dets" # must be a writable, already-existing directory

Once a batch exhausts shipper_retries, it lands in AuditTrail.DeadLetter — inspect or manage that queue directly, e.g. from iex in production:

AuditTrail.DeadLetter.count() #=> 42
AuditTrail.DeadLetter.dump() #=> [%{event_type: "item:approved", ...}, ...]
AuditTrail.DeadLetter.redrive() # don't wait for the next scheduled retry — Loki's back up now
AuditTrail.DeadLetter.clear() # give up on these entries entirely (irreversible)

Shipper retries (optional)

config :audit_trail,
shipper_timeout: 5_000, # HTTP timeout per Loki push attempt in ms (default 5000)
shipper_retries: 3 # retries with exponential backoff before dead-letter (default 3)

Crash reporting (optional)

Two mailer options — pick whichever matches what your app already has.

Option A: your app already has a Swoosh Mailer module

config :audit_trail,
crash_reporting: true,
dedup_window_minutes: 30, # suppress duplicate crash emails within this window
dedup_max_entries: 1_000,
mailer_adapter: {AuditTrail.Adapters.Swoosh, []},
swoosh_mailer: MyApp.Mailer,
from_email: {"MyApp Alerts", "alerts@myapp.com"},
crash_routing: [
%{match: {:module, ~r/MyApp\.Items/}, to: ["items-team@myapp.com"]},
%{match: {:module, ~r/MyApp\.Auth/}, to: ["security@myapp.com"]},
%{match: :default, to: ["admin@myapp.com"]}
]

Option B: no host-app Mailer — raw SMTP

AuditTrail.Adapters.SMTP talks to your SMTP server directly via Swoosh's SMTP adapter, without requiring you to define a MyApp.Mailer module of your own:

config :audit_trail,
crash_reporting: true,
mailer_adapter: {AuditTrail.Adapters.SMTP, []},
from_email: {"MyApp Alerts", "alerts@myapp.com"},
smtp: [
relay: "smtp.gmail.com",
username: "alerts@myapp.com",
password: System.get_env("SMTP_PASSWORD"),
port: 587,
tls: :always,
auth: :always
],
crash_routing: [
%{match: :default, to: ["admin@myapp.com"]}
]

smtp: accepts the same options as Swoosh.Adapters.SMTPrelay, username, password, port, tls, auth, etc.

# config/dev.exs
config :audit_trail,
enabled: true, # or false to silence all Loki calls during local dev
loki_push_url: "http://localhost:3100/loki/api/v1/push",
loki_read_url: "http://localhost:3100/loki/api/v1/query_range"
# config/test.exs
config :audit_trail,
enabled: false # never ship logs during tests
# config/runtime.exs (production)
config :audit_trail,
enabled: true,
loki_push_url: System.fetch_env!("LOKI_PUSH_URL"),
loki_read_url: System.fetch_env!("LOKI_READ_URL")

When enabled: false the buffer still accepts log calls (no errors, no warnings) but the shipper never fires. Safe to leave logging calls in your code in all environments.


Storage adapters

AuditTrail ships with four storage adapters. All four implement the same interface, so AuditTrail.get_logs/1 works identically regardless of which backend you use.

AdapterRequiresBest for
AuditTrail.Adapters.LokiGrafana LokiProduction observability stacks, long-term log retention, Grafana dashboards
AuditTrail.Adapters.PostgresYour existing Ecto RepoApps already on Postgres that don't want to run Loki
AuditTrail.Adapters.TimescaleDBTimescaleDB extensionHigh-volume logs, automatic compression, time-range query performance
AuditTrail.Adapters.TestNothingAsserting on audit events in your test suite — see Testing

The default is Loki. No adapter config is needed if you set loki_push_url.


Loki adapter (default)

# config/runtime.exs — Loki is the default, this line is optional
config :audit_trail,
storage_adapter: AuditTrail.Adapters.Loki, # can omit this line
loki_push_url: System.fetch_env!("LOKI_PUSH_URL"),
loki_read_url: System.fetch_env!("LOKI_READ_URL")

No migration needed. Logs are stored as labelled JSON streams in Loki and are queryable with LogQL via Grafana or the built-in reader.


Postgres adapter

Stores logs in an audit_logs table in your own PostgreSQL database. No external services required.

1. Configure

config :audit_trail,
storage_adapter: {AuditTrail.Adapters.Postgres, repo: MyApp.Repo}

The repo: option is required — pass your app's Ecto.Repo module.

2. Generate and run the migration

mix audit_trail.gen.migration
mix ecto.migrate

This creates a timestamped migration file in priv/repo/migrations/. The migration adds the audit_logs table with these columns:

ColumnTypeDescription
idbigserialAuto-incrementing primary key
event_idtextUUID assigned at emit time
event_typetexte.g. "auth:login"
actor_idtextUser ID or "anonymous"
actor_nametextDisplay name of the actor
actor_emailtextEmail of the actor
payloadjsonbFull structured event payload
inserted_attimestamptzWhen the event was recorded

A GIN index on payload is added automatically so JSON field searches are fast — this is also what makes the optional resource/operation filters (see Reading logs back) work without a schema change.

3. Options for the migration task

# Target a specific repo
mix audit_trail.gen.migration --repo MyApp.Repo
# Write to a custom path
mix audit_trail.gen.migration --migrations-path priv/audit_trail/migrations

TimescaleDB adapter

TimescaleDB is PostgreSQL with automatic time-based partitioning. The adapter is functionally identical to the Postgres adapter — it differs only in the migration, which also calls create_hypertable.

1. Configure

config :audit_trail,
storage_adapter: {AuditTrail.Adapters.TimescaleDB, repo: MyApp.Repo}

2. Generate and run the migration

mix audit_trail.gen.migration
mix ecto.migrate

The generated migration includes the standard table plus:

SELECT create_hypertable('audit_logs', 'inserted_at', chunk_time_interval => INTERVAL '1 day')

3. Enable compression (optional, after migrating)

Run these directly in your database to compress chunks older than 7 days:

ALTER TABLE audit_logs SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'event_type, actor_id'
);
SELECT add_compression_policy('audit_logs', INTERVAL '7 days');

The compress_segmentby columns match the fields you filter on most — queries on those fields stay fast even against compressed chunks.

Why TimescaleDB over plain Postgres?

FeaturePostgresTimescaleDB
Time-range queriesTable scan (unless indexed)Chunk pruning — only relevant chunks scanned
Storage for old dataFull row size alwaysCompressed chunks (4–20× compression typical)
Partition managementManualAutomatic by time interval

TimescaleDB is the better choice when you expect more than a few million log rows or run frequent date-range queries (the default pattern for audit logs). It is standard Postgres-compatible — your existing mix ecto.migrate and all Ecto queries work without modification.


Switching adapters

You can switch adapters at any time by changing the config. Logs already written are not migrated — the new adapter starts fresh from the moment the config change takes effect. For a zero-downtime switch, deploy the new config and run the migration before cutting over traffic.

Writing a custom adapter

Implement the AuditTrail.Adapter behaviour:

defmodule MyApp.AuditAdapter do
@behaviour AuditTrail.Adapter
@impl true
def push(logs) do
# logs is a list of sanitized map()s with at minimum:
# :event_type, :actor_id, :actor_name, :actor_email, :timestamp
:ok
end
@impl true
def get_logs(filters) do
# filters: %{actor_id:, type:, status:, search:, start_date:, end_date:, limit:}
# Must return {:ok, [%{timestamp:, labels: map(), payload: map()}]}
# or {:error, reason}
{:ok, []}
end
@impl true
def child_spec(_opts), do: nil # return nil if no supervised process needed
end

Then configure it:

config :audit_trail, storage_adapter: MyApp.AuditAdapter
# or with opts:
config :audit_trail, storage_adapter: {MyApp.AuditAdapter, some_option: true}

The output shape of get_logs/1 must match the structure the rest of the system expects:

%{
timestamp: ~U[2026-06-12 10:34:21Z], # DateTime
labels: %{"app" => "...", "type" => "..."}, # string keys
payload: %{"event_type" => "...", ...} # string keys, full event data
}

Usage

There are five levels at which you can instrument your app. They are independent — use as many or as few as you need.


1. Schema level — automatic field-level change tracking

The zero-effort layer. Mark a schema, and every insert, update, and delete through your Repo is recorded automatically with a before/after diff.

Instrument your Repo

defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres
use AuditTrail.RepoWatcher # add this line
end

RepoWatcher overrides insert/2, update/2, delete/2 (and their bang variants). Operations on non-tracked schemas pass through completely unchanged.

Mark schemas to track

defmodule MyApp.Items.Item do
use Ecto.Schema
use AuditTrail.Schema, track: [:status, :condition, :approved_by]
schema "items" do
field :name, :string
field :status, :string # tracked ✓
field :condition, :string # tracked ✓
field :approved_by, :string # tracked ✓
field :internal_notes, :string # NOT tracked
end
end

track: :all tracks every field. A list of atoms tracks only those fields — useful to exclude high-frequency or irrelevant fields like updated_at.

Embeds and associations are diffed too

embeds_one/embeds_many and has_one/has_many/belongs_to/many_to_many (via cast_assoc/put_assoc/cast_embed) are diffed recursively, not dumped as an opaque struct:

defmodule MyApp.Items.Item do
use Ecto.Schema
use AuditTrail.Schema, track: :all
schema "items" do
field :status, :string
embeds_one :shipping_address, MyApp.Items.Address
has_many :line_items, MyApp.Items.LineItem
end
end

Updating shipping_address.city and adding a new line_items row produces:

%{
"shipping_address" => %{
"action" => "update",
"record_id" => "addr-1",
"changes" => %{"city" => %{"from" => "Nairobi", "to" => "Mombasa"}}
},
"line_items" => [
%{"action" => "insert", "changes" => %{"sku" => %{"from" => nil, "to" => "ABC-123"}}}
]
}

action per nested entry is "insert", "update", "delete", or "replace" (Ecto's own terms for what happened to that embed/association). The parent's track: field list only filters which top-level fields are tracked — once a top-level field like shipping_address is in scope, all of its nested fields are included unless the nested schema opts into its own filtering:

defmodule MyApp.Items.Address do
use Ecto.Schema
use AuditTrail.Schema, track: [:city, :country] # filters this embed's own fields
embedded_schema do
field :city, :string
field :country, :string
field :contact_phone, :string # NOT tracked, even though shipping_address is
end
end

Embeds/associations don't go through the Repo directly, so this doesn't give them independent tracking (no standalone schema:update event for just the embed) — it only scopes which of their fields show up in the parent's diff.

What gets logged

FieldExample value
event_type"schema:update"
actor_id"550e8400-e29b-41d4-a716-446655440000"
schema"Elixir.MyApp.Items.Item"
resourcenil unless resource: is set (see below)
resource_id"abc-123" — same value as record_id, kept under both names so filters can use either
operation"insert" / "update" / "delete"
record_id"abc-123"
changes{"status": {"from": "pending", "to": "approved"}}

The actor is whatever was last set via AuditTrail.set_actor/1 in the current process. Defaults to "anonymous".

Naming the resource (optional)

schema already identifies what was changed (the full module name), but that's noisy to filter on. Pass resource: for a short, business-facing label instead:

defmodule MyApp.Payments.Payment do
use Ecto.Schema
use AuditTrail.Schema, track: [:status, :amount], resource: "payment"
# ...
end

This is purely additive — omit resource: and tracking behaves exactly as before, with resource simply absent (nil) from the payload.

Repo.transaction and Ecto.Multi are commit-safe

RepoWatcher also wraps transact/2 — the function both Repo.transaction/2 and Ecto.Multi funnel through. Any audit log produced while a transaction is still open (an Ecto.Multi.insert/update/delete step, or a manual AuditTrail.emit/monitor call made inside the transaction function) is held, not shipped immediately:

Ecto.Multi.new()
|> Ecto.Multi.update(:item, item_changeset)
|> Ecto.Multi.insert(:approval, approval_changeset)
|> MyApp.Repo.transaction()

This applies regardless of nesting depth — only the outcome of the outermost transaction decides whether held logs ship or are dropped.

Correlating multi-table changes with transaction_id

A transaction touching several tables produces one audit event per table/record — an Ecto.Multi updating an item and inserting an approval produces two separate schema:update/schema:insert events, each with its own event_id. Every event dispatched while that same outermost transaction is open — schema-tracked Repo calls, plus any manual emit/monitor call made inside the transaction function — is stamped with the same transaction_id, so you can pull back the whole set as one unit:

{:ok, logs} = AuditTrail.get_logs(%{transaction_id: "a1b2c3d4..."})

transaction_id is only present on events logged while a transaction is open — standalone events (outside any Repo.transaction/Ecto.Multi) never carry it, so this doesn't change the payload shape for existing single-write flows.

Limitation

Schema tracking intercepts Repo.insert, Repo.update, Repo.delete (single-record), including when called through Ecto.Multi.insert/update/delete. It does not intercept:


2. Controller level — declarative plug

For business events that span multiple schemas, or events with no schema change. Declare once at the top of a controller — no per-function code.

defmodule MyAppWeb.Items.ItemApprovalController do
use MyAppWeb, :controller
plug AuditTrail.ControllerAudit,
write: [
api_approve: "item:approved",
api_decline: "item:declined",
api_request_revision: "item:revision_requested",
api_acknowledge: "item:revision_acknowledged"
]
def api_approve(conn, %{"id" => id} = params) do
# No AuditTrail call needed here — the plug handles it
case ItemApproval.approve(id, params) do
{:ok, updated} -> json(conn, %{data: updated})
{:error, _} = e -> respond_error(conn, e)
end
end
end

The plug hooks into Plug.Conn.register_before_send and fires after your action returns, capturing the final HTTP status code.

write vs read

KeyLogs when
writeAlways — success and failure. A rejected approval is still an auditable event.
readOnly on 2xx — failed reads (404, 403) are noise and are dropped.

What gets logged per event

FieldValue
event_typethe string you provided, e.g. "item:approved"
actionthe Phoenix action atom, e.g. api_approve
operation"write" or "read" by default — "create"/"update"/"delete" if you use those keys (see below)
resourcenil unless resource: is set on the plug (see below)
resource_idnil unless found in conn.params["id"]/conn.params["uuid"], or id_param: (see below)
http_status200, 422, 403, etc.
successtrue / false
paramssanitized request params (sensitive fields stripped automatically)

Tagging the resource, record, and CRUD operation (optional)

write/read control when an event is logged, but don't say what was changed or which one. If you want logs to carry a resource name, the specific record's id, and a specific CRUD operation (useful for filtering audit history down to e.g. "every update on paymentc9b031c6-..."), pass resource: and split actions across create:, update:, delete: instead of (or alongside) write::

plug AuditTrail.ControllerAudit,
resource: "payment",
create: [api_charge: "payment:charged"],
update: [api_refund: "payment:refunded"],
delete: [api_void: "payment:voided"],
read: [api_show: "payment:viewed"]

create, update, and delete behave exactly like write (logged on success and failure) — they just set a more specific operation value. This is entirely optional: existing write:/read: declarations keep working unchanged, and resource is simply nil if you don't set it.

resource_id — the id of the specific record touched — is captured automatically from conn.params["id"] or conn.params["uuid"], whichever is present. If your route uses a different param name, point at it with id_param::

plug AuditTrail.ControllerAudit,
resource: "idf",
id_param: "uuid", # matches conn.params["uuid"]; also accepts an atom or a list of names
update: [api_update_idf: "idf:updated"]

With this in place you can pull the full history of one record:

AuditTrail.get_logs(%{resource: "idf", resource_id: "c9b031c6-6d9e-494b-8399-236940ca9d10"})

If conn.params has neither the default keys nor your id_param:, resource_id is just nil — no error, no change to existing behavior.

Auto-stripped sensitive fields

The following param keys are dropped entirely before logging, regardless of what you pass:

password new_password current_password confirm_password
code token access_token refresh_token methods_token
secret client_secret api_key private_key

This list is AuditTrail.Config.sensitive_fields/0 — shared by ControllerAudit, AuditTrail.LiveViewAudit, and AuditTrail.Sanitizer (which redacts any matching key, at any nesting depth, in every payload — schema-level diffs, monitor/emit's original_record: diffing, everything — right before it ships). One list, one place to extend it:

config :audit_trail, sensitive_fields: ~w(
password token secret
kra_pin id_number tax_payer_pin
)

This replaces the defaults rather than adding to them — include what you still want redacted alongside your own app-specific fields (national ID numbers, tax PINs, anything else that shouldn't end up in Loki/Postgres). Matching is exact and case-insensitive against the key name (password_hash does not match password — list the exact field name your schema uses).

ControllerAudit/LiveViewAuditdrop the key entirely from params; Sanitizer (the backstop covering diffs, changes, and anything else) replaces the value with "[REDACTED]" instead of dropping the key — so a diff still shows that a sensitive field changed, just not to what.

Oversized values are truncated

File uploads and other large payloads (base64 blobs, big lists) are truncated before being logged, so a single upload action doesn't dump megabytes into the audit trail:

This applies recursively through nested maps/lists in params.

Login flows — set the actor before returning

Because the authentication plug runs before the login action, there is no current_user in conn.assigns at login time. Call AuditTrail.set_actor/1 inside the success branch so the plug captures the real identity when before_send fires:

plug AuditTrail.ControllerAudit,
write: [api_login: "auth:login", api_logout: "auth:logout"]
def api_login(conn, params) do
case Auth.login(params) do
{:ok, user, token} ->
AuditTrail.set_actor(user) # ← must be called before returning
json(conn, %{token: token})
{:error, reason} ->
# actor stays anonymous — failed logins are still logged
respond_error(conn, reason)
end
end

Plain atom shorthand

If you don't care about the event name and just want the action name (minus the api_ prefix), pass a plain atom:

plug AuditTrail.ControllerAudit,
write: [:api_approve, :api_decline]
# logs as "approve" and "decline"

3. API / service level — manual emit

For background jobs, external API calls, or any event that doesn't go through a controller.

AuditTrail.emit/2

AuditTrail.emit("payment:processed", %{
amount: order.total,
currency: "KES",
gateway: "mpesa",
reference: txn.ref
})

Tagging the resource, record, and CRUD operation (optional)

Pass resource:, resource_id:, and operation: in the data map to record what kind of thing this event concerns, which specific record, and which CRUD operation it represents. All three are optional — omit them and they're simply absent (nil) from the payload, exactly like before this was added:

AuditTrail.emit("payment:processed", %{
resource: "payment",
resource_id: txn.id,
operation: "update",
amount: order.total,
currency: "KES",
gateway: "mpesa",
reference: txn.ref
})

There's no fixed list of operations — use whatever fits, e.g. "create", "read", "update", "delete". If you omit resource_id: but the data map has an :id or :uuid key, that value is used automatically.

Diffing against a prior record (optional) — for data outside your own Repo

monitor/4's original_record:/params: diffing only helps when you have a conn and an Ecto result tuple. For something with no Ecto changeset at all — a row that lives in a library's own database, a third-party API's own resource — pass original_record: to emit/2 instead:

# New payment: no original_record, meta is exactly what you passed
AuditTrail.emit("payment:processed", %{
resource_id: txn.ref,
operation: "create",
amount: order.total,
currency: "KES",
gateway: "mpesa",
reference: txn.ref
})
# Existing payment moving to a new state: original_record triggers a diff
AuditTrail.emit("payment:processed", %{
resource_id: txn.ref,
original_record: previous_txn, # a plain map or struct — whatever you have
status: "success",
amount: txn.amount
})

When original_record: is present, it's popped out of the logged payload (never dumped whole) and replaced with meta.changes — a field-level diff of the rest of the data map against it, reusing the exact same comparison monitor/4 uses. operation defaults to "update" in that case (still overridable). Omit original_record: and behavior is identical to before this existed.

This has no conn/socket dependency — unlike monitor/4, emit/2 never needed one, so this works unchanged from a controller, a LiveView handle_event, an Oban job, or a webhook handler. If you also want request context (IP, user agent, request id) attached, pass ip_address:/user_agent:/request_id: explicitly — extract them however your caller has them (conn in a controller, socket.assigns if you captured them at mount/3, nothing at all in a background job).

Limitations worth knowing:

The actor is picked up from the process dictionary automatically. Override it per-event when needed:

AuditTrail.emit("nightly:sync", %{
actor_id: "system:scheduler",
actor_name: "Nightly Sync",
records: synced_count
})

AuditTrail.monitor/4 — wrap a result tuple inline

def approve(conn, %{"id" => id}) do
original = Items.get!(id)
Items.approve(id)
|> AuditTrail.monitor(conn, "item:approved", original_record: original)
|> case do
{:ok, item} -> json(conn, %{data: item})
{:error, _} = e -> respond_error(conn, e)
end
end

monitor/4 returns the result unchanged so it can be piped. Available options:

OptionPurpose
original_record:record before the change — used to compute a before/after diff
params:the submitted params
extra_meta:any extra map merged into the log payload
resource:optional resource label, e.g. "payment"nil if omitted

operation is set for you automatically ("create" when original_record: is absent, "update" when it's present) — no need to pass it. resource_id is also automatic: it's the id of the record returned in result (on success) or of original_record: (on failure) — no option needed.

The second argument only ever gets pattern-matched by shape (%{remote_ip: ...}, %{req_headers: ...}) to pull IP/user-agent/request-id, never by struct name — so passing something that isn't a conn (a LiveView socket, a plain map, nil) doesn't crash, it just yields "unknown" for those three fields while the original_record:/params: diffing still works exactly the same. If you're not in a Plug pipeline at all, AuditTrail.emit/2 (below) is usually the better fit anyway — it never needed a conn in the first place.

AuditTrail.log_external_api/3 — wrap an external HTTP call

# Wrap a function — timing and success/failure captured automatically
AuditTrail.log_external_api("mpesa:stk_push", "M-Pesa", fn ->
Mpesa.initiate_stk_push(phone, amount)
end)
# Log a result you already have
AuditTrail.log_external_api("twilio:sms", "Twilio", %{
status: "success",
duration_ms: 340,
to: phone_number
})

AuditTrail.set_actor/1 — set who is acting

# From a user struct — picks up :id, :email, :name / :username automatically
AuditTrail.set_actor(user)
# For background/system actors
AuditTrail.set_actor(:system, "nightly-cleanup")

Stored in the process dictionary. Every subsequent emit, monitor, and log_external_api in the same process uses this identity. Call AuditTrail.clear_actor/0 to reset it back to "anonymous" — mainly useful for pooled/long-lived processes handling work for multiple actors in turn (a GenServer working through a job queue, shared test processes), so one actor's identity doesn't leak into the next unit of work.

Background jobs: new processes (Oban jobs, Task.async) do not inherit the process dictionary. Call AuditTrail.set_actor/1 at the top of your job's perform/1. Spawning via AuditTrail.Task.async/start/start_link instead of Task's own propagates both the actor and tenant (see below) automatically.

AuditTrail.Task is a drop-in replacement for the Task module's own async/1, start/1, and start_link/1 — same signatures (a zero-arity function), same return values, just with the actor/tenant context copied into the spawned process before your function runs:

# Instead of Task.async(fn -> ... end):
task = AuditTrail.Task.async(fn ->
# AuditTrail.get_actor()/get_tenant() here return the *caller's*
# actor/tenant, not "anonymous" — every emit/monitor call in here
# is attributed to whoever kicked off the original request.
Payments.reconcile(payment_id)
end)
AuditTrail.Task.await(task)
# Fire-and-forget, same idea:
AuditTrail.Task.start(fn -> Notifications.send_receipt(order) end)

There's no AuditTrail.Task equivalent of Task.async_stream/2 or Task.Supervisor — for anything beyond a single async/start/start_link call, propagate the context yourself: capture AuditTrail.get_actor()/AuditTrail.get_tenant() in the parent process and call AuditTrail.set_actor/1/set_tenant/1 at the top of each child.

AuditTrail.set_tenant/1 — multi-tenant scoping

For multi-tenant apps, tag every subsequent event in the current process with an org/tenant id:

AuditTrail.set_tenant(org.id)

Like set_actor/1, this is stored in the process dictionary — set it once per request (e.g. in a plug, alongside set_actor/1) and every emit/monitor/schema-tracked Repo call in that process picks it up automatically as a tenant field. On the Loki adapter it's also added as a stream label (only when set, so single-tenant apps see no change), so AuditTrail.get_logs(%{tenant: org.id}) is a fast label-scoped query rather than a full JSON body scan. On Postgres/TimescaleDB it's a JSONB field filter, same as resource/operation. AuditTrail.clear_tenant/0 resets it back to nil, same use case as clear_actor/0.


4. Pipeline level — blanket HTTP logging

AuditTrail.Plug logs every HTTP request. This is high volume for an API-only app (one entry per request, including health checks). Only use it if you need a full HTTP access log separate from your business events.

# router.ex
pipeline :api do
plug :accepts, ["json"]
plug AuditTrail.Plug # logs every request
end

For most apps, AuditTrail.ControllerAudit on specific controllers is the better choice — it logs only the actions you explicitly care about.

Custom actor extractor

If your conn.assigns key isn't :current_user:

config :audit_trail, actor_extractor: MyApp.AuditActorExtractor
defmodule MyApp.AuditActorExtractor do
def extract(conn) do
user = conn.assigns[:my_user]
%{id: to_string(user.id), name: user.display_name, email: user.email}
end
end

5. LiveView level — declarative handle_event auditing

AuditTrail.ControllerAudit only covers Phoenix controllers. AuditTrail.LiveViewAudit is the LiveView equivalent, hooking into handle_event/3 via Phoenix.LiveView.attach_hook/4:

defmodule MyAppWeb.ItemLive do
use MyAppWeb, :live_view
on_mount {AuditTrail.LiveViewAudit,
resource: "item",
write: ["approve", "decline"],
read: ["view_details"]}
def handle_event("approve", %{"id" => id}, socket) do
# No AuditTrail call needed here — the hook handles it
...
end
end

Or attach directly (e.g. inside mount/3) if you don't use on_mount:

def mount(_params, _session, socket) do
{:ok, AuditTrail.LiveViewAudit.attach(socket, resource: "item", write: ["approve"])}
end

This isn't identical to ControllerAudit.attach_hook intercepts handle_event/3before your callback runs — there's no LiveView equivalent of register_before_send to check an outcome afterward. So every configured event is logged the moment it's dispatched, regardless of whether your handle_event/3 clause then succeeds, raises, or no-ops. write:/read: behave identically today — the split exists so the config shape matches ControllerAudit and operation gets tagged for filtering. resource_id is picked up from the event params' "id"/"uuid" key (or id_param:), and sensitive keys are stripped, same conventions as ControllerAudit.


Reading logs back

# All logs (last 24 hours by default)
{:ok, logs} = AuditTrail.get_logs()
# Only a specific user's logs
{:ok, logs} = AuditTrail.get_logs(%{actor_id: "user-uuid"})
# Filter by event type
{:ok, logs} = AuditTrail.get_logs(%{type: "auth:login"})
# Filter by resource and CRUD operation (only matches logs that set these — see below)
{:ok, logs} = AuditTrail.get_logs(%{resource: "payment", operation: "update"})
# Full history of one specific record
{:ok, logs} = AuditTrail.get_logs(%{resource: "idf", resource_id: "c9b031c6-6d9e-494b-8399-236940ca9d10"})
# Date range + search term
{:ok, logs} = AuditTrail.get_logs(%{
start_date: "2026-06-01",
end_date: "2026-06-12",
search: "alice@example.com",
limit: 200
})

Available filters

FilterTypeDescription
actor_idstringReturns only this user's logs
typestringMatch a specific event type e.g. "auth:login"
statusstring"success" or "failure"
resourcestring(optional field) Match logs tagged with this resource, e.g. "payment"
resource_idstring(optional field) Match logs for this one record's id, e.g. a uuid — pairs well with resource to get a single record's full history
operationstring(optional field) Match logs tagged with this CRUD operation, e.g. "update"
tenantstring(optional field) Match logs tagged with this tenant/org id — see AuditTrail.set_tenant/1
transaction_idstring(optional field) Match every event logged inside one Repo.transaction/Ecto.Multi — see "Correlating multi-table changes with transaction_id" under Schema level
searchstringSubstring match on the log line body
start_date"YYYY-MM-DD"Start of window (default: 24 hours ago — logged via Logger.info when defaulted)
end_date"YYYY-MM-DD"End of window (default: now)
limitintegerMax results returned (default 100, max 500)
beforestringCursor for the next page — see AuditTrail.get_logs_page/1

String-keyed filter maps (e.g. straight from conn.params) are normalized automatically — %{"resource_id" => "abc"} and %{resource_id: "abc"} both work.

resource, resource_id, and operation are optional. They only appear in the payload for events where you explicitly set them, or where ControllerAudit could auto-detect resource_id from id/uuid params. See "Tagging the resource, record, and CRUD operation" under Controller level and API / service level. Logs written without these fields simply won't match a resource/resource_id/operation filter — existing code and queries are unaffected.

Log entry structure

%{
timestamp: ~U[2026-06-12 10:34:21Z],
labels: %{
"app" => "heart-ke",
"type" => "auth:login",
"user" => "550e8400-e29b-41d4-a716-446655440000"
},
payload: %{
"event_id" => "d4e5f6...",
"event_type" => "auth:login",
"actor_id" => "550e8400-...",
"actor_name" => "Alice Kamau",
"actor_email" => "alice@example.com",
"http_status" => 200,
"success" => true,
"action" => "api_login",
"resource" => nil,
"resource_id" => nil,
"operation" => "write",
"params" => %{"email" => "alice@example.com"},
"timestamp" => "2026-06-12T10:34:21Z"
}
}

resource and resource_id are nil, and operation is "write"/"read", by default. They only carry a more specific value (e.g. "payment" / "c9b031c6-..." / "update") when you opt in — see the "Tagging the resource, record, and CRUD operation" subsections above.

Access control pattern

The reader does no access control — enforce it in your controller:

defmodule MyAppWeb.AuditLogController do
def index(conn, params) do
user = conn.assigns.current_user
is_admin = user_is_admin?(user)
# Non-admins see only their own logs
filters = if is_admin, do: params, else: Map.put(params, :actor_id, to_string(user.id))
case AuditTrail.get_logs(filters) do
{:ok, logs} -> json(conn, %{data: logs})
{:error, _} -> json(conn, %{data: []}) # degrade gracefully if Loki is down
end
end
end

Testing

enabled: false (the recommended config/test.exs setting) silences AuditTrail entirely — good for not spamming Loki in CI, bad for asserting your code actually logs what it should. Use AuditTrail.Adapters.Test instead when you want that coverage:

# config/test.exs
config :audit_trail,
enabled: true,
storage_adapter: AuditTrail.Adapters.Test,
batch_size: 1 # flush every log immediately (default flush_interval is 2000ms)
defmodule MyApp.Items.ApprovalTest do
use MyApp.DataCase, async: false # shared in-memory table — don't run async
setup do
AuditTrail.Test.clear()
:ok
end
test "approving an item logs an audit event" do
{:ok, item} = Items.approve(item.id)
AuditTrail.Test.assert_logged("item:approved", %{resource_id: item.id})
end
test "rejecting an item does not log an approval" do
Items.reject(item.id)
AuditTrail.Test.assert_not_logged("item:approved")
end
end

Loki stream labels

Every log entry is stored in Loki as a stream with three labels:

LabelSourceExample
appConfig.app_name()"heart-ke"
typeevent type"auth:login"
useractor ID"550e8400-..."

All other fields live in the JSON log line body. Loki stream labels are what make queries fast — the actor_id filter works because the user label is indexed.

Cardinality note: the user label creates one stream per (app, type, user) combination. For apps with tens of thousands of users this is high cardinality. If Loki performance degrades, switch to a JSON line filter in the reader: | json | actor_id="<uuid>" and remove user from the stream labels in Shipper.build_loki_body/1.


Crash reporting

When crash_reporting: true (the default), AuditTrail registers an OTP :logger handler that intercepts :error, :critical, :emergency, and :alert level events.

Deduplication — one immediate email, then periodic digests

The first time a given exception/message/stacktrace combination is seen, it's emailed immediately. If the same crash (hashed on exception + message + the first 3 stacktrace lines) happens again within dedup_window_minutes, it is not re-emailed immediately — instead it's counted. Every dedup_window_minutes, a digest email is sent for any crash that recurred more than once in that window, showing the occurrence count plus first/last-seen timestamps, so a crash looping 500 times doesn't send 500 emails but you still find out it kept happening. Once a crash hasn't recurred within the window, its dedup state is dropped and the next occurrence is treated as new (another immediate email).

dedup_max_entries caps how many distinct crash hashes are tracked at once (oldest evicted first) — this state lives in ETS and does not survive a node restart.

Routing — crash_routing

Each rule is %{match: ..., to: [...]}, checked in order; all matching rules' recipients are notified (not just the first match):

config :audit_trail,
crash_routing: [
%{match: {:module, ~r/MyApp\.Payments/}, to: ["payments@company.com"]},
%{match: {:exception, "Ecto.ConstraintError"}, to: ["db-team@company.com"]},
%{match: {:source, :oban}, to: ["jobs-team@company.com"]},
%{match: :default, to: ["engineering@company.com"]}
]
match:Matches when...
{:module, ~r/.../} or {:module, SomeModule}The crashing module's name matches the regex, or contains the given module/atom as a substring
{:exception, "SomeException"}The exception name (as a string, e.g. "RuntimeError", "Ecto.ConstraintError") matches exactly
{:source, :oban | :elixir | :erlang}The crash originated from an Oban job, ordinary Elixir code, or raw Erlang/OTP respectively (best-effort detection from logger metadata)
:defaultAlways matches — use as a catch-all

If no rule matches a given crash, it falls back to whichever rule has match: :default (if any); with no :default rule and no match, the crash is logged (Logger.error) but no email is sent.

To disable crash reporting entirely:

config :audit_trail, crash_reporting: false

Telemetry

AuditTrail emits :telemetry events for the health of its own pipeline — not for the audit events you log (those go through your storage adapter), but for when the logging pipeline itself is degraded: retries exhausted, entries dropped, a crash reported. Attach handlers the usual way:

:telemetry.attach_many(
"my-app-audit-trail-alerts",
[
[:audit_trail, :buffer, :dropped],
[:audit_trail, :shipper, :failed],
[:audit_trail, :dead_letter, :dropped],
[:audit_trail, :dead_letter, :redrive, :failed]
],
&MyApp.AuditTrailAlerts.handle_event/4,
nil
)
EventMeasurementsMetadataFires when
[:audit_trail, :buffer, :dropped]%{count: 1}%{max_buffer: n}A partition's buffer is full and a log is dropped instead of queued — see max_buffer in the config section
[:audit_trail, :buffer, :flushed]%{count: n}%{adapter: module}A batch is handed to the storage adapter (on batch_size or flush_interval, whichever comes first)
[:audit_trail, :shipper, :succeeded]%{count: n, attempts: n}%{}The Loki adapter successfully pushes a batch (possibly after retries)
[:audit_trail, :shipper, :failed]%{count: n, attempts: n}%{dead_lettered: bool}The Loki adapter exhausts shipper_retries without a successful push
[:audit_trail, :dead_letter, :dropped]%{count: n}%{max_dead_letter: n}The dead-letter queue is at max_dead_letter capacity and entries are dropped instead of queued
[:audit_trail, :dead_letter, :redrive, :succeeded]%{count: n}%{}A redrive attempt (scheduled or manual via AuditTrail.DeadLetter.redrive/0) successfully redelivers queued entries
[:audit_trail, :dead_letter, :redrive, :failed]%{count: n}%{reason: term}A redrive attempt fails — entries remain queued
[:audit_trail, :crash, :reported]%{count: n}%{level:, exception:, module:, digest: bool}A crash email is sent (immediate first-occurrence email or a periodic digest) — see Crash reporting

None of these fire for a healthy pipeline under normal load — if you see buffer:dropped or shipper:failed in production, something needs attention (Loki is down/slow, or throughput has outgrown batch_size/partition_count).


Limitations

LimitationDetail
Loki dependency (Loki adapter only)If Loki is unreachable, logs are retried up to shipper_retries times then dropped to dead-letter. By default dead-letter is in-memory (ETS) and does not survive a node restart — set dead_letter_persistent: true to back it with :dets instead (see Buffer tuning), or use the Postgres/TimescaleDB adapter to avoid needing this queue at all.
No bulk operation trackingRepo.update_all, Repo.delete_all, Ecto.Multi.insert_all/update_all/delete_all, and raw Ecto.Query calls bypass RepoWatcher — there's no changeset to diff. Repo.insert_or_update/Ecto.Multi.insert_or_update also bypass it (Ecto resolves these without calling back through the repo's public insert/2/update/2).
Nested-field filtering requires opting the embed/assoc in separatelyThe parent's track: [...] only filters top-level fields. To filter within an embed/association, add use AuditTrail.Schema, track: [...] to that nested schema module too — see Embeds and associations are diffed too. Embeds/associations still don't get an independent schema:* event of their own, since they don't go through the Repo directly.
Process-local actor contextset_actor/1 uses the process dictionary and is not inherited by child processes. Background jobs must call it themselves.
Log immutability (Loki adapter)Loki logs cannot be updated or deleted once written. Postgres/TimescaleDB logs can be deleted via SQL but there is no built-in API for it.
Label changes not retroactive (Loki adapter)Adding or removing a stream label (e.g. user) only affects new logs. Old logs remain unfiltered by that label.
Default 24h read window (Loki adapter)get_logs/1 with the Loki adapter defaults to the last 24 hours. Pass start_date for a longer range. Postgres/TimescaleDB adapters query all rows up to limit if no date filters are given.
Adapter migration is one-waySwitching adapters does not migrate historical logs. Both backends can run in parallel temporarily by writing a thin adapter that fans out to both.
In-memory deduplicationCrash dedup state is in ETS and is lost on restart.
resource/resource_id/operation are opt-in, not retroactiveThey're plain payload fields (no migration needed — payload is already jsonb/free-form). Logs written before you start setting them have resource: nil and won't match a resource/resource_id/operation filter. resource_id auto-detection in ControllerAudit only checks id/uuid params unless you set id_param: — a different param name won't be picked up without it.

Troubleshooting

UndefinedFunctionError: AuditTrail.ControllerAudit.init/1 is undefined

The plug macro calls init/1 at compile time. Recompile with force:

mix deps.compile audit_trail --force && mix compile --force

Warnings about undefined AuditTrail.ControllerAudit during mix compile

Same root cause as above. The library beam files must exist before the host app compiles its controllers.

Regular users see no logs (empty list)

The user Loki stream label must match what was written. Logs written before the user label was added to the shipper won't be matched by {user="<id>"}. Only newly generated logs filter correctly.

{:already_started, #PID<...>} on startup

AuditTrail is listed in application.ex children. Remove it — it starts itself as an OTP application.

Connection refused spam in dev logs

Set enabled: false in config/dev.exs. The buffer accepts calls silently but the shipper never fires.

Loki crash-loops on startup

Check for field buckets not found — the S3 config key must be bucketnames not buckets. Also ensure schema_config is present (required in Loki 3.x even with filesystem storage).