diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..df5c667 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. Remember that we don't provide support to third-party libraries such as Tower. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/README.md b/README.md index 5e79930..aa5505b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,18 @@ Latest release View documentation -**An Elixir based built-in error tracking solution.** ErrorTracker captures errors in your application and stores them in the database. It also provides a web dashboard from where you can find, inspect and resolve captured errors. +**An Elixir based built-in error tracking solution.** + +ErrorTracker captures errors in your application and stores them in the database. It also provides a web dashboard from where you can find, inspect and resolve captured errors. + +**Does it send notifications or integrate with issue trackers?** + +ErrorTrackers's goal is to track errors. Period. It provides a nice Telemetry integration that you can attach to and use to send notifications, open tickets in your issue tracker and whatnot. + +**Why another error tracker?** + +While there are multiple SaaS error trackers available, this is the only Elixir-native built-in error tracker that runs as part of your application. It gives you full control over where, how and what data is stored so it is always on your control and doesn't leave your system.\ +You can see a more detailed explanation [here](https://crbelaus.com/2024/07/31/built-in-elixir-error-reporting-tracking). ErrorTracker web dashboard 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..d94c3bc 100644 --- a/guides/Getting Started.md +++ b/guides/Getting Started.md @@ -14,7 +14,7 @@ The first step to add ErrorTracker to your application is to declare the package # mix.exs defp deps do [ - {:error_tracker, "~> 0.5"} + {:error_tracker, "~> 0.6"} ] end ``` @@ -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/schemas/stacktrace.ex b/lib/error_tracker/schemas/stacktrace.ex index 24e8bb0..eb3162d 100644 --- a/lib/error_tracker/schemas/stacktrace.ex +++ b/lib/error_tracker/schemas/stacktrace.ex @@ -55,7 +55,7 @@ defmodule ErrorTracker.Stacktrace do application, just the first line. """ def source(stack = %__MODULE__{}) do - client_app = Application.fetch_env!(:error_tracker, :otp_app) + client_app = Application.fetch_env!(:error_tracker, :otp_app) |> to_string() Enum.find(stack.lines, &(&1.application == client_app)) || List.first(stack.lines) end 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..12eb26d 100644 --- a/lib/error_tracker/web/components/core_components.ex +++ b/lib/error_tracker/web/components/core_components.ex @@ -130,4 +130,75 @@ defmodule ErrorTracker.Web.CoreComponents do """ end + + attr :name, :string, values: ~w[bell bell-slash arrow-left arrow-right] + + def icon(assigns = %{name: "bell"}) do + ~H""" + + + + """ + end + + def icon(assigns = %{name: "bell-slash"}) do + ~H""" + + + + + """ + end + + def icon(assigns = %{name: "arrow-left"}) do + ~H""" + + + + """ + end + + def icon(assigns = %{name: "arrow-right"}) do + ~H""" + + + + """ + end end diff --git a/lib/error_tracker/web/components/layouts.ex b/lib/error_tracker/web/components/layouts.ex index e02f12e..2ad77a4 100644 --- a/lib/error_tracker/web/components/layouts.ex +++ b/lib/error_tracker/web/components/layouts.ex @@ -4,8 +4,14 @@ defmodule ErrorTracker.Web.Layouts do @default_socket_config %{path: "/live", transport: :websocket} - @css :code.priv_dir(:error_tracker) |> Path.join("static/app.css") |> File.read!() - @js :code.priv_dir(:error_tracker) |> Path.join("static/app.js") |> File.read!() + @css_path Application.app_dir(:error_tracker, ["priv", "static", "app.css"]) + @js_path Application.app_dir(:error_tracker, ["priv", "static", "app.js"]) + + @external_resource @css_path + @external_resource @js_path + + @css File.read!(@css_path) + @js File.read!(@js_path) embed_templates "layouts/*" diff --git a/lib/error_tracker/web/live/dashboard.ex b/lib/error_tracker/web/live/dashboard.ex index de46f28..f372fed 100644 --- a/lib/error_tracker/web/live/dashboard.ex +++ b/lib/error_tracker/web/live/dashboard.ex @@ -7,24 +7,28 @@ defmodule ErrorTracker.Web.Live.Dashboard do alias ErrorTracker.Error alias ErrorTracker.Repo + alias ErrorTracker.Web.Search @per_page 10 @impl Phoenix.LiveView def handle_params(params, uri, socket) do - {search, search_form} = search_terms(params) - path = struct(URI, uri |> URI.parse() |> Map.take([:path, :query])) {:noreply, socket - |> assign(path: path, search: search, page: 1, search_form: search_form) + |> assign( + path: path, + search: Search.from_params(params), + page: 1, + search_form: Search.to_form(params) + ) |> paginate_errors()} end @impl Phoenix.LiveView def handle_event("search", params, socket) do - {search, _search_form} = search_terms(params["search"] || %{}) + search = Search.from_params(params["search"] || %{}) path_w_filters = %URI{socket.assigns.path | query: URI.encode_query(search)} @@ -57,6 +61,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 @@ -93,15 +113,6 @@ defmodule ErrorTracker.Web.Live.Dashboard do ) end - defp search_terms(params) do - data = %{} - types = %{reason: :string, source_line: :string, source_function: :string, status: :string} - - changeset = Ecto.Changeset.cast({data, types}, params, Map.keys(types)) - - {Ecto.Changeset.apply_changes(changeset), to_form(changeset, as: :search)} - end - defp filter(query, search) do Enum.reduce(search, query, &do_filter/2) end diff --git a/lib/error_tracker/web/live/dashboard.html.heex b/lib/error_tracker/web/live/dashboard.html.heex index 4c81d2f..b9f76ae 100644 --- a/lib/error_tracker/web/live/dashboard.html.heex +++ b/lib/error_tracker/web/live/dashboard.html.heex @@ -63,7 +63,7 @@ class="border-b bg-gray-400/10 border-y border-gray-900 hover:bg-gray-800/60 last-of-type:border-b-0" > - <.link navigate={error_path(@socket, error)} class="absolute inset-1"> + <.link navigate={error_path(@socket, error, @search)} class="absolute inset-1"> (<%= sanitize_module(error.kind) %>) <%= error.reason %>

