diff --git a/config/prod.exs b/config/prod.exs index 1d416e4f..95b59cb8 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -15,8 +15,7 @@ config :music_library, monitoring_routes: true config :error_tracker, enabled: true -config :error_tracker_notifier, - notification_type: :email, +config :music_library, MusicLibrary.ErrorNotifier, from_email: "postmaster@mailgun.fullyforged.com", to_email: "claudio@fullyforged.com", mailer: MusicLibrary.Mailer, diff --git a/docs/architecture.md b/docs/architecture.md index cda145ef..5f2c8d13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,6 +27,7 @@ Key capabilities: ``` MusicLibrary.Application (one_for_one) +├── MusicLibrary.ErrorNotifier # Telemetry-driven error email notifications ├── MusicLibrary.Vault # Cloak encryption vault ├── MusicLibrary.Repo # Main SQLite repo ├── MusicLibrary.BackgroundRepo # Oban SQLite repo (separate DB) @@ -122,6 +123,8 @@ Last.fm schemas (separate, not Ecto-persisted to main DB): | `RecordChat` | Chat implementation for records (OpenAI streaming, web search enabled) | | `ArtistChat` | Chat implementation for artists (OpenAI streaming, uses Wikipedia/artist context) | | `Country` | Country code (alpha-2, alpha-3, subdivision, IETF) to flag emoji conversion | +| `ErrorNotifier` | GenServer: attaches to ErrorTracker telemetry, throttles repeated errors, dispatches email notifications | +| `ErrorNotifier.Email` | Builds and sends Swoosh error notification emails with stack trace formatting | | `FormatNumber` | Number formatting utility | --- diff --git a/lib/music_library/application.ex b/lib/music_library/application.ex index 42d96fb0..053a213f 100644 --- a/lib/music_library/application.ex +++ b/lib/music_library/application.ex @@ -13,7 +13,7 @@ defmodule MusicLibrary.Application do _ = Req.RateLimiter.new() children = [ - {ErrorTrackerNotifier, []}, + MusicLibrary.ErrorNotifier, MusicLibrary.Vault, MusicLibrary.Repo, MusicLibrary.BackgroundRepo, diff --git a/lib/music_library/error_notifier.ex b/lib/music_library/error_notifier.ex new file mode 100644 index 00000000..a4d7083c --- /dev/null +++ b/lib/music_library/error_notifier.ex @@ -0,0 +1,135 @@ +defmodule MusicLibrary.ErrorNotifier do + @moduledoc false + + use GenServer + require Logger + + alias MusicLibrary.ErrorNotifier.Email + + @cleanup_interval :timer.minutes(5) + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + if has_valid_config?() do + attach_telemetry() + schedule_cleanup() + {:ok, %{errors: %{}}} + else + :ignore + end + end + + @impl true + def handle_info({:telemetry_event, event_name, _measurements, metadata}, state) do + case event_name do + [:error_tracker, :error, :new] -> + reason = truncate_reason(metadata.occurrence.reason) + {_result, new_state} = maybe_notify(metadata.occurrence, "New Error! (#{reason})", state) + {:noreply, new_state} + + [:error_tracker, :occurrence, :new] -> + reason = truncate_reason(metadata.occurrence.reason) + {_result, new_state} = maybe_notify(metadata.occurrence, "Error: #{reason}", state) + {:noreply, new_state} + + _ -> + {:noreply, state} + end + end + + def handle_info(:cleanup, state) do + schedule_cleanup() + {:noreply, cleanup_old_records(state)} + end + + def handle_info(_msg, state), do: {:noreply, state} + + @doc false + def handle_telemetry_event(event_name, measurements, metadata, _config) do + case Process.whereis(__MODULE__) do + nil -> :ok + pid -> send(pid, {:telemetry_event, event_name, measurements, metadata}) + end + end + + # -- Private -- + + defp attach_telemetry do + events = [ + [:error_tracker, :error, :new], + [:error_tracker, :occurrence, :new] + ] + + :telemetry.detach("error-tracker-notifications") + + :telemetry.attach_many( + "error-tracker-notifications", + events, + &__MODULE__.handle_telemetry_event/4, + nil + ) + end + + defp maybe_notify(occurrence, header, state) do + error_id = occurrence.error_id + now = System.system_time(:second) + throttle_seconds = config()[:throttle_seconds] || 10 + + error_state = Map.get(state.errors, error_id, %{count: 0, last_time: 0}) + time_since_last = now - error_state.last_time + + if error_state.last_time == 0 || time_since_last >= throttle_seconds do + header_with_count = format_header_with_count(header, error_state.count) + result = Email.send(occurrence, header_with_count) + updated_errors = Map.put(state.errors, error_id, %{count: 0, last_time: now}) + {result, %{state | errors: updated_errors}} + else + updated_error = %{count: error_state.count + 1, last_time: error_state.last_time} + updated_errors = Map.put(state.errors, error_id, updated_error) + + Logger.debug("Throttled notification for error #{error_id}. Count: #{updated_error.count}") + + {:throttled, %{state | errors: updated_errors}} + end + end + + defp cleanup_old_records(state) do + now = System.system_time(:second) + one_hour_ago = now - 3600 + + cleaned_errors = + state.errors + |> Enum.filter(fn {_error_id, %{last_time: last_time}} -> last_time >= one_hour_ago end) + |> Map.new() + + %{state | errors: cleaned_errors} + end + + defp format_header_with_count(header, count) when count > 1, + do: "#{header} (#{count} occurrences)" + + defp format_header_with_count(header, _count), do: header + + defp schedule_cleanup do + Process.send_after(self(), :cleanup, @cleanup_interval) + end + + defp truncate_reason(reason) when is_binary(reason) do + if String.length(reason) > 80, do: String.slice(reason, 0, 77) <> "...", else: reason + end + + defp truncate_reason(reason), do: reason |> inspect() |> truncate_reason() + + defp has_valid_config? do + conf = config() + not is_nil(conf[:from_email]) and not is_nil(conf[:to_email]) and not is_nil(conf[:mailer]) + end + + defp config do + Application.get_env(:music_library, __MODULE__, []) + end +end diff --git a/lib/music_library/error_notifier/email.ex b/lib/music_library/error_notifier/email.ex new file mode 100644 index 00000000..dc614171 --- /dev/null +++ b/lib/music_library/error_notifier/email.ex @@ -0,0 +1,139 @@ +defmodule MusicLibrary.ErrorNotifier.Email do + @moduledoc false + + import Swoosh.Email + require Logger + + def send(occurrence, header) do + conf = config() + from_email = Keyword.fetch!(conf, :from_email) + to_email = Keyword.fetch!(conf, :to_email) + mailer = Keyword.fetch!(conf, :mailer) + + first_line = first_stack_line(occurrence) + file = if first_line, do: first_line.file, else: "unknown" + line = if first_line, do: first_line.line, else: "?" + error_name = String.slice(occurrence.reason, 0, 80) + + email = + new() + |> to(to_email) + |> from({"MusicLibrary", from_email}) + |> subject("[MusicLibrary] Error: #{error_name} - #{file}:#{line}") + |> html_body(build_html(occurrence, header)) + + case mailer.deliver(email) do + {:ok, _} -> + Logger.info("Error notification email sent") + {:ok, :sent} + + {:error, reason} -> + Logger.error("Failed to send error notification: #{inspect(reason)}") + {:error, reason} + end + end + + # -- Private -- + + defp build_html(occurrence, header) do + first_line = first_stack_line(occurrence) + + location = + if first_line do + "#{first_line.module}.#{first_line.function} (#{first_line.file}:#{first_line.line})" + else + "Unknown location" + end + + view = occurrence.context["live_view.view"] || "N/A" + path = occurrence.context["request.path"] || "N/A" + error_url = error_url(occurrence.error_id) + + escaped_header = html_escape(header) + escaped_error_id = html_escape(occurrence.error_id) + escaped_reason = occurrence.reason |> String.slice(0..199) |> html_escape() + escaped_location = html_escape(location) + escaped_view = html_escape(view) + escaped_path = html_escape(path) + escaped_url = html_escape(error_url) + stack_trace_html = format_stack_trace(occurrence) + + """ +
+ ErrorTracker has detected an error: +
+ +Error ID: #{escaped_error_id}
+Reason: #{escaped_reason}
+Location: #{escaped_location}
+ #{stack_trace_html} +View: #{escaped_view}
+Request Path: #{escaped_path}
+Time: #{DateTime.utc_now()}
+Stack Trace:
+#{formatted}
+ """
+
+ _ ->
+ ""
+ end
+ end
+
+ defp error_url(error_id) do
+ conf = config()
+ base_url = Keyword.get(conf, :base_url, "")
+ error_path = Keyword.get(conf, :error_tracker_path, "/dev/errors")
+
+ error_path = error_path |> String.trim_trailing("/")
+ base_url = base_url |> String.trim_trailing("/")
+
+ "#{base_url}#{error_path}/#{error_id}"
+ end
+
+ defp first_stack_line(occurrence) do
+ case occurrence.stacktrace do
+ %{lines: [first | _]} -> first
+ _ -> nil
+ end
+ end
+
+ defp html_escape(value) do
+ value |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
+ end
+
+ defp config do
+ Application.get_env(:music_library, MusicLibrary.ErrorNotifier, [])
+ end
+end
diff --git a/mix.exs b/mix.exs
index af75c03b..859b280a 100644
--- a/mix.exs
+++ b/mix.exs
@@ -123,7 +123,6 @@ defmodule MusicLibrary.MixProject do
# Prod error/perf tooling
{:error_tracker, "~> 0.7"},
- {:error_tracker_notifier, "~> 0.2.1"},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:recon, "~> 2.5"},
{:telemetry_metrics, "~> 1.0"},
diff --git a/mix.lock b/mix.lock
index 5b37f090..b7b03f8b 100644
--- a/mix.lock
+++ b/mix.lock
@@ -5,7 +5,6 @@
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
- "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"cloak": {:hex, :cloak, "1.1.4", "aba387b22ea4d80d92d38ab1890cc528b06e0e7ef2a4581d71c3fdad59e997e7", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "92b20527b9aba3d939fab0dd32ce592ff86361547cfdc87d74edce6f980eb3d7"},
"cloak_ecto": {:hex, :cloak_ecto, "1.3.0", "0de127c857d7452ba3c3367f53fb814b0410ff9c680a8d20fbe8b9a3c57a1118", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "314beb0c123b8a800418ca1d51065b27ba3b15f085977e65c0f7b2adab2de1cc"},
@@ -21,7 +20,6 @@
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"error_tracker": {:hex, :error_tracker, "0.8.0", "15774836205c1b4cd8275b594d99c10f4de168eb00573743526b6a919f2aca4a", [:mix], [{:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, ">= 0.0.0", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, ">= 0.0.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "00ab8342932de7ad63be567ddd36ea238c840c122c7b2c5d1a42638ce6f39220"},
- "error_tracker_notifier": {:hex, :error_tracker_notifier, "0.2.1", "c13452605f4f27c9c9665136faab6c82fa626b7fadd59198ad948d61dc633715", [:mix], [{:error_tracker, "~> 0.5", [hex: :error_tracker, repo: "hexpm", optional: false]}, {:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fda342098c28ca2df45463821de49be6a1402b30321c076aae689204c5e3fecc"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"},
@@ -31,10 +29,8 @@
"fluxon": {:hex, :fluxon, "2.3.3", "c2b3582fd717f10f48c0d75889dd033a5e7d1de468614e586cb043881270ca64", [:mix], [{:phoenix_live_view, ">= 1.0.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "fluxon", "d3ffef984495d5ec3e084ab99ec7349c27db1862b2b50f8116e290a232a36b86"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
- "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
- "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
@@ -42,9 +38,7 @@
"live_debugger": {:hex, :live_debugger, "0.6.1", "da9c9813105380c92d3318a40d47c7514b078d85b2502238788dc22aa6e2f5c2", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e0893569b427abc5e94fd43d24ddd9cbbe68e57d99364ae1efb8a6c1d597c9db"},
"live_toast": {:hex, :live_toast, "0.8.0", "d7e24801a9a966d8e48872767ac33c50fc14640ac5272128becfc534fabd99b7", [:mix], [{:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:gettext, ">= 0.26.2", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.19", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.0.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "7e64435131b7731698908d59f7ba9f5092df8a3720814f26edf57e99d97a803c"},
"lumis": {:hex, :lumis, "0.1.1", "cafa14b023d5ef69cf13f12c57cd4ebf1a934c125709ace0f68fb71bf540fc8b", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f7b9eb466d496969f07a6d035fcc8017676d1177cd4b06ff09c03773dbe191be"},
- "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
- "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"multipart": {:hex, :multipart, "0.6.0", "73eae40ac88b67a8dd7cf6a2972762c1152470efeb0e18d68c7651f3f70c471b", [:mix], [{:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "818366054b1ff7215b7c091b73859968aa16e9620ec4a8a79a3a7362960b72bf"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
@@ -54,7 +48,6 @@
"oban_met": {:hex, :oban_met, "1.0.6", "2a5500aff496b7ac4b830b0b03b08e920625a051bb6890981fbb53b15f1cbdc0", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "15ea3303de76225878a8e6c25a9d62bd1e2e9dd1c46ac8487d873b9f99e8dcee"},
"oban_web": {:hex, :oban_web, "2.11.8", "be6521b5b1eb6d4182f40f5acc948ea65d243451b94c26f06a7329575748f695", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "d0c04a836d929ef037e96be142285238275aabbafe62543bbdcc3f541d29ec30"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
- "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
@@ -75,7 +68,6 @@
"server_sent_events": {:hex, :server_sent_events, "0.2.1", "f83b34f01241302a8bf451efc8dde3a36c533d5715463c31c653f3db8695f636", [:mix], [], "hexpm", "c8099ce4f9acd610eb7c8e0f89dba7d5d1c13300ea9884b0bd8662401d3cf96f"},
"sourceror": {:hex, :sourceror, "1.11.0", "df2cdaffdc323e804009ff50b50bb31e6f2d6e116d936ccf22981f592594d624", [:mix], [], "hexpm", "6e26f572bdfc21d7ad397f596b4cfbbf31d7112126fe3e902c120947073231a8"},
"spitfire": {:hex, :spitfire, "0.3.9", "01e99ded7372c123549329e5d2c0d4d6ddd0ef87044dec7ad703982e87969045", [:mix], [], "hexpm", "513d2758d9a1fc6d30d85785a0d014e31d01052648af35f8cbb9cb8507169bb1"},
- "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
"swoosh": {:hex, :swoosh, "1.22.1", "8450ac62d0a7cb82f0765592037cab2d30cbc7801acd879f77b8f672a9b49f58", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13795cd69e137c7a6b99850b938177fa3713bd6b95e92b3bdcdb29b70e88868e"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
diff --git a/test/music_library/error_notifier_test.exs b/test/music_library/error_notifier_test.exs
new file mode 100644
index 00000000..fced4ee1
--- /dev/null
+++ b/test/music_library/error_notifier_test.exs
@@ -0,0 +1,154 @@
+defmodule MusicLibrary.ErrorNotifierTest do
+ use ExUnit.Case, async: false
+
+ import Swoosh.TestAssertions
+
+ alias MusicLibrary.ErrorNotifier
+
+ @config [
+ from_email: "test@example.com",
+ to_email: "admin@example.com",
+ mailer: MusicLibrary.Mailer,
+ base_url: "https://example.com"
+ ]
+
+ defp occurrence(attrs \\ %{}) do
+ Map.merge(
+ %{
+ error_id: 42,
+ reason: "** (RuntimeError) something went wrong",
+ context: %{"request.path" => "/test", "live_view.view" => "TestLive"},
+ stacktrace: %{
+ lines: [
+ %{
+ module: "MyApp.Controller",
+ function: "index/2",
+ file: "lib/my_app/controller.ex",
+ line: 10
+ },
+ %{
+ module: "Phoenix.Router",
+ function: "call/2",
+ file: "lib/phoenix/router.ex",
+ line: 50
+ }
+ ]
+ }
+ },
+ attrs
+ )
+ end
+
+ setup :set_swoosh_global
+
+ setup do
+ previous = Application.get_env(:music_library, ErrorNotifier)
+
+ case Process.whereis(ErrorNotifier) do
+ nil -> :ok
+ pid -> GenServer.stop(pid)
+ end
+
+ on_exit(fn ->
+ case Process.whereis(ErrorNotifier) do
+ nil -> :ok
+ pid -> GenServer.stop(pid)
+ end
+
+ Application.put_env(:music_library, ErrorNotifier, previous || [])
+ end)
+
+ :ok
+ end
+
+ describe "init/1" do
+ test "returns :ignore when unconfigured" do
+ Application.put_env(:music_library, ErrorNotifier, [])
+ assert :ignore = ErrorNotifier.init(:unused)
+ end
+ end
+
+ describe "Email.send/2" do
+ test "builds and delivers an email with expected content" do
+ Application.put_env(:music_library, ErrorNotifier, @config)
+
+ assert {:ok, :sent} = ErrorNotifier.Email.send(occurrence(), "New Error! (something)")
+
+ assert_email_sent(fn email ->
+ assert email.subject =~ "[MusicLibrary] Error:"
+ assert email.subject =~ "controller.ex"
+ assert [{"", "admin@example.com"}] = email.to
+ assert {"MusicLibrary", "test@example.com"} = email.from
+
+ body = email.html_body
+ assert body =~ "New Error!"
+ assert body =~ "something went wrong"
+ assert body =~ "MyApp.Controller.index/2"
+ assert body =~ "https://example.com/dev/errors/42"
+ assert body =~ "/test"
+ assert body =~ "TestLive"
+ true
+ end)
+ end
+
+ test "handles occurrence without stacktrace lines" do
+ Application.put_env(:music_library, ErrorNotifier, @config)
+
+ occ = occurrence(%{stacktrace: %{lines: []}})
+ assert {:ok, :sent} = ErrorNotifier.Email.send(occ, "Error")
+
+ assert_email_sent(fn email ->
+ assert email.subject =~ "unknown"
+ assert not (email.html_body =~ "Stack Trace:")
+ end)
+ end
+ end
+
+ describe "throttling" do
+ test "throttles repeated errors within the window" do
+ Application.put_env(
+ :music_library,
+ ErrorNotifier,
+ Keyword.put(@config, :throttle_seconds, 60)
+ )
+
+ {:ok, pid} = ErrorNotifier.start_link([])
+
+ :telemetry.execute([:error_tracker, :error, :new], %{}, %{
+ error: %{id: 1},
+ occurrence: occurrence(%{error_id: 1})
+ })
+
+ Process.sleep(50)
+ assert_email_sent()
+
+ # Second notification for same error: throttled
+ :telemetry.execute([:error_tracker, :occurrence, :new], %{}, %{
+ occurrence: occurrence(%{error_id: 1})
+ })
+
+ Process.sleep(50)
+ refute_email_sent()
+
+ GenServer.stop(pid)
+ end
+ end
+
+ describe "telemetry integration" do
+ test "sends email on :error_tracker :error :new event" do
+ Application.put_env(:music_library, ErrorNotifier, @config)
+
+ {:ok, pid} = ErrorNotifier.start_link([])
+
+ :telemetry.execute([:error_tracker, :error, :new], %{}, %{
+ error: %{id: 99},
+ occurrence: occurrence(%{error_id: 99})
+ })
+
+ Process.sleep(50)
+ assert_email_sent(subject: ~r/MusicLibrary/)
+
+ GenServer.stop(pid)
+ end
+ end
+end