From b370d5cda1b8b717bb1ab00139420e9bbbf7ebe8 Mon Sep 17 00:00:00 2001 From: Sean Brennan <102389629+sean-regents@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:04:18 -0400 Subject: [PATCH] feat: let the filter sanitize the error message ErrorTracker.Filter gains an optional sanitize_reason/1 callback. When the configured filter implements it, the error message is passed through it before it is saved, on both the error and the occurrence. An error saved before the callback existed takes the cleaned message at its next occurrence. The ignorer still receives the original message. Co-Authored-By: Claude Opus 5.5 --- lib/error_tracker.ex | 29 +++++++++++++++++-- lib/error_tracker/filter.ex | 24 ++++++++++++++++ test/error_tracker/filter_test.exs | 46 +++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/lib/error_tracker.ex b/lib/error_tracker.ex index aff02a1..59e63ff 100644 --- a/lib/error_tracker.ex +++ b/lib/error_tracker.ex @@ -140,8 +140,10 @@ defmodule ErrorTracker do if enabled?() && !ignored?(error, context) do sanitized_context = sanitize_context(context) + sanitized_reason = sanitize_reason(reason) + error = %{error | reason: sanitized_reason} - upsert_error!(error, stacktrace, sanitized_context, breadcrumbs, reason) + upsert_error!(error, stacktrace, sanitized_context, breadcrumbs, sanitized_reason) else :noop end @@ -300,6 +302,15 @@ defmodule ErrorTracker do else: context end + defp sanitize_reason(reason) do + filter_mod = Application.get_env(:error_tracker, :filter) + + if filter_mod && Code.ensure_loaded?(filter_mod) && + function_exported?(filter_mod, :sanitize_reason, 1), + do: filter_mod.sanitize_reason(reason), + else: reason + end + defp normalize_exception(%struct{} = ex, _stacktrace) when is_exception(ex) do {to_string(struct), Exception.message(ex)} end @@ -344,12 +355,24 @@ defmodule ErrorTracker do Repo.with_adapter(fn :mysql -> Repo.insert!(error, - on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]] + on_conflict: [ + set: [ + status: :unresolved, + last_occurrence_at: DateTime.utc_now(), + reason: error.reason + ] + ] ) _other -> Repo.insert!(error, - on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]], + on_conflict: [ + set: [ + status: :unresolved, + last_occurrence_at: DateTime.utc_now(), + reason: error.reason + ] + ], conflict_target: :fingerprint ) end) diff --git a/lib/error_tracker/filter.ex b/lib/error_tracker/filter.ex index 69f1ac7..7489cef 100644 --- a/lib/error_tracker/filter.ex +++ b/lib/error_tracker/filter.ex @@ -18,6 +18,22 @@ defmodule ErrorTracker.Filter do With this configuration in place, the ErrorTracker will call `MyApp.Filter.sanitize/1` to get a context before saving error occurrence. + The error message (the exception's `reason`) is saved as well, and it can carry + user data such as an email address or a query value. Implement the optional + `sanitize_reason/1` callback to change it before it is saved: + + defmodule MyApp.ErrorFilter do + @behaviour ErrorTracker.Filter + + @impl true + def sanitize(context), do: context + + @impl true + def sanitize_reason(reason), do: String.slice(reason, 0, 300) + end + + The ignorer still receives the original message, since nothing it sees is saved. + > #### A note on performance {: .warning} > > Keep in mind that the `sanitize/1` will be called in the context of the ErrorTracker itself. @@ -29,4 +45,12 @@ defmodule ErrorTracker.Filter do This function will be given an error context to inspect/modify before it's saved. """ @callback sanitize(context :: map()) :: map() + + @doc """ + This function will be given the error message to inspect/modify before it's saved, + on both the error and the occurrence. + """ + @callback sanitize_reason(reason :: String.t()) :: String.t() + + @optional_callbacks sanitize_reason: 1 end diff --git a/test/error_tracker/filter_test.exs b/test/error_tracker/filter_test.exs index 118cd8a..03b8b2e 100644 --- a/test/error_tracker/filter_test.exs +++ b/test/error_tracker/filter_test.exs @@ -1,6 +1,9 @@ defmodule ErrorTracker.FilterTest do use ErrorTracker.Test.Case + alias ErrorTracker.FilterTest.AuthHeaderHider + alias ErrorTracker.FilterTest.EmailHider + setup context do if filter = context[:filter] do previous_setting = Application.get_env(:error_tracker, :filter) @@ -28,7 +31,7 @@ defmodule ErrorTracker.FilterTest do assert ctx == @sensitive_ctx end - @tag filter: ErrorTracker.FilterTest.AuthHeaderHider + @tag filter: AuthHeaderHider test "user defined filter should be used to sanitize the context before it's saved." do assert %ErrorTracker.Occurrence{context: ctx} = report_error(fn -> raise "BOOM" end, @sensitive_ctx) @@ -40,6 +43,47 @@ defmodule ErrorTracker.FilterTest do assert cleaned_header_value == "REMOVED" end + + @tag filter: AuthHeaderHider + test "without sanitize_reason/1, the error message is saved as it is." do + assert %ErrorTracker.Occurrence{reason: "BOOM jane@example.com"} = + report_error(fn -> raise "BOOM jane@example.com" end) + end + + @tag filter: EmailHider + test "sanitize_reason/1 changes the error message before it's saved." do + occurrence = report_error(fn -> raise "BOOM jane@example.com" end) + + assert %ErrorTracker.Occurrence{reason: "BOOM [email]"} = + repo().get!(ErrorTracker.Occurrence, occurrence.id) + + assert %ErrorTracker.Error{reason: "BOOM [email]"} = + repo().get!(ErrorTracker.Error, occurrence.error_id) + end + + test "an error saved before sanitize_reason/1 is cleaned when it happens again." do + report = fn -> report_error(fn -> raise "BOOM jane@example.com" end) end + first = report.() + + previous_setting = Application.get_env(:error_tracker, :filter) + Application.put_env(:error_tracker, :filter, EmailHider) + on_exit(fn -> Application.put_env(:error_tracker, :filter, previous_setting) end) + again = report.() + + assert again.error_id == first.error_id + + assert %ErrorTracker.Error{reason: "BOOM [email]"} = + repo().get!(ErrorTracker.Error, first.error_id) + end +end + +defmodule ErrorTracker.FilterTest.EmailHider do + @moduledoc false + @behaviour ErrorTracker.Filter + + def sanitize(context), do: context + + def sanitize_reason(reason), do: String.replace(reason, ~r/\S+@\S+/, "[email]") end defmodule ErrorTracker.FilterTest.AuthHeaderHider do