AuditTrail (audit_trail v0.1.2)
Copy MarkdownReusable structured audit logging, DB change tracking, and crash reporting.
Quick start
1. Add to mix.exs
{:audit_trail, path: "../audit_trail"} # dev / monorepo
{:audit_trail, git: "https://..."} # shared2. Configure
config :audit_trail,
app_name: "heart-ke",
loki_push_url: System.get_env("LOKI_PUSH_URL"),
loki_read_url: System.get_env("LOKI_READ_URL"),
mailer_adapter: {AuditTrail.Adapters.Swoosh, []},
swoosh_mailer: MyApp.Mailer,
from_email: {"MyApp", "alerts@myapp.com"},
crash_routing: [
%{match: {:module, ~r/MyApp\.Items/}, to: ["items@myapp.com"]},
%{match: {:module, ~r/MyApp\.Auth/}, to: ["security@myapp.com"]},
%{match: :default, to: ["admin@myapp.com"]}
]3. Add AuditTrail to your application supervision tree
{AuditTrail, []} # in application.ex children list4. Add to your Repo (schema-level tracking)
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres
use AuditTrail.RepoWatcher
end5. Mark schemas to track
defmodule MyApp.Items.Item do
use Ecto.Schema
use AuditTrail.Schema, track: [:status, :price, :approved_by]
...
end6. Add the plug to your Phoenix pipeline
pipeline :api do
plug :accepts, ["json"]
plug AuditTrail.Plug
end7. Use in controllers
def approve(conn, params) do
with {:ok, item} = result <- Items.approve(params) do
AuditTrail.monitor(result, conn, "item:approved", original_record: old_item)
json(conn, item)
end
end
Summary
Functions
Clears the actor set in this process, reverting get_actor/0 to its
"anonymous" default. Rarely needed for one-off requests/jobs (the
process dies anyway), but useful for pooled/long-lived processes that
handle work for multiple actors in turn — e.g. a GenServer processing a
queue of jobs for different users, or between test cases sharing a
process — so one actor's identity doesn't leak into the next unit of work
Clears the tenant set in this process, reverting get_tenant/0 to nil.
Same use case as clear_actor/0 — pooled/long-lived processes that
shouldn't leak one tenant's scope into the next unit of work.
Logs one event with no conn/schema required — the right entry point for a
background job, webhook handler, LiveView handle_event, or anywhere else
that isn't a controller action.
Returns the actor currently set in this process via set_actor/1/set_actor/2
Reads audit logs back from whichever storage adapter is configured (Loki, Postgres, TimescaleDB, or Test) — the same filters and return shape work regardless of adapter.
Cursor-paginated get_logs/1. Returns %{logs: [...], next_cursor: cursor | nil}.
Returns the tenant/org id currently set in this process via set_tenant/1
Logs a call to an external service (payment gateway, SMS provider, etc.), timing it and capturing success/failure. Two forms
Manual Ecto changeset-based tracking for when you have a real
%Ecto.Changeset{} and a repo result, but aren't going through
AuditTrail.Schema/AuditTrail.RepoWatcher (e.g. the schema isn't tagged
for automatic tracking) and don't have a conn to pull actor/request
context from — you supply that context explicitly instead
Wraps a controller result tuple, logging it with full conn context (actor,
IP, user agent, request id) and returning result unchanged so it can be
piped
Sets the current actor for every emit/monitor/schema-tracked Repo call
made in this process afterward. Stored in the process dictionary — set
it once per request/job, not per event.
Sets a non-user actor — currently only for system/background actors,
type must be the literal atom :system (there is no other type today)
Tags every subsequent audit event in the current process with a tenant/org
id — for multi-tenant apps that want to scope queries to one tenant
without doing it at the application layer. Stored in the process
dictionary like set_actor/1; propagated by AuditTrail.Task.
Functions
Clears the actor set in this process, reverting get_actor/0 to its
"anonymous" default. Rarely needed for one-off requests/jobs (the
process dies anyway), but useful for pooled/long-lived processes that
handle work for multiple actors in turn — e.g. a GenServer processing a
queue of jobs for different users, or between test cases sharing a
process — so one actor's identity doesn't leak into the next unit of work:
AuditTrail.set_actor(user)
# ... do work attributed to `user` ...
AuditTrail.clear_actor()
# ... get_actor/0 is back to "anonymous" here ...
Clears the tenant set in this process, reverting get_tenant/0 to nil.
Same use case as clear_actor/0 — pooled/long-lived processes that
shouldn't leak one tenant's scope into the next unit of work.
AuditTrail.set_tenant(org.id)
# ... do work scoped to `org` ...
AuditTrail.clear_tenant()
Logs one event with no conn/schema required — the right entry point for a
background job, webhook handler, LiveView handle_event, or anywhere else
that isn't a controller action.
AuditTrail.emit("payment:processed", %{
resource: "payment",
resource_id: txn.id,
operation: "update",
amount: order.total,
gateway: "mpesa"
})resource:, resource_id:, and operation: are optional — omit them and
they're simply absent (nil) from the payload. If you omit resource_id:
but data has an :id/:uuid key, that value is used automatically.
Pass original_record: to get a field-level changes diff against the
rest of data, for data that has no Ecto changeset at all (a row in
another service's database, a third-party API's own resource):
AuditTrail.emit("payment:processed", %{
resource_id: txn.ref,
original_record: previous_txn, # any plain map or struct
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. operation then
defaults to "update" (still overridable). This diff is shallow
(top-level fields only) and self-reported — there's no changeset to verify
it against, unlike schema-level tracking via AuditTrail.Schema.
The actor is read from the process dictionary (set_actor/1) automatically,
but can be overridden per-event:
AuditTrail.emit("nightly:sync", %{actor_id: "system:scheduler", records: count})See AuditTrail.Logger.emit/2 for the full implementation.
Returns the actor currently set in this process via set_actor/1/set_actor/2:
AuditTrail.get_actor()
#=> %{id: "42", name: "Alice", email: "alice@example.com"}Returns %{id: "anonymous", name: "anonymous", email: nil} if nothing has
been set yet in the current process.
Reads audit logs back from whichever storage adapter is configured (Loki, Postgres, TimescaleDB, or Test) — the same filters and return shape work regardless of adapter.
{:ok, logs} = AuditTrail.get_logs(%{actor_id: "user-uuid"})
{:ok, logs} = AuditTrail.get_logs(%{resource: "payment", operation: "update"})
{:ok, logs} = AuditTrail.get_logs(%{start_date: "2026-06-01", end_date: "2026-06-12", limit: 200})Returns {:ok, [log]} or {:error, reason}. Each log is
%{timestamp: DateTime, labels: map(), payload: map()} — see "Log entry
structure" in the README for a full example. String-keyed filter maps
(straight from conn.params) are normalized automatically, so
%{"resource_id" => "abc"} and %{resource_id: "abc"} both work; any
other key is silently dropped rather than raising.
| Filter | Type | Description |
|---|---|---|
actor_id | string | Only this user's logs |
type | string | Exact event type, e.g. "auth:login" |
status | string | "success" or "failure" |
resource / resource_id / operation | string | Only logs where you opted into this tagging — see the README's "Tagging the resource, record, and CRUD operation" sections |
tenant | string | Only logs tagged with this tenant/org id — see set_tenant/1 |
search | string | Substring match on the log line body |
start_date / end_date | "YYYY-MM-DD" | Window bounds (Loki adapter defaults to the last 24h if both are omitted) |
limit | integer | Max results (default 100, 500 max on Postgres/TimescaleDB) |
before | string | Pagination cursor — see get_logs_page/1 |
For querying who did what without an access-control layer of your own, see the README's "Access control pattern" — this function does no authorization itself.
Cursor-paginated get_logs/1. Returns %{logs: [...], next_cursor: cursor | nil}.
{:ok, page1} = AuditTrail.get_logs_page(%{resource: "payment", limit: 50})
{:ok, page2} = AuditTrail.get_logs_page(%{resource: "payment", limit: 50, before: page1.next_cursor})next_cursor is nil once there are no more pages. Works the same way
regardless of storage adapter (Loki, Postgres, TimescaleDB).
Returns the tenant/org id currently set in this process via set_tenant/1:
AuditTrail.set_tenant(org.id)
AuditTrail.get_tenant()
#=> "org-42"Returns nil if nothing has been set yet in the current process (unlike
get_actor/0, there is no default placeholder value).
Logs a call to an external service (payment gateway, SMS provider, etc.), timing it and capturing success/failure. Two forms:
# Wrap a function — timing and status 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
})The function form expects a zero-arity function and infers status from
its return value ({:ok, %{status: 200..299}} → "success",
{:error, _} → "failure", etc.), then calls the map form with the
measured duration_ms — both eventually go through the same code path, so
either works from a controller, a LiveView, or a background job.
See AuditTrail.Logger.log_external_api/3 for the full implementation.
Manual Ecto changeset-based tracking for when you have a real
%Ecto.Changeset{} and a repo result, but aren't going through
AuditTrail.Schema/AuditTrail.RepoWatcher (e.g. the schema isn't tagged
for automatic tracking) and don't have a conn to pull actor/request
context from — you supply that context explicitly instead:
changeset = Item.changeset(item, params)
case Repo.update(changeset) do
{:ok, _updated} = result ->
AuditTrail.log_repo("item:updated", result, :update, changeset, %{
actor_id: current_user.id,
actor_name: current_user.name,
ip: conn.remote_ip,
request_id: Logger.metadata()[:request_id]
})
result
error ->
error
endArguments:
| Arg | Purpose |
|---|---|
type | event type string, e.g. "item:updated" |
result | the repo call's result — {:ok, _}, {:error, _}, or an Ecto.Multi-shaped/bulk-count tuple |
action | what happened, e.g. :insert/:update/:delete — stringified into the payload |
source | the %Ecto.Changeset{} (or schema struct/module) the change came from — diffed via its own .changes/.data if it's a changeset |
ctx | a plain map/keyword with :actor_id, :actor_name, :ip, :request_id — string or atom keys both work; each defaults to "anonymous"/"unknown"/"" if absent |
Returns result unchanged, so it can be used the same way as monitor/4.
For most controller actions, monitor/4 (which pulls this same context
from a conn) is simpler — reach for log_repo/5 when you have a
changeset but no conn, e.g. from a GenServer or a non-HTTP job.
See AuditTrail.Logger.log_repo/5 for the full implementation.
Wraps a controller result tuple, logging it with full conn context (actor,
IP, user agent, request id) and returning result unchanged so it can be
piped:
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
endresult must be an {:ok, record} / {:error, reason} tuple — monitor/4
branches on that shape to decide status/operation and to build the
before/after diff. Options:
| Option | Purpose |
|---|---|
original_record: | record before the change — diffed against params: or the result record |
params: | the submitted params, diffed against original_record: |
extra_meta: | any extra map merged into the logged payload |
resource: | optional resource label, e.g. "payment" |
operation is set automatically ("create" when original_record: is
absent, "update" when present). The second argument is only ever
pattern-matched by shape (%{remote_ip: ...}, %{req_headers: ...}), never
by struct name — passing something that isn't a conn (a LiveView
socket, nil) doesn't crash, it just yields "unknown" for
IP/user-agent/request-id. If you're not in a Plug pipeline at all, emit/2
is usually the better fit — it never needed a conn.
See AuditTrail.Logger.monitor/4 for the full implementation.
Sets the current actor for every emit/monitor/schema-tracked Repo call
made in this process afterward. Stored in the process dictionary — set
it once per request/job, not per event.
# From a user struct/map — picks :id, :email, and :username/:name
# (in that order) off whatever keys are present, atom or string
AuditTrail.set_actor(current_user)Defaults to %{id: "anonymous", name: "anonymous", email: nil} if never
set in the current process — background jobs (Oban, Task.async) start a
new process and do not inherit this, so call it at the top of the
job, or spawn via AuditTrail.Task.async/start/start_link instead of
Task's own to propagate it automatically.
Sets a non-user actor — currently only for system/background actors,
type must be the literal atom :system (there is no other type today):
AuditTrail.set_actor(:system, "nightly-cleanup")
# => actor_id becomes "system:nightly-cleanup", actor_name "nightly-cleanup"Same process-dictionary storage and lifetime as set_actor/1.
Tags every subsequent audit event in the current process with a tenant/org
id — for multi-tenant apps that want to scope queries to one tenant
without doing it at the application layer. Stored in the process
dictionary like set_actor/1; propagated by AuditTrail.Task.
AuditTrail.set_tenant(org.id)