Packages

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

Current section

Files

Jump to
audit_trail lib mix tasks audit_trail.gen.migration.ex
Raw

lib/mix/tasks/audit_trail.gen.migration.ex

defmodule Mix.Tasks.AuditTrail.Gen.Migration do
use Mix.Task
@shortdoc "Generate the AuditTrail audit_logs table migration"
@moduledoc """
Generates an Ecto migration for the `audit_logs` table.
The generated migration is tailored to whichever storage adapter
is configured in your application:
- `AuditTrail.Adapters.Postgres` → standard Postgres table + indexes
- `AuditTrail.Adapters.TimescaleDB` → same table + `create_hypertable` call
## Usage
mix audit_trail.gen.migration
Options:
--repo MyApp.Repo Ecto repo to target (default: auto-detected from config)
--migrations-path PATH Where to write the file (default: priv/repo/migrations)
After generating, run:
mix ecto.migrate
"""
@switches [repo: :string, migrations_path: :string]
@impl Mix.Task
def run(args) do
{opts, _, _} = OptionParser.parse(args, switches: @switches)
repo_mod = resolve_repo(opts)
adapter = detect_adapter()
mig_path = migrations_path(opts, repo_mod)
timestamp = timestamp()
filename = "#{timestamp}_create_audit_logs.exs"
dest = Path.join(mig_path, filename)
content = migration_content(adapter, repo_mod)
File.mkdir_p!(mig_path)
File.write!(dest, content)
Mix.shell().info([:green, "* creating ", :reset, dest])
Mix.shell().info("")
Mix.shell().info("Run the migration with:")
Mix.shell().info(" mix ecto.migrate")
if adapter == :timescaledb do
Mix.shell().info("")
Mix.shell().info([
:yellow,
"TimescaleDB note: ",
:reset,
"run compression setup manually after migrating — see AuditTrail.Adapters.TimescaleDB docs."
])
end
end
# ── Helpers ─────────────────────────────────────────────────────────────────
defp detect_adapter do
case AuditTrail.Config.storage_adapter() do
{AuditTrail.Adapters.TimescaleDB, _} -> :timescaledb
{AuditTrail.Adapters.Postgres, _} -> :postgres
AuditTrail.Adapters.TimescaleDB -> :timescaledb
AuditTrail.Adapters.Postgres -> :postgres
_ -> :postgres
end
end
defp resolve_repo(opts) do
cond do
repo = opts[:repo] ->
Module.concat([repo])
repo = repo_from_config() ->
repo
true ->
Mix.raise("""
Could not determine the Ecto Repo to use.
Pass it explicitly:
mix audit_trail.gen.migration --repo MyApp.Repo
Or configure it:
config :audit_trail,
storage_adapter: {AuditTrail.Adapters.Postgres, repo: MyApp.Repo}
""")
end
end
defp repo_from_config do
case AuditTrail.Config.storage_adapter() do
{_, opts} when is_list(opts) -> Keyword.get(opts, :repo)
_ -> nil
end
end
defp migrations_path(opts, repo_mod) do
cond do
path = opts[:migrations_path] ->
path
function_exported?(repo_mod, :config, 0) ->
priv = repo_mod.config() |> Keyword.get(:priv, "priv/repo")
Path.join(priv, "migrations")
true ->
"priv/repo/migrations"
end
end
defp timestamp do
{{y, mo, d}, {h, mi, s}} = :calendar.universal_time()
:io_lib.format("~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B", [y, mo, d, h, mi, s])
|> IO.chardata_to_string()
end
# ── Migration templates ──────────────────────────────────────────────────────
defp migration_content(:timescaledb, repo_mod) do
module_name = migration_module_name(repo_mod)
"""
defmodule #{module_name}.CreateAuditLogs do
use Ecto.Migration
def up do
create table(:audit_logs, primary_key: false) do
add :id, :bigserial, primary_key: true
add :event_id, :text
add :event_type, :text, null: false
add :actor_id, :text
add :actor_name, :text
add :actor_email, :text
add :payload, :map, null: false, default: %{}
add :inserted_at, :utc_datetime, null: false, default: fragment("NOW()")
end
create index(:audit_logs, [:event_type])
create index(:audit_logs, [:actor_id])
create index(:audit_logs, [:inserted_at])
execute "CREATE INDEX audit_logs_payload_gin ON audit_logs USING GIN (payload jsonb_path_ops)"
# Convert to TimescaleDB hypertable, partitioned by inserted_at.
# Chunk interval of 1 day is a good default for audit logs.
execute "SELECT create_hypertable('audit_logs', 'inserted_at', chunk_time_interval => INTERVAL '1 day')"
end
def down do
drop table(:audit_logs)
end
end
"""
end
defp migration_content(:postgres, repo_mod) do
module_name = migration_module_name(repo_mod)
"""
defmodule #{module_name}.CreateAuditLogs do
use Ecto.Migration
def up do
create table(:audit_logs) do
add :event_id, :text
add :event_type, :text, null: false
add :actor_id, :text
add :actor_name, :text
add :actor_email, :text
add :payload, :map, null: false, default: %{}
timestamps(updated_at: false, type: :utc_datetime)
end
create index(:audit_logs, [:event_type])
create index(:audit_logs, [:actor_id])
create index(:audit_logs, [:inserted_at])
execute "CREATE INDEX audit_logs_payload_gin ON audit_logs USING GIN (payload jsonb_path_ops)"
end
def down do
drop table(:audit_logs)
end
end
"""
end
defp migration_module_name(repo_mod) do
repo_mod
|> Module.split()
|> Enum.drop(-1)
|> Module.concat()
|> then(fn prefix ->
if prefix == :"", do: "Migrations", else: "#{prefix}.Migrations"
end)
end
end