From ada0304af331df9dc9c578063403c226184f6545 Mon Sep 17 00:00:00 2001 From: crbelaus Date: Wed, 5 Jun 2024 19:24:54 +0200 Subject: [PATCH 1/3] Oban like migration system This commit introduces an Oban like migration system for the ErrorTracker. Users can generate their own migrations that call the ErrorTracker migrations and possibly set an alternative database prefix in case that they want to store the errors in a separate schema than the rest of the application. This commit also updates the dev script to showcase how to use the new migration system on an alternative prefix. --- dev.exs | 27 +--- lib/error_tracker.ex | 9 +- lib/error_tracker/migrations.ex | 124 +++++++++++++++++++ lib/error_tracker/migrations/postgres.ex | 88 +++++++++++++ lib/error_tracker/migrations/postgres/v01.ex | 35 ++++++ 5 files changed, 257 insertions(+), 26 deletions(-) create mode 100644 lib/error_tracker/migrations.ex create mode 100644 lib/error_tracker/migrations/postgres.ex create mode 100644 lib/error_tracker/migrations/postgres/v01.ex 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..e0e5f1b --- /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 "Error tracker 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..51e9f18 --- /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() + 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(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 From cb0b9f4493daa885a11aaeb3d1ed3780bf3f0483 Mon Sep 17 00:00:00 2001 From: crbelaus Date: Sat, 8 Jun 2024 08:28:58 +0200 Subject: [PATCH 2/3] Use utc_datetime_usec for timestamps --- lib/error_tracker/migrations/postgres/v01.ex | 4 ++-- lib/error_tracker/models/error.ex | 2 +- lib/error_tracker/models/occurrence.ex | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/error_tracker/migrations/postgres/v01.ex b/lib/error_tracker/migrations/postgres/v01.ex index 51e9f18..a9d1784 100644 --- a/lib/error_tracker/migrations/postgres/v01.ex +++ b/lib/error_tracker/migrations/postgres/v01.ex @@ -12,7 +12,7 @@ defmodule ErrorTracker.Migrations.Postgres.V01 do add :status, :string, null: false add :fingerprint, :string, null: false - timestamps() + timestamps(type: :utc_datetime_usec) end create unique_index(:error_tracker_errors, [:fingerprint], prefix: prefix) @@ -22,7 +22,7 @@ defmodule ErrorTracker.Migrations.Postgres.V01 do add :stacktrace, :map, null: false add :error_id, references(:error_tracker_errors, on_delete: :delete_all), null: false - timestamps(updated_at: false) + timestamps(type: :utc_datetime_usec, updated_at: false) end create index(:error_tracker_occurrences, [:error_id], prefix: prefix) 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 From 16b02d1453bc21685726f42421bb80f4adfde21b Mon Sep 17 00:00:00 2001 From: crbelaus Date: Sat, 8 Jun 2024 08:29:44 +0200 Subject: [PATCH 3/3] Fix typo --- lib/error_tracker/migrations.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/error_tracker/migrations.ex b/lib/error_tracker/migrations.ex index e0e5f1b..b61fa7d 100644 --- a/lib/error_tracker/migrations.ex +++ b/lib/error_tracker/migrations.ex @@ -118,7 +118,7 @@ defmodule ErrorTracker.Migration do defp migrator do case ErrorTracker.repo().__adapter__() do Ecto.Adapters.Postgres -> ErrorTracker.Migrations.Postgres - adapter -> raise "Error tracker does not support #{adapter}" + adapter -> raise "ErrorTracker does not support #{adapter}" end end end