diff --git a/dev.exs b/dev.exs index a09f496..af25241 100644 --- a/dev.exs +++ b/dev.exs @@ -4,7 +4,7 @@ # Mix.install([ {:ecto_sqlite3, ">= 0.0.0"}, - {:error_tracker, path: "."}, + {:error_tracker, path: ".", force: true}, {:phoenix_playground, "~> 0.1.7"} ]) diff --git a/guides/Getting Started.md b/guides/Getting Started.md index 053b71b..516cf5c 100644 --- a/guides/Getting Started.md +++ b/guides/Getting Started.md @@ -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) @@ -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. diff --git a/lib/error_tracker.ex b/lib/error_tracker.ex index 43a1fce..fa0184a 100644 --- a/lib/error_tracker.ex +++ b/lib/error_tracker.ex @@ -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 @@ -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. @@ -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 -> @@ -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 @@ -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 diff --git a/lib/error_tracker/ignorer.ex b/lib/error_tracker/ignorer.ex index 28e19b9..d0b74fb 100644 --- a/lib/error_tracker/ignorer.ex +++ b/lib/error_tracker/ignorer.ex @@ -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. diff --git a/lib/error_tracker/migration/mysql.ex b/lib/error_tracker/migration/mysql.ex index 0c28986..ff9efd9 100644 --- a/lib/error_tracker/migration/mysql.ex +++ b/lib/error_tracker/migration/mysql.ex @@ -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 diff --git a/lib/error_tracker/migration/mysql/v05.ex b/lib/error_tracker/migration/mysql/v05.ex new file mode 100644 index 0000000..db54d13 --- /dev/null +++ b/lib/error_tracker/migration/mysql/v05.ex @@ -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 diff --git a/lib/error_tracker/migration/postgres.ex b/lib/error_tracker/migration/postgres.ex index ccb7014..f0a6a38 100644 --- a/lib/error_tracker/migration/postgres.ex +++ b/lib/error_tracker/migration/postgres.ex @@ -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 diff --git a/lib/error_tracker/migration/postgres/v05.ex b/lib/error_tracker/migration/postgres/v05.ex new file mode 100644 index 0000000..0b85543 --- /dev/null +++ b/lib/error_tracker/migration/postgres/v05.ex @@ -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 diff --git a/lib/error_tracker/migration/sqlite.ex b/lib/error_tracker/migration/sqlite.ex index 383446a..7d51024 100644 --- a/lib/error_tracker/migration/sqlite.ex +++ b/lib/error_tracker/migration/sqlite.ex @@ -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 diff --git a/lib/error_tracker/migration/sqlite/v05.ex b/lib/error_tracker/migration/sqlite/v05.ex new file mode 100644 index 0000000..c740ca0 --- /dev/null +++ b/lib/error_tracker/migration/sqlite/v05.ex @@ -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 diff --git a/lib/error_tracker/schemas/error.ex b/lib/error_tracker/schemas/error.ex index de7efd9..d2d8d4c 100644 --- a/lib/error_tracker/schemas/error.ex +++ b/lib/error_tracker/schemas/error.ex @@ -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 diff --git a/lib/error_tracker/telemetry.ex b/lib/error_tracker/telemetry.ex index 94f31e7..075b3f9 100644 --- a/lib/error_tracker/telemetry.ex +++ b/lib/error_tracker/telemetry.ex @@ -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 diff --git a/lib/error_tracker/web/components/core_components.ex b/lib/error_tracker/web/components/core_components.ex index 99af7a6..4943c16 100644 --- a/lib/error_tracker/web/components/core_components.ex +++ b/lib/error_tracker/web/components/core_components.ex @@ -130,4 +130,41 @@ defmodule ErrorTracker.Web.CoreComponents do """ end + + attr :name, :string, values: ~w[bell bell-slash] + + def icon(assigns = %{name: "bell"}) do + ~H""" + + """ + end + + def icon(assigns = %{name: "bell-slash"}) do + ~H""" + + """ + end end diff --git a/lib/error_tracker/web/live/dashboard.ex b/lib/error_tracker/web/live/dashboard.ex index de46f28..da66a31 100644 --- a/lib/error_tracker/web/live/dashboard.ex +++ b/lib/error_tracker/web/live/dashboard.ex @@ -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 diff --git a/lib/error_tracker/web/live/dashboard.html.heex b/lib/error_tracker/web/live/dashboard.html.heex index 4c81d2f..77474cc 100644 --- a/lib/error_tracker/web/live/dashboard.html.heex +++ b/lib/error_tracker/web/live/dashboard.html.heex @@ -87,21 +87,36 @@ <.badge :if={error.status == :unresolved} color={:red}>Unresolved