@@ -87,21 +87,36 @@ <.badge :if={error.status == :unresolved} color={:red}>Unresolved - <.button - :if={error.status == :unresolved} - phx-click="resolve" - phx-value-error_id={error.id} - > - Resolve - +

+ <.button + :if={error.status == :unresolved} + phx-click="resolve" + phx-value-error_id={error.id} + > + Resolve + - <.button - :if={error.status == :resolved} - phx-click="unresolve" - phx-value-error_id={error.id} - > - Unresolve - + <.button + :if={error.status == :resolved} + phx-click="unresolve" + phx-value-error_id={error.id} + > + Unresolve + + + <.button :if={!error.muted} phx-click="mute" type="link" phx-value-error_id={error.id}> + <.icon name="bell-slash" /> Mute + + + <.button + :if={error.muted} + phx-click="unmute" + type="link" + phx-value-error_id={error.id} + > + <.icon name="bell" /> Unmute + +
diff --git a/lib/error_tracker/web/live/show.ex b/lib/error_tracker/web/live/show.ex index 0982219..93ebaff 100644 --- a/lib/error_tracker/web/live/show.ex +++ b/lib/error_tracker/web/live/show.ex @@ -7,41 +7,40 @@ defmodule ErrorTracker.Web.Live.Show do alias ErrorTracker.Error alias ErrorTracker.Occurrence alias ErrorTracker.Repo + alias ErrorTracker.Web.Search @occurrences_to_navigate 50 @impl Phoenix.LiveView - def mount(%{"id" => id}, _session, socket) do + def mount(params = %{"id" => id}, _session, socket) do error = Repo.get!(Error, id) - {:ok, assign(socket, error: error, app: Application.fetch_env!(:error_tracker, :otp_app))} + + {:ok, + assign(socket, + error: error, + app: Application.fetch_env!(:error_tracker, :otp_app), + search: Search.from_params(params) + )} end @impl Phoenix.LiveView - def handle_params(%{"occurrence_id" => occurrence_id}, _uri, socket) do + def handle_params(params, _uri, socket) do occurrence = - socket.assigns.error - |> Ecto.assoc(:occurrences) - |> Repo.get!(occurrence_id) - - socket = - socket - |> assign(:occurrence, occurrence) - |> load_related_occurrences() - - {:noreply, socket} - end - - def handle_params(_, _uri, socket) do - [occurrence] = - socket.assigns.error - |> Ecto.assoc(:occurrences) - |> order_by([o], desc: o.id) - |> limit(1) - |> Repo.all() + if occurrence_id = params["occurrence_id"] do + socket.assigns.error + |> Ecto.assoc(:occurrences) + |> Repo.get!(occurrence_id) + else + socket.assigns.error + |> Ecto.assoc(:occurrences) + |> order_by([o], desc: o.id) + |> limit(1) + |> Repo.one() + end socket = socket - |> assign(:occurrence, occurrence) + |> assign(occurrence: occurrence) |> load_related_occurrences() {:noreply, socket} @@ -49,10 +48,14 @@ defmodule ErrorTracker.Web.Live.Show do @impl Phoenix.LiveView def handle_event("occurrence_navigation", %{"occurrence_id" => id}, socket) do - {:noreply, - push_patch(socket, - to: occurrence_path(socket, %Occurrence{error_id: socket.assigns.error.id, id: id}) - )} + occurrence_path = + occurrence_path( + socket, + %Occurrence{error_id: socket.assigns.error.id, id: id}, + socket.assigns.search + ) + + {:noreply, push_patch(socket, to: occurrence_path)} end @impl Phoenix.LiveView @@ -69,6 +72,20 @@ defmodule ErrorTracker.Web.Live.Show do {:noreply, assign(socket, :error, updated_error)} end + @impl Phoenix.LiveView + def handle_event("mute", _params, socket) do + {:ok, updated_error} = ErrorTracker.mute(socket.assigns.error) + + {:noreply, assign(socket, :error, updated_error)} + end + + @impl Phoenix.LiveView + def handle_event("unmute", _params, socket) do + {:ok, updated_error} = ErrorTracker.unmute(socket.assigns.error) + + {:noreply, assign(socket, :error, updated_error)} + end + defp load_related_occurrences(socket) do current_occurrence = socket.assigns.occurrence base_query = Ecto.assoc(socket.assigns.error, :occurrences) @@ -109,9 +126,27 @@ defmodule ErrorTracker.Web.Live.Show do |> Ecto.assoc(:occurrences) |> Repo.aggregate(:count) + next_occurrence = + base_query + |> where([o], o.id > ^current_occurrence.id) + |> order_by([o], asc: o.id) + |> limit(1) + |> select([:id, :error_id, :inserted_at]) + |> Repo.one() + + prev_occurrence = + base_query + |> where([o], o.id < ^current_occurrence.id) + |> order_by([o], desc: o.id) + |> limit(1) + |> select([:id, :error_id, :inserted_at]) + |> Repo.one() + socket |> assign(:occurrences, occurrences) |> assign(:total_occurrences, total_occurrences) + |> assign(:next, next_occurrence) + |> assign(:prev, prev_occurrence) end defp related_occurrences(query, num_results) do diff --git a/lib/error_tracker/web/live/show.html.heex b/lib/error_tracker/web/live/show.html.heex index 765c8b4..0fe6a72 100644 --- a/lib/error_tracker/web/live/show.html.heex +++ b/lib/error_tracker/web/live/show.html.heex @@ -1,5 +1,7 @@
- <.button type="link" href={dashboard_path(@socket)}>« Back to the dashboard + <.link navigate={dashboard_path(@socket, @search)}> + <.icon name="arrow-left" /> Back to the dashboard +
diff --git a/lib/error_tracker/web/router/routes.ex b/lib/error_tracker/web/router/routes.ex index a2e7dd4..696ca0e 100644 --- a/lib/error_tracker/web/router/routes.ex +++ b/lib/error_tracker/web/router/routes.ex @@ -8,21 +8,36 @@ defmodule ErrorTracker.Web.Router.Routes do @doc """ Returns the dashboard path """ - def dashboard_path(socket = %Socket{}) do - socket.private[:dashboard_path] + def dashboard_path(socket = %Socket{}, params \\ %{}) do + socket + |> dashboard_uri(params) + |> URI.to_string() end @doc """ Returns the path to see the details of an error """ - def error_path(socket = %Socket{}, %Error{id: id}) do - dashboard_path(socket) <> "/#{id}" + def error_path(socket = %Socket{}, %Error{id: id}, params \\ %{}) do + socket + |> dashboard_uri(params) + |> URI.append_path("/#{id}") + |> URI.to_string() end @doc """ Returns the path to see the details of an occurrence """ - def occurrence_path(socket = %Socket{}, %Occurrence{id: id, error_id: error_id}) do - dashboard_path(socket) <> "/#{error_id}/#{id}" + def occurrence_path(socket = %Socket{}, %Occurrence{id: id, error_id: error_id}, params \\ %{}) do + socket + |> dashboard_uri(params) + |> URI.append_path("/#{error_id}/#{id}") + |> URI.to_string() + end + + defp dashboard_uri(socket = %Socket{}, params) do + %URI{ + path: socket.private[:dashboard_path], + query: if(Enum.any?(params), do: URI.encode_query(params)) + } end end diff --git a/lib/error_tracker/web/search.ex b/lib/error_tracker/web/search.ex new file mode 100644 index 0000000..c5939d4 --- /dev/null +++ b/lib/error_tracker/web/search.ex @@ -0,0 +1,24 @@ +defmodule ErrorTracker.Web.Search do + @moduledoc false + + @types %{ + reason: :string, + source_line: :string, + source_function: :string, + status: :string + } + + defp changeset(params) do + Ecto.Changeset.cast({%{}, @types}, params, Map.keys(@types)) + end + + @spec from_params(map()) :: %{atom() => String.t()} + def from_params(params) do + params |> changeset() |> Ecto.Changeset.apply_changes() + end + + @spec to_form(map()) :: Phoenix.HTML.Form.t() + def to_form(params) do + params |> changeset() |> Phoenix.Component.to_form(as: :search) + end +end diff --git a/mix.exs b/mix.exs index 7e3b8a4..56bb047 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule ErrorTracker.MixProject do def project do [ app: :error_tracker, - version: "0.5.2", + version: "0.6.0", elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/mix.lock b/mix.lock index f2aa82c..b65cc5f 100644 --- a/mix.lock +++ b/mix.lock @@ -14,7 +14,7 @@ "ecto_sql": {:hex, :ecto_sql, "3.11.2", "c7cc7f812af571e50b80294dc2e535821b3b795ce8008d07aa5f336591a185a8", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "73c07f995ac17dbf89d3cfaaf688fcefabcd18b7b004ac63b0dc4ef39499ed6b"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.16.0", "1cdc8ea6319e7cb1bc273a36db0ecde69ad56b4dea3037689ad8c0afc6a91e16", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "73c9dd56830d67c951bc254c082cb0a7f9fa139d44866bc3186c8859d1b4d787"}, "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, - "ex_doc": {:hex, :ex_doc, "0.36.0", "9c4519323dfe2f88d0643c1b911d1d343105f5a9d75bd0eeb01274e7aa3e9ed7", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "fca4211955a7d107132ec549614c8b48d26245d7cb9349526f8bab71533ecf93"}, + "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, "exqlite": {:hex, :exqlite, "0.23.0", "6e851c937a033299d0784994c66da24845415072adbc455a337e20087bce9033", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "404341cceec5e6466aaed160cf0b58be2019b60af82588c215e1224ebd3ec831"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, diff --git a/priv/static/app.css b/priv/static/app.css index 2574a39..706e0a2 100644 --- a/priv/static/app.css +++ b/priv/static/app.css @@ -766,6 +766,10 @@ select { border-width: 0; } +.static { + position: static; +} + .absolute { position: absolute; } @@ -841,6 +845,10 @@ select { margin-top: 1.5rem; } +.mt-2 { + margin-top: 0.5rem; +} + .block { display: block; } @@ -873,6 +881,15 @@ select { display: none; } +.size-4 { + width: 1rem; + height: 1rem; +} + +.\!h-4 { + height: 1rem !important; +} + .h-10 { height: 2.5rem; } @@ -881,6 +898,10 @@ select { height: 1.25rem; } +.\!w-4 { + width: 1rem !important; +} + .w-10 { width: 2.5rem; } @@ -917,6 +938,10 @@ select { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .flex-col { flex-direction: column; } @@ -945,6 +970,10 @@ select { gap: 0.5rem; } +.gap-y-4 { + row-gap: 1rem; +} + .space-y-8 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse))); diff --git a/test/error_tracker/telemetry_test.exs b/test/error_tracker/telemetry_test.exs index ce1cd45..6a760b3 100644 --- a/test/error_tracker/telemetry_test.exs +++ b/test/error_tracker/telemetry_test.exs @@ -11,22 +11,35 @@ defmodule ErrorTracker.TelemetryTest do end test "events are emitted for new errors" do + {exception, stacktrace} = + try do + raise "This is a test" + rescue + e -> {e, __STACKTRACE__} + end + # Since the error is new, both the new error and new occurrence events will be emitted - report_error(fn -> raise "This is a test" end) + %Occurrence{error: error = %Error{}} = ErrorTracker.report(exception, stacktrace) assert_receive {:telemetry_event, [:error_tracker, :error, :new], _, %{error: %Error{}}} assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, - %{occurrence: %Occurrence{}}} + %{occurrence: %Occurrence{}, muted: false}} # The error is already known so the new error event won't be emitted - report_error(fn -> raise "This is a test" end) + ErrorTracker.report(exception, stacktrace) - refute_receive {:telemetry_event, [:error_tracker, :error, :new], _, - %{occurrence: %Occurrence{}}}, + refute_receive {:telemetry_event, [:error_tracker, :error, :new], _, %{error: %Error{}}}, 150 assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, - %{occurrence: %Occurrence{}}} + %{occurrence: %Occurrence{}, muted: false}} + + # The error is muted so the new occurrence event will include the muted=true metadata + ErrorTracker.mute(error) + ErrorTracker.report(exception, stacktrace) + + assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, + %{occurrence: %Occurrence{}, muted: true}} end test "events are emitted for resolved and unresolved errors" do diff --git a/test/error_tracker_test.exs b/test/error_tracker_test.exs index 00538fe..b5121ee 100644 --- a/test/error_tracker_test.exs +++ b/test/error_tracker_test.exs @@ -4,7 +4,10 @@ defmodule ErrorTrackerTest do alias ErrorTracker.Error alias ErrorTracker.Occurrence - @relative_file_path Path.relative_to(__ENV__.file, File.cwd!()) + # We use this file path because for some reason the test scripts are not + # handled as part of the application, so the last line of the app executed is + # on the case module. + @relative_file_path "test/support/case.ex" describe inspect(&ErrorTracker.report/3) do setup context do @@ -29,7 +32,7 @@ defmodule ErrorTrackerTest do test "reports badarith errors" do string_var = to_string(1) - %Occurrence{error: error = %Error{}} = + %Occurrence{error: error = %Error{}, stacktrace: %{lines: [last_line | _]}} = report_error(fn -> 1 + string_var end) assert error.kind == to_string(ArithmeticError) @@ -37,10 +40,17 @@ defmodule ErrorTrackerTest do # Elixir 1.17.0 reports these errors differently than previous versions if Version.compare(System.version(), "1.17.0") == :lt do - assert error.source_line =~ @relative_file_path + assert last_line.module == "ErrorTrackerTest" + assert last_line.function =~ "&ErrorTracker.report/3 reports badarith errors" + assert last_line.arity == 1 + assert last_line.file + assert last_line.line else - assert error.source_function == "erlang.+/2" - assert error.source_line == "(nofile)" + assert last_line.module == "erlang" + assert last_line.function == "+" + assert last_line.arity == 2 + refute last_line.file + refute last_line.line end end