diff --git a/lib/aprsme/application.ex b/lib/aprsme/application.ex
index c64cceb..633b7d3 100644
--- a/lib/aprsme/application.ex
+++ b/lib/aprsme/application.ex
@@ -80,6 +80,9 @@ defmodule Aprsme.Application do
opts = [strategy: :one_for_one, name: Aprsme.Supervisor]
{:ok, sup} = Supervisor.start_link(children, opts)
+ # Attach error notification telemetry handler
+ Aprsme.ErrorNotifier.attach()
+
# Now that the Repo is started, run the refresh in a background task
# Skip in test environment to avoid DBConnection.OwnershipError
env = Application.get_env(:aprsme, :env)
diff --git a/lib/aprsme/error_notifier.ex b/lib/aprsme/error_notifier.ex
new file mode 100644
index 0000000..e749fca
--- /dev/null
+++ b/lib/aprsme/error_notifier.ex
@@ -0,0 +1,147 @@
+defmodule Aprsme.ErrorNotifier do
+ @moduledoc """
+ Sends email notifications when errors occur in production.
+
+ Integrates with ErrorTracker to send emails only for the first occurrence
+ of each unique error (based on error signature).
+ """
+
+ import Swoosh.Email
+
+ require Logger
+
+ @doc """
+ Attaches telemetry handler for error occurrences.
+ Called during application startup.
+ """
+ def attach do
+ :telemetry.attach(
+ "aprsme-error-email-notifier",
+ [:error_tracker, :occurrence, :created],
+ &__MODULE__.handle_error_occurrence/4,
+ nil
+ )
+ end
+
+ @doc """
+ Telemetry handler for error occurrences.
+ Sends email if this is the first occurrence and we're in production.
+ """
+ def handle_error_occurrence(_event, _measurements, metadata, _config) do
+ occurrence = metadata.occurrence
+
+ # Only send email for first occurrence in production
+ if should_send_email?(occurrence) do
+ send_error_email(occurrence)
+ end
+
+ :ok
+ end
+
+ defp should_send_email?(occurrence) do
+ occurrence.occurrence_count == 1 && production?()
+ end
+
+ defp production? do
+ Application.get_env(:aprsme, :env) == :prod
+ end
+
+ defp send_error_email(occurrence) do
+ # Extract file and line information for the subject line
+ first_line = get_first_stack_line(occurrence)
+ file = if first_line, do: first_line.file, else: "unknown_file"
+ line = if first_line, do: first_line.line, else: "?"
+ error_name = String.slice(occurrence.reason, 0, 80) || "Unknown error"
+
+ subject = "[APRS.me] Error: #{error_name} - #{file} - #{line}"
+
+ email =
+ new()
+ |> to({"", "graham@mcintire.me"})
+ |> from({"APRS.me Alerts", "alerts@aprs.me"})
+ |> subject(subject)
+ |> html_body(occurrence_email_html(occurrence))
+
+ case Aprsme.Mailer.deliver(email) do
+ {:ok, _} ->
+ Logger.info("Error notification email sent successfully for error #{occurrence.error_id}")
+ {:ok, "Email sent successfully"}
+
+ {:error, reason} ->
+ Logger.error("Failed to send error notification: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+
+ defp occurrence_email_html(occurrence) do
+ # Extract the first line from the stacktrace for the error location
+ first_line = get_first_stack_line(occurrence)
+
+ # Extract useful information from the stacktrace line
+ error_location =
+ if first_line do
+ "#{first_line.module}.#{first_line.function} (#{first_line.file}:#{first_line.line})"
+ else
+ "Unknown location"
+ end
+
+ # Extract useful context information
+ view = occurrence.context["live_view.view"] || "Unknown view"
+ path = occurrence.context["request.path"] || "Unknown path"
+
+ # Get the error URL
+ error_url = "https://aprs.me/dev/error-tracker/errors/#{occurrence.error_id}"
+
+ # Escape all user-controlled fields to prevent XSS
+ escaped_error_id = occurrence.error_id |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
+
+ escaped_reason =
+ occurrence.reason |> String.slice(0..199) |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
+
+ escaped_location = error_location |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
+ escaped_view = view |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
+ escaped_path = path |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
+ escaped_url = error_url |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
+
+ """
+
+
+
+ New Error Detected
+
+
+ ErrorTracker has detected a new error in production:
+
+
+
+
Error ID: #{escaped_error_id}
+
Reason: #{escaped_reason}
+
Location: #{escaped_location}
+
View: #{escaped_view}
+
Request Path: #{escaped_path}
+
Time: #{format_time()}
+
+
+
+
+ View Error Details
+
+
+
+
+ """
+ end
+
+ defp get_first_stack_line(occurrence) do
+ case occurrence.stacktrace do
+ %{lines: [first | _]} -> first
+ _ -> nil
+ end
+ end
+
+ defp format_time do
+ DateTime.to_string(DateTime.utc_now())
+ end
+end
diff --git a/test/aprsme/error_notifier_test.exs b/test/aprsme/error_notifier_test.exs
new file mode 100644
index 0000000..bdc32a6
--- /dev/null
+++ b/test/aprsme/error_notifier_test.exs
@@ -0,0 +1,173 @@
+defmodule Aprsme.ErrorNotifierTest do
+ use Aprsme.DataCase, async: false
+
+ import Swoosh.TestAssertions
+
+ alias Aprsme.ErrorNotifier
+ alias Swoosh.Adapters.Test
+
+ setup do
+ # Store original config
+ original_env = Application.get_env(:aprsme, :env)
+ original_mailer = Application.get_env(:aprsme, Aprsme.Mailer)
+
+ on_exit(fn ->
+ Application.put_env(:aprsme, :env, original_env)
+ Application.put_env(:aprsme, Aprsme.Mailer, original_mailer)
+ end)
+
+ :ok
+ end
+
+ describe "handle_error_occurrence/4" do
+ test "sends email for first occurrence of an error in production" do
+ # Set environment to production
+ Application.put_env(:aprsme, :env, :prod)
+ Application.put_env(:aprsme, Aprsme.Mailer, adapter: Test)
+
+ occurrence_data = %{
+ error_id: "test-error-123",
+ reason: "RuntimeError: Something went wrong",
+ occurrence_count: 1,
+ stacktrace: %{
+ lines: [
+ %{
+ module: "Aprsme.SomeModule",
+ function: "some_function/2",
+ file: "lib/aprsme/some_module.ex",
+ line: 42
+ }
+ ]
+ },
+ context: %{
+ "live_view.view" => "AprsmeWeb.MapLive.Index",
+ "request.path" => "/map"
+ }
+ }
+
+ measurements = %{}
+ metadata = %{occurrence: occurrence_data}
+
+ ErrorNotifier.handle_error_occurrence(
+ [:error_tracker, :occurrence, :created],
+ measurements,
+ metadata,
+ nil
+ )
+
+ assert_email_sent(fn email ->
+ assert email.to == [{"", "graham@mcintire.me"}]
+ assert email.subject =~ "Something went wrong"
+ assert email.subject =~ "lib/aprsme/some_module.ex"
+ assert email.subject =~ "42"
+ assert email.html_body =~ "test-error-123"
+ assert email.html_body =~ "RuntimeError: Something went wrong"
+ assert email.html_body =~ "Aprsme.SomeModule.some_function/2"
+ assert email.html_body =~ "https://aprs.me/dev/error-tracker/errors/test-error-123"
+ end)
+ end
+
+ test "does not send email for subsequent occurrences" do
+ Application.put_env(:aprsme, :env, :prod)
+ Application.put_env(:aprsme, Aprsme.Mailer, adapter: Test)
+
+ occurrence_data = %{
+ error_id: "test-error-123",
+ reason: "RuntimeError: Something went wrong",
+ occurrence_count: 5,
+ stacktrace: %{lines: []},
+ context: %{}
+ }
+
+ measurements = %{}
+ metadata = %{occurrence: occurrence_data}
+
+ ErrorNotifier.handle_error_occurrence(
+ [:error_tracker, :occurrence, :created],
+ measurements,
+ metadata,
+ nil
+ )
+
+ assert_no_email_sent()
+ end
+
+ test "does not send email in development environment" do
+ Application.put_env(:aprsme, :env, :dev)
+ Application.put_env(:aprsme, Aprsme.Mailer, adapter: Test)
+
+ occurrence_data = %{
+ error_id: "test-error-123",
+ reason: "RuntimeError: Something went wrong",
+ occurrence_count: 1,
+ stacktrace: %{lines: []},
+ context: %{}
+ }
+
+ measurements = %{}
+ metadata = %{occurrence: occurrence_data}
+
+ ErrorNotifier.handle_error_occurrence(
+ [:error_tracker, :occurrence, :created],
+ measurements,
+ metadata,
+ nil
+ )
+
+ assert_no_email_sent()
+ end
+
+ test "does not send email in test environment" do
+ Application.put_env(:aprsme, :env, :test)
+ Application.put_env(:aprsme, Aprsme.Mailer, adapter: Test)
+
+ occurrence_data = %{
+ error_id: "test-error-123",
+ reason: "RuntimeError: Something went wrong",
+ occurrence_count: 1,
+ stacktrace: %{lines: []},
+ context: %{}
+ }
+
+ measurements = %{}
+ metadata = %{occurrence: occurrence_data}
+
+ ErrorNotifier.handle_error_occurrence(
+ [:error_tracker, :occurrence, :created],
+ measurements,
+ metadata,
+ nil
+ )
+
+ assert_no_email_sent()
+ end
+
+ test "handles missing stacktrace gracefully" do
+ Application.put_env(:aprsme, :env, :prod)
+ Application.put_env(:aprsme, Mailer, adapter: Test)
+
+ occurrence_data = %{
+ error_id: "test-error-123",
+ reason: "RuntimeError: Something went wrong",
+ occurrence_count: 1,
+ stacktrace: %{lines: []},
+ context: %{}
+ }
+
+ measurements = %{}
+ metadata = %{occurrence: occurrence_data}
+
+ ErrorNotifier.handle_error_occurrence(
+ [:error_tracker, :occurrence, :created],
+ measurements,
+ metadata,
+ nil
+ )
+
+ assert_email_sent(fn email ->
+ assert email.subject =~ "unknown_file"
+ assert email.html_body =~ "Unknown location"
+ end)
+ end
+ end
+end