From debdd1a21f08395a7b6563745ad3c2ae51172571 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 10:33:48 -0600 Subject: [PATCH] feat: email raw stacktrace on every Honeybadger exception Add a Honeybadger NoticeFilter that sends an email with the full error class, message, stacktrace, and server info to graham@mcintire.me whenever an exception is reported. The notice passes through unchanged so Honeybadger still receives it normally. --- config/config.exs | 3 +- lib/towerops/honeybadger_notice_filter.ex | 80 ++++++++++++++ .../honeybadger_notice_filter_test.exs | 103 ++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 lib/towerops/honeybadger_notice_filter.ex create mode 100644 test/towerops/honeybadger_notice_filter_test.exs diff --git a/config/config.exs b/config/config.exs index 423eb48b..4fa96764 100644 --- a/config/config.exs +++ b/config/config.exs @@ -25,7 +25,8 @@ config :honeybadger, environment_name: config_env(), insights_enabled: true, use_logger: true, - filter: Towerops.HoneybadgerFilter + filter: Towerops.HoneybadgerFilter, + notice_filter: Towerops.HoneybadgerNoticeFilter # Configure Elixir's Logger config :logger, :default_formatter, diff --git a/lib/towerops/honeybadger_notice_filter.ex b/lib/towerops/honeybadger_notice_filter.ex new file mode 100644 index 00000000..80f1bafb --- /dev/null +++ b/lib/towerops/honeybadger_notice_filter.ex @@ -0,0 +1,80 @@ +defmodule Towerops.HoneybadgerNoticeFilter do + @moduledoc """ + Honeybadger notice filter that emails a raw stacktrace to the + ops contact whenever an exception is reported. + + Implements `Honeybadger.NoticeFilter` — receives the fully-built + notice, sends an email, and returns the notice unchanged so it + still reaches Honeybadger. + """ + + @behaviour Honeybadger.NoticeFilter + + import Swoosh.Email + + alias Towerops.Mailer + + require Logger + + @recipient "graham@mcintire.me" + + @impl Honeybadger.NoticeFilter + def filter(%Honeybadger.Notice{} = notice) do + send_error_email(notice) + notice + end + + defp send_error_email(%Honeybadger.Notice{error: error, server: server}) do + from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"}) + subject = "[#{server.environment_name}] #{error.class}: #{truncate(error.message, 120)}" + + body = format_body(error, server) + + email = + new() + |> to(@recipient) + |> from(from_address) + |> subject(subject) + |> text_body(body) + + case Mailer.deliver(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Failed to send Honeybadger error email: #{inspect(reason)}") + :error + end + end + + defp format_body(error, server) do + backtrace = format_backtrace(error.backtrace) + + """ + Exception: #{error.class} + Message: #{error.message} + + Environment: #{server.environment_name} + Hostname: #{server.hostname} + + Stacktrace: + #{backtrace} + """ + end + + defp format_backtrace(backtrace) when is_list(backtrace) do + Enum.map_join(backtrace, "\n", fn entry -> + " #{entry.file}:#{entry.number} in #{entry.method}" + end) + end + + defp format_backtrace(_), do: " (no stacktrace available)" + + defp truncate(nil, _max), do: "(no message)" + + defp truncate(message, max) when byte_size(message) > max do + String.slice(message, 0, max) <> "..." + end + + defp truncate(message, _max), do: message +end diff --git a/test/towerops/honeybadger_notice_filter_test.exs b/test/towerops/honeybadger_notice_filter_test.exs new file mode 100644 index 00000000..fb90bb3f --- /dev/null +++ b/test/towerops/honeybadger_notice_filter_test.exs @@ -0,0 +1,103 @@ +defmodule Towerops.HoneybadgerNoticeFilterTest do + use ExUnit.Case, async: true + + import Swoosh.TestAssertions + + alias Towerops.HoneybadgerNoticeFilter + + defp build_notice(attrs \\ %{}) do + default_error = %{ + class: "RuntimeError", + message: "something went wrong", + backtrace: [ + %{file: "lib/towerops_web/live/dashboard_live.ex", number: 42, method: "mount/3"}, + %{file: "lib/phoenix_live_view/channel.ex", number: 100, method: "handle_info/2"} + ], + tags: [], + fingerprint: "" + } + + default_server = %{ + environment_name: :test, + hostname: "test-host", + project_root: "/app", + revision: nil + } + + %Honeybadger.Notice{ + error: Map.merge(default_error, Map.get(attrs, :error, %{})), + server: Map.merge(default_server, Map.get(attrs, :server, %{})), + request: Map.get(attrs, :request, %{}), + notifier: %{name: "honeybadger-elixir", url: "", version: "0.0.0"}, + breadcrumbs: %{}, + correlation_context: %{} + } + end + + describe "filter/1" do + test "returns the notice unchanged so Honeybadger still receives it" do + notice = build_notice() + + result = HoneybadgerNoticeFilter.filter(notice) + + assert result == notice + end + + test "sends an email with the error class and message" do + notice = build_notice(%{error: %{class: "ArgumentError", message: "bad argument"}}) + + HoneybadgerNoticeFilter.filter(notice) + + assert_email_sent(fn email -> + assert email.to == [{"", "graham@mcintire.me"}] + assert email.subject =~ "ArgumentError" + assert email.subject =~ "bad argument" + end) + end + + test "email body contains the full stacktrace" do + notice = + build_notice(%{ + error: %{ + backtrace: [ + %{file: "lib/my_app/worker.ex", number: 10, method: "perform/1"}, + %{file: "lib/oban/queue/executor.ex", number: 55, method: "safe_call/1"} + ] + } + }) + + HoneybadgerNoticeFilter.filter(notice) + + assert_email_sent(fn email -> + body = email.text_body + assert body =~ "lib/my_app/worker.ex:10 in perform/1" + assert body =~ "lib/oban/queue/executor.ex:55 in safe_call/1" + end) + end + + test "email body includes server environment info" do + notice = + build_notice(%{ + server: %{environment_name: :prod, hostname: "web-1.towerops.net"} + }) + + HoneybadgerNoticeFilter.filter(notice) + + assert_email_sent(fn email -> + body = email.text_body + assert body =~ "prod" + assert body =~ "web-1.towerops.net" + end) + end + + test "email is sent from the configured mailer_from address" do + notice = build_notice() + + HoneybadgerNoticeFilter.filter(notice) + + assert_email_sent(fn email -> + assert email.from == {"Towerops", "hi@towerops.net"} + end) + end + end +end