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