AuditTrail (audit_trail v0.1.2)

Copy Markdown

Reusable 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://..."}       # shared

2. 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 list

4. 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
end

5. Mark schemas to track

defmodule MyApp.Items.Item do
  use Ecto.Schema
  use AuditTrail.Schema, track: [:status, :price, :approved_by]
  ...
end

6. Add the plug to your Phoenix pipeline

pipeline :api do
  plug :accepts, ["json"]
  plug AuditTrail.Plug
end

7. 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

clear_actor()

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 ...

clear_tenant()

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()

emit(event_type, data)

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.

get_actor()

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.

get_logs(filters \\ %{})

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.

FilterTypeDescription
actor_idstringOnly this user's logs
typestringExact event type, e.g. "auth:login"
statusstring"success" or "failure"
resource / resource_id / operationstringOnly logs where you opted into this tagging — see the README's "Tagging the resource, record, and CRUD operation" sections
tenantstringOnly logs tagged with this tenant/org id — see set_tenant/1
searchstringSubstring 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)
limitintegerMax results (default 100, 500 max on Postgres/TimescaleDB)
beforestringPagination 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.

get_logs_page(filters \\ %{})

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).

get_tenant()

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).

log_external_api(type, service, data_or_fun)

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.

log_repo(type, result, action, source, ctx)

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
end

Arguments:

ArgPurpose
typeevent type string, e.g. "item:updated"
resultthe repo call's result — {:ok, _}, {:error, _}, or an Ecto.Multi-shaped/bulk-count tuple
actionwhat happened, e.g. :insert/:update/:delete — stringified into the payload
sourcethe %Ecto.Changeset{} (or schema struct/module) the change came from — diffed via its own .changes/.data if it's a changeset
ctxa 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.

monitor(result, conn, event_type, opts \\ [])

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
end

result 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:

OptionPurpose
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.

set_actor(actor)

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.

set_actor(type, label)

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.

set_tenant(tenant_id)

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)