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