diff --git a/dev.exs b/dev.exs index 3d69251..895749d 100644 --- a/dev.exs +++ b/dev.exs @@ -71,14 +71,20 @@ defmodule ErrorTrackerDevWeb.PageController do end def call(conn, :noroute) do + ErrorTracker.add_breadcrumb("ErrorTrackerDevWeb.PageController.no_route") raise Phoenix.Router.NoRouteError, conn: conn, router: ErrorTrackerDevWeb.Router end def call(_conn, :exception) do - raise "This is a controller exception" + ErrorTracker.add_breadcrumb("ErrorTrackerDevWeb.PageController.exception") + + raise CustomException, + message: "This is a controller exception", + bread_crumbs: ["First", "Second"] end def call(_conn, :exit) do + ErrorTracker.add_breadcrumb("ErrorTrackerDevWeb.PageController.exit") exit(:timeout) end @@ -89,6 +95,10 @@ defmodule ErrorTrackerDevWeb.PageController do end end +defmodule CustomException do + defexception [:message, :bread_crumbs] +end + defmodule ErrorTrackerDevWeb.ErrorView do def render("404.html", _assigns) do "This is a 404" @@ -142,10 +152,16 @@ defmodule ErrorTrackerDevWeb.Endpoint do plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug :add_breadcrumb plug :maybe_exception plug :set_csp plug ErrorTrackerDevWeb.Router + def add_breadcrumb(conn, _) do + ErrorTracker.add_breadcrumb("ErrorTrackerDevWeb.Endpoint.add_breadcrumb") + conn + end + def maybe_exception(%Plug.Conn{path_info: ["plug-exception"]}, _), do: raise("Plug exception") def maybe_exception(conn, _), do: conn diff --git a/guides/Getting Started.md b/guides/Getting Started.md index d2d0024..391eb39 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: 3) + def up, do: ErrorTracker.Migration.up(version: 4) # We specify `version: 1` in `down`, to ensure we remove all migrations. def down, do: ErrorTracker.Migration.down(version: 1) diff --git a/lib/error_tracker.ex b/lib/error_tracker.ex index 0c5d09f..6511316 100644 --- a/lib/error_tracker.ex +++ b/lib/error_tracker.ex @@ -60,6 +60,24 @@ defmodule ErrorTracker do As we had seen before, you can use `ErrorTracker.report/3` to manually report an error. The third parameter of this function is optional and allows you to include extra context that will be tracked along with the error. + + ## Breadcrumbs + + Aside from contextual information, it is sometimes useful to know in which points + of your code the code was executed in a given request / process. + + Using breadcrumbs allows you to add that information to any error generated and + stored on a given process / request. And if you are using `Ash` or `Splode`their + exceptions' breadcrumbs will be automatically populated. + + If you want to add a breadcrumb you can do so: + + ```elixir + ErrorTracker.add_breadcrumb("Executed my super secret code") + ``` + + Breadcrumbs can be viewed in the dashboard while viewing the details of an + occurrence. """ @typedoc """ @@ -119,15 +137,14 @@ defmodule ErrorTracker do {:ok, stacktrace} = ErrorTracker.Stacktrace.new(stacktrace) {:ok, error} = Error.new(kind, reason, stacktrace) context = Map.merge(get_context(), given_context) - - context = - if bread_crumbs = bread_crumbs(exception), - do: Map.put(context, "bread_crumbs", bread_crumbs), - else: context + breadcrumbs = get_breadcrumbs() ++ exception_breadcrumbs(exception) if enabled?() && !ignored?(error, context) do sanitized_context = sanitize_context(context) - {_error, occurrence} = upsert_error!(error, stacktrace, sanitized_context, reason) + + {_error, occurrence} = + upsert_error!(error, stacktrace, sanitized_context, breadcrumbs, reason) + occurrence else :noop @@ -205,6 +222,40 @@ defmodule ErrorTracker do Process.get(:error_tracker_context, %{}) end + @doc """ + Adds a breadcrumb to the current process. + + The new breadcrumb will be added as the most recent entry of the breadcrumbs + list. + + ## Breadcrumbs limit + + Breadcrumbs are a powerful tool that allows to add an infinite number of + entries. However, it is not recommended to store errors with an excessive + amount of breadcrumbs. + + As they are stored as an array of strings under the hood, storing many + entries per error can lead to some delays and using extra disk space on the + database. + """ + @spec add_breadcrumb(String.t()) :: list(String.t()) + def add_breadcrumb(breadcrumb) when is_binary(breadcrumb) do + current_breadcrumbs = Process.get(:error_tracker_breadcrumbs, []) + new_breadcrumbs = current_breadcrumbs ++ [breadcrumb] + + Process.put(:error_tracker_breadcrumbs, new_breadcrumbs) + + new_breadcrumbs + end + + @doc """ + Obtain the breadcrumbs of the current process. + """ + @spec get_breadcrumbs() :: list(String.t()) + def get_breadcrumbs do + Process.get(:error_tracker_breadcrumbs, []) + end + defp enabled? do !!Application.get_env(:error_tracker, :enabled, true) end @@ -237,15 +288,15 @@ defmodule ErrorTracker do end end - defp bread_crumbs(exception) do + defp exception_breadcrumbs(exception) do case exception do - {_kind, exception} -> bread_crumbs(exception) - %{bread_crumbs: bread_crumbs} -> bread_crumbs - _other -> nil + {_kind, exception} -> exception_breadcrumbs(exception) + %{bread_crumbs: breadcrumbs} -> breadcrumbs + _other -> [] end end - defp upsert_error!(error, stacktrace, context, reason) do + defp upsert_error!(error, stacktrace, context, breadcrumbs, reason) do existing_status = Repo.one(from e in Error, where: [fingerprint: ^error.fingerprint], select: e.status) @@ -271,6 +322,7 @@ defmodule ErrorTracker do |> Occurrence.changeset(%{ stacktrace: stacktrace, context: context, + breadcrumbs: breadcrumbs, reason: reason }) |> Repo.insert!() diff --git a/lib/error_tracker/migration/mysql.ex b/lib/error_tracker/migration/mysql.ex index de38c01..0c28986 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 3 + @current_version 4 @impl ErrorTracker.Migration def up(opts) do diff --git a/lib/error_tracker/migration/mysql/v04.ex b/lib/error_tracker/migration/mysql/v04.ex new file mode 100644 index 0000000..3b9a3aa --- /dev/null +++ b/lib/error_tracker/migration/mysql/v04.ex @@ -0,0 +1,17 @@ +defmodule ErrorTracker.Migration.MySQL.V04 do + @moduledoc false + + use Ecto.Migration + + def up(_opts) do + alter table(:error_tracker_occurrences) do + add :breadcrumbs, :json, null: true + end + end + + def down(_opts) do + alter table(:error_tracker_occurrences) do + remove :breadcrumbs + end + end +end diff --git a/lib/error_tracker/migration/postgres.ex b/lib/error_tracker/migration/postgres.ex index 54af369..ccb7014 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 3 + @current_version 4 @default_prefix "public" @impl ErrorTracker.Migration diff --git a/lib/error_tracker/migration/postgres/v04.ex b/lib/error_tracker/migration/postgres/v04.ex new file mode 100644 index 0000000..1fb05a1 --- /dev/null +++ b/lib/error_tracker/migration/postgres/v04.ex @@ -0,0 +1,17 @@ +defmodule ErrorTracker.Migration.Postgres.V04 do + @moduledoc false + + use Ecto.Migration + + def up(_opts) do + alter table(:error_tracker_occurrences) do + add :breadcrumbs, {:array, :string}, default: [], null: false + end + end + + def down(_opts) do + alter table(:error_tracker_occurrences) do + remove :breadcrumbs + end + end +end diff --git a/lib/error_tracker/migration/sqlite.ex b/lib/error_tracker/migration/sqlite.ex index 04fd7a7..383446a 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 3 + @current_version 4 @impl ErrorTracker.Migration def up(opts) do diff --git a/lib/error_tracker/migration/sqlite/v04.ex b/lib/error_tracker/migration/sqlite/v04.ex new file mode 100644 index 0000000..95fa579 --- /dev/null +++ b/lib/error_tracker/migration/sqlite/v04.ex @@ -0,0 +1,17 @@ +defmodule ErrorTracker.Migration.SQLite.V04 do + @moduledoc false + + use Ecto.Migration + + def up(_opts) do + alter table(:error_tracker_occurrences) do + add :breadcrumbs, {:array, :string}, default: [], null: false + end + end + + def down(_opts) do + alter table(:error_tracker_occurrences) do + remove :breadcrumbs + end + end +end diff --git a/lib/error_tracker/schemas/occurrence.ex b/lib/error_tracker/schemas/occurrence.ex index 226c284..1c4288e 100644 --- a/lib/error_tracker/schemas/occurrence.ex +++ b/lib/error_tracker/schemas/occurrence.ex @@ -15,9 +15,11 @@ defmodule ErrorTracker.Occurrence do @type t :: %__MODULE__{} schema "error_tracker_occurrences" do - field :context, :map field :reason, :string + field :context, :map + field :breadcrumbs, {:array, :string} + embeds_one :stacktrace, ErrorTracker.Stacktrace belongs_to :error, ErrorTracker.Error @@ -27,7 +29,7 @@ defmodule ErrorTracker.Occurrence do @doc false def changeset(occurrence, attrs) do occurrence - |> cast(attrs, [:context, :reason]) + |> cast(attrs, [:context, :reason, :breadcrumbs]) |> maybe_put_stacktrace() |> validate_required([:reason, :stacktrace]) |> validate_context() diff --git a/lib/error_tracker/web/live/show.html.heex b/lib/error_tracker/web/live/show.html.heex index b14cda4..765c8b4 100644 --- a/lib/error_tracker/web/live/show.html.heex +++ b/lib/error_tracker/web/live/show.html.heex @@ -25,6 +25,25 @@ <%= @error.source_line %> + <.section :if={@occurrence.breadcrumbs != []} title="Bread crumbs"> +
+ + Enum.reverse() |> Enum.with_index() + } + class="border-b bg-gray-400/10 border-gray-900 last:border-b-0" + > + + + +
+ <%= length(@occurrence.breadcrumbs) - index %>. + <%= breadcrumb %>
+
+ + <.section :if={@occurrence.stacktrace.lines != []} title="Stacktrace">
diff --git a/priv/static/app.css b/priv/static/app.css index f2ce984..30edff5 100644 --- a/priv/static/app.css +++ b/priv/static/app.css @@ -885,6 +885,10 @@ select { width: 2.5rem; } +.w-11 { + width: 2.75rem; +} + .w-28 { width: 7rem; } @@ -1129,6 +1133,10 @@ select { padding-bottom: 11.5px; } +.pl-2 { + padding-left: 0.5rem; +} + .pr-2 { padding-right: 0.5rem; } @@ -1145,6 +1153,10 @@ select { text-align: center; } +.text-right { + text-align: right; +} + .align-top { vertical-align: top; } @@ -1318,6 +1330,10 @@ select { border-radius: 4px; } +.last\:border-b-0:last-child { + border-bottom-width: 0px; +} + .last-of-type\:border-b-0:last-of-type { border-bottom-width: 0px; } diff --git a/test/error_tracker_test.exs b/test/error_tracker_test.exs index aacf7b2..00538fe 100644 --- a/test/error_tracker_test.exs +++ b/test/error_tracker_test.exs @@ -100,15 +100,36 @@ defmodule ErrorTrackerTest do assert report_error(fn -> raise "Sample error" end) == :noop end - test "includes bread crumbs in the context if present" do - bread_crumbs = ["bread crumb 1", "bread crumb 2"] + test "includes breadcrumbs if present" do + breadcrumbs = ["breadcrumb 1", "breadcrumb 2"] occurrence = report_error(fn -> - raise ErrorWithBreadcrumbs, message: "test", bread_crumbs: bread_crumbs + raise ErrorWithBreadcrumbs, message: "test", bread_crumbs: breadcrumbs end) - assert occurrence.context["bread_crumbs"] == bread_crumbs + assert occurrence.breadcrumbs == breadcrumbs + end + + test "includes breadcrumbs if stored by the user" do + ErrorTracker.add_breadcrumb("breadcrumb 1") + ErrorTracker.add_breadcrumb("breadcrumb 2") + + occurrence = report_error(fn -> raise "Sample error" end) + + assert occurrence.breadcrumbs == ["breadcrumb 1", "breadcrumb 2"] + end + + test "merges breadcrumbs stored by the user and contained on the exception" do + ErrorTracker.add_breadcrumb("breadcrumb 1") + ErrorTracker.add_breadcrumb("breadcrumb 2") + + occurrence = + report_error(fn -> + raise ErrorWithBreadcrumbs, message: "test", bread_crumbs: ["breadcrumb 3"] + end) + + assert occurrence.breadcrumbs == ["breadcrumb 1", "breadcrumb 2", "breadcrumb 3"] end end @@ -129,6 +150,15 @@ defmodule ErrorTrackerTest do assert {:ok, %Error{status: :unresolved}} = ErrorTracker.unresolve(resolved) end end + + describe inspect(&ErrorTracker.add_breadcrumb/1) do + test "adds an entry to the breadcrumbs list" do + ErrorTracker.add_breadcrumb("breadcrumb 1") + ErrorTracker.add_breadcrumb("breadcrumb 2") + + assert ["breadcrumb 1", "breadcrumb 2"] = ErrorTracker.get_breadcrumbs() + end + end end defmodule ErrorWithBreadcrumbs do