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.
This commit is contained in:
Graham McIntire 2026-02-12 10:33:48 -06:00
parent aa1bac0d46
commit debdd1a21f
No known key found for this signature in database
3 changed files with 185 additions and 1 deletions

View file

@ -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,

View file

@ -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

View file

@ -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