diff --git a/dev.exs b/dev.exs index 05538a1..524f4fa 100644 --- a/dev.exs +++ b/dev.exs @@ -44,6 +44,7 @@ Application.put_env(:error_tracker, ErrorTrackerDevWeb.Endpoint, # Setup up the ErrorTracker configuration Application.put_env(:error_tracker, :repo, ErrorTrackerDev.Repo) Application.put_env(:error_tracker, :application, :error_tracker_dev) +Application.put_env(:error_tracker, :prefix, "private") defmodule ErrorTrackerDevWeb.PageController do import Plug.Conn @@ -112,30 +113,8 @@ end defmodule Migration0 do use Ecto.Migration - def change do - create table(:error_tracker_errors) do - add :kind, :string, null: false - add :reason, :text, null: false - add :source_line, :text, null: false - add :source_function, :text, null: false - add :status, :string, null: false - add :fingerprint, :string, null: false - - timestamps() - end - - create unique_index(:error_tracker_errors, :fingerprint) - - create table(:error_tracker_occurrences) do - add :context, :map, null: false - add :stacktrace, :map, null: false - add :error_id, references(:error_tracker_errors, on_delete: :delete_all), null: false - - timestamps(updated_at: false) - end - - create index(:error_tracker_occurrences, :error_id) - end + def up, do: ErrorTracker.Migrations.up(prefix: "private") + def down, do: ErrorTracker.Migrations.down(prefix: "private") end Application.put_env(:phoenix, :serve_endpoints, true) diff --git a/lib/error_tracker.ex b/lib/error_tracker.ex index a837d02..43fd509 100644 --- a/lib/error_tracker.ex +++ b/lib/error_tracker.ex @@ -6,15 +6,20 @@ defmodule ErrorTracker do error = repo().insert!(error, on_conflict: [set: [status: :unresolved]], - conflict_target: :fingerprint + conflict_target: :fingerprint, + prefix: prefix() ) error |> Ecto.build_assoc(:occurrences, stacktrace: stacktrace, context: context) - |> repo().insert!() + |> repo().insert!(prefix: prefix()) end def repo do Application.fetch_env!(:error_tracker, :repo) end + + def prefix do + Application.get_env(:error_tracker, :prefix, "public") + end end diff --git a/lib/error_tracker/migrations.ex b/lib/error_tracker/migrations.ex new file mode 100644 index 0000000..b61fa7d --- /dev/null +++ b/lib/error_tracker/migrations.ex @@ -0,0 +1,124 @@ +defmodule ErrorTracker.Migrations do + @moduledoc """ + Create and modify the database tables for the ErrorTracker. + + ## Usage + + To use the ErrorTracker migrations in your application you will need to generate + a regular `Ecto.Migration` that performs the relevant calls to `ErrorTracker.Migration`. + + ```bash + mix ecto.gen.migration add_error_tracker + ``` + + Open the generated migration file and call the `up` and `down` functions on + `ErrorTracker.Migration`. + + ```elixir + defmodule MyApp.Repo.Migrations.AddErrorTracker do + use Ecto.Migration + + def up, do: ErrorTracker.Migrations.up() + def down, do: ErrorTracker.Migrations.down() + end + ``` + + This will run every ErrorTracker migration for your database. You can now run the migration + and perform the database changes: + + ```bash + mix ecto.migrate + ``` + + As new versions of the ErrorTracker are released you may need to run additional migrations. + To do this you can follow the previous process and create a new migration: + + ```bash + mix ecto.gen.migration update_error_tracker_to_vN + ``` + + Open the generated migration file and call the `up` and `down` functions on the + `ErrorTracker.Migration` passing the desired `version`. + + ```elixir + defmodule MyApp.Repo.Migrations.UpdateErrorTrackerToVN do + use Ecto.Migration + + def up, do: ErrorTracker.Migrations.up(version: N) + def down, do: ErrorTracker.Migrations.down(version: N) + end + ``` + + Then run the migrations to perform the database changes: + + ```bash + mix ecto.migrate + ``` + + ## Custom prefix + + ErrorTracker supports namespacing its own tables using PostgreSQL schemas, also known + as "prefixes" in Ecto. With prefixes your error tables can reside outside of your primary + schema (which is usually named "public"). + + To use a prefix you need to specify it in your migrations: + + ```elixir + defmodule MyApp.Repo.Migrations.AddErrorTracker do + use Ecto.Migration + + def up, do: ErrorTracker.Migrations.up(prefix: "custom_schema") + def down, do: ErrorTracker.Migrations.down(prefix: "custom_schema") + end + ``` + + This will automatically create the database schema for you. If the schema does already exist + the migration may fail when trying to recreate it. In such cases you can instruct the ErrorTracker + not to create the schema again: + + ```elixir + defmodule MyApp.Repo.Migrations.AddErrorTracker do + use Ecto.Migration + + def up, do: ErrorTracker.Migrations.up(prefix: "custom_schema", create_schema: false) + def down, do: ErrorTracker.Migrations.down(prefix: "custom_schema") + end + ``` + + If you are using a custom schema other than the default "public" you need to configure the + ErrorTracker to use that schema: + + ```elixir + config :error_tracker, :prefix, "custom_schema" + ``` + """ + defdelegate up(opts \\ []), to: ErrorTracker.Migration + defdelegate down(opts \\ []), to: ErrorTracker.Migration +end + +defmodule ErrorTracker.Migration do + @moduledoc false + + @callback up(Keyword.t()) :: :ok + @callback down(Keyword.t()) :: :ok + @callback current_version(Keyword.t()) :: non_neg_integer() + + def up(opts \\ []) when is_list(opts) do + migrator().up(opts) + end + + def down(opts \\ []) when is_list(opts) do + migrator().down(opts) + end + + def migrated_version(opts \\ []) when is_list(opts) do + migrator().migrated_version(opts) + end + + defp migrator do + case ErrorTracker.repo().__adapter__() do + Ecto.Adapters.Postgres -> ErrorTracker.Migrations.Postgres + adapter -> raise "ErrorTracker does not support #{adapter}" + end + end +end diff --git a/lib/error_tracker/migrations/postgres.ex b/lib/error_tracker/migrations/postgres.ex new file mode 100644 index 0000000..faa4a63 --- /dev/null +++ b/lib/error_tracker/migrations/postgres.ex @@ -0,0 +1,88 @@ +defmodule ErrorTracker.Migrations.Postgres do + @behaviour ErrorTracker.Migration + + use Ecto.Migration + + import Ecto.Query + + @initial_version 1 + @current_version 1 + @default_prefix "public" + + @impl ErrorTracker.Migration + def up(opts) do + opts = with_defaults(opts, @current_version) + initial = current_version(opts) + + cond do + initial == 0 -> + change(@initial_version..opts.version, :up, opts) + + initial < opts.version -> + change((initial + 1)..opts.version, :up, opts) + + true -> + :ok + end + end + + @impl ErrorTracker.Migration + def down(opts) do + opts = with_defaults(opts, @initial_version) + initial = max(current_version(opts), @initial_version) + + if initial >= opts.version do + change(initial..opts.version, :down, opts) + end + end + + @impl ErrorTracker.Migration + def current_version(opts) do + opts = with_defaults(opts, @initial_version) + repo = Map.get_lazy(opts, :repo, fn -> repo() end) + + query = + from pg_class in "pg_class", + left_join: pg_description in "pg_description", + on: pg_description.objoid == pg_class.oid, + left_join: pg_namespace in "pg_namespace", + on: pg_namespace.oid == pg_class.relnamespace, + where: pg_class.relname == "error_tracker_errors", + where: pg_namespace.nspname == ^opts.escaped_prefix, + select: pg_description.description + + case repo.one(query, log: false) do + version when is_binary(version) -> String.to_integer(version) + _other -> 0 + end + end + + defp change(versions_range, direction, opts) do + for version <- versions_range do + padded_version = String.pad_leading(to_string(version), 2, "0") + + migration_module = Module.concat(__MODULE__, "V#{padded_version}") + apply(migration_module, direction, [opts]) + end + + case direction do + :up -> record_version(opts, Enum.max(versions_range)) + :down -> record_version(opts, Enum.min(versions_range) - 1) + end + end + + defp record_version(%{prefix: prefix}, version) do + case version do + 0 -> :ok + _other -> execute "COMMENT ON TABLE #{inspect(prefix)}.error_tracker_errors IS '#{version}'" + end + end + + defp with_defaults(opts, version) do + opts = Enum.into(opts, %{prefix: @default_prefix, version: version}) + + opts + |> Map.put_new(:create_schema, opts.prefix != @default_prefix) + |> Map.put_new(:escaped_prefix, String.replace(opts.prefix, "'", "\\'")) + end +end diff --git a/lib/error_tracker/migrations/postgres/v01.ex b/lib/error_tracker/migrations/postgres/v01.ex new file mode 100644 index 0000000..a9d1784 --- /dev/null +++ b/lib/error_tracker/migrations/postgres/v01.ex @@ -0,0 +1,35 @@ +defmodule ErrorTracker.Migrations.Postgres.V01 do + use Ecto.Migration + + def up(%{create_schema: create_schema, prefix: prefix}) do + if create_schema, do: execute("CREATE SCHEMA IF NOT EXISTS #{prefix}") + + create table(:error_tracker_errors, prefix: prefix) do + add :kind, :string, null: false + add :reason, :text, null: false + add :source_line, :text, null: false + add :source_function, :text, null: false + add :status, :string, null: false + add :fingerprint, :string, null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:error_tracker_errors, [:fingerprint], prefix: prefix) + + create table(:error_tracker_occurrences, prefix: prefix) do + add :context, :map, null: false + add :stacktrace, :map, null: false + add :error_id, references(:error_tracker_errors, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + create index(:error_tracker_occurrences, [:error_id], prefix: prefix) + end + + def down(%{prefix: prefix}) do + drop table(:error_tracker_occurrences, prefix: prefix) + drop table(:error_tracker_errors, prefix: prefix) + end +end diff --git a/lib/error_tracker/models/error.ex b/lib/error_tracker/models/error.ex index e343770..0adb78b 100644 --- a/lib/error_tracker/models/error.ex +++ b/lib/error_tracker/models/error.ex @@ -11,7 +11,7 @@ defmodule ErrorTracker.Error do has_many :occurrences, ErrorTracker.Occurrence - timestamps() + timestamps(type: :utc_datetime_usec) end def new(exception, stacktrace = %ErrorTracker.Stacktrace{}) do diff --git a/lib/error_tracker/models/occurrence.ex b/lib/error_tracker/models/occurrence.ex index b0aa90e..f14facc 100644 --- a/lib/error_tracker/models/occurrence.ex +++ b/lib/error_tracker/models/occurrence.ex @@ -7,6 +7,6 @@ defmodule ErrorTracker.Occurrence do embeds_one :stacktrace, ErrorTracker.Stacktrace belongs_to :error, ErrorTracker.Error - timestamps(updated_at: false) + timestamps(type: :utc_datetime_usec, updated_at: false) end end