Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions lib/error_tracker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions lib/error_tracker/filter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
46 changes: 45 additions & 1 deletion test/error_tracker/filter_test.exs
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down