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