Skip to content

Mute error occurrences #130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Mar 3, 2025
Merged
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
2 changes: 1 addition & 1 deletion dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#
Mix.install([
{:ecto_sqlite3, ">= 0.0.0"},
{:error_tracker, path: "."},
{:error_tracker, path: ".", force: true},
{:phoenix_playground, "~> 0.1.7"}
])

Expand Down
26 changes: 22 additions & 4 deletions guides/Getting Started.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Open the generated migration and call the `up` and `down` functions on `ErrorTra
defmodule MyApp.Repo.Migrations.AddErrorTracker do
use Ecto.Migration

def up, do: ErrorTracker.Migration.up(version: 4)
def up, do: ErrorTracker.Migration.up(version: 5)

# We specify `version: 1` in `down`, to ensure we remove all migrations.
def down, do: ErrorTracker.Migration.down(version: 1)
Expand Down Expand Up @@ -152,9 +152,27 @@ environments where you may want to prune old errors that have been resolved.
The `ErrorTracker.Plugins.Pruner` module provides automatic pruning functionality with a configurable
interval and error age.

## Ignoring errors
## Ignoring and Muting Errors

ErrorTracker provides two different ways to silence errors:

### Ignoring Errors

ErrorTracker tracks every error by default. In certain cases some errors may be expected or just not interesting to track.
ErrorTracker provides functionality that allows you to ignore errors based on their attributes and context.
The `ErrorTracker.Ignorer` behaviour allows you to ignore errors based on their attributes and context.

When an error is ignored, its occurrences are not tracked at all. This is useful for expected errors that you don't want to store in your database.

### Muting Errors

Sometimes you may want to keep tracking error occurrences but avoid receiving notifications about them. For these cases,
ErrorTracker allows you to mute specific errors.

When an error is muted:
- New occurrences are still tracked and stored in the database
- You can still see the error and its occurrences in the web UI
- [Telemetry events](ErrorTracker.Telemetry.html) for new occurrences include the `muted: true` flag so you can ignore them as needed.

This is particularly useful for noisy errors that you want to keep tracking but don't want to receive notifications about.

Take a look at the `ErrorTracker.Ignorer` behaviour for more information about how to implement your own ignorer.
You can mute and unmute errors manually through the web UI or programmatically using the `ErrorTracker.mute/1` and `ErrorTracker.unmute/1` functions.
56 changes: 46 additions & 10 deletions lib/error_tracker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,7 @@ defmodule ErrorTracker do
if enabled?() && !ignored?(error, context) do
sanitized_context = sanitize_context(context)

{_error, occurrence} =
upsert_error!(error, stacktrace, sanitized_context, breadcrumbs, reason)

occurrence
upsert_error!(error, stacktrace, sanitized_context, breadcrumbs, reason)
else
:noop
end
Expand Down Expand Up @@ -179,6 +176,37 @@ defmodule ErrorTracker do
end
end

@doc """
Mutes the error so new occurrences won't send telemetry events.

When an error is muted:
- New occurrences are still tracked and stored in the database
- No telemetry events are emitted for new occurrences
- You can still see the error and its occurrences in the web UI

This is useful for noisy errors that you want to keep tracking but don't want to
receive notifications about.
"""
@spec mute(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}
def mute(error = %Error{}) do
changeset = Ecto.Changeset.change(error, muted: true)

Repo.update(changeset)
end

@doc """
Unmutes the error so new occurrences will send telemetry events again.

This reverses the effect of `mute/1`, allowing telemetry events to be emitted
for new occurrences of this error again.
"""
@spec unmute(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}
def unmute(error = %Error{}) do
changeset = Ecto.Changeset.change(error, muted: false)

Repo.update(changeset)
end

@doc """
Sets the current process context.

Expand Down Expand Up @@ -300,8 +328,16 @@ defmodule ErrorTracker do
end

defp upsert_error!(error, stacktrace, context, breadcrumbs, reason) do
existing_status =
Repo.one(from e in Error, where: [fingerprint: ^error.fingerprint], select: e.status)
status_and_muted_query =
from e in Error,
where: [fingerprint: ^error.fingerprint],
select: {e.status, e.muted}

{existing_status, muted} =
case Repo.one(status_and_muted_query) do
{existing_status, muted} -> {existing_status, muted}
nil -> {nil, false}
end

{:ok, {error, occurrence}} =
Repo.transaction(fn ->
Expand Down Expand Up @@ -333,6 +369,8 @@ defmodule ErrorTracker do
{error, occurrence}
end)

occurrence = %Occurrence{occurrence | error: error}

# If the error existed and was marked as resolved before this exception,
# sent a Telemetry event
# If it is a new error, sent a Telemetry event
Expand All @@ -342,9 +380,7 @@ defmodule ErrorTracker do
nil -> Telemetry.new_error(error)
end

# Always send a new occurrence Telemetry event
Telemetry.new_occurrence(occurrence)

{error, occurrence}
Telemetry.new_occurrence(occurrence, muted)
occurrence
end
end
6 changes: 6 additions & 0 deletions lib/error_tracker/ignorer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ defmodule ErrorTracker.Ignorer do
@moduledoc """
Behaviour for ignoring errors.

> #### Ignoring vs muting errors {: .info}
>
> Ignoring an error keeps it from being tracked by the ErrorTracker. While this may be useful in
> certain cases, in other cases you may prefer to track the error but don't send telemetry events.
> Take a look at the `ErrorTracker.mute/1` function to see how to mute errors.

The ErrorTracker tracks every error that happens in your application. In certain cases you may
want to ignore some errors and don't track them. To do so you can implement this behaviour.

Expand Down
2 changes: 1 addition & 1 deletion lib/error_tracker/migration/mysql.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule ErrorTracker.Migration.MySQL do
alias ErrorTracker.Migration.SQLMigrator

@initial_version 3
@current_version 4
@current_version 5

@impl ErrorTracker.Migration
def up(opts) do
Expand Down
17 changes: 17 additions & 0 deletions lib/error_tracker/migration/mysql/v05.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule ErrorTracker.Migration.MySQL.V05 do
@moduledoc false

use Ecto.Migration

def up(_opts) do
alter table(:error_tracker_errors) do
add :muted, :boolean, default: false, null: false
end
end

def down(_opts) do
alter table(:error_tracker_errors) do
remove :muted
end
end
end
2 changes: 1 addition & 1 deletion lib/error_tracker/migration/postgres.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule ErrorTracker.Migration.Postgres do
alias ErrorTracker.Migration.SQLMigrator

@initial_version 1
@current_version 4
@current_version 5
@default_prefix "public"

@impl ErrorTracker.Migration
Expand Down
17 changes: 17 additions & 0 deletions lib/error_tracker/migration/postgres/v05.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule ErrorTracker.Migration.Postgres.V05 do
@moduledoc false

use Ecto.Migration

def up(%{prefix: prefix}) do
alter table(:error_tracker_errors, prefix: prefix) do
add :muted, :boolean, default: false, null: false
end
end

def down(%{prefix: prefix}) do
alter table(:error_tracker_errors, prefix: prefix) do
remove :muted
end
end
end
2 changes: 1 addition & 1 deletion lib/error_tracker/migration/sqlite.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule ErrorTracker.Migration.SQLite do
alias ErrorTracker.Migration.SQLMigrator

@initial_version 2
@current_version 4
@current_version 5

@impl ErrorTracker.Migration
def up(opts) do
Expand Down
17 changes: 17 additions & 0 deletions lib/error_tracker/migration/sqlite/v05.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule ErrorTracker.Migration.SQLite.V05 do
@moduledoc false

use Ecto.Migration

def up(_opts) do
alter table(:error_tracker_errors) do
add :muted, :boolean, default: false, null: false
end
end

def down(_opts) do
alter table(:error_tracker_errors) do
remove :muted
end
end
end
1 change: 1 addition & 0 deletions lib/error_tracker/schemas/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule ErrorTracker.Error do
field :status, Ecto.Enum, values: [:resolved, :unresolved], default: :unresolved
field :fingerprint, :binary
field :last_occurrence_at, :utc_datetime_usec
field :muted, :boolean

has_many :occurrences, ErrorTracker.Occurrence

Expand Down
28 changes: 17 additions & 11 deletions lib/error_tracker/telemetry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,39 +31,45 @@ defmodule ErrorTracker.Telemetry do
Each event is emitted with some measures and metadata, which can be used to
receive information without having to query the database again:

| event | measures | metadata |
| --------------------------------------- | -------------- | ------------- |
| `[:error_tracker, :error, :new]` | `:system_time` | `:error` |
| `[:error_tracker, :error, :unresolved]` | `:system_time` | `:error` |
| `[:error_tracker, :error, :resolved]` | `:system_time` | `:error` |
| `[:error_tracker, :occurrence, :new]` | `:system_time` | `:occurrence` |
| event | measures | metadata |
| --------------------------------------- | -------------- | ----------------------------------|
| `[:error_tracker, :error, :new]` | `:system_time` | `:error` |
| `[:error_tracker, :error, :unresolved]` | `:system_time` | `:error` |
| `[:error_tracker, :error, :resolved]` | `:system_time` | `:error` |
| `[:error_tracker, :occurrence, :new]` | `:system_time` | `:occurrence`, `:error`, `:muted` |

The metadata keys contain the following data:

* `:error` - An `%ErrorTracker.Error{}` struct representing the error.
* `:occurrence` - An `%ErrorTracker.Occurrence{}` struct representing the occurrence.
* `:muted` - A boolean indicating whether the error is muted or not.
"""

@doc false
def new_error(error) do
def new_error(error = %ErrorTracker.Error{}) do
measurements = %{system_time: System.system_time()}
metadata = %{error: error}
:telemetry.execute([:error_tracker, :error, :new], measurements, metadata)
end

@doc false
def unresolved_error(error) do
def unresolved_error(error = %ErrorTracker.Error{}) do
measurements = %{system_time: System.system_time()}
metadata = %{error: error}
:telemetry.execute([:error_tracker, :error, :unresolved], measurements, metadata)
end

@doc false
def resolved_error(error) do
def resolved_error(error = %ErrorTracker.Error{}) do
measurements = %{system_time: System.system_time()}
metadata = %{error: error}
:telemetry.execute([:error_tracker, :error, :resolved], measurements, metadata)
end

@doc false
def new_occurrence(occurrence) do
def new_occurrence(occurrence = %ErrorTracker.Occurrence{}, muted) when is_boolean(muted) do
measurements = %{system_time: System.system_time()}
metadata = %{occurrence: occurrence}
metadata = %{error: occurrence.error, occurrence: occurrence, muted: muted}
:telemetry.execute([:error_tracker, :occurrence, :new], measurements, metadata)
end
end
37 changes: 37 additions & 0 deletions lib/error_tracker/web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,41 @@ defmodule ErrorTracker.Web.CoreComponents do
</div>
"""
end

attr :name, :string, values: ~w[bell bell-slash]

def icon(assigns = %{name: "bell"}) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="!h-4 !w-4 inline-block"
>
<path
fill-rule="evenodd"
d="M12 5a4 4 0 0 0-8 0v2.379a1.5 1.5 0 0 1-.44 1.06L2.294 9.707a1 1 0 0 0-.293.707V11a1 1 0 0 0 1 1h2a3 3 0 1 0 6 0h2a1 1 0 0 0 1-1v-.586a1 1 0 0 0-.293-.707L12.44 8.44A1.5 1.5 0 0 1 12 7.38V5Zm-5.5 7a1.5 1.5 0 0 0 3 0h-3Z"
clip-rule="evenodd"
/>
</svg>
"""
end

def icon(assigns = %{name: "bell-slash"}) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="!h-4 !w-4 inline-block"
>
<path
fill-rule="evenodd"
d="M4 7.379v-.904l6.743 6.742A3 3 0 0 1 5 12H3a1 1 0 0 1-1-1v-.586a1 1 0 0 1 .293-.707L3.56 8.44A1.5 1.5 0 0 0 4 7.38ZM6.5 12a1.5 1.5 0 0 0 3 0h-3Z"
clip-rule="evenodd"
/>
<path d="M14 11a.997.997 0 0 1-.096.429L4.92 2.446A4 4 0 0 1 12 5v2.379c0 .398.158.779.44 1.06l1.267 1.268a1 1 0 0 1 .293.707V11ZM2.22 2.22a.75.75 0 0 1 1.06 0l10.5 10.5a.75.75 0 1 1-1.06 1.06L2.22 3.28a.75.75 0 0 1 0-1.06Z" />
</svg>
"""
end
end
16 changes: 16 additions & 0 deletions lib/error_tracker/web/live/dashboard.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ defmodule ErrorTracker.Web.Live.Dashboard do
{:noreply, paginate_errors(socket)}
end

@impl Phoenix.LiveView
def handle_event("mute", %{"error_id" => id}, socket) do
error = Repo.get(Error, id)
{:ok, _muted} = ErrorTracker.mute(error)

{:noreply, paginate_errors(socket)}
end

@impl Phoenix.LiveView
def handle_event("unmute", %{"error_id" => id}, socket) do
error = Repo.get(Error, id)
{:ok, _unmuted} = ErrorTracker.unmute(error)

{:noreply, paginate_errors(socket)}
end

defp paginate_errors(socket) do
%{page: page, search: search} = socket.assigns
offset = (page - 1) * @per_page
Expand Down
Loading