Packages

Reusable structured audit logging, DB change tracking, and crash reporting for Elixir/Phoenix apps

Current section

Files

Jump to
audit_trail lib audit_trail.ex
Raw

lib/audit_trail.ex

defmodule AuditTrail do
@moduledoc """
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
"""
defdelegate monitor(result, conn, event_type, opts \\ []), to: AuditTrail.Logger
defdelegate emit(event_type, data), to: AuditTrail.Logger
defdelegate log_repo(type, result, action, source, ctx), to: AuditTrail.Logger
defdelegate log_external_api(type, service, data_or_fun), to: AuditTrail.Logger
defdelegate set_actor(actor), to: AuditTrail.ActorStore, as: :set
defdelegate set_actor(type, label), to: AuditTrail.ActorStore, as: :set
defdelegate get_actor(), to: AuditTrail.ActorStore, as: :get
@doc """
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)
"""
defdelegate set_tenant(tenant_id), to: AuditTrail.TenantStore, as: :set
defdelegate get_tenant(), to: AuditTrail.TenantStore, as: :get
@filter_keys ~w(actor_id type status resource resource_id operation search start_date end_date limit view before offset tenant)a
def get_logs(filters \\ %{}) do
{adapter, _opts} = AuditTrail.Adapter.resolve()
adapter.get_logs(normalize_filters(filters))
end
@doc """
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).
"""
def get_logs_page(filters \\ %{}) do
limit = Map.get(filters, :limit, 100)
case get_logs(filters) do
{:ok, logs} ->
next_cursor = if length(logs) >= limit, do: cursor_for(List.last(logs))
{:ok, %{logs: logs, next_cursor: next_cursor}}
{:error, _} = error ->
error
end
end
defp cursor_for(nil), do: nil
defp cursor_for(%{timestamp: %DateTime{} = ts}),
do: ts |> DateTime.to_unix(:nanosecond) |> to_string()
defp cursor_for(_), do: nil
# Filters are commonly built from `conn.params` (string keys) and passed
# straight through. Every filter builder in Reader/Postgres/TimescaleDB
# does an atom-key lookup, so a string-keyed map would silently match
# nothing and every filter would no-op — returning all logs instead of
# the filtered set. Normalize once here so callers don't have to know that.
defp normalize_filters(filters) when is_map(filters) do
Enum.reduce(filters, %{}, fn {k, v}, acc ->
case to_filter_key(k) do
nil -> acc
key -> Map.put(acc, key, v)
end
end)
end
defp to_filter_key(k) when k in @filter_keys, do: k
defp to_filter_key(k) when is_binary(k) do
Enum.find(@filter_keys, &(Atom.to_string(&1) == k))
end
defp to_filter_key(_), do: nil
def child_spec(_opts) do
%{
id: __MODULE__,
start: {AuditTrail.Application, :start, [:normal, []]},
type: :supervisor
}
end
end